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