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 }