@@ -0,0 +1,56 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GormStore : UserStore adossé à PostgreSQL via GORM.
|
||||
type GormStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewGormStore(db *gorm.DB) *GormStore {
|
||||
return &GormStore{db: db}
|
||||
}
|
||||
|
||||
// ByUsername cherche un utilisateur (requête paramétrée : anti-injection).
|
||||
func (s *GormStore) ByUsername(username string) (User, bool) {
|
||||
var u User
|
||||
err := s.db.Where("username = ?", NormalizeUsername(username)).First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return User{}, false
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
// Create insère un utilisateur (usage seed/admin), mot de passe déjà haché.
|
||||
func (s *GormStore) Create(username, passwordHash string, role Role) (User, error) {
|
||||
u := User{
|
||||
ID: uuid.NewString(),
|
||||
Username: NormalizeUsername(username),
|
||||
PasswordHash: passwordHash,
|
||||
Role: role,
|
||||
}
|
||||
if role == RoleAdmin {
|
||||
u.TypeAbo = "admin"
|
||||
}
|
||||
if err := s.db.Create(&u).Error; err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// EnsureUser crée l'utilisateur s'il n'existe pas encore (idempotent, pour le seed).
|
||||
func (s *GormStore) EnsureUser(username, passwordHash string, role Role) error {
|
||||
if _, found := s.ByUsername(username); found {
|
||||
return nil
|
||||
}
|
||||
_, err := s.Create(username, passwordHash, role)
|
||||
return err
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,63 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var ErrInvalidToken = errors.New("token invalide")
|
||||
|
||||
// Claims du token Omnex : porte le rôle et l'identifiant de session Redis (sid).
|
||||
// La signature (secret) authentifie le token ; le sid permet la révocation.
|
||||
type Claims struct {
|
||||
Role Role `json:"role"`
|
||||
Username string `json:"username"`
|
||||
SessionID string `json:"sid"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Issuer signe et vérifie des JWT HS256.
|
||||
type Issuer struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewIssuer(secret []byte, ttl time.Duration) *Issuer {
|
||||
return &Issuer{secret: secret, ttl: ttl}
|
||||
}
|
||||
|
||||
// Issue crée un token pour un utilisateur, lié à une session Redis (sid).
|
||||
func (i *Issuer) Issue(userID string, username string, role Role, sid string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
Role: role,
|
||||
SessionID: sid,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
Issuer: "omnex",
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(i.secret)
|
||||
}
|
||||
|
||||
// Verify valide la signature + l'expiration et renvoie les claims.
|
||||
func (i *Issuer) Verify(tokenStr string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
_, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
|
||||
// Refuse tout algo autre que HS256 (protection alg-confusion).
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return i.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/omnex/control-plane/api/internal/session"
|
||||
)
|
||||
|
||||
const ctxPrincipalKey = "omnex.principal"
|
||||
|
||||
// Principal : identité authentifiée injectée dans le contexte de requête.
|
||||
type Principal struct {
|
||||
UserID string
|
||||
Username string
|
||||
Role Role
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// tokenFromRequest lit le JWT : cookie httpOnly en priorité, sinon Bearer.
|
||||
func tokenFromRequest(c *gin.Context) string {
|
||||
if ck, err := c.Request.Cookie(session.CookieName); err == nil && ck.Value != "" {
|
||||
return ck.Value
|
||||
}
|
||||
if t, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer "); ok {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RequireAuth valide le JWT (signature) PUIS l'existence de la session Redis.
|
||||
// Un logout supprime la session : le JWT devient alors invalide avant expiration.
|
||||
func RequireAuth(iss *Issuer, mgr session.Manager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := tokenFromRequest(c)
|
||||
if raw == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentification requise"})
|
||||
return
|
||||
}
|
||||
claims, err := iss.Verify(raw)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token invalide"})
|
||||
return
|
||||
}
|
||||
_, found, err := mgr.Get(c.Request.Context(), claims.SessionID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expirée ou révoquée"})
|
||||
return
|
||||
}
|
||||
c.Set(ctxPrincipalKey, Principal{UserID: claims.Subject, Username: claims.Username, Role: claims.Role, SessionID: claims.SessionID})
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRole impose un rôle minimal (admin > rôle demandé).
|
||||
func RequireRole(role Role) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
p := PrincipalFrom(c)
|
||||
if p == nil || (p.Role != role && p.Role != RoleAdmin) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "accès refusé"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// PrincipalFrom récupère l'identité injectée par RequireAuth.
|
||||
func PrincipalFrom(c *gin.Context) *Principal {
|
||||
v, ok := c.Get(ctxPrincipalKey)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
p, _ := v.(Principal)
|
||||
return &p
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
|
||||
PasswordHash string `gorm:"not null" json:"-"`
|
||||
Role Role `gorm:"size:20;not null" json:"role"`
|
||||
TypeAbo string `gorm:"type:varchar(35);default:demo" json:"type_abonnement"`
|
||||
ExpiredAt time.Time `json:"expired_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package auth : hachage de mots de passe (argon2id) et JWT.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Paramètres argon2id (OWASP Password Storage Cheat Sheet).
|
||||
const (
|
||||
argonTime = 1
|
||||
argonMemory = 64 * 1024 // 64 MiB
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
argonSaltLen = 16
|
||||
)
|
||||
|
||||
var ErrInvalidHash = errors.New("format de hash invalide")
|
||||
|
||||
// HashPassword produit un hash argon2id encodé (format PHC).
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, argonSaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemory, argonTime, argonThreads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(key),
|
||||
), nil
|
||||
}
|
||||
|
||||
// VerifyPassword compare un mot de passe à un hash encodé, en temps constant.
|
||||
func VerifyPassword(password, encoded string) (bool, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, ErrInvalidHash
|
||||
}
|
||||
var memory, time uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
|
||||
return false, ErrInvalidHash
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, ErrInvalidHash
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, ErrInvalidHash
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package auth
|
||||
|
||||
// Role : rôle applicatif d'un utilisateur du control-plane.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleClient Role = "client"
|
||||
RoleAdmin Role = "admin"
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const codeCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
// GenerateCode génère un code de 16 caractères alphanumériques,
|
||||
// regroupés par 4 et séparés par des tirets (ex: "A3F9-K2M7-XQ4R-8ZT1").
|
||||
func GenerateCode() (string, error) {
|
||||
const length = 16
|
||||
const groupSize = 4
|
||||
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i := 0; i < length; i++ {
|
||||
if i > 0 && i%groupSize == 0 {
|
||||
sb.WriteByte('-')
|
||||
}
|
||||
sb.WriteByte(codeCharset[int(b[i])%len(codeCharset)])
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func NormalizeUsername(u string) string {
|
||||
return strings.ToLower(strings.TrimSpace(u))
|
||||
}
|
||||
Reference in New Issue
Block a user