258 lines
7.6 KiB
Go
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
|
|
}
|