feat: v1
This commit is contained in:
257
backend/internal/handlers/auth_handlers.go
Normal file
257
backend/internal/handlers/auth_handlers.go
Normal file
@ -0,0 +1,257 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user