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 } // Login authentifie, ouvre une session Redis et pose le cookie httpOnly. // Réponse volontairement uniforme (pas d'énumération d'utilisateurs). 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 { c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"}) return } token, err := h.startSession(c, user) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) return } c.JSON(http.StatusOK, gin.H{"token": token, "token_type": "Bearer", "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 _, exists := h.users.ByUsername(req.Username); exists { 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 } token, err := h.startSession(c, user) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) return } c.JSON(http.StatusCreated, gin.H{"token": token, "token_type": "Bearer", "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"