feat: new openid connect authentication layer
Some checks are pending
Cadoles/bouncer/pipeline/pr-develop Build started...
Some checks are pending
Cadoles/bouncer/pipeline/pr-develop Build started...
This commit is contained in:
parent
bb5796ab8c
commit
de70fa89f7
@ -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
|
||||
|
6
Makefile
6
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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
61
doc/fr/references/layers/authn/README.md
Normal file
61
doc/fr/references/layers/authn/README.md
Normal 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>` où `<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>"
|
||||
}
|
||||
}
|
||||
```
|
72
doc/fr/references/layers/authn/oidc.md
Normal file
72
doc/fr/references/layers/authn/oidc.md
Normal 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
|
||||
```
|
81
doc/fr/tutorials/add-oidc-authn-layer.md
Normal file
81
doc/fr/tutorials/add-oidc-authn-layer.md
Normal 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
|
||||
```
|
||||
|
||||
Où
|
||||
|
||||
- `<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
16
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
|
||||
|
32
go.sum
32
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=
|
||||
|
@ -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)
|
||||
|
@ -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
29
internal/admin/util.go
Normal 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
|
||||
}
|
65
internal/command/server/dummy/index.gohtml
Normal file
65
internal/command/server/dummy/index.gohtml
Normal 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>
|
15
internal/command/server/dummy/root.go
Normal file
15
internal/command/server/dummy/root.go
Normal 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(),
|
||||
},
|
||||
}
|
||||
}
|
69
internal/command/server/dummy/run.go
Normal file
69
internal/command/server/dummy/run.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
26
internal/proxy/director/layer/authn/authenticator.go
Normal file
26
internal/proxy/director/layer/authn/authenticator.go
Normal 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
|
||||
}
|
94
internal/proxy/director/layer/authn/header.go
Normal file
94
internal/proxy/director/layer/authn/header.go
Normal 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
|
||||
}
|
38
internal/proxy/director/layer/authn/layer-options.json
Normal file
38
internal/proxy/director/layer/authn/layer-options.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
103
internal/proxy/director/layer/authn/layer.go
Normal file
103
internal/proxy/director/layer/authn/layer.go
Normal 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{}
|
92
internal/proxy/director/layer/authn/layer_options.go
Normal file
92
internal/proxy/director/layer/authn/layer_options.go
Normal 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
|
||||
}
|
||||
}
|
278
internal/proxy/director/layer/authn/oidc/authenticator.go
Normal file
278
internal/proxy/director/layer/authn/oidc/authenticator.go
Normal 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{}
|
||||
)
|
291
internal/proxy/director/layer/authn/oidc/client.go
Normal file
291
internal/proxy/director/layer/authn/oidc/client.go
Normal 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,
|
||||
}
|
||||
}
|
76
internal/proxy/director/layer/authn/oidc/client_options.go
Normal file
76
internal/proxy/director/layer/authn/oidc/client_options.go
Normal 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
|
||||
}
|
127
internal/proxy/director/layer/authn/oidc/layer-options.json
Normal file
127
internal/proxy/director/layer/authn/oidc/layer-options.json
Normal 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"
|
||||
]
|
||||
}
|
13
internal/proxy/director/layer/authn/oidc/layer.go
Normal file
13
internal/proxy/director/layer/authn/oidc/layer.go
Normal 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})
|
||||
}
|
62
internal/proxy/director/layer/authn/oidc/layer_options.go
Normal file
62
internal/proxy/director/layer/authn/oidc/layer_options.go
Normal 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
|
||||
}
|
39
internal/proxy/director/layer/authn/oidc/metrics.go
Normal file
39
internal/proxy/director/layer/authn/oidc/metrics.go
Normal 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},
|
||||
)
|
||||
)
|
8
internal/proxy/director/layer/authn/oidc/schema.go
Normal file
8
internal/proxy/director/layer/authn/oidc/schema.go
Normal file
@ -0,0 +1,8 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed layer-options.json
|
||||
var RawLayerOptionsSchema []byte
|
8
internal/proxy/director/layer/authn/schema.go
Normal file
8
internal/proxy/director/layer/authn/schema.go
Normal file
@ -0,0 +1,8 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed layer-options.json
|
||||
var RawLayerOptionsSchema []byte
|
17
internal/proxy/director/layer/authn/user.go
Normal file
17
internal/proxy/director/layer/authn/user.go
Normal 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,
|
||||
}
|
||||
}
|
@ -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
39
internal/schema/extend.go
Normal 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
|
||||
}
|
62
internal/session/adapter/redis/adapter.go
Normal file
62
internal/session/adapter/redis/adapter.go
Normal 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{}
|
||||
)
|
47
internal/session/options.go
Normal file
47
internal/session/options.go
Normal 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
182
internal/session/store.go
Normal 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
|
||||
}
|
29
internal/setup/authn_oidc_layer.go
Normal file
29
internal/setup/authn_oidc_layer.go
Normal 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
|
||||
}
|
27
misc/bootstrap.d/dummy.yml
Normal file
27
misc/bootstrap.d/dummy.yml
Normal 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
|
34
modd.conf
34
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
**/*.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"
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user