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, ) }