Files
template-vitrine/backend/internal/modules/auth/service.go
T
2026-09-14 20:50:19 +02:00

124 lines
3.8 KiB
Go

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
}