Authentification OIDC : flux de connexion et protection des routes
internal/auth/oidc.go : découverte, /auth/login (PKCE S256, state, nonce), /auth/callback (échange du code, vérification de l'ID Token, groupes), /auth/logout (RP-Initiated Logout), middleware de session. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018GBsBpHJW7GxFDBV7z1zmo
This commit is contained in:
1 parent
7397f0f789
commit
a069e6a35a
1 file changed
+549
@@ -0,0 +1,549 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookie = "excalicloud_session"
|
||||||
|
stateCookie = "excalicloud_oidc"
|
||||||
|
stateTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// LoginHeader accompagne les réponses 401 de l'API : le frontend y lit
|
||||||
|
// l'adresse de connexion.
|
||||||
|
LoginHeader = "X-Auth-Login"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config regroupe les réglages OIDC (variables OIDC_* et SESSION_*).
|
||||||
|
type Config struct {
|
||||||
|
Issuer string // OIDC_ISSUER
|
||||||
|
ClientID string // OIDC_CLIENT_ID
|
||||||
|
ClientSecret string // OIDC_CLIENT_SECRET (vide : client public, PKCE seul)
|
||||||
|
RedirectURL string // OIDC_REDIRECT_URL (vide : https://<hôte>/auth/callback)
|
||||||
|
Scopes []string // OIDC_SCOPES
|
||||||
|
// AllowedGroups restreint l'accès aux membres d'au moins un de ces groupes
|
||||||
|
// (claim GroupsClaim) ; vide : tout utilisateur authentifié.
|
||||||
|
AllowedGroups []string
|
||||||
|
GroupsClaim string // OIDC_GROUPS_CLAIM, « groups » par défaut
|
||||||
|
EndSession bool // OIDC_LOGOUT : déconnexion aussi chez le fournisseur
|
||||||
|
SessionTTL time.Duration
|
||||||
|
Secret []byte // clé HMAC des cookies
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// discovery est la partie utile de /.well-known/openid-configuration.
|
||||||
|
type discovery struct {
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||||
|
TokenEndpoint string `json:"token_endpoint"`
|
||||||
|
JWKSURI string `json:"jwks_uri"`
|
||||||
|
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDC authentifie les utilisateurs auprès d'un fournisseur OpenID Connect
|
||||||
|
// (Authentik, Keycloak, Entra ID, Google, Dex...).
|
||||||
|
type OIDC struct {
|
||||||
|
cfg Config
|
||||||
|
signer signer
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
disc *discovery // nil : pas encore découvert
|
||||||
|
keys map[string]crypto.PublicKey
|
||||||
|
keysAt time.Time
|
||||||
|
discErr time.Time // dernier échec de découverte (limite les tentatives)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOIDC prépare l'authentification ; la découverte du fournisseur est
|
||||||
|
// tentée tout de suite puis, en cas d'échec, à la première connexion.
|
||||||
|
func NewOIDC(ctx context.Context, cfg Config) (*OIDC, error) {
|
||||||
|
if cfg.Issuer == "" || cfg.ClientID == "" {
|
||||||
|
return nil, errors.New("OIDC_ISSUER et OIDC_CLIENT_ID sont requis en mode oidc")
|
||||||
|
}
|
||||||
|
if len(cfg.Secret) < 16 {
|
||||||
|
return nil, errors.New("clé de session trop courte")
|
||||||
|
}
|
||||||
|
if cfg.RedirectURL != "" {
|
||||||
|
u, err := url.Parse(cfg.RedirectURL)
|
||||||
|
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||||
|
return nil, errors.New("OIDC_REDIRECT_URL invalide (URL absolue attendue)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cfg.Scopes) == 0 {
|
||||||
|
cfg.Scopes = []string{"openid", "profile", "email"}
|
||||||
|
}
|
||||||
|
if !slices.Contains(cfg.Scopes, "openid") {
|
||||||
|
cfg.Scopes = append([]string{"openid"}, cfg.Scopes...)
|
||||||
|
}
|
||||||
|
if cfg.GroupsClaim == "" {
|
||||||
|
cfg.GroupsClaim = "groups"
|
||||||
|
}
|
||||||
|
if cfg.SessionTTL <= 0 {
|
||||||
|
cfg.SessionTTL = 24 * time.Hour
|
||||||
|
}
|
||||||
|
if cfg.Client == nil {
|
||||||
|
cfg.Client = &http.Client{Timeout: 15 * time.Second}
|
||||||
|
}
|
||||||
|
a := &OIDC{cfg: cfg, signer: signer{cfg.Secret}}
|
||||||
|
if _, err := a.provider(ctx); err != nil {
|
||||||
|
slog.Warn("fournisseur OIDC injoignable, nouvel essai à la première connexion", "issuer", cfg.Issuer, "err", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *OIDC) getJSON(ctx context.Context, u string, v any) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
res, err := a.cfg.Client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("%s : HTTP %d", u, res.StatusCode)
|
||||||
|
}
|
||||||
|
return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// provider renvoie la configuration découverte (mise en cache).
|
||||||
|
func (a *OIDC) provider(ctx context.Context) (*discovery, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.disc != nil {
|
||||||
|
return a.disc, nil
|
||||||
|
}
|
||||||
|
if time.Since(a.discErr) < 5*time.Second {
|
||||||
|
return nil, errors.New("fournisseur OIDC injoignable")
|
||||||
|
}
|
||||||
|
var d discovery
|
||||||
|
u := strings.TrimRight(a.cfg.Issuer, "/") + "/.well-known/openid-configuration"
|
||||||
|
if err := a.getJSON(ctx, u, &d); err != nil {
|
||||||
|
a.discErr = time.Now()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimRight(d.Issuer, "/") != strings.TrimRight(a.cfg.Issuer, "/") {
|
||||||
|
a.discErr = time.Now()
|
||||||
|
return nil, fmt.Errorf("l'émetteur annoncé (%s) ne correspond pas à OIDC_ISSUER", d.Issuer)
|
||||||
|
}
|
||||||
|
if d.AuthorizationEndpoint == "" || d.TokenEndpoint == "" || d.JWKSURI == "" {
|
||||||
|
a.discErr = time.Now()
|
||||||
|
return nil, errors.New("configuration OIDC incomplète (authorization, token ou jwks absent)")
|
||||||
|
}
|
||||||
|
a.disc = &d
|
||||||
|
slog.Info("fournisseur OIDC découvert", "issuer", d.Issuer)
|
||||||
|
return a.disc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// key renvoie la clé publique kid, en rechargeant le JWKS si elle est
|
||||||
|
// inconnue (rotation de clés), au plus une fois par minute.
|
||||||
|
func (a *OIDC) key(ctx context.Context, d *discovery, kid, alg string) (crypto.PublicKey, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
find := func() crypto.PublicKey {
|
||||||
|
if k, ok := a.keys[kid]; ok {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
if kid == "" && len(a.keys) == 1 {
|
||||||
|
for _, k := range a.keys {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if k := find(); k != nil {
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
if time.Since(a.keysAt) < time.Minute && a.keys != nil {
|
||||||
|
return nil, fmt.Errorf("clé %q inconnue", kid)
|
||||||
|
}
|
||||||
|
var set struct {
|
||||||
|
Keys []jwk `json:"keys"`
|
||||||
|
}
|
||||||
|
if err := a.getJSON(ctx, d.JWKSURI, &set); err != nil {
|
||||||
|
return nil, fmt.Errorf("JWKS : %w", err)
|
||||||
|
}
|
||||||
|
a.keys = map[string]crypto.PublicKey{}
|
||||||
|
a.keysAt = time.Now()
|
||||||
|
for _, j := range set.Keys {
|
||||||
|
if j.Use != "" && j.Use != "sig" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pk, err := j.publicKey(); err == nil {
|
||||||
|
a.keys[j.Kid] = pk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if k := find(); k != nil {
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("clé %q absente du JWKS (alg %s)", kid, alg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Routes /auth/* -----------------------------------------------------------
|
||||||
|
|
||||||
|
// Register ajoute /auth/login, /auth/callback, /auth/logout et /auth/logged-out.
|
||||||
|
func (a *OIDC) Register(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /auth/login", a.login)
|
||||||
|
mux.HandleFunc("GET /auth/callback", a.callback)
|
||||||
|
mux.HandleFunc("GET /auth/logout", a.logout)
|
||||||
|
mux.HandleFunc("POST /auth/logout", a.logout)
|
||||||
|
mux.HandleFunc("GET /auth/logged-out", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
page(w, http.StatusOK, "Déconnecté", "Vous êtes déconnecté.", "/", "Se reconnecter")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func secure(r *http.Request) bool {
|
||||||
|
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseURL est l'origine publique du service : celle de OIDC_REDIRECT_URL si
|
||||||
|
// elle est fixée, sinon celle de la requête (en-têtes X-Forwarded-* compris).
|
||||||
|
func (a *OIDC) baseURL(r *http.Request) string {
|
||||||
|
if a.cfg.RedirectURL != "" {
|
||||||
|
u, _ := url.Parse(a.cfg.RedirectURL)
|
||||||
|
return u.Scheme + "://" + u.Host
|
||||||
|
}
|
||||||
|
scheme := "http"
|
||||||
|
if secure(r) {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
host := r.Host
|
||||||
|
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
|
||||||
|
host, _, _ = strings.Cut(h, ",")
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
}
|
||||||
|
return scheme + "://" + host
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *OIDC) redirectURL(r *http.Request) string {
|
||||||
|
if a.cfg.RedirectURL != "" {
|
||||||
|
return a.cfg.RedirectURL
|
||||||
|
}
|
||||||
|
return a.baseURL(r) + "/auth/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *OIDC) setCookie(w http.ResponseWriter, r *http.Request, name, value, path string, ttl time.Duration) {
|
||||||
|
c := &http.Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: value,
|
||||||
|
Path: path,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secure(r) || strings.HasPrefix(a.cfg.RedirectURL, "https://"),
|
||||||
|
SameSite: http.SameSiteLaxMode, // envoyé au retour du fournisseur (navigation GET)
|
||||||
|
}
|
||||||
|
if ttl < 0 {
|
||||||
|
c.MaxAge = -1
|
||||||
|
} else {
|
||||||
|
c.Expires = now().Add(ttl)
|
||||||
|
c.MaxAge = int(ttl / time.Second)
|
||||||
|
}
|
||||||
|
http.SetCookie(w, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeNext n'accepte que les chemins locaux (pas de redirection ouverte).
|
||||||
|
func safeNext(next string) string {
|
||||||
|
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") ||
|
||||||
|
strings.HasPrefix(next, "/auth/") || strings.ContainsAny(next, "\r\n") {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *OIDC) login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
d, err := a.provider(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("OIDC : découverte", "err", err)
|
||||||
|
page(w, http.StatusBadGateway, "Connexion impossible",
|
||||||
|
"Le fournisseur d'identité est injoignable pour l'instant.", r.URL.RequestURI(), "Réessayer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st := loginState{
|
||||||
|
State: randomString(24),
|
||||||
|
Nonce: randomString(24),
|
||||||
|
Verifier: randomString(48), // 64 caractères (RFC 7636 : 43 à 128)
|
||||||
|
Next: safeNext(r.URL.Query().Get("next")),
|
||||||
|
Exp: now().Add(stateTTL).Unix(),
|
||||||
|
}
|
||||||
|
v, err := a.signer.encode(st)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.setCookie(w, r, stateCookie, v, "/auth/", stateTTL)
|
||||||
|
|
||||||
|
challenge := sha256.Sum256([]byte(st.Verifier))
|
||||||
|
q := url.Values{
|
||||||
|
"response_type": {"code"},
|
||||||
|
"client_id": {a.cfg.ClientID},
|
||||||
|
"redirect_uri": {a.redirectURL(r)},
|
||||||
|
"scope": {strings.Join(a.cfg.Scopes, " ")},
|
||||||
|
"state": {st.State},
|
||||||
|
"nonce": {st.Nonce},
|
||||||
|
"code_challenge": {b64.EncodeToString(challenge[:])},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
}
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(d.AuthorizationEndpoint, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
http.Redirect(w, r, d.AuthorizationEndpoint+sep+q.Encode(), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *OIDC) callback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
q := r.URL.Query()
|
||||||
|
retry := "/auth/login"
|
||||||
|
var st loginState
|
||||||
|
c, err := r.Cookie(stateCookie)
|
||||||
|
if err == nil {
|
||||||
|
err = a.signer.decode(c.Value, &st)
|
||||||
|
}
|
||||||
|
if err == nil && st.Next != "/" {
|
||||||
|
retry += "?next=" + url.QueryEscape(st.Next)
|
||||||
|
}
|
||||||
|
a.setCookie(w, r, stateCookie, "", "/auth/", -1) // usage unique
|
||||||
|
if e := q.Get("error"); e != "" {
|
||||||
|
slog.Warn("OIDC : refus du fournisseur", "error", e, "description", q.Get("error_description"))
|
||||||
|
page(w, http.StatusForbidden, "Connexion refusée", "Le fournisseur d'identité a refusé la connexion ("+e+").", retry, "Réessayer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || st.Exp < now().Unix() || st.State == "" || q.Get("state") != st.State {
|
||||||
|
page(w, http.StatusBadRequest, "Connexion expirée",
|
||||||
|
"La demande de connexion est expirée ou invalide (cookies bloqués ?).", retry, "Recommencer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := a.exchange(r, q.Get("code"), st)
|
||||||
|
if errors.Is(err, errForbidden) {
|
||||||
|
slog.Warn("OIDC : accès refusé", "sub", user.Sub, "name", user.Name)
|
||||||
|
page(w, http.StatusForbidden, "Accès refusé",
|
||||||
|
"Votre compte ("+user.Name+") n'appartient à aucun groupe autorisé.", "/auth/logout", "Changer de compte")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("OIDC : échange du code", "err", err)
|
||||||
|
page(w, http.StatusBadGateway, "Connexion impossible", "La vérification de l'identité a échoué.", retry, "Réessayer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.startSession(w, r, user); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("connexion OIDC", "sub", user.Sub, "name", user.Name)
|
||||||
|
http.Redirect(w, r, st.Next, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errForbidden = errors.New("utilisateur hors des groupes autorisés")
|
||||||
|
|
||||||
|
// exchange échange le code contre les jetons, vérifie l'ID Token et en tire
|
||||||
|
// l'utilisateur.
|
||||||
|
func (a *OIDC) exchange(r *http.Request, code string, st loginState) (User, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
d, err := a.provider(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"code": {code},
|
||||||
|
"redirect_uri": {a.redirectURL(r)},
|
||||||
|
"code_verifier": {st.Verifier},
|
||||||
|
"client_id": {a.cfg.ClientID},
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.TokenEndpoint, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if a.cfg.ClientSecret != "" { // client_secret_basic (RFC 6749 2.3.1)
|
||||||
|
req.SetBasicAuth(url.QueryEscape(a.cfg.ClientID), url.QueryEscape(a.cfg.ClientSecret))
|
||||||
|
}
|
||||||
|
res, err := a.cfg.Client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
var tok struct {
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Desc string `json:"error_description"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&tok); err != nil {
|
||||||
|
return User{}, fmt.Errorf("réponse du token endpoint (HTTP %d) : %w", res.StatusCode, err)
|
||||||
|
}
|
||||||
|
if res.StatusCode != http.StatusOK || tok.IDToken == "" {
|
||||||
|
return User{}, fmt.Errorf("token endpoint : HTTP %d %s %s", res.StatusCode, tok.Error, tok.Desc)
|
||||||
|
}
|
||||||
|
return a.verifyIDToken(ctx, d, tok.IDToken, st.Nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *OIDC) verifyIDToken(ctx context.Context, d *discovery, raw, nonce string) (User, error) {
|
||||||
|
h, body, signed, sig, err := parseJWT(raw)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
var key crypto.PublicKey
|
||||||
|
if !strings.HasPrefix(h.Alg, "HS") {
|
||||||
|
if key, err = a.key(ctx, d, h.Kid, h.Alg); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := verifySignature(h.Alg, key, []byte(a.cfg.ClientSecret), signed, sig); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
var c idClaims
|
||||||
|
if err := json.Unmarshal(body, &c); err != nil {
|
||||||
|
return User{}, errors.New("claims illisibles")
|
||||||
|
}
|
||||||
|
if err := c.validate(d.Issuer, a.cfg.ClientID, nonce, now()); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
u := User{Sub: c.Sub, Email: c.Email, Name: c.PreferredUsername}
|
||||||
|
for _, n := range []string{c.Name, c.Email, c.Sub} {
|
||||||
|
if u.Name == "" {
|
||||||
|
u.Name = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(a.cfg.AllowedGroups) > 0 {
|
||||||
|
var all map[string]any
|
||||||
|
_ = json.Unmarshal(body, &all)
|
||||||
|
groups := stringList(claimPath(all, a.cfg.GroupsClaim))
|
||||||
|
ok := slices.ContainsFunc(groups, func(g string) bool {
|
||||||
|
// Keycloak préfixe les groupes par « / » quand le chemin complet est inclus.
|
||||||
|
return slices.Contains(a.cfg.AllowedGroups, g) || slices.Contains(a.cfg.AllowedGroups, strings.TrimPrefix(g, "/"))
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return u, errForbidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *OIDC) startSession(w http.ResponseWriter, r *http.Request, u User) error {
|
||||||
|
v, err := a.signer.encode(session{User: u, Exp: now().Add(a.cfg.SessionTTL).Unix()})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setCookie(w, r, sessionCookie, v, "/", a.cfg.SessionTTL)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *OIDC) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.setCookie(w, r, sessionCookie, "", "/", -1)
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
back := a.baseURL(r) + "/auth/logged-out"
|
||||||
|
if a.cfg.EndSession {
|
||||||
|
if d, err := a.provider(r.Context()); err == nil && d.EndSessionEndpoint != "" {
|
||||||
|
// RP-Initiated Logout 1.0 : client_id identifie le client sans id_token_hint.
|
||||||
|
q := url.Values{"client_id": {a.cfg.ClientID}, "post_logout_redirect_uri": {back}}
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(d.EndSessionEndpoint, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, d.EndSessionEndpoint+sep+q.Encode(), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, back, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Protection des routes -------------------------------------------------------
|
||||||
|
|
||||||
|
type ctxKey struct{}
|
||||||
|
|
||||||
|
// UserFrom renvoie l'utilisateur de la requête (nil hors session OIDC).
|
||||||
|
func UserFrom(ctx context.Context) *User {
|
||||||
|
u, _ := ctx.Value(ctxKey{}).(*User)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session renvoie l'utilisateur du cookie de session s'il est valide.
|
||||||
|
func (a *OIDC) session(r *http.Request) (session, bool) {
|
||||||
|
var s session
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil || a.signer.decode(c.Value, &s) != nil || s.Exp < now().Unix() || s.Sub == "" {
|
||||||
|
return s, false
|
||||||
|
}
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware exige une session sauf pour /auth/* et les chemins publics.
|
||||||
|
// Navigation sans session : page qui renvoie vers /auth/login en conservant
|
||||||
|
// l'URL complète (fragment #/dossier/schéma compris). API : 401 + LoginHeader.
|
||||||
|
// La session est prolongée à chaque requête passée la moitié de sa durée.
|
||||||
|
func (a *OIDC) Middleware(next http.Handler, public func(string) bool) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := r.URL.Path
|
||||||
|
if strings.HasPrefix(p, "/auth/") || public(p) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, ok := a.session(r)
|
||||||
|
if !ok {
|
||||||
|
if strings.HasPrefix(p, "/api/") || r.Method != http.MethodGet {
|
||||||
|
w.Header().Set(LoginHeader, "/auth/login")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"session expirée : reconnexion nécessaire"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loginRedirect(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Until(time.Unix(s.Exp, 0)) < a.cfg.SessionTTL/2 {
|
||||||
|
_ = a.startSession(w, r, s.User)
|
||||||
|
}
|
||||||
|
u := s.User
|
||||||
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, &u)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginRedirect renvoie vers /auth/login par script pour conserver le
|
||||||
|
// fragment de l'URL, que le navigateur n'envoie pas au serveur.
|
||||||
|
func loginRedirect(w http.ResponseWriter) {
|
||||||
|
h := w.Header()
|
||||||
|
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
h.Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = io.WriteString(w, `<!doctype html><meta charset="utf-8"><title>Connexion</title>
|
||||||
|
<script>location.replace("/auth/login?next="+encodeURIComponent(location.pathname+location.search+location.hash))</script>
|
||||||
|
<noscript><a href="/auth/login">Se connecter</a></noscript>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// page affiche un message simple (erreur de connexion, déconnexion).
|
||||||
|
func page(w http.ResponseWriter, status int, title, msg, link, label string) {
|
||||||
|
h := w.Header()
|
||||||
|
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
h.Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
e := html.EscapeString
|
||||||
|
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="fr"><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"><title>%s</title>
|
||||||
|
<style>body{font:16px system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0;color:#1b1b1f;background:#fff}
|
||||||
|
@media(prefers-color-scheme:dark){body{color:#e3e3e8;background:#121212}}main{max-width:28rem;padding:1rem;text-align:center}
|
||||||
|
a{display:inline-block;margin-top:1rem;padding:.6rem 1.2rem;border-radius:8px;background:#6965db;color:#fff;text-decoration:none}</style>
|
||||||
|
<main><h1>%s</h1><p>%s</p><a href="%s">%s</a></main></html>`, e(title), e(title), e(msg), e(link), e(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me décrit la session courante pour le frontend.
|
||||||
|
type Me struct {
|
||||||
|
Mode string `json:"mode"` // none, local ou oidc
|
||||||
|
User *User `json:"user,omitempty"`
|
||||||
|
Logout string `json:"logout,omitempty"` // URL de déconnexion, si disponible
|
||||||
|
}
|
||||||
Reference in new issue
Block a user