544 lines
13 KiB
Go
544 lines
13 KiB
Go
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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,
|
|
)
|
|
}
|