@@ -0,0 +1,176 @@
|
||||
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"
|
||||
Reference in New Issue
Block a user