feat(auth): implement user and device authentication

This commit is contained in:
QiuSW
2026-07-26 15:18:48 +08:00
parent c5d3b215ff
commit 49db5b8305
66 changed files with 6216 additions and 271 deletions
@@ -0,0 +1,7 @@
package usecase
import "encoding/base64"
func rawURLDecode(value string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(value)
}
@@ -0,0 +1,57 @@
package usecase
import "errors"
const (
ErrorKindUnauthorized ErrorKind = "UNAUTHORIZED"
ErrorKindForbidden ErrorKind = "FORBIDDEN"
)
func wrapAuthRepositoryError(err error) error {
switch {
case errors.Is(err, ErrAuthCredentials):
return newError(
ErrorKindUnauthorized,
"AUTH_INVALID_CREDENTIALS",
"invalid credentials",
err,
)
case errors.Is(err, ErrAuthDisabled):
return newError(
ErrorKindForbidden,
"AUTH_ACCOUNT_OR_DEVICE_DISABLED",
"account or device is disabled",
err,
)
case errors.Is(err, ErrAuthForbidden):
return newError(
ErrorKindForbidden,
"AUTH_FORBIDDEN",
"identity is not allowed to perform this operation",
err,
)
case errors.Is(err, ErrAuthExpired):
return newError(
ErrorKindUnauthorized,
"AUTH_SESSION_EXPIRED",
"session expired",
err,
)
case errors.Is(err, ErrAuthRevoked):
return newError(
ErrorKindUnauthorized,
"AUTH_INVALID_TOKEN",
"invalid token",
err,
)
case errors.Is(err, ErrAuthConflict):
return newError(
ErrorKindConflict,
"AUTH_RESOURCE_CONFLICT",
"authentication resource already exists",
err,
)
default:
return wrapRepositoryError(err)
}
}
@@ -0,0 +1,62 @@
package usecase
import (
"context"
"errors"
"time"
"cmroubao/backend-api/internal/domain"
)
type PasswordManager interface {
Hash(string) (string, error)
Verify(string, string) error
VerifyDummy(string)
}
type OpaqueTokenGenerator interface {
NewToken() (string, error)
}
type AuthRepository interface {
FindUserByUsername(context.Context, string) (domain.User, error)
ProvisionUser(context.Context, domain.User) (domain.User, error)
ProvisionDevice(context.Context, domain.Device) (domain.Device, error)
SetUserActive(context.Context, string, bool, time.Time) error
SetDeviceEnabled(context.Context, string, bool, time.Time) error
CreateAdminSession(
context.Context,
domain.AdminSession,
time.Time,
) error
AuthenticateAdminSession(
context.Context,
string,
time.Time,
) (domain.AuthPrincipal, error)
RevokeAdminSession(context.Context, string, time.Time) error
CreateAccessTokenAndBindDevice(
context.Context,
string,
string,
string,
string,
string,
domain.AccessToken,
time.Time,
) (domain.Device, error)
AuthenticateAccessToken(
context.Context,
string,
time.Time,
) (domain.AuthPrincipal, error)
}
var (
ErrAuthCredentials = errors.New("authentication credentials are invalid")
ErrAuthDisabled = errors.New("authentication subject is disabled")
ErrAuthForbidden = errors.New("authentication role is forbidden")
ErrAuthExpired = errors.New("authentication session expired")
ErrAuthRevoked = errors.New("authentication session revoked")
ErrAuthConflict = errors.New("authentication resource conflict")
)
@@ -0,0 +1,20 @@
package usecase
import (
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
const opaqueTokenBytes = 32
type CryptoTokenGenerator struct{}
func (CryptoTokenGenerator) NewToken() (string, error) {
value := make([]byte, opaqueTokenBytes)
if _, err := io.ReadFull(rand.Reader, value); err != nil {
return "", errors.New("generate opaque token")
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
@@ -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,
)
}
@@ -0,0 +1,481 @@
package usecase
import (
"context"
"encoding/base64"
"errors"
"fmt"
"testing"
"time"
"cmroubao/backend-api/internal/domain"
)
func TestAuthServiceAdminSessionLifecycle(t *testing.T) {
fixture := newAuthServiceFixture(t)
fixture.repository.users["admin"] = domain.User{
ID: "user-admin",
Username: "admin",
PasswordHash: "hashed:password-123",
Role: domain.UserRoleAdmin,
IsActive: true,
}
result, err := fixture.service.LoginAdmin(
context.Background(),
LoginAdminCommand{Username: " ADMIN ", Password: "password-123"},
)
if err != nil {
t.Fatalf("LoginAdmin() error = %v", err)
}
if result.ExpiresAt.Sub(fixture.clock.now) != AdminSessionLifetime {
t.Fatalf("admin lifetime = %s", result.ExpiresAt.Sub(fixture.clock.now))
}
if result.User.PasswordHash != "" {
t.Fatal("LoginAdmin() exposed password hash")
}
if fixture.repository.adminSession.TokenHash == result.Token ||
len(fixture.repository.adminSession.TokenHash) != 64 {
t.Fatal("admin session was not stored as a SHA-256 hash")
}
fixture.repository.adminPrincipal = domain.AuthPrincipal{
UserID: result.User.ID,
Username: result.User.Username,
Role: domain.UserRoleAdmin,
SessionID: fixture.repository.adminSession.ID,
ExpiresAt: result.ExpiresAt,
}
principal, err := fixture.service.AuthenticateAdmin(
context.Background(),
result.Token,
)
if err != nil {
t.Fatalf("AuthenticateAdmin() error = %v", err)
}
if principal.Role != domain.UserRoleAdmin || principal.DeviceID != "" {
t.Fatalf("AuthenticateAdmin() principal = %+v", principal)
}
if err := fixture.service.LogoutAdmin(
context.Background(),
result.Token,
); err != nil {
t.Fatalf("LogoutAdmin() error = %v", err)
}
if fixture.repository.revokedHash !=
fixture.repository.adminSession.TokenHash {
t.Fatal("LogoutAdmin() did not revoke the hashed token")
}
}
func TestAuthServiceBuyerDeviceTokenLifecycle(t *testing.T) {
fixture := newAuthServiceFixture(t)
fixture.repository.users["buyer"] = domain.User{
ID: "user-buyer",
Username: "buyer",
PasswordHash: "hashed:password-123",
Role: domain.UserRoleBuyer,
IsActive: true,
}
deviceToken := validTestToken(99)
fixture.repository.device = domain.Device{
ID: "device-1",
Name: "Device",
TokenHash: hashSecret(deviceToken),
IsEnabled: true,
}
result, err := fixture.service.LoginBuyerDevice(
context.Background(),
LoginBuyerDeviceCommand{
Username: "buyer",
Password: "password-123",
DeviceID: "device-1",
DeviceToken: deviceToken,
AppVersion: "0.1.0",
AndroidVersion: "16",
},
)
if err != nil {
t.Fatalf("LoginBuyerDevice() error = %v", err)
}
if result.ExpiresAt.Sub(fixture.clock.now) != AccessTokenLifetime {
t.Fatalf("access lifetime = %s", result.ExpiresAt.Sub(fixture.clock.now))
}
if result.User.PasswordHash != "" || result.Device.TokenHash != "" {
t.Fatal("LoginBuyerDevice() exposed a credential hash")
}
if fixture.repository.access.TokenHash == result.Token ||
len(fixture.repository.access.TokenHash) != 64 {
t.Fatal("access token was not stored as a SHA-256 hash")
}
if fixture.repository.deviceTokenHash != hashSecret(deviceToken) {
t.Fatal("device credential was not passed as a hash")
}
fixture.repository.accessPrincipal = domain.AuthPrincipal{
UserID: result.User.ID,
Username: result.User.Username,
Role: domain.UserRoleBuyer,
SessionID: fixture.repository.access.ID,
DeviceID: result.Device.ID,
ExpiresAt: result.ExpiresAt,
}
principal, err := fixture.service.AuthenticateAccessToken(
context.Background(),
result.Token,
)
if err != nil {
t.Fatalf("AuthenticateAccessToken() error = %v", err)
}
if principal.DeviceID != "device-1" {
t.Fatalf("AuthenticateAccessToken() principal = %+v", principal)
}
}
func TestAuthServiceRejectsWrongRoleDisabledAndMalformedTokens(t *testing.T) {
fixture := newAuthServiceFixture(t)
fixture.repository.users["buyer"] = domain.User{
ID: "buyer",
Username: "buyer",
PasswordHash: "hashed:password-123",
Role: domain.UserRoleBuyer,
IsActive: true,
}
_, err := fixture.service.LoginAdmin(
context.Background(),
LoginAdminCommand{Username: "buyer", Password: "password-123"},
)
assertAuthCode(t, err, "AUTH_INVALID_CREDENTIALS")
fixture.repository.users["buyer"] = domain.User{
ID: "buyer",
Username: "buyer",
PasswordHash: "hashed:password-123",
Role: domain.UserRoleBuyer,
IsActive: false,
}
_, err = fixture.service.LoginBuyerDevice(
context.Background(),
LoginBuyerDeviceCommand{
Username: "buyer",
Password: "password-123",
DeviceID: "device",
DeviceToken: validTestToken(90),
AppVersion: "0.1.0",
AndroidVersion: "16",
},
)
assertAuthCode(t, err, "AUTH_ACCOUNT_OR_DEVICE_DISABLED")
_, err = fixture.service.AuthenticateAdmin(
context.Background(),
"not-a-token",
)
assertAuthCode(t, err, "AUTH_INVALID_TOKEN")
}
func TestAuthServiceProvisionsHashedCredentials(t *testing.T) {
fixture := newAuthServiceFixture(t)
user, err := fixture.service.ProvisionUser(
context.Background(),
ProvisionUserCommand{
Username: " Admin ",
Password: "password-123",
Role: domain.UserRoleAdmin,
Active: true,
},
)
if err != nil {
t.Fatalf("ProvisionUser() error = %v", err)
}
if user.Username != "admin" || user.PasswordHash != "" {
t.Fatalf("ProvisionUser() = %+v", user)
}
stored := fixture.repository.users["admin"]
if stored.PasswordHash != "hashed:password-123" {
t.Fatalf("stored password hash = %q", stored.PasswordHash)
}
device, err := fixture.service.ProvisionDevice(
context.Background(),
ProvisionDeviceCommand{Name: " Test Device ", Enabled: true},
)
if err != nil {
t.Fatalf("ProvisionDevice() error = %v", err)
}
if device.DeviceToken == "" || device.Device.TokenHash != "" {
t.Fatalf("ProvisionDevice() = %+v", device)
}
if fixture.repository.device.TokenHash == device.DeviceToken ||
fixture.repository.device.TokenHash != hashSecret(device.DeviceToken) {
t.Fatal("stored device token is not a SHA-256 hash")
}
}
func TestAuthServiceChangesUserAndDeviceStatus(t *testing.T) {
fixture := newAuthServiceFixture(t)
if err := fixture.service.SetUserActive(
context.Background(),
SetUserActiveCommand{Username: " Buyer ", Active: false},
); err != nil {
t.Fatalf("SetUserActive() error = %v", err)
}
deviceID := "00000000-0000-4000-8000-000000000099"
if err := fixture.service.SetDeviceEnabled(
context.Background(),
SetDeviceEnabledCommand{DeviceID: deviceID, Enabled: false},
); err != nil {
t.Fatalf("SetDeviceEnabled() error = %v", err)
}
if fixture.repository.userStatusName != "buyer" ||
fixture.repository.userActive ||
fixture.repository.deviceStatusID != deviceID ||
fixture.repository.deviceEnabled {
t.Fatalf(
"user/device state = %q/%v %q/%v",
fixture.repository.userStatusName,
fixture.repository.userActive,
fixture.repository.deviceStatusID,
fixture.repository.deviceEnabled,
)
}
err := fixture.service.SetDeviceEnabled(
context.Background(),
SetDeviceEnabledCommand{DeviceID: "not-a-uuid", Enabled: true},
)
assertAuthCode(t, err, "AUTH_VALIDATION_FAILED")
}
func TestAuthServiceUnknownUserUsesPrecomputedDummyVerification(t *testing.T) {
fixture := newAuthServiceFixture(t)
_, err := fixture.service.LoginAdmin(
context.Background(),
LoginAdminCommand{
Username: "missing-user",
Password: "password-123",
},
)
assertAuthCode(t, err, "AUTH_INVALID_CREDENTIALS")
if fixture.passwords.dummyVerifications != 1 {
t.Fatalf(
"dummy verifications = %d, want 1",
fixture.passwords.dummyVerifications,
)
}
if fixture.passwords.hashCalls != 0 {
t.Fatalf("Hash() calls for missing user = %d", fixture.passwords.hashCalls)
}
}
func assertAuthCode(t *testing.T, err error, code string) {
t.Helper()
var authError *Error
if !errors.As(err, &authError) || authError.Code != code {
t.Fatalf("error = %v, want code %s", err, code)
}
}
type authServiceFixture struct {
service *AuthService
repository *fakeAuthRepository
clock fixedAuthClock
passwords *fakePasswordManager
}
func newAuthServiceFixture(t *testing.T) authServiceFixture {
t.Helper()
repository := &fakeAuthRepository{
users: make(map[string]domain.User),
}
clock := fixedAuthClock{
now: time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC),
}
passwords := &fakePasswordManager{}
service, err := NewAuthService(
repository,
passwords,
clock,
&sequenceIDGenerator{},
&sequenceTokenGenerator{},
)
if err != nil {
t.Fatalf("NewAuthService() error = %v", err)
}
return authServiceFixture{
service: service,
repository: repository,
clock: clock,
passwords: passwords,
}
}
type fixedAuthClock struct {
now time.Time
}
func (clock fixedAuthClock) Now() time.Time {
return clock.now
}
type fakePasswordManager struct {
hashCalls int
dummyVerifications int
}
func (manager *fakePasswordManager) Hash(value string) (string, error) {
manager.hashCalls++
return "hashed:" + value, nil
}
func (*fakePasswordManager) Verify(encoded, plain string) error {
if encoded != "hashed:"+plain {
return errors.New("mismatch")
}
return nil
}
func (manager *fakePasswordManager) VerifyDummy(string) {
manager.dummyVerifications++
}
type sequenceIDGenerator struct {
next int
}
func (generator *sequenceIDGenerator) NewID() (string, error) {
generator.next++
return fmt.Sprintf("id-%d", generator.next), nil
}
type sequenceTokenGenerator struct {
next byte
}
func (generator *sequenceTokenGenerator) NewToken() (string, error) {
generator.next++
return validTestToken(generator.next), nil
}
func validTestToken(seed byte) string {
value := make([]byte, opaqueTokenBytes)
for index := range value {
value[index] = seed + byte(index)
}
return base64.RawURLEncoding.EncodeToString(value)
}
type fakeAuthRepository struct {
users map[string]domain.User
device domain.Device
adminSession domain.AdminSession
adminPrincipal domain.AuthPrincipal
access domain.AccessToken
accessPrincipal domain.AuthPrincipal
deviceTokenHash string
revokedHash string
authenticationErr error
userActive bool
userStatusName string
deviceEnabled bool
deviceStatusID string
}
func (repository *fakeAuthRepository) FindUserByUsername(
_ context.Context,
username string,
) (domain.User, error) {
user, found := repository.users[username]
if !found {
return domain.User{}, ErrRepositoryNotFound
}
return user, nil
}
func (repository *fakeAuthRepository) ProvisionUser(
_ context.Context,
user domain.User,
) (domain.User, error) {
repository.users[user.Username] = user
return user, nil
}
func (repository *fakeAuthRepository) ProvisionDevice(
_ context.Context,
device domain.Device,
) (domain.Device, error) {
repository.device = device
return device, nil
}
func (repository *fakeAuthRepository) SetUserActive(
_ context.Context,
username string,
active bool,
_ time.Time,
) error {
repository.userStatusName = username
repository.userActive = active
return nil
}
func (repository *fakeAuthRepository) SetDeviceEnabled(
_ context.Context,
deviceID string,
enabled bool,
_ time.Time,
) error {
repository.deviceStatusID = deviceID
repository.deviceEnabled = enabled
return nil
}
func (repository *fakeAuthRepository) CreateAdminSession(
_ context.Context,
session domain.AdminSession,
_ time.Time,
) error {
repository.adminSession = session
return nil
}
func (repository *fakeAuthRepository) AuthenticateAdminSession(
_ context.Context,
_ string,
_ time.Time,
) (domain.AuthPrincipal, error) {
return repository.adminPrincipal, repository.authenticationErr
}
func (repository *fakeAuthRepository) RevokeAdminSession(
_ context.Context,
tokenHash string,
_ time.Time,
) error {
repository.revokedHash = tokenHash
return nil
}
func (repository *fakeAuthRepository) CreateAccessTokenAndBindDevice(
_ context.Context,
_ string,
_ string,
deviceTokenHash string,
_ string,
_ string,
access domain.AccessToken,
_ time.Time,
) (domain.Device, error) {
repository.deviceTokenHash = deviceTokenHash
repository.access = access
return repository.device, nil
}
func (repository *fakeAuthRepository) AuthenticateAccessToken(
_ context.Context,
_ string,
_ time.Time,
) (domain.AuthPrincipal, error) {
return repository.accessPrincipal, repository.authenticationErr
}
+45 -24
View File
@@ -29,6 +29,7 @@ type TaskService struct {
type CreateTaskCommand struct {
CreatorSubject string
ActorUserID string
IdempotencyKey string
SourceRef *string
Title string
@@ -61,6 +62,7 @@ type TaskPage struct {
type CancelTaskCommand struct {
CreatorSubject string
ActorUserID string
TaskID string
Reason string
}
@@ -91,6 +93,7 @@ func (s *TaskService) Create(
return CreateTaskResult{}, err
}
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
command.Title = strings.TrimSpace(command.Title)
command.SKU = strings.TrimSpace(command.SKU)
command.ImageAssetID = strings.TrimSpace(command.ImageAssetID)
@@ -105,6 +108,13 @@ func (s *TaskService) Create(
map[string]string{"image_asset_id": "must be a UUID"},
)
}
if !isUUID(command.ActorUserID) {
return CreateTaskResult{}, invalidError(
"TASK_VALIDATION_FAILED",
"task validation failed",
map[string]string{"actor_user_id": "must be a UUID"},
)
}
if err := domain.ValidateTaskInput(
command.CreatorSubject,
command.SourceRef,
@@ -156,28 +166,31 @@ func (s *TaskService) Create(
)
}
now := s.clock.Now().UTC()
actorUserID := command.ActorUserID
task := domain.PurchaseTask{
ID: taskID,
CreatorSubject: command.CreatorSubject,
SourceRef: command.SourceRef,
Title: command.Title,
Description: command.Description,
SKU: command.SKU,
ImageAssetID: command.ImageAssetID,
Quantity: command.Quantity,
MaxBudgetCents: budget,
Currency: domain.CurrencyCNY,
Status: domain.TaskStatusPending,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
ID: taskID,
CreatorSubject: command.CreatorSubject,
CreatedByUserID: &actorUserID,
SourceRef: command.SourceRef,
Title: command.Title,
Description: command.Description,
SKU: command.SKU,
ImageAssetID: command.ImageAssetID,
Quantity: command.Quantity,
MaxBudgetCents: budget,
Currency: domain.CurrencyCNY,
Status: domain.TaskStatusPending,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}
event := domain.TaskEvent{
ID: eventID,
TaskID: taskID,
Type: "TASK_CREATED",
Message: "task created",
OccurredAt: now,
ID: eventID,
TaskID: taskID,
ActorUserID: &actorUserID,
Type: "TASK_CREATED",
Message: "task created",
OccurredAt: now,
}
requestHash, err := hashCreateTaskCommand(command, budget)
if err != nil {
@@ -323,6 +336,7 @@ func (s *TaskService) Cancel(
command CancelTaskCommand,
) (domain.PurchaseTask, error) {
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
command.TaskID = strings.TrimSpace(command.TaskID)
command.Reason = strings.TrimSpace(command.Reason)
fields := make(map[string]string)
@@ -332,6 +346,9 @@ func (s *TaskService) Cancel(
if !isUUID(command.TaskID) {
fields["task_id"] = "must be a UUID"
}
if !isUUID(command.ActorUserID) {
fields["actor_user_id"] = "must be a UUID"
}
if len([]byte(command.Reason)) > domain.MaxCancelReasonBytes {
fields["reason"] = "too long"
}
@@ -352,12 +369,14 @@ func (s *TaskService) Cancel(
)
}
now := s.clock.Now().UTC()
actorUserID := command.ActorUserID
event := domain.TaskEvent{
ID: eventID,
TaskID: command.TaskID,
Type: "TASK_CANCELED",
Message: "task canceled",
OccurredAt: now,
ID: eventID,
TaskID: command.TaskID,
ActorUserID: &actorUserID,
Type: "TASK_CANCELED",
Message: "task canceled",
OccurredAt: now,
}
task, err := s.repository.CancelPendingTask(
ctx,
@@ -378,6 +397,7 @@ func hashCreateTaskCommand(
budget *int64,
) (string, error) {
payload := struct {
ActorUserID string `json:"actor_user_id"`
SourceRef *string `json:"source_ref"`
Title string `json:"title"`
Description string `json:"description"`
@@ -386,6 +406,7 @@ func hashCreateTaskCommand(
Quantity int `json:"quantity"`
MaxBudgetCents *int64 `json:"max_budget_cents"`
}{
ActorUserID: command.ActorUserID,
SourceRef: command.SourceRef,
Title: command.Title,
Description: command.Description,
@@ -17,6 +17,7 @@ func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) {
result, err := service.Create(context.Background(), CreateTaskCommand{
CreatorSubject: " local-admin ",
ActorUserID: "00000000-0000-4000-8000-000000000099",
IdempotencyKey: " create-1 ",
SourceRef: &sourceRef,
Title: " Demo title ",
@@ -34,6 +35,9 @@ func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) {
result.Task.SKU != "SKU-1" ||
result.Task.SourceRef == nil ||
*result.Task.SourceRef != "source-1" ||
result.Task.CreatedByUserID == nil ||
*result.Task.CreatedByUserID !=
"00000000-0000-4000-8000-000000000099" ||
result.Task.MaxBudgetCents == nil ||
*result.Task.MaxBudgetCents != 2000 {
t.Fatalf("created task = %+v", result.Task)
@@ -46,7 +50,10 @@ func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) {
)
}
if repository.event.Type != "TASK_CREATED" ||
repository.event.TaskID != result.Task.ID {
repository.event.TaskID != result.Task.ID ||
repository.event.ActorUserID == nil ||
*repository.event.ActorUserID !=
"00000000-0000-4000-8000-000000000099" {
t.Fatalf("event = %+v", repository.event)
}
}
@@ -56,6 +63,7 @@ func TestTaskServiceCreateMapsValidationAndRepositoryErrors(t *testing.T) {
service := mustTaskService(t, repository)
_, err := service.Create(context.Background(), CreateTaskCommand{
CreatorSubject: "local-admin",
ActorUserID: "00000000-0000-4000-8000-000000000099",
IdempotencyKey: "create-1",
Title: "title",
SKU: "sku",
@@ -66,6 +74,7 @@ func TestTaskServiceCreateMapsValidationAndRepositoryErrors(t *testing.T) {
_, err = service.Create(context.Background(), CreateTaskCommand{
CreatorSubject: "local-admin",
ActorUserID: "00000000-0000-4000-8000-000000000099",
IdempotencyKey: "create-2",
Title: "",
SKU: "",
@@ -112,10 +121,16 @@ func TestTaskServiceCancelMapsStateConflict(t *testing.T) {
service := mustTaskService(t, repository)
_, err := service.Cancel(context.Background(), CancelTaskCommand{
CreatorSubject: "local-admin",
ActorUserID: "00000000-0000-4000-8000-000000000099",
TaskID: "00000000-0000-4000-8000-000000000001",
Reason: "no longer needed",
})
assertUsecaseError(t, err, ErrorKindConflict, "TASK_STATE_CONFLICT")
if repository.event.ActorUserID == nil ||
*repository.event.ActorUserID !=
"00000000-0000-4000-8000-000000000099" {
t.Fatalf("cancel event = %+v", repository.event)
}
}
type fakeClock struct{}
@@ -194,8 +209,9 @@ func (repository *fakeTaskRepository) CancelPendingTask(
_ string,
_ string,
_ time.Time,
_ domain.TaskEvent,
event domain.TaskEvent,
) (domain.PurchaseTask, error) {
repository.event = event
return domain.PurchaseTask{}, repository.cancelErr
}