196 lines
6.0 KiB
Go
196 lines
6.0 KiB
Go
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"
|