From 43841fbdb9f0088ecb2e7a9f812e5741af45ac6d 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 +- 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 | 72 +++++ doc/fr/tutorials/add-oidc-authn-layer.md | 81 +++++ go.mod | 16 +- go.sum | 32 +- internal/admin/bootstrap.go | 16 +- internal/admin/proxy_route.go | 19 +- internal/admin/util.go | 29 ++ internal/command/server/dummy/index.gohtml | 65 ++++ internal/command/server/dummy/root.go | 15 + internal/command/server/dummy/run.go | 69 +++++ internal/command/server/root.go | 2 + 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 | 278 +++++++++++++++++ .../proxy/director/layer/authn/oidc/client.go | 291 ++++++++++++++++++ .../layer/authn/oidc/client_options.go | 76 +++++ .../layer/authn/oidc/layer-options.json | 127 ++++++++ .../proxy/director/layer/authn/oidc/layer.go | 13 + .../layer/authn/oidc/layer_options.go | 62 ++++ .../director/layer/authn/oidc/metrics.go | 39 +++ .../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 | 34 +- 42 files changed, 2155 insertions(+), 62 deletions(-) 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/command/server/dummy/index.gohtml create mode 100644 internal/command/server/dummy/root.go create mode 100644 internal/command/server/dummy/run.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/metrics.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/doc/README.md b/doc/README.md index e41fafc..5f6e39d 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-oidc-authn-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..bfa2871 --- /dev/null +++ b/doc/fr/references/layers/authn/oidc.md @@ -0,0 +1,72 @@ +# 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). + +## Objet `user` et attributs + +L'objet `user` exposé au moteur de règles sera construit de la manière suivante: + +- `user.subject` sera valué avec la valeur [claim](https://openid.net/specs/openid-connect-core-1_0.html#Claims) `sub` extrait de l'`idToken` récupéré lors de l'authentification ; +- `user.attrs` comportera les propriétés suivantes: + + - L'ensemble des `claims` provenant de l'`idToken` seront transposés en `claim_` (ex: `idToken.iss` sera transposé en `user.attrs.claim_iss`) ; + - `user.attrs.access_token`: le jeton d'accès associé à l'authentification ; + - `user.attrs.refresh_token`: le jeton de rafraîchissement associé à l'authentification (si disponible, en fonction des `scopes` demandés par le client) ; + - `user.attrs.token_expiry`: Horodatage Unix (en secondes) associé à la date d'expiration du jeton d'accès ; + - `user.attrs.logout_url`: URL de déconnexion pour la suppression de la session Bouncer. + + **Attention** Cette URL ne permet dans la plupart des cas que de supprimer la session côté Bouncer. La suppression de la session côté fournisseur d'identité est conditionné à la présence ou non de l'attribut [`end_session_endpoint`](https://openid.net/specs/openid-connect-session-1_0-17.html#OPMetadata) dans les données du endpoint `.wellknown/openid-configuration`. + +## Métriques + +Les [métriques Prometheus](../../metrics.md) suivantes sont exposées par ce layer. + +### `bouncer_layer_authn_oidc_login_requests_total{layer=,proxy=}` + +- **Type:** `counter` +- **Description**: Nombre total de demandes d'authentification +- **Exemple** + + ``` + # HELP bouncer_layer_authn_oidc_login_requests_total Bouncer's authn-oidc layer total login requests + # TYPE bouncer_layer_authn_oidc_login_requests_total counter + bouncer_layer_authn_oidc_login_requests_total{layer="my-layer",proxy="my-proxy"} 1 + ``` + +### `bouncer_layer_authn_oidc_login_successes_total{layer=,proxy=}` + +- **Type:** `counter` +- **Description**: Nombre total d'authentifications réussies +- **Exemple** + + ``` + # HELP bouncer_layer_authn_oidc_login_successes_total Bouncer's authn-oidc layer total login successes + # TYPE bouncer_layer_authn_oidc_login_successes_total counter + bouncer_layer_authn_oidc_login_successes_total{layer="my-layer",proxy="my-proxy"} 1 + ``` + +### `bouncer_layer_authn_oidc_logout_total{layer=,proxy=}` + +- **Type:** `counter` +- **Description**: Nombre total de déconnexions +- **Exemple** + + ``` + # HELP bouncer_layer_authn_oidc_logout_total Bouncer's authn-oidc layer total logouts + # TYPE bouncer_layer_authn_oidc_logout_total counter + bouncer_layer_authn_oidc_logout_total{layer="my-layer",proxy="my-proxy"} 1 + ``` 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..6a80fbc --- /dev/null +++ b/doc/fr/tutorials/add-oidc-authn-layer.md @@ -0,0 +1,81 @@ +# 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 + +### Création d'une application OAuth2 + +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`. + +### Démarrer le serveur `dummy` pour l'introspection des entêtes reçus + +Bouncer intègre un serveur de test qui permet l'introspection des entêtes HTTP reçus dans la requête. Nous allons utiliser celui ci comme service distant afin de visualiser les entêtes générés par notre layer d'authentification. + +Pour le lancer: + +```shell +# Avec le binaire +bouncer server dummy run + +# Avec Docker +docker run -it --rm -p 8082:8082 --read-only reg.cadoles.com/cadoles/bouncer:latest bouncer server dummy run +``` + +Par défaut ce serveur écoute sur le port 8082. Il est possible de modifier l'adresse d'écoute via le drapeau `--address`. + +## É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 http://localhost:8082 + ``` + + Où http://localhost:8082 est l'adresse de notre serveur `dummy` de test, lancé dans les prérequis. + +2. Activer le proxy `my-proxy` + + ```shell + bouncer admin proxy update --proxy-name my-proxy --proxy-enabled + ``` + +3. À ce stade, vous devriez pouvoir afficher la page du serveur `dummy` 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/" }}' --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. + +6. À ce stade en ouvrant l'URL de votre instance Bouncer vous devriez être redirigé vers la forge Cadoles vous demandant de vous authentifier. Une fois authentifié vous devriez arriver sur la page du serveur `dummy` avec les nouveaux entêtes liés à votre authentification (entêtes `Remote-User-*`). + +## 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/command/server/dummy/index.gohtml b/internal/command/server/dummy/index.gohtml new file mode 100644 index 0000000..53a2b5e --- /dev/null +++ b/internal/command/server/dummy/index.gohtml @@ -0,0 +1,65 @@ + + +

