feat(auth): implement user and device authentication
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user