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
+146
View File
@@ -0,0 +1,146 @@
package domain
import (
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
)
type UserRole string
const (
UserRoleAdmin UserRole = "ADMIN"
UserRoleBuyer UserRole = "BUYER"
MaxUsernameBytes = 128
MaxDeviceNameBytes = 128
MaxVersionBytes = 128
MinPasswordBytes = 12
MaxPasswordBytes = 72
)
type User struct {
ID string
Username string
PasswordHash string
Role UserRole
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
}
type Device struct {
ID string
Name string
TokenHash string
BoundUserID *string
AppVersion *string
AndroidVersion *string
PDDVersion *string
LastSeenAt *time.Time
IsEnabled bool
CreatedAt time.Time
UpdatedAt time.Time
}
type AdminSession struct {
ID string
TokenHash string
UserID string
ExpiresAt time.Time
RevokedAt *time.Time
CreatedAt time.Time
}
type AccessToken struct {
ID string
TokenHash string
UserID string
DeviceID string
ExpiresAt time.Time
RevokedAt *time.Time
CreatedAt time.Time
}
type AuthPrincipal struct {
UserID string
Username string
Role UserRole
SessionID string
DeviceID string
ExpiresAt time.Time
}
type AuthValidationError struct {
Fields map[string]string
}
func (e *AuthValidationError) Error() string {
return "authentication input validation failed"
}
func NormalizeUsername(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func ValidateUserInput(
username string,
password string,
role UserRole,
) error {
fields := make(map[string]string)
normalized := NormalizeUsername(username)
switch {
case normalized == "":
fields["username"] = "required"
case !utf8.ValidString(normalized):
fields["username"] = "must be valid UTF-8"
case len([]byte(normalized)) > MaxUsernameBytes:
fields["username"] = fmt.Sprintf(
"must not exceed %d UTF-8 bytes",
MaxUsernameBytes,
)
}
switch {
case password == "":
fields["password"] = "required"
case !utf8.ValidString(password):
fields["password"] = "must be valid UTF-8"
case len([]byte(password)) < MinPasswordBytes:
fields["password"] = fmt.Sprintf(
"must be at least %d UTF-8 bytes",
MinPasswordBytes,
)
case len([]byte(password)) > MaxPasswordBytes:
fields["password"] = fmt.Sprintf(
"must not exceed %d UTF-8 bytes",
MaxPasswordBytes,
)
}
if role != UserRoleAdmin && role != UserRoleBuyer {
fields["role"] = "must be ADMIN or BUYER"
}
if len(fields) > 0 {
return &AuthValidationError{Fields: fields}
}
return nil
}
func ValidateDeviceName(name string) error {
name = strings.TrimSpace(name)
switch {
case name == "":
return errors.New("device name is required")
case !utf8.ValidString(name):
return errors.New("device name must be valid UTF-8")
case len([]byte(name)) > MaxDeviceNameBytes:
return fmt.Errorf(
"device name must not exceed %d UTF-8 bytes",
MaxDeviceNameBytes,
)
default:
return nil
}
}
+63
View File
@@ -0,0 +1,63 @@
package domain
import (
"strings"
"testing"
)
func TestNormalizeUsernameAndValidateUserInput(t *testing.T) {
if got := NormalizeUsername(" Admin.User "); got != "admin.user" {
t.Fatalf("NormalizeUsername() = %q", got)
}
if err := ValidateUserInput(
"admin.user",
"strong-password",
UserRoleAdmin,
); err != nil {
t.Fatalf("ValidateUserInput(valid) error = %v", err)
}
for name, input := range map[string]struct {
username string
password string
role UserRole
}{
"blank username": {" ", "password", UserRoleAdmin},
"blank password": {"admin", "", UserRoleAdmin},
"short password": {"admin", "short", UserRoleAdmin},
"invalid password UTF-8": {
"admin",
string([]byte{0xff, 0xfe, 0xfd}),
UserRoleAdmin,
},
"oversized password": {
"admin",
strings.Repeat("x", MaxPasswordBytes+1),
UserRoleAdmin,
},
"invalid role": {"admin", "password", "AUDITOR"},
} {
t.Run(name, func(t *testing.T) {
if err := ValidateUserInput(
input.username,
input.password,
input.role,
); err == nil {
t.Fatal("ValidateUserInput() error = nil")
}
})
}
}
func TestValidateDeviceName(t *testing.T) {
if err := ValidateDeviceName("Test device"); err != nil {
t.Fatalf("ValidateDeviceName(valid) error = %v", err)
}
if err := ValidateDeviceName(" "); err == nil {
t.Fatal("ValidateDeviceName(blank) error = nil")
}
if err := ValidateDeviceName(
strings.Repeat("x", MaxDeviceNameBytes+1),
); err == nil {
t.Fatal("ValidateDeviceName(oversized) error = nil")
}
}
+23 -21
View File
@@ -33,30 +33,32 @@ const (
)
type PurchaseTask struct {
ID string
CreatorSubject string
SourceRef *string
Title string
Description string
SKU string
ImageAssetID string
Quantity int
MaxBudgetCents *int64
Currency string
Status TaskStatus
Version int64
CancelReason *string
CanceledAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
ID string
CreatorSubject string
CreatedByUserID *string
SourceRef *string
Title string
Description string
SKU string
ImageAssetID string
Quantity int
MaxBudgetCents *int64
Currency string
Status TaskStatus
Version int64
CancelReason *string
CanceledAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type TaskEvent struct {
ID string
TaskID string
Type string
Message string
OccurredAt time.Time
ID string
TaskID string
ActorUserID *string
Type string
Message string
OccurredAt time.Time
}
type TaskDetail struct {