Création d'un layer d'authentification OpenID Connect #20

Merged
wpetit merged 1 commits from authn-oidc into develop 2024-05-17 11:53:52 +02:00
42 changed files with 2155 additions and 62 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
- [Circuit Breaker](./circuitbreaker.md) - Coupure d'accès à un site ou une sous section de celui ci

View File

@ -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-<name>``<name>` 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": "<string>",
// Table associative des attributs associés à l'utilisateur
// La liste de ces attributs dépend du layer d'authentification
"attrs": {
"key": "<value>"
}
}
```

View File

@ -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_<name>` (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=<layerName>,proxy=<proxyName>}`
- **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=<layerName>,proxy=<proxyName>}`
- **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=<layerName>,proxy=<proxyName>}`
- **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
```

View File

@ -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:
```
<base_url>/.bouncer/authn/oidc/<proxy_name>/<layer_name>/callback
```
- `<base_url>` 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 ;
- `<proxy_name>` est le nom du proxy créé dans Bouncer, dans ce tutoriel `my-proxy` ;
- `<layer_name>` 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": "<clientId>", "clientSecret":"<clientSecret>", "issuerURL": "https://forge.cadoles.com/" }}' --layer-enabled
```
Où:
- `<clientId>` est l'identifiant du client OIDC récupéré dans les prérequis ;
- `<clientSecret>` 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)

16
go.mod
View File

@ -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

32
go.sum
View File

@ -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=

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

@ -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

View File

@ -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
}
}
**/*.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"
}