feat: add api
docker-publish / publish (push) Failing after 14m14s

This commit is contained in:
Nuxgrid
2026-07-27 16:06:19 +02:00
parent 8397a4470f
commit 6b4274d12b
46 changed files with 3922 additions and 0 deletions
@@ -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
}
+176
View File
@@ -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"
+63
View File
@@ -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
}
+14
View File
@@ -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
}
+9
View File
@@ -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"
)
+33
View File
@@ -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))
}