From 7397f0f7898ab0f30f8024c83e5cda4c4ea8e5cc Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 8 Oct 2026 08:21:57 +0200 Subject: [PATCH] Authentification OIDC : package internal/auth et menu utilisateur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite du commit précédent : flux OIDC (découverte, PKCE, vérification de l'ID Token, groupes), sessions signées et bouton de déconnexion. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018GBsBpHJW7GxFDBV7z1zmo --- frontend/src/UserMenu.tsx | 35 ++++++ internal/auth/jwt.go | 243 ++++++++++++++++++++++++++++++++++++++ internal/auth/session.go | 118 ++++++++++++++++++ 3 files changed, 396 insertions(+) create mode 100644 frontend/src/UserMenu.tsx create mode 100644 internal/auth/jwt.go create mode 100644 internal/auth/session.go diff --git a/frontend/src/UserMenu.tsx b/frontend/src/UserMenu.tsx new file mode 100644 index 0000000..6eefbad --- /dev/null +++ b/frontend/src/UserMenu.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; +import { api, type Me } from "./api"; +import { LogoutIcon } from "./icons"; + +interface Props { + /** Appelé avant la déconnexion (enregistrement du schéma ouvert). */ + beforeLogout: () => Promise; +} + +/** Utilisateur connecté et bouton de déconnexion (mode OIDC uniquement). */ +export function UserMenu({ beforeLogout }: Props) { + const [me, setMe] = useState(null); + + useEffect(() => { + api + .me() + .then(setMe) + .catch(() => setMe(null)); + }, []); + + if (!me?.user || !me.logout) return null; + const logout = me.logout; + const who = me.user.email && me.user.email !== me.user.name ? `${me.user.name} (${me.user.email})` : me.user.name; + + return ( + + ); +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..fd9f304 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,243 @@ +package auth + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/hmac" + "crypto/rsa" + _ "crypto/sha256" // enregistre SHA-256 pour crypto.Hash + _ "crypto/sha512" // enregistre SHA-384 et SHA-512 + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + "time" +) + +// jwk est une clé publique d'un JWKS (RFC 7517), RSA ou EC. +type jwk struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use"` + Alg string `json:"alg"` + N string `json:"n"` + E string `json:"e"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +func (k jwk) publicKey() (crypto.PublicKey, error) { + switch k.Kty { + case "RSA": + n, err1 := b64.DecodeString(k.N) + e, err2 := b64.DecodeString(k.E) + if err1 != nil || err2 != nil || len(n) == 0 || len(e) == 0 || len(e) > 4 { + return nil, errors.New("clé RSA invalide") + } + return &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())}, nil + case "EC": + var c elliptic.Curve + switch k.Crv { + case "P-256": + c = elliptic.P256() + case "P-384": + c = elliptic.P384() + case "P-521": + c = elliptic.P521() + default: + return nil, fmt.Errorf("courbe %q non prise en charge", k.Crv) + } + x, err1 := b64.DecodeString(k.X) + y, err2 := b64.DecodeString(k.Y) + if err1 != nil || err2 != nil { + return nil, errors.New("clé EC invalide") + } + pub := &ecdsa.PublicKey{Curve: c, X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)} + if !c.IsOnCurve(pub.X, pub.Y) { + return nil, errors.New("clé EC invalide") + } + return pub, nil + } + return nil, fmt.Errorf("type de clé %q non pris en charge", k.Kty) +} + +var algHash = map[string]crypto.Hash{ + "RS256": crypto.SHA256, "RS384": crypto.SHA384, "RS512": crypto.SHA512, + "PS256": crypto.SHA256, "PS384": crypto.SHA384, "PS512": crypto.SHA512, + "ES256": crypto.SHA256, "ES384": crypto.SHA384, "ES512": crypto.SHA512, + "HS256": crypto.SHA256, "HS384": crypto.SHA384, "HS512": crypto.SHA512, +} + +type jwtHeader struct { + Alg string `json:"alg"` + Kid string `json:"kid"` +} + +// parseJWT découpe un JWS compact et renvoie l'en-tête, les claims bruts, le +// contenu signé et la signature. +func parseJWT(token string) (h jwtHeader, claims []byte, signed string, sig []byte, err error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return h, nil, "", nil, errors.New("jeton mal formé") + } + hb, err := b64.DecodeString(parts[0]) + if err != nil || json.Unmarshal(hb, &h) != nil { + return h, nil, "", nil, errors.New("en-tête de jeton invalide") + } + if claims, err = b64.DecodeString(parts[1]); err != nil { + return h, nil, "", nil, errors.New("contenu de jeton invalide") + } + if sig, err = b64.DecodeString(parts[2]); err != nil { + return h, nil, "", nil, errors.New("signature de jeton invalide") + } + return h, claims, parts[0] + "." + parts[1], sig, nil +} + +// verifySignature vérifie la signature avec une clé publique (RS, PS, ES) ou +// le secret client (HS, clé symétrique utilisée par exemple par Authentik +// quand aucun certificat de signature n'est choisi). +func verifySignature(alg string, key crypto.PublicKey, secret []byte, signed string, sig []byte) error { + hash, ok := algHash[alg] + if !ok { + return fmt.Errorf("algorithme %q refusé", alg) + } + if strings.HasPrefix(alg, "HS") { + if len(secret) == 0 { + return fmt.Errorf("jeton %s sans OIDC_CLIENT_SECRET", alg) + } + m := hmac.New(hash.New, secret) + m.Write([]byte(signed)) + if !hmac.Equal(m.Sum(nil), sig) { + return errors.New("signature invalide") + } + return nil + } + d := hash.New() + d.Write([]byte(signed)) + digest := d.Sum(nil) + switch k := key.(type) { + case *rsa.PublicKey: + var err error + switch alg[:2] { + case "RS": + err = rsa.VerifyPKCS1v15(k, hash, digest, sig) + case "PS": + err = rsa.VerifyPSS(k, hash, digest, sig, &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}) + default: + err = errors.New("algorithme incompatible avec une clé RSA") + } + if err != nil { + return errors.New("signature invalide") + } + return nil + case *ecdsa.PublicKey: + size := (k.Curve.Params().BitSize + 7) / 8 + if alg[:2] != "ES" || len(sig) != 2*size { + return errors.New("signature invalide") + } + r, s := new(big.Int).SetBytes(sig[:size]), new(big.Int).SetBytes(sig[size:]) + if !ecdsa.Verify(k, digest, r, s) { + return errors.New("signature invalide") + } + return nil + } + return errors.New("clé de vérification inadaptée") +} + +// idClaims regroupe les claims utiles d'un ID Token. +type idClaims struct { + Iss string `json:"iss"` + Sub string `json:"sub"` + Aud json.RawMessage `json:"aud"` + Azp string `json:"azp"` + Exp json.Number `json:"exp"` + Iat json.Number `json:"iat"` + Nonce string `json:"nonce"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Email string `json:"email"` +} + +func (c idClaims) audiences() []string { + var one string + if json.Unmarshal(c.Aud, &one) == nil { + return []string{one} + } + var many []string + _ = json.Unmarshal(c.Aud, &many) + return many +} + +const clockSkew = 2 * time.Minute + +// validate contrôle émetteur, audience, dates et nonce (OIDC Core 3.1.3.7). +func (c idClaims) validate(issuer, clientID, nonce string, t time.Time) error { + if c.Iss != issuer { + return fmt.Errorf("émetteur %q inattendu", c.Iss) + } + if c.Sub == "" { + return errors.New("claim sub absent") + } + aud := c.audiences() + found := false + for _, a := range aud { + found = found || a == clientID + } + if !found { + return errors.New("jeton destiné à un autre client") + } + if len(aud) > 1 && c.Azp != "" && c.Azp != clientID { + return errors.New("azp inattendu") + } + exp, err := c.Exp.Int64() + if err != nil { + f, ferr := c.Exp.Float64() + if ferr != nil { + return errors.New("claim exp invalide") + } + exp = int64(f) + } + if t.After(time.Unix(exp, 0).Add(clockSkew)) { + return errors.New("jeton expiré") + } + if iat, err := c.Iat.Float64(); err == nil && time.Unix(int64(iat), 0).After(t.Add(clockSkew)) { + return errors.New("jeton émis dans le futur (horloge ?)") + } + if !hmac.Equal([]byte(c.Nonce), []byte(nonce)) { + return errors.New("nonce invalide") + } + return nil +} + +// stringList lit un claim chaîne ou tableau de chaînes (groupes, rôles). +func stringList(v any) []string { + switch x := v.(type) { + case string: + return []string{x} + case []any: + out := make([]string, 0, len(x)) + for _, e := range x { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out + } + return nil +} + +// claimPath lit un claim éventuellement imbriqué (« realm_access.roles »). +func claimPath(all map[string]any, p string) any { + var cur any = all + for _, part := range strings.Split(p, ".") { + m, ok := cur.(map[string]any) + if !ok { + return nil + } + cur = m[part] + } + return cur +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..7c2671e --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,118 @@ +// Package auth fournit l'authentification OpenID Connect (flux Authorization +// Code + PKCE) et les sessions signées associées, sans dépendance externe. +package auth + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "time" +) + +// User est l'utilisateur authentifié, tel que décrit par le fournisseur. +type User struct { + Sub string `json:"sub"` + Name string `json:"name"` + Email string `json:"email,omitempty"` +} + +// session est le contenu du cookie de session (signé, non chiffré : il ne +// contient que l'identité, pas de jeton). +type session struct { + User + Exp int64 `json:"exp"` +} + +// loginState accompagne l'aller-retour vers le fournisseur (cookie de 10 min). +type loginState struct { + State string `json:"state"` + Nonce string `json:"nonce"` + Verifier string `json:"verifier"` + Next string `json:"next"` + Exp int64 `json:"exp"` +} + +var errBadCookie = errors.New("cookie invalide") + +// signer signe et vérifie des valeurs JSON (HMAC-SHA256). +type signer struct{ key []byte } + +var b64 = base64.RawURLEncoding + +func (s signer) mac(payload string) string { + m := hmac.New(sha256.New, s.key) + m.Write([]byte(payload)) + return b64.EncodeToString(m.Sum(nil)) +} + +func (s signer) encode(v any) (string, error) { + raw, err := json.Marshal(v) + if err != nil { + return "", err + } + p := b64.EncodeToString(raw) + return p + "." + s.mac(p), nil +} + +// decode vérifie la signature puis décode ; le contrôle d'expiration revient +// à l'appelant (champ Exp). +func (s signer) decode(value string, v any) error { + p, sig, ok := strings.Cut(value, ".") + if !ok || !hmac.Equal([]byte(sig), []byte(s.mac(p))) { + return errBadCookie + } + raw, err := b64.DecodeString(p) + if err != nil { + return errBadCookie + } + if json.Unmarshal(raw, v) != nil { + return errBadCookie + } + return nil +} + +func randomString(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) // crypto/rand ne échoue pas sur les systèmes supportés + } + return b64.EncodeToString(b) +} + +// LoadSecret renvoie la clé de signature des sessions : secret si fourni +// (16 caractères minimum), sinon le contenu de dir/.session-secret, créé +// aléatoirement au premier démarrage (0600). +func LoadSecret(secret, dir string) ([]byte, error) { + if secret != "" { + if len(secret) < 16 { + return nil, errors.New("SESSION_SECRET trop court (16 caractères minimum)") + } + return []byte(secret), nil + } + file := filepath.Join(dir, ".session-secret") + if b, err := os.ReadFile(file); err == nil { + if k, err := hex.DecodeString(strings.TrimSpace(string(b))); err == nil && len(k) >= 32 { + return k, nil + } + return nil, errors.New(file + " invalide (supprimer le fichier pour en générer un nouveau)") + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + k := make([]byte, 32) + if _, err := rand.Read(k); err != nil { + return nil, err + } + if err := os.WriteFile(file, []byte(hex.EncodeToString(k)+"\n"), 0o600); err != nil { + return nil, err + } + return k, nil +} + +func now() time.Time { return time.Now() }