124 lines
3.9 KiB
Go
124 lines
3.9 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 {
|
|
FindByUsername(ctx context.Context, username 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, username, password string) (*TokenPair, *users.User, error) {
|
|
user, err := s.users.FindByUsername(ctx, username)
|
|
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
|
|
}
|