first
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user