This commit is contained in:
CFOU
2026-09-14 20:50:19 +02:00
commit 3091fb4020
135 changed files with 10262 additions and 0 deletions
@@ -0,0 +1,130 @@
package auth
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"backend/internal/modules/users"
"backend/internal/platform/middleware"
"backend/internal/platform/security"
)
const (
adminRefreshCookieName = "admin_refresh_token"
adminRefreshCookiePath = "/api/auth/admin"
)
// AdminHandler exposes the admin-space auth endpoints. It is kept entirely
// separate from CustomerHandler: different cookie name, different cookie
// path, different JWT audience, so an admin session can never be replayed
// against a future customer-facing endpoint.
type AdminHandler struct {
service *Service
cookieSecure bool
}
func NewAdminHandler(service *Service, cookieSecure bool) *AdminHandler {
return &AdminHandler{service: service, cookieSecure: cookieSecure}
}
type loginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type authUserResponse struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
}
type accessTokenResponse struct {
AccessToken string `json:"access_token"`
User *authUserResponse `json:"user,omitempty"`
}
func (h *AdminHandler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
return
}
pair, user, err := h.service.Login(c.Request.Context(), security.AudienceAdmin, users.RoleAdmin, req.Email, req.Password)
if err != nil {
h.respondLoginError(c, err)
return
}
h.setRefreshCookie(c, pair.RefreshToken)
c.JSON(http.StatusOK, accessTokenResponse{
AccessToken: pair.AccessToken,
User: &authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role},
})
}
func (h *AdminHandler) Refresh(c *gin.Context) {
token, err := c.Cookie(adminRefreshCookieName)
if err != nil || token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing refresh token"})
return
}
pair, err := h.service.Refresh(c.Request.Context(), security.AudienceAdmin, token)
if err != nil {
h.clearRefreshCookie(c)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"})
return
}
h.setRefreshCookie(c, pair.RefreshToken)
c.JSON(http.StatusOK, accessTokenResponse{AccessToken: pair.AccessToken})
}
func (h *AdminHandler) Logout(c *gin.Context) {
if token, err := c.Cookie(adminRefreshCookieName); err == nil && token != "" {
_ = h.service.Logout(c.Request.Context(), security.AudienceAdmin, token)
}
h.clearRefreshCookie(c)
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
func (h *AdminHandler) Me(c *gin.Context) {
userID, ok := middleware.GetUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
user, err := h.service.Me(c.Request.Context(), userID)
if err != nil {
if errors.Is(err, users.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
c.JSON(http.StatusOK, authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role})
}
func (h *AdminHandler) respondLoginError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrAccountDisabled):
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"})
}
}
func (h *AdminHandler) setRefreshCookie(c *gin.Context, token string) {
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(adminRefreshCookieName, token, int(h.service.RefreshTTL().Seconds()), adminRefreshCookiePath, "", h.cookieSecure, true)
}
func (h *AdminHandler) clearRefreshCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(adminRefreshCookieName, "", -1, adminRefreshCookiePath, "", h.cookieSecure, true)
}
@@ -0,0 +1,14 @@
package auth
import "github.com/gin-gonic/gin"
// RegisterAdminRoutes mounts the admin-space auth endpoints. loginRateLimit
// is applied only to /login to blunt credential-stuffing/brute-force
// attempts without throttling normal authenticated traffic.
func RegisterAdminRoutes(rg *gin.RouterGroup, h *AdminHandler, requireAdmin gin.HandlerFunc, loginRateLimit gin.HandlerFunc) {
group := rg.Group("/auth/admin")
group.POST("/login", loginRateLimit, h.Login)
group.POST("/refresh", h.Refresh)
group.POST("/logout", h.Logout)
group.GET("/me", requireAdmin, h.Me)
}
@@ -0,0 +1,112 @@
package auth
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"backend/internal/modules/users"
"backend/internal/platform/middleware"
"backend/internal/platform/security"
)
// customer cookie name/path are intentionally distinct from the admin ones
// (see admin_handler.go) so the two spaces never share a session cookie.
const (
customerRefreshCookieName = "customer_refresh_token"
customerRefreshCookiePath = "/api/auth/customer"
)
// CustomerHandler mirrors AdminHandler but issues/accepts only
// customer-audience tokens. Not mounted by main.go in this phase (no
// customer-facing module needs it yet) — it exists so the future
// cart/orders module can activate customer auth without touching the admin
// code path at all.
type CustomerHandler struct {
service *Service
cookieSecure bool
}
func NewCustomerHandler(service *Service, cookieSecure bool) *CustomerHandler {
return &CustomerHandler{service: service, cookieSecure: cookieSecure}
}
func (h *CustomerHandler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
return
}
pair, user, err := h.service.Login(c.Request.Context(), security.AudienceCustomer, users.RoleCustomer, req.Email, req.Password)
if err != nil {
switch {
case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrAccountDisabled):
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"})
}
return
}
h.setRefreshCookie(c, pair.RefreshToken)
c.JSON(http.StatusOK, accessTokenResponse{
AccessToken: pair.AccessToken,
User: &authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role},
})
}
func (h *CustomerHandler) Refresh(c *gin.Context) {
token, err := c.Cookie(customerRefreshCookieName)
if err != nil || token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing refresh token"})
return
}
pair, err := h.service.Refresh(c.Request.Context(), security.AudienceCustomer, token)
if err != nil {
h.clearRefreshCookie(c)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"})
return
}
h.setRefreshCookie(c, pair.RefreshToken)
c.JSON(http.StatusOK, accessTokenResponse{AccessToken: pair.AccessToken})
}
func (h *CustomerHandler) Logout(c *gin.Context) {
if token, err := c.Cookie(customerRefreshCookieName); err == nil && token != "" {
_ = h.service.Logout(c.Request.Context(), security.AudienceCustomer, token)
}
h.clearRefreshCookie(c)
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
func (h *CustomerHandler) Me(c *gin.Context) {
userID, ok := middleware.GetUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
user, err := h.service.Me(c.Request.Context(), userID)
if err != nil {
if errors.Is(err, users.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
c.JSON(http.StatusOK, authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role})
}
func (h *CustomerHandler) setRefreshCookie(c *gin.Context, token string) {
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(customerRefreshCookieName, token, int(h.service.RefreshTTL().Seconds()), customerRefreshCookiePath, "", h.cookieSecure, true)
}
func (h *CustomerHandler) clearRefreshCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(customerRefreshCookieName, "", -1, customerRefreshCookiePath, "", h.cookieSecure, true)
}
@@ -0,0 +1,15 @@
package auth
import "github.com/gin-gonic/gin"
// RegisterCustomerRoutes mounts the customer-space auth endpoints. Deliberately
// NOT called from cmd/api/main.go in this phase: there is no customer-facing
// module (cart/orders) yet, so exposing these routes today would just be a
// dead surface. Wiring is ready for when that module is activated.
func RegisterCustomerRoutes(rg *gin.RouterGroup, h *CustomerHandler, requireCustomer gin.HandlerFunc, loginRateLimit gin.HandlerFunc) {
group := rg.Group("/auth/customer")
group.POST("/login", loginRateLimit, h.Login)
group.POST("/refresh", h.Refresh)
group.POST("/logout", h.Logout)
group.GET("/me", requireCustomer, h.Me)
}
+21
View File
@@ -0,0 +1,21 @@
// Package auth issues and verifies sessions (access + refresh tokens) for
// both the admin space and the (not-yet-mounted) customer space. It has no
// database table of its own: user identity/credentials live in the users
// module, and refresh-token/session state lives entirely in Redis.
package auth
import "errors"
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrInvalidRefreshToken = errors.New("invalid refresh token")
ErrAccountDisabled = errors.New("account disabled")
)
// TokenPair is returned to the client on login/refresh: the access token is
// meant to be kept in memory by the frontend, the refresh token is meant to
// be set as an httpOnly cookie by the handler (never returned to JS).
type TokenPair struct {
AccessToken string
RefreshToken string
}
+118
View File
@@ -0,0 +1,118 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"backend/internal/platform/security"
)
// RefreshStore tracks live refresh tokens in Redis, scoped per audience
// (admin/customer) so a session in one space is never visible to the other.
type RefreshStore interface {
Issue(ctx context.Context, aud security.Audience, userID uuid.UUID, ttl time.Duration) (string, error)
Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (newToken string, userID uuid.UUID, err error)
Revoke(ctx context.Context, aud security.Audience, token string) error
RevokeAllForUser(ctx context.Context, aud security.Audience, userID uuid.UUID) error
}
type redisRefreshStore struct {
client *redis.Client
}
func NewRefreshStore(client *redis.Client) RefreshStore {
return &redisRefreshStore{client: client}
}
func sessionKey(aud security.Audience, token string) string {
return fmt.Sprintf("session:%s:%s", aud, token)
}
func userSetKey(aud security.Audience, userID uuid.UUID) string {
return fmt.Sprintf("session:%s:user:%s", aud, userID)
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func (s *redisRefreshStore) Issue(ctx context.Context, aud security.Audience, userID uuid.UUID, ttl time.Duration) (string, error) {
token, err := randomToken()
if err != nil {
return "", fmt.Errorf("generate refresh token: %w", err)
}
if err := s.client.Set(ctx, sessionKey(aud, token), userID.String(), ttl).Err(); err != nil {
return "", fmt.Errorf("store refresh token: %w", err)
}
// Best-effort secondary index for RevokeAllForUser; a token still present
// here after its primary key has naturally expired is harmless (RevokeAllForUser
// simply issues a DEL for an already-gone key).
s.client.SAdd(ctx, userSetKey(aud, userID), token)
return token, nil
}
func (s *redisRefreshStore) Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (string, uuid.UUID, error) {
val, err := s.client.GetDel(ctx, sessionKey(aud, oldToken)).Result()
if errors.Is(err, redis.Nil) {
return "", uuid.UUID{}, ErrInvalidRefreshToken
}
if err != nil {
return "", uuid.UUID{}, fmt.Errorf("lookup refresh token: %w", err)
}
userID, err := uuid.Parse(val)
if err != nil {
return "", uuid.UUID{}, fmt.Errorf("corrupt session value: %w", err)
}
s.client.SRem(ctx, userSetKey(aud, userID), oldToken)
newToken, err := s.Issue(ctx, aud, userID, ttl)
if err != nil {
return "", uuid.UUID{}, err
}
return newToken, userID, nil
}
func (s *redisRefreshStore) Revoke(ctx context.Context, aud security.Audience, token string) error {
val, err := s.client.GetDel(ctx, sessionKey(aud, token)).Result()
if errors.Is(err, redis.Nil) {
return nil // already revoked/expired: logout is idempotent
}
if err != nil {
return fmt.Errorf("revoke refresh token: %w", err)
}
if userID, parseErr := uuid.Parse(val); parseErr == nil {
s.client.SRem(ctx, userSetKey(aud, userID), token)
}
return nil
}
func (s *redisRefreshStore) RevokeAllForUser(ctx context.Context, aud security.Audience, userID uuid.UUID) error {
setKey := userSetKey(aud, userID)
tokens, err := s.client.SMembers(ctx, setKey).Result()
if err != nil {
return fmt.Errorf("list sessions: %w", err)
}
if len(tokens) > 0 {
keys := make([]string, len(tokens))
for i, t := range tokens {
keys[i] = sessionKey(aud, t)
}
if err := s.client.Del(ctx, keys...).Err(); err != nil {
return fmt.Errorf("revoke sessions: %w", err)
}
}
s.client.Del(ctx, setKey)
return nil
}
+123
View File
@@ -0,0 +1,123 @@
package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"backend/internal/modules/users"
"backend/internal/platform/security"
)
// UserFinder is the minimal slice of the users module this service needs.
// Defined here (consumer side) rather than depending on the full
// users.Repository, so auth only ever reads user records, never writes them.
type UserFinder interface {
FindByEmail(ctx context.Context, email string) (*users.User, error)
FindByID(ctx context.Context, id uuid.UUID) (*users.User, error)
}
type Service struct {
users UserFinder
refresh RefreshStore
jwtSecret string
accessTTL time.Duration
refreshTTL time.Duration
}
func NewService(userFinder UserFinder, refresh RefreshStore, jwtSecret string, accessTTL, refreshTTL time.Duration) *Service {
return &Service{
users: userFinder,
refresh: refresh,
jwtSecret: jwtSecret,
accessTTL: accessTTL,
refreshTTL: refreshTTL,
}
}
func (s *Service) RefreshTTL() time.Duration { return s.refreshTTL }
// Login authenticates a user for a specific space (aud) and enforces that
// the account's role matches that space (an admin account cannot log into
// the customer space and vice versa, even before the customer module ships).
func (s *Service) Login(ctx context.Context, aud security.Audience, expectedRole, email, password string) (*TokenPair, *users.User, error) {
user, err := s.users.FindByEmail(ctx, email)
if err != nil {
if errors.Is(err, users.ErrNotFound) {
return nil, nil, ErrInvalidCredentials
}
return nil, nil, err
}
if !user.IsActive {
return nil, nil, ErrAccountDisabled
}
if user.Role != expectedRole {
return nil, nil, ErrInvalidCredentials
}
ok, err := security.VerifyPassword(user.PasswordHash, password)
if err != nil || !ok {
return nil, nil, ErrInvalidCredentials
}
pair, err := s.issuePair(ctx, aud, user)
if err != nil {
return nil, nil, err
}
return pair, user, nil
}
// Refresh rotates a refresh token (single use) and issues a fresh pair. If
// the presented token is unknown to the store (already used, revoked, or
// expired) it is treated as invalid; genuine reuse of a stolen token simply
// fails from that point on since the old token was deleted at issuance time.
func (s *Service) Refresh(ctx context.Context, aud security.Audience, refreshToken string) (*TokenPair, error) {
newRefreshToken, userID, err := s.refresh.Rotate(ctx, aud, refreshToken, s.refreshTTL)
if err != nil {
return nil, err
}
user, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("lookup user for refresh: %w", err)
}
if !user.IsActive {
_ = s.refresh.RevokeAllForUser(ctx, aud, userID)
return nil, ErrAccountDisabled
}
access, err := security.IssueAccessToken(s.jwtSecret, s.accessTTL, user.ID, user.Role, aud)
if err != nil {
return nil, fmt.Errorf("issue access token: %w", err)
}
return &TokenPair{AccessToken: access, RefreshToken: newRefreshToken}, nil
}
func (s *Service) Logout(ctx context.Context, aud security.Audience, refreshToken string) error {
return s.refresh.Revoke(ctx, aud, refreshToken)
}
func (s *Service) LogoutAll(ctx context.Context, aud security.Audience, userID uuid.UUID) error {
return s.refresh.RevokeAllForUser(ctx, aud, userID)
}
func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*users.User, error) {
return s.users.FindByID(ctx, userID)
}
func (s *Service) issuePair(ctx context.Context, aud security.Audience, user *users.User) (*TokenPair, error) {
access, err := security.IssueAccessToken(s.jwtSecret, s.accessTTL, user.ID, user.Role, aud)
if err != nil {
return nil, fmt.Errorf("issue access token: %w", err)
}
refreshToken, err := s.refresh.Issue(ctx, aud, user.ID, s.refreshTTL)
if err != nil {
return nil, fmt.Errorf("issue refresh token: %w", err)
}
return &TokenPair{AccessToken: access, RefreshToken: refreshToken}, nil
}