Files
PdfEditor/backend/internal/handlers/auth_handlers.go
2026-08-17 19:08:20 +02:00

258 lines
7.6 KiB
Go

package handlers
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"pdfeditor/internal/auth"
)
// userResponse représente un utilisateur tel qu'exposé par l'API.
type userResponse struct {
ID string `json:"id"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
// credentials est le corps des requêtes register/login.
type credentials struct {
Email string `json:"email"`
Password string `json:"password"`
}
// tokenPair regroupe un couple access/refresh token.
type tokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
// authResponse est la réponse de register/login.
type authResponse struct {
User userResponse `json:"user"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
// validateCredentials applique des règles minimales de validation.
func validateCredentials(c credentials) (string, bool) {
email := strings.TrimSpace(strings.ToLower(c.Email))
if email == "" || !strings.Contains(email, "@") || len(email) > 254 {
return "", false
}
if len(c.Password) < 8 || len(c.Password) > 128 {
return "", false
}
return email, true
}
// Register crée un nouvel utilisateur et renvoie un couple de tokens.
func (a *API) Register(w http.ResponseWriter, r *http.Request) {
var creds credentials
if !decodeJSON(w, r, &creds) {
return
}
email, ok := validateCredentials(creds)
if !ok {
writeError(w, http.StatusBadRequest, "email invalide ou mot de passe trop court (8 caractères minimum)")
return
}
hash, err := auth.HashPassword(creds.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur interne")
return
}
ctx := r.Context()
var user userResponse
err = a.DB.QueryRow(ctx,
`INSERT INTO users (email, password_hash) VALUES ($1, $2)
RETURNING id, email, created_at`,
email, hash,
).Scan(&user.ID, &user.Email, &user.CreatedAt)
if err != nil {
// Violation de contrainte d'unicité (email déjà pris).
if strings.Contains(err.Error(), "users_email_key") || strings.Contains(err.Error(), "duplicate key") {
writeError(w, http.StatusConflict, "cet email est déjà utilisé")
return
}
writeError(w, http.StatusInternalServerError, "erreur lors de la création du compte")
return
}
pair, err := a.issueTokens(ctx, user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur lors de la génération des tokens")
return
}
writeJSON(w, http.StatusCreated, authResponse{User: user, AccessToken: pair.AccessToken, RefreshToken: pair.RefreshToken})
}
// Login authentifie un utilisateur et renvoie un couple de tokens.
func (a *API) Login(w http.ResponseWriter, r *http.Request) {
var creds credentials
if !decodeJSON(w, r, &creds) {
return
}
email := strings.TrimSpace(strings.ToLower(creds.Email))
if email == "" || creds.Password == "" {
writeError(w, http.StatusBadRequest, "email et mot de passe requis")
return
}
ctx := r.Context()
var user userResponse
var passwordHash string
err := a.DB.QueryRow(ctx,
`SELECT id, email, created_at, password_hash FROM users WHERE email = $1`,
email,
).Scan(&user.ID, &user.Email, &user.CreatedAt, &passwordHash)
if err != nil {
// On ne distingue pas "email inconnu" de "mauvais mot de passe" (anti-énumération).
writeError(w, http.StatusUnauthorized, "identifiants invalides")
return
}
if !auth.CheckPassword(passwordHash, creds.Password) {
writeError(w, http.StatusUnauthorized, "identifiants invalides")
return
}
pair, err := a.issueTokens(ctx, user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur lors de la génération des tokens")
return
}
writeJSON(w, http.StatusOK, authResponse{User: user, AccessToken: pair.AccessToken, RefreshToken: pair.RefreshToken})
}
// refreshRequest est le corps de la requête de rafraîchissement.
type refreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
// Refresh échange un refresh token valide contre un nouveau couple de tokens.
// L'ancien refresh token est révoqué (rotation).
func (a *API) Refresh(w http.ResponseWriter, r *http.Request) {
var req refreshRequest
if !decodeJSON(w, r, &req) {
return
}
if req.RefreshToken == "" {
writeError(w, http.StatusBadRequest, "refresh_token requis")
return
}
ctx := r.Context()
hash := auth.HashToken(req.RefreshToken)
var tokenID, userID string
var expiresAt time.Time
var revoked bool
err := a.DB.QueryRow(ctx,
`SELECT id, user_id, expires_at, revoked FROM refresh_tokens WHERE token_hash = $1`,
hash,
).Scan(&tokenID, &userID, &expiresAt, &revoked)
if err != nil {
writeError(w, http.StatusUnauthorized, "refresh token invalide")
return
}
if revoked || time.Now().After(expiresAt) {
writeError(w, http.StatusUnauthorized, "refresh token expiré ou révoqué")
return
}
// Rotation : on révoque l'ancien token avant d'en émettre un nouveau.
if _, err := a.DB.Exec(ctx, `UPDATE refresh_tokens SET revoked = TRUE WHERE id = $1`, tokenID); err != nil {
writeError(w, http.StatusInternalServerError, "erreur interne")
return
}
pair, err := a.issueTokens(ctx, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur lors de la génération des tokens")
return
}
writeJSON(w, http.StatusOK, pair)
}
// Logout révoque le refresh token fourni. Auth requise.
func (a *API) Logout(w http.ResponseWriter, r *http.Request) {
var req refreshRequest
// Le refresh token peut être fourni dans le corps ; s'il est absent, on
// révoque malgré tout tous les tokens de l'utilisateur courant.
_ = decodeJSON(w, r, &req)
userID := userIDFromContext(r.Context())
ctx := r.Context()
if req.RefreshToken != "" {
hash := auth.HashToken(req.RefreshToken)
_, err := a.DB.Exec(ctx,
`UPDATE refresh_tokens SET revoked = TRUE WHERE token_hash = $1 AND user_id = $2`,
hash, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur interne")
return
}
} else {
// Pas de token précisé : on révoque toutes les sessions de l'utilisateur.
if _, err := a.DB.Exec(ctx,
`UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = $1 AND revoked = FALSE`,
userID); err != nil {
writeError(w, http.StatusInternalServerError, "erreur interne")
return
}
}
w.WriteHeader(http.StatusNoContent)
}
// Me renvoie le profil de l'utilisateur authentifié.
func (a *API) Me(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
var user userResponse
err := a.DB.QueryRow(r.Context(),
`SELECT id, email, created_at FROM users WHERE id = $1`, userID,
).Scan(&user.ID, &user.Email, &user.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "utilisateur introuvable")
return
}
writeError(w, http.StatusInternalServerError, "erreur interne")
return
}
writeJSON(w, http.StatusOK, user)
}
// issueTokens génère un access token et un refresh token, persiste ce dernier
// (hashé) en base, et retourne le couple.
func (a *API) issueTokens(ctx context.Context, userID string) (tokenPair, error) {
accessToken, err := a.Auth.GenerateAccessToken(userID)
if err != nil {
return tokenPair{}, err
}
refreshToken, refreshHash, expiresAt, err := a.Auth.GenerateRefreshToken()
if err != nil {
return tokenPair{}, err
}
_, err = a.DB.Exec(ctx,
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
userID, refreshHash, expiresAt)
if err != nil {
return tokenPair{}, err
}
return tokenPair{AccessToken: accessToken, RefreshToken: refreshToken}, nil
}