package auth import ( "net/http" "time" "github.com/gin-gonic/gin" "github.com/omnex/control-plane/api/internal/session" ) func (User) TableName() string { return "users" } type UserStore interface { ByUsername(username string) (User, bool) Create(username, passwordHash string, role Role) (User, error) } type Handler struct { users UserStore sessions session.Manager iss *Issuer secure bool // cookie Secure (activé en prod/HTTPS) } func NewHandler(users UserStore, sessions session.Manager, iss *Issuer, secure bool) *Handler { return &Handler{users: users, sessions: sessions, iss: iss, secure: secure} } type loginRequest struct { Username string Password string // Role : page de connexion utilisée ("admin" ou "client"). La page // admin et la page client sont désormais séparées côté front — un // compte client ne peut pas se connecter depuis la page admin et // inversement, même avec des identifiants valides. Role Role `json:"role" binding:"required,oneof=admin client"` } // Login authentifie, ouvre une session Redis et pose le cookie httpOnly. // Réponse volontairement uniforme (pas d'énumération d'utilisateurs, ni du // rôle réel du compte en cas de connexion depuis la mauvaise page). func (h *Handler) Login(c *gin.Context) { var req loginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) return } user, found := h.users.ByUsername(req.Username) if !found { // On hache quand même une valeur bidon pour égaliser le temps de réponse. _, _ = VerifyPassword(req.Password, dummyHash) c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"}) return } ok, err := VerifyPassword(req.Password, user.PasswordHash) if err != nil || !ok || user.Role != req.Role { c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"}) return } // Le JWT n'est plus jamais renvoyé dans le corps JSON (pentest F-003) : // uniquement posé en cookie HttpOnly par startSession. Le renvoyer ici // permettait au frontend de le dupliquer en localStorage, annulant la // protection HttpOnly contre un vol de session via XSS. if _, err := h.startSession(c, user); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) return } c.JSON(http.StatusOK, gin.H{"role": user.Role}) } // Me renvoie l'identité de la session courante (dont le rôle, pour le front). func (h *Handler) Me(c *gin.Context) { p := PrincipalFrom(c) if p == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) return } user, found := h.users.ByUsername(p.Username) if !found { c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) return } typeAbo := user.TypeAbo if typeAbo == "premium" && time.Now().UTC().After(user.ExpiredAt) { typeAbo = "demo" // expiré : on ne le renvoie plus comme premium } c.JSON(http.StatusOK, gin.H{ "user_id": p.UserID, "username": p.Username, "role": p.Role, "type_abonnement": typeAbo, "expired_at": user.ExpiredAt, }) } type registerRequest struct { Username string Password string } // Register crée un compte commercial puis ouvre directement la session. func (h *Handler) Register(c *gin.Context) { var req registerRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) return } if existing, exists := h.users.ByUsername(req.Username); exists { // Un statut 409 distinct de la création (201) permet d'énumérer les // comptes existants (pentest F-002) — inévitable pour un flux // d'inscription instantané sans vérification email (l'utilisateur a // besoin de savoir qu'il doit choisir un autre nom). On protège au // moins la cible à plus fort enjeu : un compte admin ne confirme // jamais son existence, la réponse est indiscernable d'un nom // d'utilisateur simplement invalide. if existing.Role == RoleAdmin { c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) return } c.JSON(http.StatusConflict, gin.H{"error": "nom d'utilisateur déjà pris"}) return } hash, err := HashPassword(req.Password) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) return } user, err := h.users.Create(req.Username, hash, RoleClient) if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "nom d'utilisateur déjà pris"}) return } if _, err := h.startSession(c, user); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) return } c.JSON(http.StatusCreated, gin.H{"role": user.Role}) } // startSession ouvre une session Redis, signe le JWT et pose le cookie. func (h *Handler) startSession(c *gin.Context, user User) (string, error) { sid, err := h.sessions.Create(c.Request.Context(), session.Session{ UserID: user.ID, Role: string(user.Role), }) if err != nil { return "", err } token, err := h.iss.Issue(user.ID, user.Username, user.Role, sid) if err != nil { _ = h.sessions.Delete(c.Request.Context(), sid) // fail-secure return "", err } h.setSessionCookie(c, token, int(h.sessions.TTL().Seconds())) return token, nil } // Logout révoque la session Redis courante et efface le cookie. func (h *Handler) Logout(c *gin.Context) { if p := PrincipalFrom(c); p != nil { if err := h.sessions.Delete(c.Request.Context(), p.SessionID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) return } } h.setSessionCookie(c, "", -1) c.JSON(http.StatusOK, gin.H{"status": "déconnecté"}) } func (h *Handler) setSessionCookie(c *gin.Context, value string, maxAge int) { http.SetCookie(c.Writer, &http.Cookie{ Name: session.CookieName, Value: value, Path: "/", MaxAge: maxAge, HttpOnly: true, Secure: h.secure, SameSite: http.SameSiteLaxMode, }) } // dummyHash : argon2id d'une valeur arbitraire, pour la mitigation de timing. const dummyHash = "=19=65536,t=1,p=4$" + "3Vk0m5m8c1m6l0k9j8h7g6f5d4s3a2z1x0c9v8b7n6m"