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