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
+249
View File
@@ -0,0 +1,249 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"strings"
"time"
"cmroubao/backend-api/internal/config"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/database"
"cmroubao/backend-api/internal/platform/migration"
"cmroubao/backend-api/internal/platform/password"
repository "cmroubao/backend-api/internal/repository/sqlite"
"cmroubao/backend-api/internal/usecase"
)
const passwordEnvironment = "CMROUBAO_AUTH_PASSWORD"
func main() {
if err := run(
os.Args[1:],
os.LookupEnv,
os.Stdout,
); err != nil {
log.Printf("auth command failed: %v", err)
os.Exit(1)
}
}
func run(
arguments []string,
lookup config.LookupEnvironment,
output io.Writer,
) error {
command, value, role, err := parseArguments(arguments)
if err != nil {
return err
}
databasePath, err := config.LoadDatabasePath(lookup)
if err != nil {
return err
}
var plainPassword string
if command == "create-user" {
var exists bool
plainPassword, exists = lookup(passwordEnvironment)
if !exists || plainPassword == "" {
return errors.New(
passwordEnvironment + " must be set for create-user",
)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
db, err := database.Open(ctx, databasePath)
if err != nil {
return errors.New("database startup failed")
}
defer func() {
if err := db.Close(); err != nil {
log.Print("database close failed")
}
}()
runner, err := migration.New(db)
if err != nil {
return errors.New("migration setup failed")
}
if err := requireCurrentMigrations(ctx, runner); err != nil {
return err
}
store, err := repository.New(db)
if err != nil {
return errors.New("auth repository setup failed")
}
passwords, err := password.NewBcrypt(12)
if err != nil {
return errors.New("password manager setup failed")
}
service, err := usecase.NewAuthService(
store,
passwords,
usecase.SystemClock{},
usecase.UUIDGenerator{},
usecase.CryptoTokenGenerator{},
)
if err != nil {
return errors.New("auth service setup failed")
}
switch command {
case "create-user":
user, err := service.ProvisionUser(
ctx,
usecase.ProvisionUserCommand{
Username: value,
Password: plainPassword,
Role: role,
Active: true,
},
)
if err != nil {
return publicProvisioningError(err)
}
_, err = fmt.Fprintf(
output,
"user_id=%s username=%s role=%s\n",
user.ID,
user.Username,
user.Role,
)
return err
case "create-device":
result, err := service.ProvisionDevice(
ctx,
usecase.ProvisionDeviceCommand{
Name: value,
Enabled: true,
},
)
if err != nil {
return publicProvisioningError(err)
}
_, err = fmt.Fprintf(
output,
"device_id=%s\ndevice_token=%s\n",
result.Device.ID,
result.DeviceToken,
)
return err
case "enable-user", "disable-user":
active := command == "enable-user"
if err := service.SetUserActive(
ctx,
usecase.SetUserActiveCommand{
Username: value,
Active: active,
},
); err != nil {
return publicProvisioningError(err)
}
_, err = fmt.Fprintf(
output,
"username=%s active=%t\n",
domain.NormalizeUsername(value),
active,
)
return err
case "enable-device", "disable-device":
enabled := command == "enable-device"
if err := service.SetDeviceEnabled(
ctx,
usecase.SetDeviceEnabledCommand{
DeviceID: value,
Enabled: enabled,
},
); err != nil {
return publicProvisioningError(err)
}
_, err = fmt.Fprintf(
output,
"device_id=%s enabled=%t\n",
strings.TrimSpace(value),
enabled,
)
return err
default:
return errors.New("unsupported auth command")
}
}
func parseArguments(
arguments []string,
) (string, string, domain.UserRole, error) {
if len(arguments) == 2 && arguments[0] == "create-device" {
return "create-device", arguments[1], "", nil
}
if len(arguments) == 2 {
switch arguments[0] {
case "enable-user", "disable-user",
"enable-device", "disable-device":
return arguments[0], arguments[1], "", nil
}
}
if len(arguments) == 3 && arguments[0] == "create-user" {
role := domain.UserRole(strings.ToUpper(arguments[1]))
if role != domain.UserRoleAdmin && role != domain.UserRoleBuyer {
return "", "", "", errors.New(
"role must be ADMIN or BUYER",
)
}
return "create-user", arguments[2], role, nil
}
return "", "", "", errors.New(
"usage: authctl create-user <ADMIN|BUYER> <username> | " +
"authctl create-device <name> | " +
"authctl <enable-user|disable-user> <username> | " +
"authctl <enable-device|disable-device> <device-id>",
)
}
type migrationStatusReader interface {
Status(context.Context) ([]migration.Status, error)
}
func requireCurrentMigrations(
ctx context.Context,
reader migrationStatusReader,
) error {
statuses, err := reader.Status(ctx)
if err != nil {
return errors.New("database migration status failed")
}
if len(statuses) == 0 {
return errors.New("database has no known migrations")
}
for _, status := range statuses {
if !status.Applied {
return errors.New(
"database migrations are pending; run migrate up",
)
}
}
return nil
}
func publicProvisioningError(err error) error {
var typed *usecase.Error
if !errors.As(err, &typed) {
return errors.New("authentication provisioning failed")
}
switch typed.Kind {
case usecase.ErrorKindInvalid:
return errors.New("authentication input is invalid")
case usecase.ErrorKindConflict:
return errors.New("authentication resource already exists")
case usecase.ErrorKindUnavailable:
return errors.New("authentication storage is unavailable")
case usecase.ErrorKindNotFound:
return errors.New("authentication resource was not found")
default:
return errors.New("authentication provisioning failed")
}
}
+252
View File
@@ -0,0 +1,252 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"path/filepath"
"strings"
"testing"
"cmroubao/backend-api/internal/config"
"cmroubao/backend-api/internal/platform/database"
"cmroubao/backend-api/internal/platform/migration"
"golang.org/x/crypto/bcrypt"
)
func TestRunCreatesUserWithoutExposingPassword(t *testing.T) {
databasePath := migratedDatabase(t)
const secret = "correct-horse-password"
lookup := testLookup(databasePath, secret)
var output bytes.Buffer
err := run(
[]string{"create-user", "ADMIN", "Admin01"},
lookup,
&output,
)
if err != nil {
t.Fatalf("run(create-user) error = %v", err)
}
if strings.Contains(output.String(), secret) ||
!strings.Contains(output.String(), "username=admin01 role=ADMIN") {
t.Fatalf("output = %q", output.String())
}
db, err := database.Open(context.Background(), databasePath)
if err != nil {
t.Fatalf("database.Open() error = %v", err)
}
defer db.Close()
var hash string
if err := db.QueryRow(
"SELECT password_hash FROM users WHERE username = 'admin01'",
).Scan(&hash); err != nil {
t.Fatalf("query user: %v", err)
}
if hash == secret ||
bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret)) != nil {
t.Fatal("stored password is missing, plaintext, or invalid")
}
output.Reset()
err = run(
[]string{"create-user", "ADMIN", "admin01"},
lookup,
&output,
)
if err == nil ||
strings.Contains(err.Error(), secret) ||
output.Len() != 0 {
t.Fatalf("duplicate error/output = %v / %q", err, output.String())
}
}
func TestRunCreatesDeviceAndStoresOnlyTokenHash(t *testing.T) {
databasePath := migratedDatabase(t)
var output bytes.Buffer
err := run(
[]string{"create-device", "buyer-phone-01"},
testLookup(databasePath, ""),
&output,
)
if err != nil {
t.Fatalf("run(create-device) error = %v", err)
}
lines := strings.Split(strings.TrimSpace(output.String()), "\n")
if len(lines) != 2 ||
!strings.HasPrefix(lines[0], "device_id=") ||
!strings.HasPrefix(lines[1], "device_token=") {
t.Fatalf("output = %q", output.String())
}
deviceID := strings.TrimPrefix(lines[0], "device_id=")
token := strings.TrimPrefix(lines[1], "device_token=")
db, err := database.Open(context.Background(), databasePath)
if err != nil {
t.Fatalf("database.Open() error = %v", err)
}
defer db.Close()
var tokenHash string
if err := db.QueryRow(
"SELECT token_hash FROM devices WHERE id = ?",
deviceID,
).Scan(&tokenHash); err != nil {
t.Fatalf("query device: %v", err)
}
sum := sha256.Sum256([]byte(token))
if tokenHash == token ||
tokenHash != hex.EncodeToString(sum[:]) {
t.Fatal("stored device token is not the expected hash")
}
}
func TestRunEnablesAndDisablesUsersAndDevices(t *testing.T) {
databasePath := migratedDatabase(t)
lookup := testLookup(databasePath, "correct-horse-password")
if err := run(
[]string{"create-user", "BUYER", "buyer01"},
lookup,
&bytes.Buffer{},
); err != nil {
t.Fatalf("create user: %v", err)
}
var deviceOutput bytes.Buffer
if err := run(
[]string{"create-device", "buyer-phone-01"},
lookup,
&deviceOutput,
); err != nil {
t.Fatalf("create device: %v", err)
}
deviceID := strings.TrimPrefix(
strings.Split(strings.TrimSpace(deviceOutput.String()), "\n")[0],
"device_id=",
)
var output bytes.Buffer
if err := run(
[]string{"disable-user", "BUYER01"},
lookup,
&output,
); err != nil {
t.Fatalf("disable user: %v", err)
}
if !strings.Contains(output.String(), "username=buyer01 active=false") {
t.Fatalf("disable user output = %q", output.String())
}
output.Reset()
if err := run(
[]string{"disable-device", deviceID},
lookup,
&output,
); err != nil {
t.Fatalf("disable device: %v", err)
}
db, err := database.Open(context.Background(), databasePath)
if err != nil {
t.Fatalf("database.Open() error = %v", err)
}
defer db.Close()
var userActive, deviceEnabled bool
if err := db.QueryRow(
"SELECT is_active FROM users WHERE username = 'buyer01'",
).Scan(&userActive); err != nil {
t.Fatalf("query user status: %v", err)
}
if err := db.QueryRow(
"SELECT is_enabled FROM devices WHERE id = ?",
deviceID,
).Scan(&deviceEnabled); err != nil {
t.Fatalf("query device status: %v", err)
}
if userActive || deviceEnabled {
t.Fatalf(
"user/device enabled = %v/%v",
userActive,
deviceEnabled,
)
}
}
func TestRunRejectsWeakPasswordAndInvalidCommands(t *testing.T) {
databasePath := migratedDatabase(t)
err := run(
[]string{"create-user", "BUYER", "buyer"},
testLookup(databasePath, "too-short"),
&bytes.Buffer{},
)
if err == nil || err.Error() != "authentication input is invalid" {
t.Fatalf("weak password error = %v", err)
}
for _, arguments := range [][]string{
nil,
{"create-user", "OWNER", "user"},
{"create-device"},
{"unknown"},
} {
if err := run(
arguments,
testLookup(databasePath, ""),
&bytes.Buffer{},
); err == nil {
t.Fatalf("run(%v) error = nil", arguments)
}
}
}
func TestRunRequiresCurrentMigrations(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "pending.db")
err := run(
[]string{"create-device", "phone"},
testLookup(databasePath, ""),
&bytes.Buffer{},
)
if err == nil ||
err.Error() != "database migrations are pending; run migrate up" {
t.Fatalf("pending migration error = %v", err)
}
}
func migratedDatabase(t *testing.T) string {
t.Helper()
databasePath := filepath.Join(t.TempDir(), "authctl.db")
db, err := database.Open(context.Background(), databasePath)
if err != nil {
t.Fatalf("database.Open() error = %v", err)
}
runner, err := migration.New(db)
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if _, err := runner.Up(context.Background()); err != nil {
t.Fatalf("migration.Up() error = %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("db.Close() error = %v", err)
}
return databasePath
}
func testLookup(
databasePath string,
password string,
) config.LookupEnvironment {
return func(name string) (string, bool) {
switch name {
case config.DatabasePathEnvironment:
return databasePath, true
case passwordEnvironment:
if password == "" {
return "", false
}
return password, true
default:
return "", false
}
}
}