Received request

+

Incoming headers

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

Incoming cookies

+ + + + + + + + + + + + + + + + {{ range $cookie := .Request.Cookies }} + + + + + + + + + + + + {{ + end + }} + +
NameDomainPathSecureMaxAgeHttpOnlySameSiteExpiresValue
+ {{ $cookie.Name }} + {{ $cookie.Domain }}{{ $cookie.Path }}{{ $cookie.Secure }}{{ $cookie.MaxAge }}{{ $cookie.HttpOnly }}{{ $cookie.SameSite }}{{ $cookie.Expires }} + {{ $cookie.Value }} +
+ + diff --git a/internal/command/server/dummy/root.go b/internal/command/server/dummy/root.go new file mode 100644 index 0000000..6835270 --- /dev/null +++ b/internal/command/server/dummy/root.go @@ -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(), + }, + } +} diff --git a/internal/command/server/dummy/run.go b/internal/command/server/dummy/run.go new file mode 100644 index 0000000..1d8f9ea --- /dev/null +++ b/internal/command/server/dummy/run.go @@ -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 + }, + } +} diff --git a/internal/command/server/root.go b/internal/command/server/root.go index 2d5d5ce..3b840fd 100644 --- a/internal/command/server/root.go +++ b/internal/command/server/root.go @@ -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(), }, } } 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..d4c7b19 --- /dev/null +++ b/internal/proxy/director/layer/authn/oidc/authenticator.go @@ -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{} +) 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..04ec085 --- /dev/null +++ b/internal/proxy/director/layer/authn/oidc/layer-options.json @@ -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 '/'.", + "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" + ] + }, + "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" + ] +} \ 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/metrics.go b/internal/proxy/director/layer/authn/oidc/metrics.go new file mode 100644 index 0000000..80ccb86 --- /dev/null +++ b/internal/proxy/director/layer/authn/oidc/metrics.go @@ -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}, + ) +) 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..f6423cc 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,22 @@ layers/** misc/prometheus/prometheus.yml { daemon +sigint: make run-prometheus -} \ No newline at end of file +} + + +**/*.go +**/*.gohtml +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 dummy run" + daemon: make run BOUNCER_CMD="--debug --config config.yml server admin run" + daemon: make run BOUNCER_CMD="--debug --config config.yml server proxy run" +}