Authentification OIDC : package internal/auth et menu utilisateur
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018GBsBpHJW7GxFDBV7z1zmo
This commit is contained in:
1 parent
80fd60d006
commit
7397f0f789
3 files changed
+396
No files matched your search
@@ -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<unknown>;
|
||||
}
|
||||
|
||||
/** Utilisateur connecté et bouton de déconnexion (mode OIDC uniquement). */
|
||||
export function UserMenu({ beforeLogout }: Props) {
|
||||
const [me, setMe] = useState<Me | null>(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 (
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label={`Se déconnecter (${who})`}
|
||||
title={`Connecté : ${who}\nCliquer pour se déconnecter`}
|
||||
onClick={() => beforeLogout().finally(() => location.assign(logout))}
|
||||
>
|
||||
<LogoutIcon />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }
|
||||
Reference in new issue
Block a user