Files
cmroubao/backend-api/internal/usecase/auth_service.go
T

592 lines
14 KiB
Go
Raw Normal View History

package usecase
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
"unicode/utf8"
"cmroubao/backend-api/internal/domain"
)
const (
AdminSessionLifetime = 8 * time.Hour
AccessTokenLifetime = time.Hour
)
type AuthService struct {
repository AuthRepository
passwords PasswordManager
clock Clock
ids IDGenerator
tokens OpaqueTokenGenerator
}
type LoginAdminCommand struct {
Username string
Password string
}
type AdminSessionResult struct {
Token string
ExpiresAt time.Time
User domain.User
}
type LoginBuyerDeviceCommand struct {
Username string
Password string
DeviceID string
DeviceToken string
AppVersion string
AndroidVersion string
}
type AccessTokenResult struct {
Token string
ExpiresAt time.Time
User domain.User
Device domain.Device
}
type ProvisionUserCommand struct {
Username string
Password string
Role domain.UserRole
Active bool
}
type ProvisionDeviceCommand struct {
Name string
Enabled bool
}
type ProvisionDeviceResult struct {
Device domain.Device
DeviceToken string
}
type SetUserActiveCommand struct {
Username string
Active bool
}
2026-07-29 08:43:35 +08:00
type ResetUserPasswordCommand struct {
Username string
Password string
}
type SetDeviceEnabledCommand struct {
DeviceID string
Enabled bool
}
func NewAuthService(
repository AuthRepository,
passwords PasswordManager,
clock Clock,
ids IDGenerator,
tokens OpaqueTokenGenerator,
) (*AuthService, error) {
switch {
case repository == nil:
return nil, errors.New("auth repository is required")
case passwords == nil:
return nil, errors.New("password manager is required")
case clock == nil:
return nil, errors.New("clock is required")
case ids == nil:
return nil, errors.New("ID generator is required")
case tokens == nil:
return nil, errors.New("opaque token generator is required")
default:
return &AuthService{
repository: repository,
passwords: passwords,
clock: clock,
ids: ids,
tokens: tokens,
}, nil
}
}
func (s *AuthService) LoginAdmin(
ctx context.Context,
command LoginAdminCommand,
) (AdminSessionResult, error) {
username, err := validateLoginInput(command.Username, command.Password)
if err != nil {
return AdminSessionResult{}, err
}
user, err := s.verifiedUser(ctx, username, command.Password)
if err != nil {
return AdminSessionResult{}, err
}
if !user.IsActive {
return AdminSessionResult{}, wrapAuthRepositoryError(ErrAuthDisabled)
}
if user.Role != domain.UserRoleAdmin {
return AdminSessionResult{}, wrapAuthRepositoryError(ErrAuthCredentials)
}
rawToken, session, now, err := s.newAdminSession(user.ID)
if err != nil {
return AdminSessionResult{}, err
}
if err := s.repository.CreateAdminSession(ctx, session, now); err != nil {
return AdminSessionResult{}, wrapAuthRepositoryError(err)
}
user.PasswordHash = ""
return AdminSessionResult{
Token: rawToken,
ExpiresAt: session.ExpiresAt,
User: user,
}, nil
}
func (s *AuthService) AuthenticateAdmin(
ctx context.Context,
rawToken string,
) (domain.AuthPrincipal, error) {
if !validOpaqueToken(rawToken) {
return domain.AuthPrincipal{},
wrapAuthRepositoryError(ErrAuthRevoked)
}
principal, err := s.repository.AuthenticateAdminSession(
ctx,
hashSecret(rawToken),
s.clock.Now().UTC(),
)
if err != nil {
return domain.AuthPrincipal{}, wrapAuthRepositoryError(err)
}
if principal.Role != domain.UserRoleAdmin || principal.DeviceID != "" {
return domain.AuthPrincipal{},
wrapAuthRepositoryError(ErrAuthForbidden)
}
return principal, nil
}
func (s *AuthService) LogoutAdmin(
ctx context.Context,
rawToken string,
) error {
if !validOpaqueToken(rawToken) {
return nil
}
if err := s.repository.RevokeAdminSession(
ctx,
hashSecret(rawToken),
s.clock.Now().UTC(),
); err != nil {
return wrapAuthRepositoryError(err)
}
return nil
}
func (s *AuthService) LoginBuyerDevice(
ctx context.Context,
command LoginBuyerDeviceCommand,
) (AccessTokenResult, error) {
username, err := validateLoginInput(command.Username, command.Password)
if err != nil {
return AccessTokenResult{}, err
}
if strings.TrimSpace(command.DeviceID) == "" ||
!validOpaqueToken(command.DeviceToken) {
return AccessTokenResult{},
wrapAuthRepositoryError(ErrAuthCredentials)
}
appVersion := strings.TrimSpace(command.AppVersion)
androidVersion := strings.TrimSpace(command.AndroidVersion)
versionFields := make(map[string]string)
if appVersion == "" {
versionFields["app_version"] = "required"
} else if len([]byte(appVersion)) > domain.MaxVersionBytes {
versionFields["app_version"] = "must not exceed 128 UTF-8 bytes"
}
if androidVersion == "" {
versionFields["android_version"] = "required"
} else if len([]byte(androidVersion)) > domain.MaxVersionBytes {
versionFields["android_version"] =
"must not exceed 128 UTF-8 bytes"
}
if len(versionFields) > 0 {
return AccessTokenResult{}, invalidError(
"AUTH_VALIDATION_FAILED",
"authentication input is invalid",
versionFields,
)
}
user, err := s.verifiedUser(ctx, username, command.Password)
if err != nil {
return AccessTokenResult{}, err
}
if !user.IsActive {
return AccessTokenResult{}, wrapAuthRepositoryError(ErrAuthDisabled)
}
if user.Role != domain.UserRoleBuyer {
return AccessTokenResult{},
wrapAuthRepositoryError(ErrAuthCredentials)
}
rawToken, access, now, err := s.newAccessToken(
user.ID,
strings.TrimSpace(command.DeviceID),
)
if err != nil {
return AccessTokenResult{}, err
}
device, err := s.repository.CreateAccessTokenAndBindDevice(
ctx,
user.ID,
access.DeviceID,
hashSecret(command.DeviceToken),
appVersion,
androidVersion,
access,
now,
)
if err != nil {
return AccessTokenResult{}, wrapAuthRepositoryError(err)
}
user.PasswordHash = ""
device.TokenHash = ""
return AccessTokenResult{
Token: rawToken,
ExpiresAt: access.ExpiresAt,
User: user,
Device: device,
}, nil
}
func (s *AuthService) AuthenticateAccessToken(
ctx context.Context,
rawToken string,
) (domain.AuthPrincipal, error) {
if !validOpaqueToken(rawToken) {
return domain.AuthPrincipal{},
wrapAuthRepositoryError(ErrAuthRevoked)
}
principal, err := s.repository.AuthenticateAccessToken(
ctx,
hashSecret(rawToken),
s.clock.Now().UTC(),
)
if err != nil {
return domain.AuthPrincipal{}, wrapAuthRepositoryError(err)
}
if principal.Role != domain.UserRoleBuyer || principal.DeviceID == "" {
return domain.AuthPrincipal{},
wrapAuthRepositoryError(ErrAuthForbidden)
}
return principal, nil
}
func (s *AuthService) ProvisionUser(
ctx context.Context,
command ProvisionUserCommand,
) (domain.User, error) {
if err := domain.ValidateUserInput(
command.Username,
command.Password,
command.Role,
); err != nil {
var validation *domain.AuthValidationError
if errors.As(err, &validation) {
return domain.User{}, invalidError(
"AUTH_VALIDATION_FAILED",
"authentication input is invalid",
validation.Fields,
)
}
return domain.User{}, invalidError(
"AUTH_VALIDATION_FAILED",
"authentication input is invalid",
map[string]string{},
)
}
passwordHash, err := s.passwords.Hash(command.Password)
if err != nil {
return domain.User{}, internalAuthFailure(err)
}
id, err := s.ids.NewID()
if err != nil {
return domain.User{}, internalAuthFailure(err)
}
now := s.clock.Now().UTC()
user, err := s.repository.ProvisionUser(ctx, domain.User{
ID: id,
Username: domain.NormalizeUsername(command.Username),
PasswordHash: passwordHash,
Role: command.Role,
IsActive: command.Active,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return domain.User{}, wrapAuthRepositoryError(err)
}
user.PasswordHash = ""
return user, nil
}
func (s *AuthService) ProvisionDevice(
ctx context.Context,
command ProvisionDeviceCommand,
) (ProvisionDeviceResult, error) {
if err := domain.ValidateDeviceName(command.Name); err != nil {
return ProvisionDeviceResult{}, invalidError(
"AUTH_VALIDATION_FAILED",
"authentication input is invalid",
map[string]string{"name": err.Error()},
)
}
id, err := s.ids.NewID()
if err != nil {
return ProvisionDeviceResult{}, internalAuthFailure(err)
}
rawToken, err := s.tokens.NewToken()
if err != nil {
return ProvisionDeviceResult{}, internalAuthFailure(err)
}
if !validOpaqueToken(rawToken) {
return ProvisionDeviceResult{},
internalAuthFailure(errors.New("token generator returned invalid token"))
}
now := s.clock.Now().UTC()
device, err := s.repository.ProvisionDevice(ctx, domain.Device{
ID: id,
Name: strings.TrimSpace(command.Name),
TokenHash: hashSecret(rawToken),
IsEnabled: command.Enabled,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return ProvisionDeviceResult{}, wrapAuthRepositoryError(err)
}
device.TokenHash = ""
return ProvisionDeviceResult{
Device: device,
DeviceToken: rawToken,
}, nil
}
func (s *AuthService) SetUserActive(
ctx context.Context,
command SetUserActiveCommand,
) error {
username := domain.NormalizeUsername(command.Username)
if username == "" ||
!utf8.ValidString(username) ||
len([]byte(username)) > domain.MaxUsernameBytes {
return invalidError(
"AUTH_VALIDATION_FAILED",
"authentication input is invalid",
map[string]string{"username": "invalid"},
)
}
if err := s.repository.SetUserActive(
ctx,
username,
command.Active,
s.clock.Now().UTC(),
); err != nil {
return wrapAuthRepositoryError(err)
}
return nil
}
2026-07-29 08:43:35 +08:00
func (s *AuthService) ResetUserPassword(
ctx context.Context,
command ResetUserPasswordCommand,
) error {
username := domain.NormalizeUsername(command.Username)
user, err := s.repository.FindUserByUsername(ctx, username)
if err != nil {
return wrapAuthRepositoryError(err)
}
if err := domain.ValidateUserInput(
username,
command.Password,
user.Role,
); err != nil {
var validation *domain.AuthValidationError
if errors.As(err, &validation) {
return invalidError(
"AUTH_VALIDATION_FAILED",
"authentication input is invalid",
validation.Fields,
)
}
return invalidError(
"AUTH_VALIDATION_FAILED",
"authentication input is invalid",
map[string]string{},
)
}
passwordHash, err := s.passwords.Hash(command.Password)
if err != nil {
return internalAuthFailure(err)
}
if err := s.repository.ResetUserPassword(
ctx,
username,
passwordHash,
s.clock.Now().UTC(),
); err != nil {
return wrapAuthRepositoryError(err)
}
return nil
}
func (s *AuthService) SetDeviceEnabled(
ctx context.Context,
command SetDeviceEnabledCommand,
) error {
deviceID := strings.TrimSpace(command.DeviceID)
if !isUUID(deviceID) {
return invalidError(
"AUTH_VALIDATION_FAILED",
"authentication input is invalid",
map[string]string{"device_id": "must be a UUID"},
)
}
if err := s.repository.SetDeviceEnabled(
ctx,
deviceID,
command.Enabled,
s.clock.Now().UTC(),
); err != nil {
return wrapAuthRepositoryError(err)
}
return nil
}
func (s *AuthService) verifiedUser(
ctx context.Context,
username string,
password string,
) (domain.User, error) {
user, err := s.repository.FindUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, ErrRepositoryNotFound) {
s.passwords.VerifyDummy(password)
return domain.User{},
wrapAuthRepositoryError(ErrAuthCredentials)
}
return domain.User{}, wrapAuthRepositoryError(err)
}
if err := s.passwords.Verify(user.PasswordHash, password); err != nil {
return domain.User{}, wrapAuthRepositoryError(ErrAuthCredentials)
}
return user, nil
}
func (s *AuthService) newAdminSession(
userID string,
) (string, domain.AdminSession, time.Time, error) {
rawToken, err := s.tokens.NewToken()
if err != nil {
return "", domain.AdminSession{}, time.Time{},
internalAuthFailure(err)
}
if !validOpaqueToken(rawToken) {
return "", domain.AdminSession{}, time.Time{},
internalAuthFailure(errors.New("token generator returned invalid token"))
}
id, err := s.ids.NewID()
if err != nil {
return "", domain.AdminSession{}, time.Time{},
internalAuthFailure(err)
}
now := s.clock.Now().UTC()
return rawToken, domain.AdminSession{
ID: id,
TokenHash: hashSecret(rawToken),
UserID: userID,
ExpiresAt: now.Add(AdminSessionLifetime),
CreatedAt: now,
}, now, nil
}
func (s *AuthService) newAccessToken(
userID string,
deviceID string,
) (string, domain.AccessToken, time.Time, error) {
rawToken, err := s.tokens.NewToken()
if err != nil {
return "", domain.AccessToken{}, time.Time{},
internalAuthFailure(err)
}
if !validOpaqueToken(rawToken) {
return "", domain.AccessToken{}, time.Time{},
internalAuthFailure(errors.New("token generator returned invalid token"))
}
id, err := s.ids.NewID()
if err != nil {
return "", domain.AccessToken{}, time.Time{},
internalAuthFailure(err)
}
now := s.clock.Now().UTC()
return rawToken, domain.AccessToken{
ID: id,
TokenHash: hashSecret(rawToken),
UserID: userID,
DeviceID: deviceID,
ExpiresAt: now.Add(AccessTokenLifetime),
CreatedAt: now,
}, now, nil
}
func validateLoginInput(username, password string) (string, error) {
normalized := domain.NormalizeUsername(username)
fields := make(map[string]string)
if normalized == "" ||
len([]byte(normalized)) > domain.MaxUsernameBytes {
fields["username"] = "invalid"
}
if password == "" ||
!utf8.ValidString(password) ||
len([]byte(password)) > domain.MaxPasswordBytes {
fields["password"] = "invalid"
}
if len(fields) > 0 {
return "", invalidError(
"AUTH_VALIDATION_FAILED",
"authentication input is invalid",
fields,
)
}
return normalized, nil
}
func hashSecret(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func validOpaqueToken(value string) bool {
decoded, err := decodeOpaqueToken(value)
return err == nil && len(decoded) == opaqueTokenBytes
}
func decodeOpaqueToken(value string) ([]byte, error) {
// Tokens are generated with RawURLEncoding; accepting padding would create
// multiple textual representations of the same credential.
return rawURLDecode(value)
}
func internalAuthFailure(err error) error {
return newError(
ErrorKindInternal,
"INTERNAL_ERROR",
"internal server error",
err,
)
}