From cc02febc308a37657883268145a27050facb4d7f Mon Sep 17 00:00:00 2001 From: William Petit Date: Fri, 12 Apr 2024 16:41:11 +0200 Subject: [PATCH] feat: new openid connect authentication layer --- Dockerfile | 2 +- Makefile | 6 +- cmd/dummy/index.gohtml | 63 ++++ cmd/dummy/main.go | 43 +++ doc/README.md | 1 + doc/fr/references/layers/README.md | 3 +- doc/fr/references/layers/authn/README.md | 61 ++++ doc/fr/references/layers/authn/oidc.md | 21 ++ doc/fr/tutorials/add-oidc-authn-layer.md | 62 ++++ go.mod | 16 +- go.sum | 32 +- internal/admin/bootstrap.go | 16 +- internal/admin/proxy_route.go | 19 +- internal/admin/util.go | 29 ++ internal/config/bootstrap.go | 11 +- internal/proxy/director/context.go | 19 +- internal/proxy/director/director.go | 4 +- .../director/layer/authn/authenticator.go | 26 ++ internal/proxy/director/layer/authn/header.go | 94 ++++++ .../director/layer/authn/layer-options.json | 38 +++ internal/proxy/director/layer/authn/layer.go | 103 +++++++ .../director/layer/authn/layer_options.go | 92 ++++++ .../layer/authn/oidc/authenticator.go | 257 ++++++++++++++++ .../proxy/director/layer/authn/oidc/client.go | 291 ++++++++++++++++++ .../layer/authn/oidc/client_options.go | 76 +++++ .../layer/authn/oidc/layer-options.json | 108 +++++++ .../proxy/director/layer/authn/oidc/layer.go | 13 + .../layer/authn/oidc/layer_options.go | 62 ++++ .../proxy/director/layer/authn/oidc/schema.go | 8 + internal/proxy/director/layer/authn/schema.go | 8 + internal/proxy/director/layer/authn/user.go | 17 + internal/proxy/director/layer/queue/queue.go | 2 +- internal/schema/extend.go | 39 +++ internal/session/adapter/redis/adapter.go | 62 ++++ internal/session/options.go | 47 +++ internal/session/store.go | 182 +++++++++++ internal/setup/authn_oidc_layer.go | 29 ++ misc/bootstrap.d/dummy.yml | 27 ++ modd.conf | 38 ++- 39 files changed, 1965 insertions(+), 62 deletions(-) create mode 100644 cmd/dummy/index.gohtml create mode 100644 cmd/dummy/main.go create mode 100644 doc/fr/references/layers/authn/README.md create mode 100644 doc/fr/references/layers/authn/oidc.md create mode 100644 doc/fr/tutorials/add-oidc-authn-layer.md create mode 100644 internal/admin/util.go create mode 100644 internal/proxy/director/layer/authn/authenticator.go create mode 100644 internal/proxy/director/layer/authn/header.go create mode 100644 internal/proxy/director/layer/authn/layer-options.json create mode 100644 internal/proxy/director/layer/authn/layer.go create mode 100644 internal/proxy/director/layer/authn/layer_options.go create mode 100644 internal/proxy/director/layer/authn/oidc/authenticator.go create mode 100644 internal/proxy/director/layer/authn/oidc/client.go create mode 100644 internal/proxy/director/layer/authn/oidc/client_options.go create mode 100644 internal/proxy/director/layer/authn/oidc/layer-options.json create mode 100644 internal/proxy/director/layer/authn/oidc/layer.go create mode 100644 internal/proxy/director/layer/authn/oidc/layer_options.go create mode 100644 internal/proxy/director/layer/authn/oidc/schema.go create mode 100644 internal/proxy/director/layer/authn/schema.go create mode 100644 internal/proxy/director/layer/authn/user.go create mode 100644 internal/schema/extend.go create mode 100644 internal/session/adapter/redis/adapter.go create mode 100644 internal/session/options.go create mode 100644 internal/session/store.go create mode 100644 internal/setup/authn_oidc_layer.go create mode 100644 misc/bootstrap.d/dummy.yml diff --git a/Dockerfile b/Dockerfile index 2228e9e..95dbe8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM reg.cadoles.com/proxy_cache/library/golang:1.21.6 AS BUILD +FROM reg.cadoles.com/proxy_cache/library/golang:1.22.0 AS BUILD RUN apt-get update \ && apt-get install -y make diff --git a/Makefile b/Makefile index 4d62fb2..e914877 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,11 @@ OPENWRT_DEVICE ?= 192.168.1.1 SIEGE_URLS_FILE ?= misc/siege/urls.txt SIEGE_CONCURRENCY ?= 100 -watch: tools/modd/bin/modd deps ## Watching updated files - live reload +data/bootstrap.d/dummy.yml: + mkdir -p data/bootstrap.d + cp misc/bootstrap.d/dummy.yml data/bootstrap.d/dummy.yml + +watch: tools/modd/bin/modd deps data/bootstrap.d/dummy.yml ## Watching updated files - live reload ( set -o allexport && source .env && set +o allexport && tools/modd/bin/modd ) .PHONY: test diff --git a/cmd/dummy/index.gohtml b/cmd/dummy/index.gohtml new file mode 100644 index 0000000..d302a59 --- /dev/null +++ b/cmd/dummy/index.gohtml @@ -0,0 +1,63 @@ + + +

