feat: new openid connect authentication layer
Some checks are pending
Cadoles/bouncer/pipeline/pr-develop Build started...

This commit is contained in:
2024-04-12 16:41:11 +02:00
parent bb5796ab8c
commit de70fa89f7
42 changed files with 2155 additions and 62 deletions

View File

@ -28,17 +28,27 @@ func (s *Server) bootstrapProxies(ctx context.Context) error {
logger.Info(ctx, "bootstrapping proxies")
for proxyName, proxyConfig := range s.bootstrapConfig.Proxies {
loopCtx := logger.With(ctx, logger.F("proxyName", proxyName), logger.F("proxyFrom", proxyConfig.From), logger.F("proxyTo", proxyConfig.To))
_, err := s.proxyRepository.GetProxy(ctx, proxyName)
if !errors.Is(err, store.ErrNotFound) {
if err != nil {
return errors.WithStack(err)
}
logger.Info(ctx, "ignoring existing proxy", logger.F("proxyName", proxyName))
continue
if proxyConfig.Recreate {
logger.Info(loopCtx, "force recreating proxy")
if err := s.deleteProxyAndLayers(ctx, proxyName); err != nil {
return errors.WithStack(err)
}
} else {
logger.Info(loopCtx, "ignoring existing proxy")
continue
}
}
logger.Info(ctx, "creating proxy", logger.F("proxyName", proxyName))
logger.Info(loopCtx, "creating proxy")
if _, err := proxyRepo.CreateProxy(ctx, proxyName, string(proxyConfig.To), proxyConfig.From...); err != nil {
return errors.WithStack(err)

View File

@ -101,7 +101,7 @@ func (s *Server) deleteProxy(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := s.proxyRepository.DeleteProxy(ctx, proxyName); err != nil {
if err := s.deleteProxyAndLayers(ctx, proxyName); err != nil {
if errors.Is(err, store.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
@ -114,23 +114,6 @@ func (s *Server) deleteProxy(w http.ResponseWriter, r *http.Request) {
return
}
layers, err := s.layerRepository.QueryLayers(ctx, proxyName)
if err != nil {
logAndCaptureError(ctx, "could not query proxy's layers", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
for _, layer := range layers {
if err := s.layerRepository.DeleteLayer(ctx, proxyName, layer.Name); err != nil {
logAndCaptureError(ctx, "could not delete layer", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
}
api.DataResponse(w, http.StatusOK, DeleteProxyResponse{
ProxyName: proxyName,
})

29
internal/admin/util.go Normal file
View File

@ -0,0 +1,29 @@
package admin
import (
"context"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
func (s *Server) deleteProxyAndLayers(ctx context.Context, proxyName store.ProxyName) error {
if err := s.proxyRepository.DeleteProxy(ctx, proxyName); err != nil {
if !errors.Is(err, store.ErrNotFound) {
return errors.WithStack(err)
}
}
layers, err := s.layerRepository.QueryLayers(ctx, proxyName)
if err != nil {
return errors.WithStack(err)
}
for _, layer := range layers {
if err := s.layerRepository.DeleteLayer(ctx, proxyName, layer.Name); err != nil {
return errors.WithStack(err)
}
}
return nil
}

View File

@ -0,0 +1,65 @@
<html>
<body>
<h1>Received request</h1>
<h2>Incoming headers</h2>
<table style="width: 100%">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{{ range $key, $val := .Request.Header }}
<tr>
<td>
<b>{{ $key }}</b>
</td>
<td>
<code>{{ $val }}</code>
</td>
</tr>
{{
end
}}
</tbody>
</table>
<h2>Incoming cookies</h2>
<table style="width: 100%">
<thead>
<tr>
<th>Name</th>
<th>Domain</th>
<th>Path</th>
<th>Secure</th>
<th>MaxAge</th>
<th>HttpOnly</th>
<th>SameSite</th>
<th>Expires</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{{ range $cookie := .Request.Cookies }}
<tr>
<td>
<b>{{ $cookie.Name }}</b>
</td>
<td>{{ $cookie.Domain }}</td>
<td>{{ $cookie.Path }}</td>
<td>{{ $cookie.Secure }}</td>
<td>{{ $cookie.MaxAge }}</td>
<td>{{ $cookie.HttpOnly }}</td>
<td>{{ $cookie.SameSite }}</td>
<td>{{ $cookie.Expires }}</td>
<td>
<code>{{ $cookie.Value }}</code>
</td>
</tr>
{{
end
}}
</tbody>
</table>
</body>
</html>

View File

@ -0,0 +1,15 @@
package dummy
import (
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "dummy",
Usage: "Dummy server related commands",
Subcommands: []*cli.Command{
RunCommand(),
},
}
}

View File

@ -0,0 +1,69 @@
package dummy
import (
"html/template"
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/logger"
_ "embed"
)
var (
//go:embed index.gohtml
indexTmpl string
)
func RunCommand() *cli.Command {
flags := common.Flags()
return &cli.Command{
Name: "run",
Usage: "Run the dummy server",
Description: "The dummy server is a very basic web application allowing the debug of incoming requests",
Flags: append(flags, &cli.StringFlag{
Name: "address",
Usage: "the dummy server listening address",
Value: ":8082",
}),
Action: func(ctx *cli.Context) error {
address := ctx.String("address")
conf, err := common.LoadConfig(ctx)
if err != nil {
return errors.Wrap(err, "could not load configuration")
}
logger.SetFormat(logger.Format(conf.Logger.Format))
logger.SetLevel(logger.Level(conf.Logger.Level))
tmpl, err := template.New("").Parse(indexTmpl)
if err != nil {
return errors.WithStack(err)
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data := struct {
Request *http.Request
}{
Request: r,
}
if err := tmpl.Execute(w, data); err != nil {
logger.Error(ctx.Context, "could not execute template", logger.E(errors.WithStack(err)))
}
})
logger.Info(ctx.Context, "listening", logger.F("address", address))
if err := http.ListenAndServe(address, handler); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -2,6 +2,7 @@ package server
import (
"forge.cadoles.com/cadoles/bouncer/internal/command/server/admin"
"forge.cadoles.com/cadoles/bouncer/internal/command/server/dummy"
"forge.cadoles.com/cadoles/bouncer/internal/command/server/proxy"
"github.com/urfave/cli/v2"
)
@ -13,6 +14,7 @@ func Root() *cli.Command {
Subcommands: []*cli.Command{
proxy.Root(),
admin.Root(),
dummy.Root(),
},
}
}

View File

@ -47,11 +47,12 @@ func (c *BootstrapConfig) UnmarshalYAML(unmarshal func(interface{}) error) error
}
type BootstrapProxyConfig struct {
Enabled InterpolatedBool `yaml:"enabled"`
Weight InterpolatedInt `yaml:"weight"`
To InterpolatedString `yaml:"to"`
From InterpolatedStringSlice `yaml:"from"`
Layers map[store.LayerName]BootstrapLayerConfig `yaml:"layers"`
Enabled InterpolatedBool `yaml:"enabled"`
Weight InterpolatedInt `yaml:"weight"`
To InterpolatedString `yaml:"to"`
From InterpolatedStringSlice `yaml:"from"`
Layers map[store.LayerName]BootstrapLayerConfig `yaml:"layers"`
Recreate InterpolatedBool `yaml:"recreate"`
}
type BootstrapLayerConfig struct {

View File

@ -2,6 +2,7 @@ package director
import (
"context"
"net/url"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
@ -10,8 +11,9 @@ import (
type contextKey string
const (
contextKeyProxy contextKey = "proxy"
contextKeyLayers contextKey = "layers"
contextKeyProxy contextKey = "proxy"
contextKeyLayers contextKey = "layers"
contextKeyOriginalURL contextKey = "originalURL"
)
var (
@ -19,6 +21,19 @@ var (
errUnexpectedContextValue = errors.New("unexpected context value")
)
func withOriginalURL(ctx context.Context, url *url.URL) context.Context {
return context.WithValue(ctx, contextKeyOriginalURL, url)
}
func OriginalURL(ctx context.Context) (*url.URL, error) {
url, err := ctxValue[*url.URL](ctx, contextKeyOriginalURL)
if err != nil {
return nil, errors.WithStack(err)
}
return url, nil
}
func withProxy(ctx context.Context, proxy *store.Proxy) context.Context {
return context.WithValue(ctx, contextKeyProxy, proxy)
}

View File

@ -28,7 +28,9 @@ func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
}
url := getRequestURL(r)
ctx = logger.With(r.Context(), logger.F("url", url.String()))
ctx = withOriginalURL(ctx, url)
ctx = logger.With(ctx, logger.F("url", url.String()))
var match *store.Proxy

View File

@ -0,0 +1,26 @@
package authn
import (
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
var (
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrSkipRequest = errors.New("skip request")
)
type Authenticator interface {
Authenticate(w http.ResponseWriter, r *http.Request, layer *store.Layer) (*User, error)
}
type PreAuthentication interface {
PreAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer) error
}
type PostAuthentication interface {
PostAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer, user *User) error
}

View File

@ -0,0 +1,94 @@
package authn
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"github.com/expr-lang/expr"
"github.com/pkg/errors"
)
func (l *Layer) getRuleOptions(r *http.Request) []expr.Option {
options := make([]expr.Option, 0)
setHeader := expr.Function(
"set_header",
func(params ...any) (any, error) {
name := params[0].(string)
rawValue := params[1]
var value string
switch v := rawValue.(type) {
case []string:
value = strings.Join(v, ",")
case time.Time:
value = strconv.FormatInt(v.UTC().Unix(), 10)
case time.Duration:
value = strconv.FormatInt(int64(v.Seconds()), 10)
default:
value = fmt.Sprintf("%v", rawValue)
}
r.Header.Set(name, value)
return true, nil
},
new(func(string, string) bool),
)
options = append(options, setHeader)
delHeaders := expr.Function(
"del_headers",
func(params ...any) (any, error) {
pattern := params[0].(string)
deleted := false
for key := range r.Header {
if !wildcard.Match(key, pattern) {
continue
}
r.Header.Del(key)
deleted = true
}
return deleted, nil
},
new(func(string) bool),
)
options = append(options, delHeaders)
return options
}
func (l *Layer) injectHeaders(r *http.Request, options *LayerOptions, user *User) error {
rules := options.Headers.Rules
if len(rules) == 0 {
return nil
}
env := map[string]any{
"user": user,
}
rulesOptions := l.getRuleOptions(r)
for i, r := range rules {
program, err := expr.Compile(r, rulesOptions...)
if err != nil {
return errors.Wrapf(err, "could not compile header rule #%d", i)
}
if _, err := expr.Run(program, env); err != nil {
return errors.Wrapf(err, "could not execute header rule #%d", i)
}
}
return nil
}

View File

@ -0,0 +1,38 @@
{
"$id": "https://forge.cadoles.com/cadoles/bouncer/schemas/authn-options",
"title": "Options de configuration commune des layers 'authn-*'",
"type": "object",
"properties": {
"matchURLs": {
"title": "Liste de filtrage des URLs sur lesquelles le layer est actif.",
"description": "Par exemple, si vous souhaitez limiter votre layer à l'ensemble d'une section '`/blog`' d'un site, vous pouvez déclarer la valeur `['*/blog*']`. Les autres URLs du site ne seront pas affectées par ce layer.",
"default": [
"*"
],
"type": "array",
"items": {
"type": "string"
}
},
"headers": {
"title": "Options de configuration du mécanisme d'injection d'entêtes HTTP liés à l'authentification",
"type": "object",
"properties": {
"rules": {
"title": "Liste des règles définissant les actions d'injection/réécriture d'entêtes HTTP",
"description": "Voir la documentation (ficher 'doc/fr/references/layers/authn/README.md', section 'Règles d'injection d'entêtes') pour plus d'informations sur le fonctionnement des règles",
"type": "array",
"default": [
"del_headers('Remote-*')",
"set_header('Remote-User', user.subject)",
"map( toPairs(user.attrs), { let name = replace(lower(string(get(#, 0))), '_', '-'); set_header('Remote-User-Attr-' + name, get(#, 1)) })"
],
"item": {
"type": "string"
}
}
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,103 @@
package authn
import (
"net/http"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Layer struct {
layerType store.LayerType
auth Authenticator
}
func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
options, err := fromStoreOptions(layer.Options)
if err != nil {
logger.Error(ctx, "could not parse layer options", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if preAuth, ok := l.auth.(PreAuthentication); ok {
if err := preAuth.PreAuthentication(w, r, layer); err != nil {
if errors.Is(err, ErrSkipRequest) {
return
}
logger.Error(ctx, "could not execute pre-auth hook", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
matches := wildcard.MatchAny(r.URL.String(), options.MatchURLs...)
if !matches {
next.ServeHTTP(w, r)
return
}
user, err := l.auth.Authenticate(w, r, layer)
if err != nil {
if errors.Is(err, ErrSkipRequest) {
return
}
logger.Error(ctx, "could not authenticate user", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if err := l.injectHeaders(r, options, user); err != nil {
logger.Error(ctx, "could not inject headers", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if postAuth, ok := l.auth.(PostAuthentication); ok {
if err := postAuth.PostAuthentication(w, r, layer, user); err != nil {
if errors.Is(err, ErrSkipRequest) {
return
}
logger.Error(ctx, "could not execute post-auth hook", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// LayerType implements director.MiddlewareLayer
func (l *Layer) LayerType() store.LayerType {
return l.layerType
}
func NewLayer(layerType store.LayerType, auth Authenticator) *Layer {
return &Layer{
layerType: layerType,
auth: auth,
}
}
var _ director.MiddlewareLayer = &Layer{}

View File

@ -0,0 +1,92 @@
package authn
import (
"reflect"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/mitchellh/mapstructure"
"github.com/pkg/errors"
)
type LayerOptions struct {
MatchURLs []string `mapstructure:"matchURLs"`
Headers HeadersOptions `mapstructure:"headers"`
}
type HeadersOptions struct {
Rules []string `mapstructure:"rules"`
}
func DefaultLayerOptions() LayerOptions {
return LayerOptions{
MatchURLs: []string{"*"},
Headers: HeadersOptions{
Rules: []string{
"del_headers('Remote-*')",
"set_header('Remote-User', user.subject)",
`map(
toPairs(user.attrs), {
let name = replace(lower(string(get(#, 0))), '_', '-');
set_header(
'Remote-User-Attr-' + name,
get(#, 1)
)
})
`,
},
},
}
}
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
layerOptions := DefaultLayerOptions()
if err := FromStoreOptions(storeOptions, &layerOptions); err != nil {
return nil, errors.WithStack(err)
}
return &layerOptions, nil
}
func FromStoreOptions(storeOptions store.LayerOptions, dest any) error {
config := mapstructure.DecoderConfig{
Result: dest,
ZeroFields: true,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
toDurationHookFunc(),
),
}
decoder, err := mapstructure.NewDecoder(&config)
if err != nil {
return errors.WithStack(err)
}
if err := decoder.Decode(storeOptions); err != nil {
return errors.WithStack(err)
}
return nil
}
func toDurationHookFunc() mapstructure.DecodeHookFunc {
return func(
f reflect.Type,
t reflect.Type,
data interface{}) (interface{}, error) {
if t != reflect.TypeOf(*new(time.Duration)) {
return data, nil
}
switch f.Kind() {
case reflect.String:
return time.ParseDuration(data.(string))
case reflect.Int64:
return time.Duration(data.(int64) * int64(time.Second)), nil
default:
return data, nil
}
// Convert it by parsing
}
}

View File

@ -0,0 +1,278 @@
package oidc
import (
"context"
"fmt"
"net/http"
"net/url"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gorilla/sessions"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"gitlab.com/wpetit/goweb/logger"
)
type Authenticator struct {
store sessions.Store
}
func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer) error {
ctx := r.Context()
originalURL, err := director.OriginalURL(ctx)
if err != nil {
return errors.WithStack(err)
}
options, err := fromStoreOptions(layer.Options)
if err != nil {
return errors.WithStack(err)
}
sess, err := a.store.Get(r, a.getCookieName(options.Cookie.Name, layer.Name))
if err != nil {
logger.Error(ctx, "could not retrieve session", logger.E(errors.WithStack(err)))
}
redirectURL := a.getRedirectURL(layer.Proxy, layer.Name, originalURL, options)
logoutURL := a.getLogoutURL(layer.Proxy, layer.Name, originalURL, options)
client, err := a.getClient(options, redirectURL.String())
if err != nil {
return errors.WithStack(err)
}
switch r.URL.Path {
case redirectURL.Path:
if err := client.HandleCallback(w, r, sess); err != nil {
return errors.WithStack(err)
}
metricLoginSuccessesTotal.With(prometheus.Labels{
metricLabelLayer: string(layer.Name),
metricLabelProxy: string(layer.Proxy),
}).Add(1)
case logoutURL.Path:
postLogoutRedirectURL := options.OIDC.PostLogoutRedirectURL
if options.OIDC.PostLogoutRedirectURL == "" {
postLogoutRedirectURL = originalURL.Scheme + "://" + originalURL.Host
}
if err := client.HandleLogout(w, r, sess, postLogoutRedirectURL); err != nil {
return errors.WithStack(err)
}
metricLogoutsTotal.With(prometheus.Labels{
metricLabelLayer: string(layer.Name),
metricLabelProxy: string(layer.Proxy),
}).Add(1)
}
return nil
}
// Authenticate implements authn.Authenticator.
func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, layer *store.Layer) (*authn.User, error) {
ctx := r.Context()
originalURL, err := director.OriginalURL(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
options, err := fromStoreOptions(layer.Options)
if err != nil {
return nil, errors.WithStack(err)
}
sess, err := a.store.Get(r, a.getCookieName(options.Cookie.Name, layer.Name))
if err != nil {
return nil, errors.WithStack(err)
}
defer func() {
if err := sess.Save(r, w); err != nil {
logger.Error(ctx, "could not save session", logger.E(errors.WithStack(err)))
}
}()
sess.Options.Domain = options.Cookie.Domain
sess.Options.HttpOnly = options.Cookie.HTTPOnly
sess.Options.MaxAge = int(options.Cookie.MaxAge.Seconds())
sess.Options.Path = options.Cookie.Path
switch options.Cookie.SameSite {
case "lax":
sess.Options.SameSite = http.SameSiteLaxMode
case "strict":
sess.Options.SameSite = http.SameSiteStrictMode
case "none":
sess.Options.SameSite = http.SameSiteNoneMode
default:
sess.Options.SameSite = http.SameSiteDefaultMode
}
redirectURL := a.getRedirectURL(layer.Proxy, layer.Name, originalURL, options)
client, err := a.getClient(options, redirectURL.String())
if err != nil {
return nil, errors.WithStack(err)
}
idToken, err := client.Authenticate(w, r, sess)
if err != nil {
if errors.Is(err, ErrLoginRequired) {
metricLoginRequestsTotal.With(prometheus.Labels{
metricLabelLayer: string(layer.Name),
metricLabelProxy: string(layer.Proxy),
}).Add(1)
return nil, errors.WithStack(authn.ErrSkipRequest)
}
return nil, errors.WithStack(err)
}
user, err := a.toUser(idToken, layer.Proxy, layer.Name, originalURL, options, sess)
if err != nil {
return nil, errors.WithStack(err)
}
return user, nil
}
type claims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Expiration int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
AuthTime int64 `json:"auth_time"`
Nonce string `json:"nonce"`
ACR string `json:"acr"`
AMR string `json:"amr"`
AZP string `json:"amp"`
Others map[string]any `json:"-"`
}
func (c claims) AsAttrs() map[string]any {
attrs := make(map[string]any)
for key, val := range c.Others {
if val != nil {
attrs["claim_"+key] = val
}
}
attrs["claim_iss"] = c.Issuer
attrs["claim_sub"] = c.Subject
attrs["claim_exp"] = c.Expiration
attrs["claim_iat"] = c.IssuedAt
if c.AuthTime != 0 {
attrs["claim_auth_time"] = c.AuthTime
}
if c.Nonce != "" {
attrs["claim_nonce"] = c.Nonce
}
if c.ACR != "" {
attrs["claim_arc"] = c.ACR
}
if c.AMR != "" {
attrs["claim_amr"] = c.AMR
}
if c.AZP != "" {
attrs["claim_azp"] = c.AZP
}
return attrs
}
func (a *Authenticator) toUser(idToken *oidc.IDToken, proxyName store.ProxyName, layerName store.LayerName, originalURL *url.URL, options *LayerOptions, sess *sessions.Session) (*authn.User, error) {
var claims claims
if err := idToken.Claims(&claims); err != nil {
return nil, errors.WithStack(err)
}
if err := idToken.Claims(&claims.Others); err != nil {
return nil, errors.WithStack(err)
}
attrs := claims.AsAttrs()
logoutURL := a.getLogoutURL(proxyName, layerName, originalURL, options)
attrs["logout_url"] = logoutURL.String()
if accessToken, exists := sess.Values[sessionKeyAccessToken]; exists && accessToken != nil {
attrs["access_token"] = accessToken
}
if refreshToken, exists := sess.Values[sessionKeyRefreshToken]; exists && refreshToken != nil {
attrs["refresh_token"] = refreshToken
}
if tokenExpiry, exists := sess.Values[sessionKeyTokenExpiry]; exists && tokenExpiry != nil {
attrs["token_expiry"] = tokenExpiry
}
user := authn.NewUser(idToken.Subject, attrs)
return user, nil
}
func (a *Authenticator) getRedirectURL(proxyName store.ProxyName, layerName store.LayerName, u *url.URL, options *LayerOptions) *url.URL {
return &url.URL{
Scheme: u.Scheme,
Host: u.Host,
Path: fmt.Sprintf(options.OIDC.LoginCallbackPath, fmt.Sprintf("%s/%s", proxyName, layerName)),
}
}
func (a *Authenticator) getLogoutURL(proxyName store.ProxyName, layerName store.LayerName, u *url.URL, options *LayerOptions) *url.URL {
return &url.URL{
Scheme: u.Scheme,
Host: u.Host,
Path: fmt.Sprintf(options.OIDC.LogoutPath, fmt.Sprintf("%s/%s", proxyName, layerName)),
}
}
func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*Client, error) {
ctx := context.Background()
if options.OIDC.SkipIssuerVerification {
ctx = oidc.InsecureIssuerURLContext(ctx, options.OIDC.IssuerURL)
}
provider, err := oidc.NewProvider(ctx, options.OIDC.IssuerURL)
if err != nil {
return nil, errors.Wrap(err, "could not create oidc provider")
}
client := NewClient(
WithCredentials(options.OIDC.ClientID, options.OIDC.ClientSecret),
WithProvider(provider),
WithRedirectURL(redirectURL),
WithScopes(options.OIDC.Scopes...),
WithAuthParams(options.OIDC.AuthParams),
)
return client, nil
}
func (a *Authenticator) getCookieName(cookieName string, layerName store.LayerName) string {
return fmt.Sprintf("%s_%s", cookieName, layerName)
}
var (
_ authn.PreAuthentication = &Authenticator{}
_ authn.Authenticator = &Authenticator{}
)

View File

@ -0,0 +1,291 @@
package oidc
import (
"bytes"
"net/http"
"net/url"
"strings"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/dchest/uniuri"
"github.com/gorilla/sessions"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
"golang.org/x/oauth2"
)
const (
sessionKeyAccessToken = "access-token"
sessionKeyRefreshToken = "refresh-token"
sessionKeyTokenExpiry = "token-expiry"
sessionKeyIDToken = "id-token"
sessionKeyPostLoginRedirectURL = "post-login-redirect-url"
sessionKeyLoginState = "login-state"
sessionKeyLoginNonce = "login-nonce"
)
var (
ErrLoginRequired = errors.New("login required")
)
type Client struct {
oauth2 *oauth2.Config
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
authParams map[string]string
}
func (c *Client) Verifier() *oidc.IDTokenVerifier {
return c.verifier
}
func (c *Client) Provider() *oidc.Provider {
return c.provider
}
func (c *Client) Authenticate(w http.ResponseWriter, r *http.Request, sess *sessions.Session) (*oidc.IDToken, error) {
idToken, err := c.getIDToken(r, sess)
if err != nil {
logger.Error(r.Context(), "could not retrieve idtoken", logger.E(errors.WithStack(err)))
c.login(w, r, sess)
return nil, errors.WithStack(ErrLoginRequired)
}
return idToken, nil
}
func (c *Client) login(w http.ResponseWriter, r *http.Request, sess *sessions.Session) {
ctx := r.Context()
state := uniuri.New()
nonce := uniuri.New()
originalURL, err := director.OriginalURL(ctx)
if err != nil {
logger.Error(ctx, "could not retrieve original url", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
sess.Values[sessionKeyLoginState] = state
sess.Values[sessionKeyLoginNonce] = nonce
sess.Values[sessionKeyPostLoginRedirectURL] = originalURL.String()
if err := sess.Save(r, w); err != nil {
logger.Error(ctx, "could not save session", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
authCodeOptions := []oauth2.AuthCodeOption{}
authCodeOptions = append(authCodeOptions, oidc.Nonce(nonce))
for key, val := range c.authParams {
authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam(key, val))
}
authCodeURL := c.oauth2.AuthCodeURL(
state,
authCodeOptions...,
)
http.Redirect(w, r, authCodeURL, http.StatusFound)
}
func (c *Client) HandleCallback(w http.ResponseWriter, r *http.Request, sess *sessions.Session) error {
token, _, rawIDToken, err := c.validate(r, sess)
if err != nil {
return errors.Wrap(err, "could not validate oidc token")
}
sess.Values[sessionKeyIDToken] = rawIDToken
sess.Values[sessionKeyAccessToken] = token.AccessToken
sess.Values[sessionKeyRefreshToken] = token.RefreshToken
sess.Values[sessionKeyTokenExpiry] = token.Expiry.UTC().Unix()
if err := sess.Save(r, w); err != nil {
return errors.WithStack(err)
}
rawPostLoginRedirectURL, exists := sess.Values[sessionKeyPostLoginRedirectURL]
if !exists {
return errors.Wrap(err, "could not find post login redirect url")
}
postLoginRedirectURL, ok := rawPostLoginRedirectURL.(string)
if !ok {
return errors.Wrapf(err, "unexpected value '%v' for post login redirect url", rawPostLoginRedirectURL)
}
http.Redirect(w, r, postLoginRedirectURL, http.StatusTemporaryRedirect)
return nil
}
func (c *Client) HandleLogout(w http.ResponseWriter, r *http.Request, sess *sessions.Session, postLogoutRedirectURL string) error {
state := uniuri.New()
sess.Values[sessionKeyLoginState] = state
ctx := r.Context()
rawIDToken, err := c.getRawIDToken(sess)
if err != nil {
logger.Error(ctx, "could not retrieve raw id token", logger.E(errors.WithStack(err)))
}
sess.Values[sessionKeyIDToken] = nil
sess.Values[sessionKeyAccessToken] = nil
sess.Values[sessionKeyRefreshToken] = nil
sess.Values[sessionKeyTokenExpiry] = nil
sess.Options.MaxAge = -1
if err := sess.Save(r, w); err != nil {
return errors.Wrap(err, "could not save session")
}
if rawIDToken == "" {
http.Redirect(w, r, postLogoutRedirectURL, http.StatusFound)
return nil
}
sessionEndURL, err := c.sessionEndURL(rawIDToken, state, postLogoutRedirectURL)
if err != nil {
return errors.Wrap(err, "could not retrieve session end url")
}
if sessionEndURL != "" {
http.Redirect(w, r, sessionEndURL, http.StatusFound)
} else {
http.Redirect(w, r, postLogoutRedirectURL, http.StatusFound)
}
return nil
}
func (c *Client) sessionEndURL(idTokenHint, state, postLogoutRedirectURL string) (string, error) {
sessionEndEndpoint := &struct {
URL string `json:"end_session_endpoint"`
}{}
if err := c.provider.Claims(&sessionEndEndpoint); err != nil {
return "", errors.Wrap(err, "could not unmarshal claims")
}
if sessionEndEndpoint.URL == "" {
return "", nil
}
var buf bytes.Buffer
buf.WriteString(sessionEndEndpoint.URL)
v := url.Values{}
if idTokenHint != "" {
v.Set("id_token_hint", idTokenHint)
}
if postLogoutRedirectURL != "" {
v.Set("post_logout_redirect_uri", postLogoutRedirectURL)
}
if state != "" {
v.Set("state", state)
}
if strings.Contains(sessionEndEndpoint.URL, "?") {
buf.WriteByte('&')
} else {
buf.WriteByte('?')
}
buf.WriteString(v.Encode())
return buf.String(), nil
}
func (c *Client) validate(r *http.Request, sess *sessions.Session) (*oauth2.Token, *oidc.IDToken, string, error) {
ctx := r.Context()
rawStoredState := sess.Values[sessionKeyLoginState]
receivedState := r.URL.Query().Get("state")
storedState, ok := rawStoredState.(string)
if !ok {
return nil, nil, "", errors.New("could not find state in session")
}
if receivedState != storedState {
return nil, nil, "", errors.New("state mismatch")
}
code := r.URL.Query().Get("code")
token, err := c.oauth2.Exchange(ctx, code)
if err != nil {
return nil, nil, "", errors.Wrap(err, "could not exchange token")
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, nil, "", errors.New("could not find id token")
}
idToken, err := c.verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, nil, "", errors.Wrap(err, "could not verify id token")
}
return token, idToken, rawIDToken, nil
}
func (c *Client) getRawIDToken(sess *sessions.Session) (string, error) {
rawIDToken, ok := sess.Values[sessionKeyIDToken].(string)
if !ok || rawIDToken == "" {
return "", errors.New("invalid id token")
}
return rawIDToken, nil
}
func (c *Client) getIDToken(r *http.Request, sess *sessions.Session) (*oidc.IDToken, error) {
rawIDToken, err := c.getRawIDToken(sess)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve raw idtoken")
}
idToken, err := c.verifier.Verify(r.Context(), rawIDToken)
if err != nil {
return nil, errors.Wrap(err, "could not verify id token")
}
return idToken, nil
}
func NewClient(funcs ...ClientOptionFunc) *Client {
opts := NewClientOptions(funcs...)
oauth2 := &oauth2.Config{
ClientID: opts.ClientID,
ClientSecret: opts.ClientSecret,
Endpoint: opts.Provider.Endpoint(),
RedirectURL: opts.RedirectURL,
Scopes: opts.Scopes,
}
verifier := opts.Provider.Verifier(&oidc.Config{
ClientID: opts.ClientID,
SkipIssuerCheck: opts.SkipIssuerCheck,
})
return &Client{
oauth2: oauth2,
provider: opts.Provider,
verifier: verifier,
authParams: opts.AuthParams,
}
}

View File

@ -0,0 +1,76 @@
package oidc
import (
"context"
"github.com/coreos/go-oidc/v3/oidc"
)
type ClientOptions struct {
Provider *oidc.Provider
ClientID string
ClientSecret string
RedirectURL string
Scopes []string
AuthParams map[string]string
SkipIssuerCheck bool
}
type ClientOptionFunc func(*ClientOptions)
func WithRedirectURL(url string) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.RedirectURL = url
}
}
func WithCredentials(clientID, clientSecret string) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.ClientID = clientID
opt.ClientSecret = clientSecret
}
}
func WithScopes(scopes ...string) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.Scopes = scopes
}
}
func WithAuthParams(params map[string]string) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.AuthParams = params
}
}
func WithSkipIssuerCheck(skip bool) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.SkipIssuerCheck = skip
}
}
func NewProvider(ctx context.Context, issuer string, skipIssuerVerification bool) (*oidc.Provider, error) {
if skipIssuerVerification {
ctx = oidc.InsecureIssuerURLContext(ctx, issuer)
}
return oidc.NewProvider(ctx, issuer)
}
func WithProvider(provider *oidc.Provider) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.Provider = provider
}
}
func NewClientOptions(funcs ...ClientOptionFunc) *ClientOptions {
opt := &ClientOptions{
Scopes: []string{oidc.ScopeOpenID, "profile"},
}
for _, f := range funcs {
f(opt)
}
return opt
}

View File

@ -0,0 +1,127 @@
{
"$id": "https://forge.cadoles.com/cadoles/bouncer/schemas/authn-oidc-layer-options",
"title": "Options de configuration du layer 'authn-oidc'",
"type": "object",
"properties": {
"oidc": {
"title": "Configuration du client OpenID Connect",
"type": "object",
"properties": {
"clientId": {
"title": "Identifiant du client OpenID Connect",
"type": "string"
},
"clientSecret": {
"title": "Secret du client OpenID Connect",
"type": "string"
},
"issuerURL": {
"title": "URL de base du fournisseur OpenID Connect (racine du .well-known/openid-configuration)",
"type": "string"
},
"postLogoutRedirectURL": {
"title": "URL de redirection après déconnexion",
"type": "string"
},
"scopes": {
"title": "Scopes associés au client OpenID Connect",
"default": [
"openid"
],
"type": "array",
"item": {
"type": "string"
}
},
"authParams": {
"title": "Paramètres d'URL supplémentaires à ajouter à la requête d'authentification OpenID Connect",
"default": {},
"description": "L'ensemble des clés valeurs renseignées seront transformées en variables d'URL lors de la requête d'authentification initiale. Permet par exemple d'ajouter les 'acr_values' requises par certains fournisseurs d'identité OpenID Connect.",
"type": "object",
"patternProperties": {
".*": {
"type": "string"
}
}
},
"loginCallbackPath": {
"title": "Chemin associé à l'URL de callback OpenID Connect",
"default": "/.bouncer/authn/oidc/%s/callback",
"description": "Le marqueur '%s' peut être utilisé pour injecter l'espace de nom '<proxy>/<layer>'.",
"type": "string"
},
"logoutPath": {
"title": "Chemin associé à l'URL de déconnexion",
"default": "/.bouncer/authn/oidc/%s/logout",
"description": "Le marqueur '%s' peut être utilisé pour injecter l'espace de nom '<proxy>/<layer>'.",
"type": "string"
},
"skipIssuerVerification": {
"title": "Activer/désactiver la vérification de concordance de l'identifiant du fournisseur d'identité",
"default": false,
"type": "boolean"
}
},
"additionalProperties": false,
"required": [
"clientId",
"clientSecret",
"issuerURL"
]
},
"cookie": {
"title": "Configuration du cookie porteur de la session utilisateur",
"type": "object",
"properties": {
"name": {
"title": "Nom du cookie",
"default": "_bouncer_authn_oidc",
"type": "string"
},
"domain": {
"title": "Domaine associé au cookie",
"description": "Par défaut le domaine associé à la requête HTTP",
"type": "string"
},
"path": {
"title": "Chemin associé au cookie",
"type": "string",
"default": "/"
},
"sameSite": {
"title": "Attribut sameSite du cookie",
"description": "Voir https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value",
"type": "string",
"enum": [
"lax",
"none",
"strict",
""
],
"default": ""
},
"httpOnly": {
"title": "Interdire ou non l'accès au cookie en Javascript",
"type": "boolean",
"default": false
},
"secure": {
"title": "Transmettre le cookie uniquement en HTTPS",
"type": "boolean",
"default": false
},
"maxAge": {
"title": "Temps de vie du cookie et de la session associée.",
"description": "Voir https://pkg.go.dev/time#ParseDuration pour le format attendu.",
"default": "1h",
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"required": [
"oidc"
]
}

View File

@ -0,0 +1,13 @@
package oidc
import (
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/gorilla/sessions"
)
const LayerType store.LayerType = "authn-oidc"
func NewLayer(store sessions.Store) *authn.Layer {
return authn.NewLayer(LayerType, &Authenticator{store: store})
}

View File

@ -0,0 +1,62 @@
package oidc
import (
"time"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
const defaultCookieName = "_bouncer_authn_oidc"
type LayerOptions struct {
authn.LayerOptions
OIDC OIDCOptions `mapstructure:"oidc"`
Cookie CookieOptions `mapstructure:"cookie"`
}
type OIDCOptions struct {
ClientID string `mapstructure:"clientId"`
ClientSecret string `mapstructure:"clientSecret"`
LoginCallbackPath string `mapstructure:"loginCallbackPath"`
LogoutPath string `mapstructure:"logoutPath"`
IssuerURL string `mapstructure:"issuerURL"`
SkipIssuerVerification bool `mapstructure:"skipIssuerVerification"`
PostLogoutRedirectURL string `mapstructure:"postLogoutRedirectURL"`
Scopes []string `mapstructure:"scopes"`
AuthParams map[string]string `mapstructure:"authParams"`
}
type CookieOptions struct {
Name string `mapstructure:"name"`
Domain string `mapstructure:"domain"`
Path string `mapstructure:"path"`
SameSite string `mapstructure:"sameSite"`
Secure bool `mapstructure:"secure"`
HTTPOnly bool `mapstructure:"httpOnly"`
MaxAge time.Duration `mapstructure:"maxAge"`
}
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
layerOptions := LayerOptions{
LayerOptions: authn.DefaultLayerOptions(),
OIDC: OIDCOptions{
LoginCallbackPath: "/.bouncer/authn/oidc/%s/callback",
LogoutPath: "/.bouncer/authn/oidc/%s/logout",
Scopes: []string{"openid"},
},
Cookie: CookieOptions{
Name: defaultCookieName,
Path: "/",
HTTPOnly: true,
MaxAge: time.Hour,
},
}
if err := authn.FromStoreOptions(storeOptions, &layerOptions); err != nil {
return nil, errors.WithStack(err)
}
return &layerOptions, nil
}

View File

@ -0,0 +1,39 @@
package oidc
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
const (
metricNamespace = "bouncer_layer_authn_oidc"
metricLabelProxy = "proxy"
metricLabelLayer = "layer"
)
var (
metricLoginRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "login_requests_total",
Help: "Bouncer's authn-oidc layer total login requests",
Namespace: metricNamespace,
},
[]string{metricLabelProxy, metricLabelLayer},
)
metricLoginSuccessesTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "login_successes_total",
Help: "Bouncer's authn-oidc layer total login successes",
Namespace: metricNamespace,
},
[]string{metricLabelProxy, metricLabelLayer},
)
metricLogoutsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "logout_total",
Help: "Bouncer's authn-oidc layer total logouts",
Namespace: metricNamespace,
},
[]string{metricLabelProxy, metricLabelLayer},
)
)

View File

@ -0,0 +1,8 @@
package oidc
import (
_ "embed"
)
//go:embed layer-options.json
var RawLayerOptionsSchema []byte

View File

@ -0,0 +1,8 @@
package authn
import (
_ "embed"
)
//go:embed layer-options.json
var RawLayerOptionsSchema []byte

View File

@ -0,0 +1,17 @@
package authn
type User struct {
Subject string `json:"subject" expr:"subject"`
Attrs map[string]any `json:"attrs" expr:"attrs"`
}
func NewUser(subject string, attrs map[string]any) *User {
if attrs == nil {
attrs = make(map[string]any)
}
return &User{
Subject: subject,
Attrs: attrs,
}
}

View File

@ -254,7 +254,7 @@ func (q *Queue) updateMetrics(ctx context.Context, proxyName store.ProxyName, la
}
func (q *Queue) getCookieName(layerName store.LayerName) string {
return fmt.Sprintf("_%s_%s", LayerType, layerName)
return fmt.Sprintf("_bouncer_%s_%s", LayerType, layerName)
}
func New(adapter Adapter, funcs ...OptionFunc) *Queue {

39
internal/schema/extend.go Normal file
View File

@ -0,0 +1,39 @@
package schema
import (
"encoding/json"
"github.com/pkg/errors"
)
func Extend(base []byte, schema []byte) ([]byte, error) {
var (
extension map[string]any
extended map[string]any
)
if err := json.Unmarshal(base, &extended); err != nil {
return nil, errors.WithStack(err)
}
if err := json.Unmarshal(schema, &extension); err != nil {
return nil, errors.WithStack(err)
}
extended["$id"] = extension["$id"]
extended["title"] = extension["title"]
props := extension["properties"].(map[string]any)
extendedProps := extended["properties"].(map[string]any)
for key, val := range props {
extendedProps[key] = val
}
extended["properties"] = extendedProps
data, err := json.MarshalIndent(extended, "", " ")
if err != nil {
return nil, errors.WithStack(err)
}
return data, nil
}

View File

@ -0,0 +1,62 @@
package redis
import (
"context"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/session"
"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
)
type StoreAdapter struct {
client redis.UniversalClient
}
// Del implements authn.StoreAdapter.
func (s *StoreAdapter) Del(ctx context.Context, key string) error {
if err := s.client.Del(ctx, key).Err(); err != nil {
return errors.WithStack(err)
}
return nil
}
// Get implements authn.StoreAdapter.
func (s *StoreAdapter) Get(ctx context.Context, key string) ([]byte, error) {
cmd := s.client.Get(ctx, key)
if err := cmd.Err(); err != nil {
if errors.Is(err, redis.Nil) {
return nil, errors.WithStack(session.ErrNotFound)
}
return nil, errors.WithStack(err)
}
data, err := cmd.Bytes()
if err != nil {
return nil, errors.WithStack(err)
}
return data, nil
}
// Set implements authn.StoreAdapter.
func (s *StoreAdapter) Set(ctx context.Context, key string, data []byte, ttl time.Duration) error {
if err := s.client.Set(ctx, key, data, ttl).Err(); err != nil {
return errors.WithStack(err)
}
return nil
}
func NewStoreAdapter(client redis.UniversalClient) *StoreAdapter {
return &StoreAdapter{
client: client,
}
}
var (
_ session.StoreAdapter = &StoreAdapter{}
)

View File

@ -0,0 +1,47 @@
package session
import (
"net/http"
"time"
"github.com/gorilla/sessions"
)
type Options struct {
Session sessions.Options
KeyPrefix string
}
type OptionFunc func(opts *Options)
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
Session: sessions.Options{
Path: "/",
Domain: "",
MaxAge: int(time.Hour.Seconds()),
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteDefaultMode,
},
KeyPrefix: "session:",
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithSessionOptions(options sessions.Options) OptionFunc {
return func(opts *Options) {
opts.Session = options
}
}
func WithKeyPrefix(prefix string) OptionFunc {
return func(opts *Options) {
opts.KeyPrefix = prefix
}
}

182
internal/session/store.go Normal file
View File

@ -0,0 +1,182 @@
package session
import (
"bytes"
"context"
"crypto/rand"
"encoding/base32"
"encoding/gob"
"io"
"net/http"
"strings"
"time"
"github.com/gorilla/sessions"
"github.com/pkg/errors"
)
var (
ErrNotFound = errors.New("not found")
)
type StoreAdapter interface {
Set(ctx context.Context, key string, data []byte, ttl time.Duration) error
Del(ctx context.Context, key string) error
Get(ctx context.Context, key string) ([]byte, error)
}
type Store struct {
adapter StoreAdapter
options sessions.Options
keyPrefix string
keyGen KeyGenFunc
serializer SessionSerializer
}
type KeyGenFunc func() (string, error)
func NewStore(adapter StoreAdapter, funcs ...OptionFunc) *Store {
opts := NewOptions(funcs...)
rs := &Store{
options: opts.Session,
adapter: adapter,
keyPrefix: opts.KeyPrefix,
keyGen: generateRandomKey,
serializer: GobSerializer{},
}
return rs
}
func (s *Store) Get(r *http.Request, name string) (*sessions.Session, error) {
return sessions.GetRegistry(r).Get(s, name)
}
func (s *Store) New(r *http.Request, name string) (*sessions.Session, error) {
session := sessions.NewSession(s, name)
opts := s.options
session.Options = &opts
session.IsNew = true
c, err := r.Cookie(name)
if err != nil {
return session, nil
}
session.ID = c.Value
err = s.load(r.Context(), session)
if err == nil {
session.IsNew = false
} else if !errors.Is(err, ErrNotFound) {
return nil, errors.WithStack(err)
}
return session, nil
}
func (s *Store) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
if session.Options.MaxAge <= 0 {
if err := s.delete(r.Context(), session); err != nil {
return errors.WithStack(err)
}
http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options))
return nil
}
if session.ID == "" {
id, err := s.keyGen()
if err != nil {
return errors.Wrap(err, "failed to generate session id")
}
session.ID = id
}
if err := s.save(r.Context(), session); err != nil {
return errors.WithStack(err)
}
http.SetCookie(w, sessions.NewCookie(session.Name(), session.ID, session.Options))
return nil
}
func (s *Store) Options(opts sessions.Options) {
s.options = opts
}
func (s *Store) KeyPrefix(keyPrefix string) {
s.keyPrefix = keyPrefix
}
func (s *Store) KeyGen(f KeyGenFunc) {
s.keyGen = f
}
func (s *Store) Serializer(ss SessionSerializer) {
s.serializer = ss
}
func (s *Store) save(ctx context.Context, session *sessions.Session) error {
b, err := s.serializer.Serialize(session)
if err != nil {
return errors.WithStack(err)
}
if err := s.adapter.Set(ctx, s.keyPrefix+session.ID, b, time.Duration(session.Options.MaxAge)*time.Second); err != nil {
return errors.WithStack(err)
}
return nil
}
// load reads session from Redis
func (s *Store) load(ctx context.Context, session *sessions.Session) error {
data, err := s.adapter.Get(ctx, s.keyPrefix+session.ID)
if err != nil {
return errors.WithStack(err)
}
return s.serializer.Deserialize(data, session)
}
// delete deletes session in Redis
func (s *Store) delete(ctx context.Context, session *sessions.Session) error {
if err := s.adapter.Del(ctx, s.keyPrefix+session.ID); err != nil {
return errors.WithStack(err)
}
return nil
}
// SessionSerializer provides an interface for serialize/deserialize a session
type SessionSerializer interface {
Serialize(s *sessions.Session) ([]byte, error)
Deserialize(b []byte, s *sessions.Session) error
}
// Gob serializer
type GobSerializer struct{}
func (gs GobSerializer) Serialize(s *sessions.Session) ([]byte, error) {
buf := new(bytes.Buffer)
enc := gob.NewEncoder(buf)
if err := enc.Encode(s.Values); err != nil {
return nil, errors.WithStack(err)
}
return buf.Bytes(), nil
}
func (gs GobSerializer) Deserialize(d []byte, s *sessions.Session) error {
dec := gob.NewDecoder(bytes.NewBuffer(d))
return dec.Decode(&s.Values)
}
// generateRandomKey returns a new random key
func generateRandomKey() (string, error) {
k := make([]byte, 64)
if _, err := io.ReadFull(rand.Reader, k); err != nil {
return "", errors.WithStack(err)
}
return strings.TrimRight(base32.StdEncoding.EncodeToString(k), "="), nil
}

View File

@ -0,0 +1,29 @@
package setup
import (
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn/oidc"
"forge.cadoles.com/cadoles/bouncer/internal/schema"
"forge.cadoles.com/cadoles/bouncer/internal/session"
"forge.cadoles.com/cadoles/bouncer/internal/session/adapter/redis"
"github.com/pkg/errors"
)
func init() {
extended, err := schema.Extend(authn.RawLayerOptionsSchema, oidc.RawLayerOptionsSchema)
if err != nil {
panic(errors.Wrap(err, "could not extend authn base layer options schema"))
}
RegisterLayer(oidc.LayerType, setupAuthnOIDCLayer, extended)
}
func setupAuthnOIDCLayer(conf *config.Config) (director.Layer, error) {
rdb := newRedisClient(conf.Redis)
adapter := redis.NewStoreAdapter(rdb)
store := session.NewStore(adapter)
return oidc.NewLayer(store), nil
}