Incoming request

+

Headers

+ + + + + + + + + {{ range $key, $val := .Request.Header }} + + + + + {{ + end + }} + +
KeyValue
+ {{ $key }} + + {{ $val }} +
+

Cookies

+ + + + + + + + + + + + + + + {{ range $cookie := .Request.Cookies }} + + + + + + + + + + + {{ + end + }} + +
NameDomainPathSecureMaxAgeHttpOnlyExpiresValue
+ {{ $cookie.Name }} + {{ $cookie.Domain }}{{ $cookie.Path }}{{ $cookie.Secure }}{{ $cookie.MaxAge }}{{ $cookie.HttpOnly }}{{ $cookie.Expires }} + {{ $cookie.Value }} +
+ + diff --git a/cmd/dummy/main.go b/cmd/dummy/main.go new file mode 100644 index 0000000..4cac91b --- /dev/null +++ b/cmd/dummy/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "html/template" + "log" + "net/http" + + _ "embed" + + "github.com/pkg/errors" +) + +var ( + //go:embed index.gohtml + indexTmpl string +) + +func main() { + tmpl, err := template.New("").Parse(indexTmpl) + if err != nil { + log.Fatalf("[FATAL] %+v", 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 { + log.Printf("[ERROR] %+v", errors.WithStack(err)) + } + }) + + addr := ":8082" + + log.Printf("[INFO] listening on '%s'", addr) + + if err := http.ListenAndServe(addr, handler); err != nil { + log.Fatalf("[FATAL] %+v", errors.WithStack(err)) + } +} diff --git a/doc/README.md b/doc/README.md index e41fafc..4d5e8dc 100644 --- a/doc/README.md +++ b/doc/README.md @@ -19,6 +19,7 @@ ### Utilisation - [(FR) - Ajouter un layer de type "file d'attente"](./fr/tutorials/add-queue-layer.md) +- [(FR) - Ajouter une authentification OpenID Connect](./fr/tutorials/add-queue-layer.md) - [(FR) - Amorçage d'un serveur Bouncer via la configuration](./fr/tutorials/bootstrapping.md) - [(FR) - Intégration avec Kubernetes](./fr/tutorials/kubernetes-integration.md) diff --git a/doc/fr/references/layers/README.md b/doc/fr/references/layers/README.md index 24c3f86..cd21b3a 100644 --- a/doc/fr/references/layers/README.md +++ b/doc/fr/references/layers/README.md @@ -2,5 +2,6 @@ Vous trouverez ci-dessous la liste des entités "Layer" activables sur vos entité "Proxy": +- [Authn (`authn-*`)](./authn/README.md) - Authentification des accès (SSO) - [Queue](./queue.md) - File d'attente dynamique -- [Circuit Breaker](./circuitbreaker.md) - Coupure d'accès à un site ou une sous section de celui ci \ No newline at end of file +- [Circuit Breaker](./circuitbreaker.md) - Coupure d'accès à un site ou une sous section de celui ci diff --git a/doc/fr/references/layers/authn/README.md b/doc/fr/references/layers/authn/README.md new file mode 100644 index 0000000..03ec90a --- /dev/null +++ b/doc/fr/references/layers/authn/README.md @@ -0,0 +1,61 @@ +# Les layers `authn-*` + +Les layers `authn-*` permettent d'activer différents modes d'authentification au sein d'un proxy Bouncer. + +Les informations liées à l'utilisateur authentifié sont ensuite injectables dans les entêtes HTTP de la requête permettant ainsi une authentification unique("SSO") basée sur les entêtes HTTP ("Trusted headers SSO"). + +## Layers + +- [`authn-oidc`](./oidc.md) - Authentification OpenID Connect + +## Schéma des options + +En plus de leurs options spécifiques tous les layers `authn-*` partagent un certain nombre d'options communes. + +Voir le [schéma](../../../../../internal/proxy/director/layer/authn/layer-options.json). + +## Règles d'injection d'entêtes + +L'option `headers.rules` permet de définir une liste de règles utilisant un DSL permettant de définir de manière dynamique quels entêtes seront injectés dans la requête transitant par le layer à destination du service distant. + +La liste des instructions est exécutée séquentiellement. + +Bouncer utilise le projet [`expr`](https://expr-lang.org/) comme DSL. En plus des fonctionnalités natives du langage, Bouncer ajoute un certain nombre de fonction spécifique à son contexte. + +Le comportement des règles par défaut est le suivant: + +1. L'ensemble des entêtes HTTP correspondant au patron `Remote-*` sont supprimés ; +2. L'identifiant de l'utilisateur identifié (`user.subject`) est exporté sous la forme de l'entête HTTP `Remote-User` ; +3. L'ensemble des attributs de l'utilisateur identifié (`user.attrs`) sont exportés sous la forme `Remote-User-Attr-` où `` est le nom de l'attribut en minuscule, avec les `_` transformés en `-`. + +### Fonctions + +#### `set_header(name string, value string)` + +Définir la valeur d'une entête HTTP via son nom `name` et sa valeur `value`. + +#### `del_headers(pattern string)` + +Supprimer un ou plusieurs entêtes HTTP dont le nom correspond au patron `pattern`. + +Le patron est défini par une chaîne comprenant un ou plusieurs caractères `*`, signifiant un ou plusieurs caractères arbitraires. + +### Environnement + +Les règles ont accès aux variables suivantes pendant leur exécution. + +#### `user` + +L'utilisateur identifié par le layer. + +```json +{ + // Identifiant de l'utilisateur, tel que récupéré par le layer + "subject": "", + // Table associative des attributs associés à l'utilisateur + // La liste de ces attributs dépend du layer d'authentification + "attrs": { + "key": "" + } +} +``` diff --git a/doc/fr/references/layers/authn/oidc.md b/doc/fr/references/layers/authn/oidc.md new file mode 100644 index 0000000..2ca44b6 --- /dev/null +++ b/doc/fr/references/layers/authn/oidc.md @@ -0,0 +1,21 @@ +# Layer `authn-oidc` + +## Description + +Ce layer permet d'ajouter une authentification OpenID Connect au service distant. + +Voir le tutoriel ["Ajouter une authentification OpenID Connect"](../../../tutorials/add-oidc-authn-layer.md) pour plus d'informations quant à son utilisation. + +## Type + +`authn-oidc` + +## Schéma des options + +Les options disponibles pour le layer sont décrites via un [schéma JSON](https://json-schema.org/specification). Elles sont documentées dans le [schéma visible ici](../../../../../internal/proxy/director/layer/authn/oidc/layer-options.json). + +En plus de ces options spécifiques le layer peut également être configuré via [les options communes aux layers `authn-*`](../../../../../internal/proxy/director/layer/authn/layer-options.json). + +## Métriques + +_Aucune [métrique Prometheus](../metrics.md) n'est exportée par ce layer._ diff --git a/doc/fr/tutorials/add-oidc-authn-layer.md b/doc/fr/tutorials/add-oidc-authn-layer.md new file mode 100644 index 0000000..609ea55 --- /dev/null +++ b/doc/fr/tutorials/add-oidc-authn-layer.md @@ -0,0 +1,62 @@ +# Ajouter une authentification OpenID Connect + +Dans ce tutoriel nous verrons comment ajouter un layer de type `oidc-authn` à un proxy pour ajouter une authentification OpenID Connect à notre service distant. + +## Prérequis + +Pour réaliser ce tutoriel nous allons utiliser la forge Cadoles comme fournisseur d'identité. Vous devrez donc créer une application OAuth2 avec votre compte Cadoles sur https://forge.cadoles.com/user/settings/applications et collecter les informations suivantes: + +- Identifiant du client ; +- Secret du client. + +Concernant l'URL de redirection, si vous ne modifiez pas l'option `oidc.loginCallbackPath` vous devrez renseigner une URL répondant au modèle suivant: + +``` +/.bouncer/authn/oidc///callback +``` + +Où + +- `` est l'URL de base d'accès à votre instance Bouncer, par exemple `http://localhost:8080` si vous avez travaillez avec une instance Bouncer locale avec la configuration par défaut ; +- `` est le nom du proxy créé dans Bouncer, dans ce tutoriel `my-proxy` ; +- `` est le nom du layer créé dans Bouncer, dans ce tutoriel `my-layer`. + +## Étapes + +1. Avec le client d'administration de Bouncer en ligne de commande, créer un nouveau proxy + + ```shell + bouncer admin proxy create --proxy-name my-proxy --proxy-to https://www.cadoles.com + ``` + +2. Activer le proxy `my-proxy` + + ```shell + bouncer admin proxy update --proxy-name my-proxy --proxy-enabled + ``` + +3. À ce stade, vous devriez pouvoir afficher le site Cadoles en ouvrant l'URL de votre instance Bouncer, par exemple `http://localhost:8080` si vous avez travaillez avec une instance Bouncer locale avec la configuration par défaut + +4. Créer un layer de type `authn-oidc` pour notre nouveau proxy + + ```shell + bouncer admin layer create --proxy-name my-proxy --layer-name my-layer --layer-type authn-oidc + ``` + +5. Configurer le nouveau layer `my-layer` avec les options collectée dans les prérequis et l'activer + + ```shell + bouncer admin layer update --proxy-name my-proxy --layer-name my-layer --layer-options '{ "oidc":{"clientId": "", "clientSecret":"", "issuerURL": "https://forge.cadoles.com/", "postLogoutRedirectURL": "" }}' --layer-enabled + ``` + + Où: + + - `` est l'identifiant du client OIDC récupéré dans les prérequis ; + - `` est le secret du client OIDC récupéré dans les prérequis ; + - `` est l'URL de redirection après déconnexion, par exemple `http://localhost:8080` si vous avez travaillez avec une instance Bouncer locale avec la configuration par défaut. + +6. À ce stade en ouvrant l'URL de votre instance Bouncer vous devriez être redirigé vers la forge Cadoles vous demandant de vous authentifier. + +## Ressources + +- [Référence du layer `authn-oidc`](../../fr/references/layers/authn/oidc.md) diff --git a/go.mod b/go.mod index e17b607..a41a5ca 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,13 @@ require ( github.com/Masterminds/sprig/v3 v3.2.3 github.com/bsm/redislock v0.9.4 github.com/btcsuite/btcd/btcutil v1.1.3 + github.com/coreos/go-oidc/v3 v3.10.0 + github.com/dchest/uniuri v1.2.0 github.com/drone/envsubst v1.0.3 + github.com/expr-lang/expr v1.16.7 github.com/getsentry/sentry-go v0.22.0 github.com/go-chi/chi/v5 v5.0.8 + github.com/gorilla/sessions v1.2.2 github.com/jedib0t/go-pretty/v6 v6.4.6 github.com/mitchellh/mapstructure v1.4.1 github.com/oklog/ulid/v2 v2.1.0 @@ -19,6 +23,7 @@ require ( github.com/prometheus/client_golang v1.16.0 github.com/qri-io/jsonschema v0.2.1 github.com/redis/go-redis/v9 v9.0.4 + golang.org/x/oauth2 v0.13.0 k8s.io/api v0.29.3 k8s.io/apimachinery v0.29.3 k8s.io/client-go v0.29.3 @@ -42,6 +47,7 @@ require ( github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.4.0 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.1 // indirect github.com/go-logr/logr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect @@ -51,6 +57,7 @@ require ( github.com/google/gnostic-models v0.6.8 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/huandu/xstrings v1.3.3 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -80,10 +87,9 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect golang.org/x/net v0.19.0 // indirect - golang.org/x/oauth2 v0.10.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.3.0 // indirect - google.golang.org/appengine v1.6.7 // indirect + google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect @@ -126,10 +132,10 @@ require ( github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect gitlab.com/wpetit/goweb v0.0.0-20230419082146-a94d9ed7202b go.opencensus.io v0.24.0 // indirect - golang.org/x/crypto v0.17.0 // indirect + golang.org/x/crypto v0.19.0 // indirect golang.org/x/mod v0.14.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/term v0.15.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/term v0.17.0 // indirect golang.org/x/tools v0.16.1 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect gopkg.in/go-playground/validator.v9 v9.29.1 // indirect diff --git a/go.sum b/go.sum index 99aee0e..9fed90b 100644 --- a/go.sum +++ b/go.sum @@ -132,6 +132,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= +github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU= +github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= @@ -146,6 +148,8 @@ github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= +github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= @@ -179,6 +183,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/expr-lang/expr v1.16.7 h1:gCIiHt5ODA0xIaDbD0DPKyZpM9Drph3b3lolYAYq2Kw= +github.com/expr-lang/expr v1.16.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= @@ -199,6 +205,8 @@ github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= +github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= @@ -317,7 +325,11 @@ github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAk github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= +github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -547,8 +559,8 @@ golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -647,8 +659,8 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= -golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= +golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= +golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -734,13 +746,13 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -750,6 +762,7 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= @@ -857,8 +870,9 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= diff --git a/internal/admin/bootstrap.go b/internal/admin/bootstrap.go index 0e49f1e..a39eefe 100644 --- a/internal/admin/bootstrap.go +++ b/internal/admin/bootstrap.go @@ -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) diff --git a/internal/admin/proxy_route.go b/internal/admin/proxy_route.go index e052c89..5ac304e 100644 --- a/internal/admin/proxy_route.go +++ b/internal/admin/proxy_route.go @@ -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, }) diff --git a/internal/admin/util.go b/internal/admin/util.go new file mode 100644 index 0000000..34d081f --- /dev/null +++ b/internal/admin/util.go @@ -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 +} diff --git a/internal/config/bootstrap.go b/internal/config/bootstrap.go index c113250..e1a2b67 100644 --- a/internal/config/bootstrap.go +++ b/internal/config/bootstrap.go @@ -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 { diff --git a/internal/proxy/director/context.go b/internal/proxy/director/context.go index 1b00a5e..3dc1868 100644 --- a/internal/proxy/director/context.go +++ b/internal/proxy/director/context.go @@ -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) } diff --git a/internal/proxy/director/director.go b/internal/proxy/director/director.go index 2bc4096..5deab83 100644 --- a/internal/proxy/director/director.go +++ b/internal/proxy/director/director.go @@ -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 diff --git a/internal/proxy/director/layer/authn/authenticator.go b/internal/proxy/director/layer/authn/authenticator.go new file mode 100644 index 0000000..a448f66 --- /dev/null +++ b/internal/proxy/director/layer/authn/authenticator.go @@ -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 +} diff --git a/internal/proxy/director/layer/authn/header.go b/internal/proxy/director/layer/authn/header.go new file mode 100644 index 0000000..71c9fe3 --- /dev/null +++ b/internal/proxy/director/layer/authn/header.go @@ -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 +} diff --git a/internal/proxy/director/layer/authn/layer-options.json b/internal/proxy/director/layer/authn/layer-options.json new file mode 100644 index 0000000..58fbde4 --- /dev/null +++ b/internal/proxy/director/layer/authn/layer-options.json @@ -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 + } + } +} \ No newline at end of file diff --git a/internal/proxy/director/layer/authn/layer.go b/internal/proxy/director/layer/authn/layer.go new file mode 100644 index 0000000..afc05eb --- /dev/null +++ b/internal/proxy/director/layer/authn/layer.go @@ -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{} diff --git a/internal/proxy/director/layer/authn/layer_options.go b/internal/proxy/director/layer/authn/layer_options.go new file mode 100644 index 0000000..a2e06ad --- /dev/null +++ b/internal/proxy/director/layer/authn/layer_options.go @@ -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 + } +} diff --git a/internal/proxy/director/layer/authn/oidc/authenticator.go b/internal/proxy/director/layer/authn/oidc/authenticator.go new file mode 100644 index 0000000..a3b67b1 --- /dev/null +++ b/internal/proxy/director/layer/authn/oidc/authenticator.go @@ -0,0 +1,257 @@ +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" + "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) + } + + case logoutURL.Path: + if err := client.HandleLogout(w, r, sess, options.OIDC.PostLogoutRedirectURL); err != nil { + return errors.WithStack(err) + } + } + + 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) { + 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{} +) diff --git a/internal/proxy/director/layer/authn/oidc/client.go b/internal/proxy/director/layer/authn/oidc/client.go new file mode 100644 index 0000000..417fb97 --- /dev/null +++ b/internal/proxy/director/layer/authn/oidc/client.go @@ -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, + } +} diff --git a/internal/proxy/director/layer/authn/oidc/client_options.go b/internal/proxy/director/layer/authn/oidc/client_options.go new file mode 100644 index 0000000..2c7b53e --- /dev/null +++ b/internal/proxy/director/layer/authn/oidc/client_options.go @@ -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 +} diff --git a/internal/proxy/director/layer/authn/oidc/layer-options.json b/internal/proxy/director/layer/authn/oidc/layer-options.json new file mode 100644 index 0000000..da5c8d8 --- /dev/null +++ b/internal/proxy/director/layer/authn/oidc/layer-options.json @@ -0,0 +1,108 @@ +{ + "$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 '/'.", + "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 '/'.", + "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", + "postLogoutRedirectURL" + ] + }, + "cookie": { + "title": "Configuration du cookie porteur de la session utilisateur", + "type": "object", + "properties": { + "name": { + "title": "Nom du cookie", + "default": "", + "type": "string" + }, + "domain": { + "type": "string" + }, + "path": { + "type": "string" + }, + "sameSite": { + "type": "string" + }, + "httpOnly": { + "type": "boolean" + }, + "secure": { + "type": "boolean" + }, + "maxAge": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "oidc" + ] +} \ No newline at end of file diff --git a/internal/proxy/director/layer/authn/oidc/layer.go b/internal/proxy/director/layer/authn/oidc/layer.go new file mode 100644 index 0000000..4a0d87c --- /dev/null +++ b/internal/proxy/director/layer/authn/oidc/layer.go @@ -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}) +} diff --git a/internal/proxy/director/layer/authn/oidc/layer_options.go b/internal/proxy/director/layer/authn/oidc/layer_options.go new file mode 100644 index 0000000..4317840 --- /dev/null +++ b/internal/proxy/director/layer/authn/oidc/layer_options.go @@ -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 +} diff --git a/internal/proxy/director/layer/authn/oidc/schema.go b/internal/proxy/director/layer/authn/oidc/schema.go new file mode 100644 index 0000000..a020e6d --- /dev/null +++ b/internal/proxy/director/layer/authn/oidc/schema.go @@ -0,0 +1,8 @@ +package oidc + +import ( + _ "embed" +) + +//go:embed layer-options.json +var RawLayerOptionsSchema []byte diff --git a/internal/proxy/director/layer/authn/schema.go b/internal/proxy/director/layer/authn/schema.go new file mode 100644 index 0000000..10d4a95 --- /dev/null +++ b/internal/proxy/director/layer/authn/schema.go @@ -0,0 +1,8 @@ +package authn + +import ( + _ "embed" +) + +//go:embed layer-options.json +var RawLayerOptionsSchema []byte diff --git a/internal/proxy/director/layer/authn/user.go b/internal/proxy/director/layer/authn/user.go new file mode 100644 index 0000000..551af83 --- /dev/null +++ b/internal/proxy/director/layer/authn/user.go @@ -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, + } +} diff --git a/internal/proxy/director/layer/queue/queue.go b/internal/proxy/director/layer/queue/queue.go index f5c8f10..fa72c34 100644 --- a/internal/proxy/director/layer/queue/queue.go +++ b/internal/proxy/director/layer/queue/queue.go @@ -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 { diff --git a/internal/schema/extend.go b/internal/schema/extend.go new file mode 100644 index 0000000..2ea1dae --- /dev/null +++ b/internal/schema/extend.go @@ -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 +} diff --git a/internal/session/adapter/redis/adapter.go b/internal/session/adapter/redis/adapter.go new file mode 100644 index 0000000..97cf09d --- /dev/null +++ b/internal/session/adapter/redis/adapter.go @@ -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{} +) diff --git a/internal/session/options.go b/internal/session/options.go new file mode 100644 index 0000000..7110beb --- /dev/null +++ b/internal/session/options.go @@ -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 + } +} diff --git a/internal/session/store.go b/internal/session/store.go new file mode 100644 index 0000000..5103bd3 --- /dev/null +++ b/internal/session/store.go @@ -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 +} diff --git a/internal/setup/authn_oidc_layer.go b/internal/setup/authn_oidc_layer.go new file mode 100644 index 0000000..41ca45e --- /dev/null +++ b/internal/setup/authn_oidc_layer.go @@ -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 +} diff --git a/misc/bootstrap.d/dummy.yml b/misc/bootstrap.d/dummy.yml new file mode 100644 index 0000000..558f798 --- /dev/null +++ b/misc/bootstrap.d/dummy.yml @@ -0,0 +1,27 @@ +from: ["*"] +to: http://localhost:8082 +enabled: true +weight: 0 +recreate: true +layers: + my-queue: + type: queue + enabled: true + weight: 100 + options: + capacity: 100 + # oidc: + # type: authn-oidc + # enabled: true + # weight: 0 + # options: + # oidc: + # clientId: my-client-id + # clientSecret: my-client-id + # issuerURL: https://forge.cadoles.com/ + # postLogoutRedirectURL: http://localhost:8080 + # scopes: ["profile", "openid", "email"] + # authParams: + # acr_values: "eidas2" + # cookie: + # maxAge: 60m diff --git a/modd.conf b/modd.conf index 1873e0c..91e9c83 100644 --- a/modd.conf +++ b/modd.conf @@ -1,17 +1,3 @@ -**/*.go -internal/**/*.json -modd.conf -config.yml -layers/** -.env { - prep: make RUN_INSTALL_TESTS=no GOTEST_ARGS="-short" test - prep: make build-bouncer - prep: make config.yml - prep: make .bouncer-token - daemon: make run BOUNCER_CMD="--debug --config config.yml server admin run" - daemon: make run BOUNCER_CMD="--debug --config config.yml server proxy run" -} - { daemon +sigint: make run-redis } @@ -19,4 +5,26 @@ layers/** misc/prometheus/prometheus.yml { daemon +sigint: make run-prometheus -} \ No newline at end of file +} + + +./cmd/dummy/*.go +./cmd/dummy/*.gohtml +{ + daemon: go run ./cmd/dummy +} + +**/*.go +internal/**/*.json +modd.conf +config.yml +layers/** +data/bootstrap.d/*.yml +.env { + prep: make RUN_INSTALL_TESTS=no GOTEST_ARGS="-short" test + prep: make build-bouncer + prep: make config.yml + prep: make .bouncer-token + daemon: make run BOUNCER_CMD="--debug --config config.yml server admin run" + daemon: make run BOUNCER_CMD="--debug --config config.yml server proxy run" +}