From 49db5b830561a6d5d27863ca8880f78b2b89ef63 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 26 Jul 2026 15:18:48 +0800 Subject: [PATCH] feat(auth): implement user and device authentication --- README.md | 8 +- backend-api/README.md | 34 +- backend-api/cmd/api/main.go | 69 ++- backend-api/cmd/api/main_test.go | 43 ++ backend-api/cmd/authctl/main.go | 249 ++++++++ backend-api/cmd/authctl/main_test.go | 252 ++++++++ backend-api/go.mod | 2 +- backend-api/internal/config/config.go | 69 +++ backend-api/internal/config/config_test.go | 29 + backend-api/internal/domain/auth.go | 146 +++++ backend-api/internal/domain/auth_test.go | 63 ++ backend-api/internal/domain/task.go | 44 +- .../platform/migration/runner_test.go | 9 +- .../internal/platform/password/bcrypt.go | 69 +++ .../internal/platform/password/bcrypt_test.go | 67 +++ .../repository/sqlite/auth_repository.go | 538 +++++++++++++++++ .../repository/sqlite/auth_repository_test.go | 533 +++++++++++++++++ .../internal/repository/sqlite/helpers.go | 7 +- .../internal/repository/sqlite/store_test.go | 42 +- .../repository/sqlite/task_repository.go | 19 +- .../transport/authcommon/attempt_limiter.go | 131 +++++ .../authcommon/attempt_limiter_test.go | 75 +++ .../transport/authcommon/authcommon.go | 49 ++ .../transport/authcommon/authcommon_test.go | 41 ++ .../internal/transport/httpapi/admin_guard.go | 36 -- .../transport/httpapi/admin_guard_test.go | 64 --- .../transport/httpapi/admin_handlers.go | 20 +- .../transport/httpapi/admin_handlers_test.go | 66 ++- .../transport/httpapi/auth_handlers.go | 355 ++++++++++++ .../transport/httpapi/auth_handlers_test.go | 405 +++++++++++++ .../internal/transport/httpapi/router.go | 23 +- .../internal/transport/httpapi/router_test.go | 44 +- .../internal/transport/webui/auth_adapter.go | 75 +++ .../internal/transport/webui/auth_handler.go | 311 ++++++++++ .../transport/webui/auth_handler_test.go | 443 ++++++++++++++ .../internal/transport/webui/handler.go | 97 +++- .../internal/transport/webui/handler_test.go | 40 +- .../internal/transport/webui/static/admin.css | 85 +++ .../internal/transport/webui/static/admin.js | 13 + .../transport/webui/templates/login.gohtml | 55 ++ .../transport/webui/templates/partials.gohtml | 6 + .../transport/webui/usecase_adapter.go | 11 + backend-api/internal/usecase/auth_encoding.go | 7 + backend-api/internal/usecase/auth_errors.go | 57 ++ backend-api/internal/usecase/auth_ports.go | 62 ++ backend-api/internal/usecase/auth_runtime.go | 20 + backend-api/internal/usecase/auth_service.go | 543 ++++++++++++++++++ .../internal/usecase/auth_service_test.go | 481 ++++++++++++++++ backend-api/internal/usecase/task_service.go | 69 ++- .../internal/usecase/task_service_test.go | 20 +- backend-api/migrations/00003_auth.sql | 136 +++++ docs/00-ai-start-here.md | 11 +- docs/02-requirements.md | 13 +- docs/03-tech-stack.md | 6 +- docs/04-architecture.md | 35 +- docs/05-coding-rules.md | 13 +- docs/07-user-stories.md | 5 +- docs/08-interaction-checklist.md | 7 +- docs/api.md | 33 +- docs/current-state.md | 35 +- docs/design/android-login.html | 7 +- docs/routes.md | 3 +- docs/tasks/T-204.md | 172 ++++++ init.ps1 | 4 + init.sh | 1 + progress.md | 10 + 66 files changed, 6216 insertions(+), 271 deletions(-) create mode 100644 backend-api/cmd/authctl/main.go create mode 100644 backend-api/cmd/authctl/main_test.go create mode 100644 backend-api/internal/domain/auth.go create mode 100644 backend-api/internal/domain/auth_test.go create mode 100644 backend-api/internal/platform/password/bcrypt.go create mode 100644 backend-api/internal/platform/password/bcrypt_test.go create mode 100644 backend-api/internal/repository/sqlite/auth_repository.go create mode 100644 backend-api/internal/repository/sqlite/auth_repository_test.go create mode 100644 backend-api/internal/transport/authcommon/attempt_limiter.go create mode 100644 backend-api/internal/transport/authcommon/attempt_limiter_test.go create mode 100644 backend-api/internal/transport/authcommon/authcommon.go create mode 100644 backend-api/internal/transport/authcommon/authcommon_test.go delete mode 100644 backend-api/internal/transport/httpapi/admin_guard.go delete mode 100644 backend-api/internal/transport/httpapi/admin_guard_test.go create mode 100644 backend-api/internal/transport/httpapi/auth_handlers.go create mode 100644 backend-api/internal/transport/httpapi/auth_handlers_test.go create mode 100644 backend-api/internal/transport/webui/auth_adapter.go create mode 100644 backend-api/internal/transport/webui/auth_handler.go create mode 100644 backend-api/internal/transport/webui/auth_handler_test.go create mode 100644 backend-api/internal/transport/webui/templates/login.gohtml create mode 100644 backend-api/internal/usecase/auth_encoding.go create mode 100644 backend-api/internal/usecase/auth_errors.go create mode 100644 backend-api/internal/usecase/auth_ports.go create mode 100644 backend-api/internal/usecase/auth_runtime.go create mode 100644 backend-api/internal/usecase/auth_service.go create mode 100644 backend-api/internal/usecase/auth_service_test.go create mode 100644 backend-api/migrations/00003_auth.sql create mode 100644 docs/tasks/T-204.md diff --git a/README.md b/README.md index e44e6fa..4ab1bc5 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ cmroubao/ ``` 管理人员使用 Web 管理端,采购人员使用 Android App;二者共享同一后端、任务数据 -和权限体系。验证版先使用单管理账号和设备身份,完整 RBAC 放到验证通过后。 +和权限体系。验证版已实现 ADMIN 服务端会话、BUYER 账号和预授权设备联合身份, +完整 RBAC 放到验证通过后。 后端采用 Go 1.23.0 + Gin 1.11.0,以 Go Blueprint v0.10.11 为一次性骨架参考, 已收敛出 SQLite、Goose 迁移、健康检查和可关闭的 HTTP Server;业务 API 按 Phase 2 @@ -61,6 +62,9 @@ fixture,默认 APK 不携带真实订单资料。 Set-Location backend-api $env:GOTOOLCHAIN = "local" go run ./cmd/migrate up +$env:CMROUBAO_AUTH_PASSWORD = "至少 12 个 UTF-8 字节" +go run ./cmd/authctl create-user ADMIN admin +Remove-Item Env:CMROUBAO_AUTH_PASSWORD go run ./cmd/api ``` @@ -80,5 +84,5 @@ go run ./cmd/api - [当前实现状态](docs/current-state.md) - [完整文档导航](docs/README.md) -Android Phase 0/1 已完成,Go 后端基础骨架已建立。真实状态以 +Android Phase 0/1 和后端 T-201 至 T-204 已完成。真实状态以 [`docs/current-state.md`](docs/current-state.md) 为准。 diff --git a/backend-api/README.md b/backend-api/README.md index 958b080..0391ea5 100644 --- a/backend-api/README.md +++ b/backend-api/README.md @@ -2,7 +2,7 @@ Go 1.23.0、Gin 1.11.0 和 SQLite 构成的单进程采购任务服务。当前提供参考图上传与 规范化、任务创建/列表/详情/取消 API、服务端渲染管理页面、健康检查和显式数据库 -迁移。T-204 正式管理登录完成前,除 `/healthz` 外的业务路由只接受 loopback 请求。 +迁移,并使用 ADMIN 服务端会话和 BUYER + 预授权设备联合身份隔离管理与执行接口。 ## 环境 @@ -14,9 +14,11 @@ Go 1.23.0、Gin 1.11.0 和 SQLite 构成的单进程采购任务服务。当前 | 变量 | 默认值 | 说明 | | --- | --- | --- | -| `CMROUBAO_HTTP_ADDR` | `127.0.0.1:8080` | HTTP 监听地址;局域网监听必须显式配置 | +| `CMROUBAO_HTTP_ADDR` | `127.0.0.1:8080` | 监听地址;无 TLS 时只允许 loopback | | `CMROUBAO_DATABASE_PATH` | `var/cmroubao.db` | SQLite 文件路径 | | `CMROUBAO_ASSET_DIR` | `var/assets` | 规范化参考图片的受控本地目录 | +| `CMROUBAO_TLS_CERT_FILE` | 无 | TLS certificate;必须和 private key 同时设置 | +| `CMROUBAO_TLS_KEY_FILE` | 无 | TLS private key;非 loopback 监听必须设置 | 不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。 @@ -28,20 +30,44 @@ go test ./... go vet ./... go build -o bin/cmroubao-api.exe ./cmd/api go build -o bin/cmroubao-migrate.exe ./cmd/migrate +go build -o bin/cmroubao-authctl.exe ./cmd/authctl go run ./cmd/migrate up go run ./cmd/migrate status + +$env:CMROUBAO_AUTH_PASSWORD = "至少 12 个 UTF-8 字节" +go run ./cmd/authctl create-user ADMIN admin +Remove-Item Env:CMROUBAO_AUTH_PASSWORD + +go run ./cmd/authctl create-device buyer-phone-01 +# 发生人员离岗或设备风险时,现有凭证会随禁用立即失效 +go run ./cmd/authctl disable-user buyer01 +go run ./cmd/authctl disable-device +# 恢复时使用对应的 enable-user / enable-device go run ./cmd/api ``` API 启动前会检查全部 migration 已应用;发现 pending migration 会拒绝启动并提示先 执行 `go run ./cmd/migrate up`,不会在服务进程内自动改表。 +`authctl create-user` 的密码只从 `CMROUBAO_AUTH_PASSWORD` 读取,不接受命令行密码。 +`create-device` 只在成功时输出一次设备 ID 和 256 bit 设备 token;原值应立即放入 +设备安全配置,不得写入 Git、普通日志或共享文档。`enable-user`、`disable-user`、 +`enable-device`、`disable-device` 是受支持的本地停用/恢复入口;禁用会让该主体的 +现有 session/access token 在下一次请求时失效。 + 服务启动后,`GET /healthz` 在数据库可用时返回 `200` 和 `{"status":"ok"}`,不可用时返回 `503` 和 `{"status":"unavailable"}`。管理页面: - `GET /tasks`:任务列表、搜索和状态筛选。 - `GET /tasks/new`:上传参考图并创建任务。 - `GET /tasks/{id}`:查看原始约束和任务状态,待领取任务可取消。 +- `GET/POST /login`、`POST /logout`:建立或撤销 8 小时 ADMIN 会话。 -API 合约见 [`../docs/api.md`](../docs/api.md)。当前 loopback 门禁不是身份认证,不能 -通过修改监听地址把管理页面直接开放到局域网。 +未登录管理页面会跳转 `/login`,未授权管理 API 返回 `401`。Cookie 认证的管理 API +写请求还需要 `X-CSRF-Token`。采购 App 使用 `POST /api/v1/auth/token`,ADMIN +Cookie 与 BUYER Bearer token 不能互换。两类登录在凭证校验前按来源地址独立限流: +5 分钟最多 10 次,超限返回 `429` 和 `Retry-After`,成功后清零。 + +默认 loopback 可使用 HTTP 开发。局域网监听必须同时设置 certificate/private key, +服务直接使用 TLS 启动,不会降级为明文。完整 API 合约见 +[`../docs/api.md`](../docs/api.md)。 diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go index 14d6417..dd58716 100644 --- a/backend-api/cmd/api/main.go +++ b/backend-api/cmd/api/main.go @@ -15,10 +15,14 @@ import ( "cmroubao/backend-api/internal/platform/assetstore" "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/transport/authcommon" "cmroubao/backend-api/internal/transport/httpapi" "cmroubao/backend-api/internal/transport/webui" "cmroubao/backend-api/internal/usecase" + + "github.com/gin-gonic/gin" ) func main() { @@ -67,6 +71,13 @@ func run() error { serverErrors := make(chan error, 1) go func() { + if cfg.TLSCertificate != "" { + serverErrors <- server.ListenAndServeTLS( + cfg.TLSCertificate, + cfg.TLSPrivateKey, + ) + return + } serverErrors <- server.ListenAndServe() }() @@ -158,6 +169,20 @@ func buildRouter( if err != nil { return nil, err } + passwords, err := password.NewBcrypt(12) + if err != nil { + return nil, err + } + auth, err := usecase.NewAuthService( + store, + passwords, + clock, + ids, + usecase.CryptoTokenGenerator{}, + ) + if err != nil { + return nil, err + } webService, err := webui.NewUsecaseAdapter(tasks, assets) if err != nil { @@ -171,6 +196,33 @@ func buildRouter( if err != nil { return nil, err } + authWebService, err := webui.NewAuthUsecaseAdapter(auth) + if err != nil { + return nil, err + } + loginLimiter, err := authcommon.NewAttemptLimiter( + 10, + 5*time.Minute, + 4096, + ) + if err != nil { + return nil, err + } + authWebHandler, err := webui.NewAuthHandler( + authWebService, + renderer, + loginLimiter, + ) + if err != nil { + return nil, err + } + registerPublicAuth, err := httpapi.NewPublicAuthRegistrar( + auth, + loginLimiter, + ) + if err != nil { + return nil, err + } registerAdminRoutes, err := httpapi.NewAdminRouteRegistrar( httpapi.AdminServices{ Assets: assets, @@ -181,9 +233,22 @@ func buildRouter( if err != nil { return nil, err } + registerProtectedRoutes := func(routes gin.IRoutes) error { + if err := registerAdminRoutes(routes); err != nil { + return err + } + authWebHandler.RegisterProtected(routes) + return nil + } return httpapi.NewRouter(httpapi.RouterDependencies{ - Database: db, - RegisterAdminRoutes: registerAdminRoutes, + Database: db, + RegisterPublicRoutes: func(routes gin.IRoutes) error { + webHandler.RegisterStatic(routes) + authWebHandler.RegisterPublic(routes) + return registerPublicAuth(routes) + }, + RegisterAdminRoutes: registerProtectedRoutes, + AdminSessions: auth, LogEvent: func(event string) { log.Print(event) }, diff --git a/backend-api/cmd/api/main_test.go b/backend-api/cmd/api/main_test.go index e604d79..edeffd3 100644 --- a/backend-api/cmd/api/main_test.go +++ b/backend-api/cmd/api/main_test.go @@ -5,9 +5,13 @@ import ( "errors" "net" "net/http" + "net/http/httptest" + "path/filepath" "testing" "time" + "cmroubao/backend-api/internal/config" + "cmroubao/backend-api/internal/platform/database" "cmroubao/backend-api/internal/platform/migration" ) @@ -114,6 +118,45 @@ func TestRequireCurrentMigrationsHidesStatusFailure(t *testing.T) { } } +func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) { + ctx := context.Background() + db, err := database.Open( + ctx, + filepath.Join(t.TempDir(), "api.db"), + ) + if err != nil { + t.Fatalf("database.Open() error = %v", err) + } + defer db.Close() + runner, err := migration.New(db) + if err != nil { + t.Fatalf("migration.New() error = %v", err) + } + if _, err := runner.Up(ctx); err != nil { + t.Fatalf("migration.Up() error = %v", err) + } + router, err := buildRouter(ctx, config.Config{ + AssetDirectory: filepath.Join(t.TempDir(), "assets"), + }, db) + if err != nil { + t.Fatalf("buildRouter() error = %v", err) + } + request := httptest.NewRequest(http.MethodPost, "/logout", nil) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusSeeOther || + response.Header().Get("Location") != + "/login?next=%2Ftasks" { + t.Fatalf( + "status/location = %d / %q", + response.Code, + response.Header().Get("Location"), + ) + } +} + type stubMigrationStatusReader struct { statuses []migration.Status err error diff --git a/backend-api/cmd/authctl/main.go b/backend-api/cmd/authctl/main.go new file mode 100644 index 0000000..45de9d9 --- /dev/null +++ b/backend-api/cmd/authctl/main.go @@ -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 | " + + "authctl create-device | " + + "authctl | " + + "authctl ", + ) +} + +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") + } +} diff --git a/backend-api/cmd/authctl/main_test.go b/backend-api/cmd/authctl/main_test.go new file mode 100644 index 0000000..533a8d6 --- /dev/null +++ b/backend-api/cmd/authctl/main_test.go @@ -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 + } + } +} diff --git a/backend-api/go.mod b/backend-api/go.mod index 9844afb..502e40e 100644 --- a/backend-api/go.mod +++ b/backend-api/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.11.0 github.com/mattn/go-sqlite3 v1.14.48 github.com/pressly/goose/v3 v3.26.0 + golang.org/x/crypto v0.40.0 golang.org/x/image v0.28.0 ) @@ -36,7 +37,6 @@ require ( go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect diff --git a/backend-api/internal/config/config.go b/backend-api/internal/config/config.go index 6b71f32..3f0a040 100644 --- a/backend-api/internal/config/config.go +++ b/backend-api/internal/config/config.go @@ -13,6 +13,8 @@ const ( HTTPAddressEnvironment = "CMROUBAO_HTTP_ADDR" DatabasePathEnvironment = "CMROUBAO_DATABASE_PATH" AssetDirectoryEnvironment = "CMROUBAO_ASSET_DIR" + TLSCertificateEnvironment = "CMROUBAO_TLS_CERT_FILE" + TLSPrivateKeyEnvironment = "CMROUBAO_TLS_KEY_FILE" defaultHTTPAddress = "127.0.0.1:8080" defaultDatabasePath = "var/cmroubao.db" @@ -25,6 +27,8 @@ type Config struct { HTTPAddress string DatabasePath string AssetDirectory string + TLSCertificate string + TLSPrivateKey string ReadHeaderTimeout time.Duration ReadTimeout time.Duration WriteTimeout time.Duration @@ -68,11 +72,39 @@ func Load(lookup LookupEnvironment) (Config, error) { if err != nil { return Config{}, err } + tlsCertificate, certificateSet, err := optionalEnvironmentValue( + lookup, + TLSCertificateEnvironment, + ) + if err != nil { + return Config{}, err + } + tlsPrivateKey, privateKeySet, err := optionalEnvironmentValue( + lookup, + TLSPrivateKeyEnvironment, + ) + if err != nil { + return Config{}, err + } + if certificateSet != privateKeySet { + return Config{}, errors.New( + TLSCertificateEnvironment + " and " + + TLSPrivateKeyEnvironment + " must be set together", + ) + } + if !certificateSet && !isLoopbackAddress(httpAddress) { + return Config{}, errors.New( + HTTPAddressEnvironment + + " must use loopback unless TLS is configured", + ) + } return Config{ HTTPAddress: httpAddress, DatabasePath: filepath.Clean(databasePath), AssetDirectory: assetDirectory, + TLSCertificate: cleanOptionalPath(tlsCertificate), + TLSPrivateKey: cleanOptionalPath(tlsPrivateKey), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, @@ -82,6 +114,43 @@ func Load(lookup LookupEnvironment) (Config, error) { }, nil } +func cleanOptionalPath(value string) string { + if value == "" { + return "" + } + return filepath.Clean(value) +} + +func optionalEnvironmentValue( + lookup LookupEnvironment, + name string, +) (string, bool, error) { + value, exists := lookup(name) + if !exists { + return "", false, nil + } + value = strings.TrimSpace(value) + if value == "" { + return "", false, errors.New(name + " must not be blank") + } + if strings.ContainsRune(value, '\x00') { + return "", false, errors.New(name + " contains an invalid character") + } + return value, true, nil +} + +func isLoopbackAddress(address string) bool { + host, _, err := net.SplitHostPort(address) + if err != nil { + return false + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + func validatedAssetDirectory(path string) (string, error) { if strings.ContainsRune(path, '\x00') { return "", errors.New( diff --git a/backend-api/internal/config/config_test.go b/backend-api/internal/config/config_test.go index baad9fc..c33c5b4 100644 --- a/backend-api/internal/config/config_test.go +++ b/backend-api/internal/config/config_test.go @@ -39,6 +39,8 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) { HTTPAddressEnvironment: "192.0.2.10:9090", DatabasePathEnvironment: "tmp/test.db", AssetDirectoryEnvironment: "tmp/assets", + TLSCertificateEnvironment: "tmp/server.crt", + TLSPrivateKeyEnvironment: "tmp/server.key", } cfg, err := Load(mapEnvironment(values)) @@ -55,6 +57,14 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) { if cfg.AssetDirectory != filepath.Clean(values[AssetDirectoryEnvironment]) { t.Fatalf("AssetDirectory = %q", cfg.AssetDirectory) } + if cfg.TLSCertificate != filepath.Clean(values[TLSCertificateEnvironment]) || + cfg.TLSPrivateKey != filepath.Clean(values[TLSPrivateKeyEnvironment]) { + t.Fatalf( + "TLS files = %q / %q", + cfg.TLSCertificate, + cfg.TLSPrivateKey, + ) + } } func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) { @@ -116,6 +126,25 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) { AssetDirectoryEnvironment: ".", }, }, + { + name: "non loopback without TLS", + values: map[string]string{ + HTTPAddressEnvironment: "0.0.0.0:8080", + }, + }, + { + name: "TLS certificate without key", + values: map[string]string{ + TLSCertificateEnvironment: "tmp/server.crt", + }, + }, + { + name: "blank TLS key", + values: map[string]string{ + TLSCertificateEnvironment: "tmp/server.crt", + TLSPrivateKeyEnvironment: " ", + }, + }, } for _, test := range tests { diff --git a/backend-api/internal/domain/auth.go b/backend-api/internal/domain/auth.go new file mode 100644 index 0000000..d13368e --- /dev/null +++ b/backend-api/internal/domain/auth.go @@ -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 + } +} diff --git a/backend-api/internal/domain/auth_test.go b/backend-api/internal/domain/auth_test.go new file mode 100644 index 0000000..7d6cdc7 --- /dev/null +++ b/backend-api/internal/domain/auth_test.go @@ -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") + } +} diff --git a/backend-api/internal/domain/task.go b/backend-api/internal/domain/task.go index c9169e8..10ba420 100644 --- a/backend-api/internal/domain/task.go +++ b/backend-api/internal/domain/task.go @@ -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 { diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go index c80c86c..fe39cf0 100644 --- a/backend-api/internal/platform/migration/runner_test.go +++ b/backend-api/internal/platform/migration/runner_test.go @@ -27,12 +27,13 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { if err != nil { t.Fatalf("Up() error = %v", err) } - if applied != 2 { - t.Fatalf("Up() applied = %d, want 2", applied) + if applied != 3 { + t.Fatalf("Up() applied = %d, want 3", applied) } assertStatuses(t, runner, map[int64]bool{ 1: true, 2: true, + 3: true, }) applied, err = runner.Up(context.Background()) @@ -48,7 +49,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { } assertStatuses(t, runner, map[int64]bool{ 1: true, - 2: false, + 2: true, + 3: false, }) applied, err = runner.Up(context.Background()) @@ -61,6 +63,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { assertStatuses(t, runner, map[int64]bool{ 1: true, 2: true, + 3: true, }) } diff --git a/backend-api/internal/platform/password/bcrypt.go b/backend-api/internal/platform/password/bcrypt.go new file mode 100644 index 0000000..8597d4a --- /dev/null +++ b/backend-api/internal/platform/password/bcrypt.go @@ -0,0 +1,69 @@ +package password + +import ( + "errors" + "unicode/utf8" + + "cmroubao/backend-api/internal/domain" + + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidPassword = errors.New("password is invalid") + ErrPasswordMismatch = errors.New("password does not match") +) + +type Bcrypt struct { + cost int + dummyHash string +} + +func NewBcrypt(cost int) (*Bcrypt, error) { + if cost < bcrypt.MinCost || cost > bcrypt.MaxCost { + return nil, errors.New("bcrypt cost is out of range") + } + dummyHash, err := bcrypt.GenerateFromPassword( + []byte("cmroubao-dummy-password"), + cost, + ) + if err != nil { + return nil, errors.New("initialize bcrypt dummy hash") + } + return &Bcrypt{cost: cost, dummyHash: string(dummyHash)}, nil +} + +func (manager *Bcrypt) Hash(plain string) (string, error) { + if !utf8.ValidString(plain) || + len([]byte(plain)) < domain.MinPasswordBytes || + len([]byte(plain)) > domain.MaxPasswordBytes { + return "", ErrInvalidPassword + } + value, err := bcrypt.GenerateFromPassword([]byte(plain), manager.cost) + if err != nil { + return "", errors.New("hash password") + } + return string(value), nil +} + +func (manager *Bcrypt) VerifyDummy(plain string) { + _ = bcrypt.CompareHashAndPassword( + []byte(manager.dummyHash), + []byte(plain), + ) +} + +func (manager *Bcrypt) Verify(encoded string, plain string) error { + if encoded == "" || + plain == "" || + len([]byte(plain)) > domain.MaxPasswordBytes { + return ErrPasswordMismatch + } + if err := bcrypt.CompareHashAndPassword( + []byte(encoded), + []byte(plain), + ); err != nil { + return ErrPasswordMismatch + } + return nil +} diff --git a/backend-api/internal/platform/password/bcrypt_test.go b/backend-api/internal/platform/password/bcrypt_test.go new file mode 100644 index 0000000..62e80f9 --- /dev/null +++ b/backend-api/internal/platform/password/bcrypt_test.go @@ -0,0 +1,67 @@ +package password + +import ( + "errors" + "strings" + "testing" + + "golang.org/x/crypto/bcrypt" +) + +func TestBcryptHashesAndVerifiesWithoutStoringPlaintext(t *testing.T) { + manager, err := NewBcrypt(bcrypt.MinCost) + if err != nil { + t.Fatalf("NewBcrypt() error = %v", err) + } + encoded, err := manager.Hash("correct horse battery staple") + if err != nil { + t.Fatalf("Hash() error = %v", err) + } + if encoded == "correct horse battery staple" { + t.Fatal("Hash() returned plaintext") + } + if err := manager.Verify(encoded, "correct horse battery staple"); err != nil { + t.Fatalf("Verify(correct) error = %v", err) + } + if err := manager.Verify(encoded, "wrong"); !errors.Is( + err, + ErrPasswordMismatch, + ) { + t.Fatalf("Verify(wrong) error = %v", err) + } +} + +func TestBcryptRejectsInvalidCostAndOversizedPasswords(t *testing.T) { + if _, err := NewBcrypt(bcrypt.MinCost - 1); err == nil { + t.Fatal("NewBcrypt(invalid) error = nil") + } + manager, err := NewBcrypt(bcrypt.MinCost) + if err != nil { + t.Fatalf("NewBcrypt() error = %v", err) + } + oversized := strings.Repeat("x", 73) + if _, err := manager.Hash("too-short"); !errors.Is( + err, + ErrInvalidPassword, + ) { + t.Fatalf("Hash(short) error = %v", err) + } + if _, err := manager.Hash(oversized); !errors.Is( + err, + ErrInvalidPassword, + ) { + t.Fatalf("Hash(oversized) error = %v", err) + } + if _, err := manager.Hash( + string([]byte{0xff, 0xfe, 0xfd}), + ); !errors.Is(err, ErrInvalidPassword) { + t.Fatalf("Hash(invalid UTF-8) error = %v", err) + } + if err := manager.Verify("$2a$04$invalid", oversized); !errors.Is( + err, + ErrPasswordMismatch, + ) { + t.Fatalf("Verify(oversized) error = %v", err) + } + manager.VerifyDummy("password-123") +} diff --git a/backend-api/internal/repository/sqlite/auth_repository.go b/backend-api/internal/repository/sqlite/auth_repository.go new file mode 100644 index 0000000..acb7710 --- /dev/null +++ b/backend-api/internal/repository/sqlite/auth_repository.go @@ -0,0 +1,538 @@ +package sqlite + +import ( + "context" + "crypto/subtle" + "database/sql" + "errors" + "time" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/usecase" +) + +func (s *Store) FindUserByUsername( + ctx context.Context, + username string, +) (domain.User, error) { + return scanUser(s.db.QueryRowContext( + ctx, + `SELECT + id, username, password_hash, role, is_active, created_at, updated_at + FROM users + WHERE username = ?`, + username, + )) +} + +func (s *Store) ProvisionUser( + ctx context.Context, + candidate domain.User, +) (domain.User, error) { + _, err := s.db.ExecContext( + ctx, + `INSERT INTO users ( + id, username, password_hash, role, is_active, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + candidate.ID, + candidate.Username, + candidate.PasswordHash, + candidate.Role, + candidate.IsActive, + formatTimestamp(candidate.CreatedAt), + formatTimestamp(candidate.UpdatedAt), + ) + if err != nil { + if isUniqueConstraint(err, "users.username") { + return domain.User{}, usecase.ErrAuthConflict + } + return domain.User{}, repositoryFailure(err) + } + return candidate, nil +} + +func (s *Store) ProvisionDevice( + ctx context.Context, + candidate domain.Device, +) (domain.Device, error) { + _, err := s.db.ExecContext( + ctx, + `INSERT INTO devices ( + id, name, token_hash, bound_user_id, app_version, pdd_version, + android_version, last_seen_at, is_enabled, created_at, updated_at + ) VALUES (?, ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)`, + candidate.ID, + candidate.Name, + candidate.TokenHash, + candidate.IsEnabled, + formatTimestamp(candidate.CreatedAt), + formatTimestamp(candidate.UpdatedAt), + ) + if err != nil { + if isUniqueConstraint(err, "devices.token_hash") { + return domain.Device{}, usecase.ErrAuthConflict + } + return domain.Device{}, repositoryFailure(err) + } + return candidate, nil +} + +func (s *Store) SetUserActive( + ctx context.Context, + username string, + active bool, + updatedAt time.Time, +) error { + result, err := s.db.ExecContext( + ctx, + `UPDATE users + SET is_active = ?, updated_at = ? + WHERE username = ?`, + active, + formatTimestamp(updatedAt), + username, + ) + return requireAffectedAuthResource(result, err) +} + +func (s *Store) SetDeviceEnabled( + ctx context.Context, + deviceID string, + enabled bool, + updatedAt time.Time, +) error { + result, err := s.db.ExecContext( + ctx, + `UPDATE devices + SET is_enabled = ?, updated_at = ? + WHERE id = ?`, + enabled, + formatTimestamp(updatedAt), + deviceID, + ) + return requireAffectedAuthResource(result, err) +} + +func requireAffectedAuthResource( + result sql.Result, + err error, +) error { + if err != nil { + return repositoryFailure(err) + } + affected, err := result.RowsAffected() + if err != nil { + return repositoryFailure(err) + } + if affected != 1 { + return usecase.ErrRepositoryNotFound + } + return nil +} + +func (s *Store) CreateAdminSession( + ctx context.Context, + session domain.AdminSession, + now time.Time, +) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + + var role domain.UserRole + var active bool + err = tx.QueryRowContext( + ctx, + "SELECT role, is_active FROM users WHERE id = ?", + session.UserID, + ).Scan(&role, &active) + if errors.Is(err, sql.ErrNoRows) { + return usecase.ErrAuthCredentials + } + if err != nil { + return repositoryFailure(err) + } + if !active { + return usecase.ErrAuthDisabled + } + if role != domain.UserRoleAdmin { + return usecase.ErrAuthForbidden + } + if !session.ExpiresAt.After(now) { + return usecase.ErrAuthExpired + } + _, err = tx.ExecContext( + ctx, + `INSERT INTO admin_sessions ( + id, token_hash, user_id, expires_at, revoked_at, created_at + ) VALUES (?, ?, ?, ?, NULL, ?)`, + session.ID, + session.TokenHash, + session.UserID, + formatTimestamp(session.ExpiresAt), + formatTimestamp(session.CreatedAt), + ) + if err != nil { + return repositoryFailure(err) + } + if err := tx.Commit(); err != nil { + return repositoryFailure(err) + } + return nil +} + +func (s *Store) AuthenticateAdminSession( + ctx context.Context, + tokenHash string, + now time.Time, +) (domain.AuthPrincipal, error) { + var principal domain.AuthPrincipal + var active bool + var expiresAt string + var revokedAt sql.NullString + err := s.db.QueryRowContext( + ctx, + `SELECT + user.id, user.username, user.role, session.id, + session.expires_at, session.revoked_at, user.is_active + FROM admin_sessions AS session + JOIN users AS user ON user.id = session.user_id + WHERE session.token_hash = ?`, + tokenHash, + ).Scan( + &principal.UserID, + &principal.Username, + &principal.Role, + &principal.SessionID, + &expiresAt, + &revokedAt, + &active, + ) + if errors.Is(err, sql.ErrNoRows) { + return domain.AuthPrincipal{}, usecase.ErrAuthRevoked + } + if err != nil { + return domain.AuthPrincipal{}, repositoryFailure(err) + } + principal.ExpiresAt, err = parseTimestamp(expiresAt) + if err != nil { + return domain.AuthPrincipal{}, err + } + switch { + case revokedAt.Valid: + return domain.AuthPrincipal{}, usecase.ErrAuthRevoked + case !principal.ExpiresAt.After(now): + return domain.AuthPrincipal{}, usecase.ErrAuthExpired + case !active: + return domain.AuthPrincipal{}, usecase.ErrAuthDisabled + case principal.Role != domain.UserRoleAdmin: + return domain.AuthPrincipal{}, usecase.ErrAuthForbidden + default: + return principal, nil + } +} + +func (s *Store) RevokeAdminSession( + ctx context.Context, + tokenHash string, + revokedAt time.Time, +) error { + _, err := s.db.ExecContext( + ctx, + `UPDATE admin_sessions + SET revoked_at = COALESCE(revoked_at, ?) + WHERE token_hash = ?`, + formatTimestamp(revokedAt), + tokenHash, + ) + return repositoryFailure(err) +} + +func (s *Store) CreateAccessTokenAndBindDevice( + ctx context.Context, + userID string, + deviceID string, + deviceTokenHash string, + appVersion string, + androidVersion string, + access domain.AccessToken, + now time.Time, +) (domain.Device, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.Device{}, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + + var role domain.UserRole + var active bool + err = tx.QueryRowContext( + ctx, + "SELECT role, is_active FROM users WHERE id = ?", + userID, + ).Scan(&role, &active) + if errors.Is(err, sql.ErrNoRows) { + return domain.Device{}, usecase.ErrAuthCredentials + } + if err != nil { + return domain.Device{}, repositoryFailure(err) + } + if !active { + return domain.Device{}, usecase.ErrAuthDisabled + } + if role != domain.UserRoleBuyer { + return domain.Device{}, usecase.ErrAuthForbidden + } + + device, err := getDeviceByID(ctx, tx, deviceID) + if errors.Is(err, usecase.ErrRepositoryNotFound) { + return domain.Device{}, usecase.ErrAuthCredentials + } + if err != nil { + return domain.Device{}, err + } + if subtle.ConstantTimeCompare( + []byte(device.TokenHash), + []byte(deviceTokenHash), + ) != 1 { + return domain.Device{}, usecase.ErrAuthCredentials + } + if !device.IsEnabled { + return domain.Device{}, usecase.ErrAuthDisabled + } + if device.BoundUserID == nil { + result, err := tx.ExecContext( + ctx, + `UPDATE devices + SET bound_user_id = ?, app_version = ?, android_version = ?, + updated_at = ? + WHERE id = ? AND bound_user_id IS NULL`, + userID, + appVersion, + androidVersion, + formatTimestamp(now), + device.ID, + ) + if err != nil { + return domain.Device{}, repositoryFailure(err) + } + affected, err := result.RowsAffected() + if err != nil { + return domain.Device{}, repositoryFailure(err) + } + if affected != 1 { + return domain.Device{}, usecase.ErrAuthConflict + } + boundUserID := userID + device.BoundUserID = &boundUserID + } else if *device.BoundUserID != userID { + return domain.Device{}, usecase.ErrAuthForbidden + } else { + _, err := tx.ExecContext( + ctx, + `UPDATE devices + SET app_version = ?, android_version = ?, updated_at = ? + WHERE id = ? AND bound_user_id = ?`, + appVersion, + androidVersion, + formatTimestamp(now), + device.ID, + userID, + ) + if err != nil { + return domain.Device{}, repositoryFailure(err) + } + } + device.AppVersion = &appVersion + device.AndroidVersion = &androidVersion + device.UpdatedAt = now + if access.UserID != userID || access.DeviceID != deviceID { + return domain.Device{}, usecase.ErrRepositoryInvariant + } + if !access.ExpiresAt.After(now) { + return domain.Device{}, usecase.ErrAuthExpired + } + _, err = tx.ExecContext( + ctx, + `INSERT INTO access_tokens ( + id, token_hash, user_id, device_id, expires_at, revoked_at, created_at + ) VALUES (?, ?, ?, ?, ?, NULL, ?)`, + access.ID, + access.TokenHash, + access.UserID, + access.DeviceID, + formatTimestamp(access.ExpiresAt), + formatTimestamp(access.CreatedAt), + ) + if err != nil { + return domain.Device{}, repositoryFailure(err) + } + if err := tx.Commit(); err != nil { + return domain.Device{}, repositoryFailure(err) + } + return device, nil +} + +func (s *Store) AuthenticateAccessToken( + ctx context.Context, + tokenHash string, + now time.Time, +) (domain.AuthPrincipal, error) { + var principal domain.AuthPrincipal + var userActive bool + var deviceEnabled bool + var boundUserID sql.NullString + var expiresAt string + var revokedAt sql.NullString + err := s.db.QueryRowContext( + ctx, + `SELECT + user.id, user.username, user.role, access.id, device.id, + access.expires_at, access.revoked_at, user.is_active, + device.is_enabled, device.bound_user_id + FROM access_tokens AS access + JOIN users AS user ON user.id = access.user_id + JOIN devices AS device ON device.id = access.device_id + WHERE access.token_hash = ?`, + tokenHash, + ).Scan( + &principal.UserID, + &principal.Username, + &principal.Role, + &principal.SessionID, + &principal.DeviceID, + &expiresAt, + &revokedAt, + &userActive, + &deviceEnabled, + &boundUserID, + ) + if errors.Is(err, sql.ErrNoRows) { + return domain.AuthPrincipal{}, usecase.ErrAuthRevoked + } + if err != nil { + return domain.AuthPrincipal{}, repositoryFailure(err) + } + principal.ExpiresAt, err = parseTimestamp(expiresAt) + if err != nil { + return domain.AuthPrincipal{}, err + } + switch { + case revokedAt.Valid: + return domain.AuthPrincipal{}, usecase.ErrAuthRevoked + case !principal.ExpiresAt.After(now): + return domain.AuthPrincipal{}, usecase.ErrAuthExpired + case !userActive || !deviceEnabled: + return domain.AuthPrincipal{}, usecase.ErrAuthDisabled + case principal.Role != domain.UserRoleBuyer: + return domain.AuthPrincipal{}, usecase.ErrAuthForbidden + case !boundUserID.Valid || boundUserID.String != principal.UserID: + return domain.AuthPrincipal{}, usecase.ErrAuthForbidden + default: + return principal, nil + } +} + +func scanUser(scanner rowScanner) (domain.User, error) { + var user domain.User + var createdAt string + var updatedAt string + err := scanner.Scan( + &user.ID, + &user.Username, + &user.PasswordHash, + &user.Role, + &user.IsActive, + &createdAt, + &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return domain.User{}, usecase.ErrRepositoryNotFound + } + if err != nil { + return domain.User{}, repositoryFailure(err) + } + user.CreatedAt, err = parseTimestamp(createdAt) + if err != nil { + return domain.User{}, err + } + user.UpdatedAt, err = parseTimestamp(updatedAt) + if err != nil { + return domain.User{}, err + } + return user, nil +} + +func getDeviceByID( + ctx context.Context, + queryer queryRower, + deviceID string, +) (domain.Device, error) { + var device domain.Device + var boundUserID sql.NullString + var appVersion sql.NullString + var androidVersion sql.NullString + var pddVersion sql.NullString + var lastSeenAt sql.NullString + var createdAt string + var updatedAt string + err := queryer.QueryRowContext( + ctx, + `SELECT + id, name, token_hash, bound_user_id, app_version, android_version, + pdd_version, last_seen_at, is_enabled, created_at, updated_at + FROM devices + WHERE id = ?`, + deviceID, + ).Scan( + &device.ID, + &device.Name, + &device.TokenHash, + &boundUserID, + &appVersion, + &androidVersion, + &pddVersion, + &lastSeenAt, + &device.IsEnabled, + &createdAt, + &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return domain.Device{}, usecase.ErrRepositoryNotFound + } + if err != nil { + return domain.Device{}, repositoryFailure(err) + } + if boundUserID.Valid { + device.BoundUserID = &boundUserID.String + } + if appVersion.Valid { + device.AppVersion = &appVersion.String + } + if androidVersion.Valid { + device.AndroidVersion = &androidVersion.String + } + if pddVersion.Valid { + device.PDDVersion = &pddVersion.String + } + if lastSeenAt.Valid { + parsed, err := parseTimestamp(lastSeenAt.String) + if err != nil { + return domain.Device{}, err + } + device.LastSeenAt = &parsed + } + device.CreatedAt, err = parseTimestamp(createdAt) + if err != nil { + return domain.Device{}, err + } + device.UpdatedAt, err = parseTimestamp(updatedAt) + if err != nil { + return domain.Device{}, err + } + return device, nil +} + +var _ usecase.AuthRepository = (*Store)(nil) diff --git a/backend-api/internal/repository/sqlite/auth_repository_test.go b/backend-api/internal/repository/sqlite/auth_repository_test.go new file mode 100644 index 0000000..4cd043e --- /dev/null +++ b/backend-api/internal/repository/sqlite/auth_repository_test.go @@ -0,0 +1,533 @@ +package sqlite_test + +import ( + "context" + "database/sql" + "encoding/base64" + "errors" + "sync" + "testing" + "time" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/platform/migration" + "cmroubao/backend-api/internal/platform/password" + repository "cmroubao/backend-api/internal/repository/sqlite" + "cmroubao/backend-api/internal/usecase" + + "golang.org/x/crypto/bcrypt" +) + +func TestAuthRepositoryPersistsOnlyHashesAndRevokesAdminSession( + t *testing.T, +) { + db := openDatabase(t) + service := newRepositoryAuthService(t, db) + ctx := context.Background() + admin, err := service.ProvisionUser(ctx, usecase.ProvisionUserCommand{ + Username: "admin", + Password: "admin-password", + Role: domain.UserRoleAdmin, + Active: true, + }) + if err != nil { + t.Fatalf("ProvisionUser() error = %v", err) + } + var passwordHash string + if err := db.QueryRow( + "SELECT password_hash FROM users WHERE id = ?", + admin.ID, + ).Scan(&passwordHash); err != nil { + t.Fatalf("query password hash: %v", err) + } + if passwordHash == "admin-password" || passwordHash == "" { + t.Fatal("database contains missing or plaintext password") + } + + login, err := service.LoginAdmin(ctx, usecase.LoginAdminCommand{ + Username: "admin", + Password: "admin-password", + }) + if err != nil { + t.Fatalf("LoginAdmin() error = %v", err) + } + var storedTokenHash string + if err := db.QueryRow( + "SELECT token_hash FROM admin_sessions WHERE user_id = ?", + admin.ID, + ).Scan(&storedTokenHash); err != nil { + t.Fatalf("query token hash: %v", err) + } + if storedTokenHash == login.Token || len(storedTokenHash) != 64 { + t.Fatal("database contains missing or plaintext admin token") + } + principal, err := service.AuthenticateAdmin(ctx, login.Token) + if err != nil { + t.Fatalf("AuthenticateAdmin() error = %v", err) + } + if principal.UserID != admin.ID || + principal.Role != domain.UserRoleAdmin || + principal.DeviceID != "" { + t.Fatalf("AuthenticateAdmin() principal = %+v", principal) + } + if err := service.SetUserActive( + ctx, + usecase.SetUserActiveCommand{ + Username: admin.Username, + Active: false, + }, + ); err != nil { + t.Fatalf("disable admin: %v", err) + } + _, err = service.AuthenticateAdmin(ctx, login.Token) + assertRepositoryAuthCode( + t, + err, + "AUTH_ACCOUNT_OR_DEVICE_DISABLED", + ) + if err := service.SetUserActive( + ctx, + usecase.SetUserActiveCommand{ + Username: admin.Username, + Active: true, + }, + ); err != nil { + t.Fatalf("enable admin: %v", err) + } + if _, err := db.Exec( + "UPDATE admin_sessions SET expires_at = ? WHERE user_id = ?", + time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC).Format( + time.RFC3339Nano, + ), + admin.ID, + ); err != nil { + t.Fatalf("expire admin session: %v", err) + } + _, err = service.AuthenticateAdmin(ctx, login.Token) + assertRepositoryAuthCode(t, err, "AUTH_SESSION_EXPIRED") + if _, err := db.Exec( + "UPDATE admin_sessions SET expires_at = ? WHERE user_id = ?", + login.ExpiresAt.Format(time.RFC3339Nano), + admin.ID, + ); err != nil { + t.Fatalf("restore admin expiry: %v", err) + } + if err := service.LogoutAdmin(ctx, login.Token); err != nil { + t.Fatalf("LogoutAdmin() error = %v", err) + } + _, err = service.AuthenticateAdmin(ctx, login.Token) + assertRepositoryAuthCode(t, err, "AUTH_INVALID_TOKEN") +} + +func TestAuthRepositoryFirstBuyerBindingIsAtomicAndPersistent( + t *testing.T, +) { + db := openDatabase(t) + service := newRepositoryAuthService(t, db) + ctx := context.Background() + for _, username := range []string{"buyer-a", "buyer-b"} { + if _, err := service.ProvisionUser( + ctx, + usecase.ProvisionUserCommand{ + Username: username, + Password: "buyer-password", + Role: domain.UserRoleBuyer, + Active: true, + }, + ); err != nil { + t.Fatalf("ProvisionUser(%s) error = %v", username, err) + } + } + device, err := service.ProvisionDevice( + ctx, + usecase.ProvisionDeviceCommand{ + Name: "PDD test device", + Enabled: true, + }, + ) + if err != nil { + t.Fatalf("ProvisionDevice() error = %v", err) + } + var storedDeviceTokenHash string + if err := db.QueryRow( + "SELECT token_hash FROM devices WHERE id = ?", + device.Device.ID, + ).Scan(&storedDeviceTokenHash); err != nil { + t.Fatalf("query device token hash: %v", err) + } + if storedDeviceTokenHash == device.DeviceToken || + len(storedDeviceTokenHash) != 64 { + t.Fatal("database contains missing or plaintext device token") + } + + start := make(chan struct{}) + results := make(chan usecase.AccessTokenResult, 2) + failures := make(chan error, 2) + var wait sync.WaitGroup + for _, username := range []string{"buyer-a", "buyer-b"} { + username := username + wait.Add(1) + go func() { + defer wait.Done() + <-start + result, err := service.LoginBuyerDevice( + ctx, + usecase.LoginBuyerDeviceCommand{ + Username: username, + Password: "buyer-password", + DeviceID: device.Device.ID, + DeviceToken: device.DeviceToken, + AppVersion: "0.1.0", + AndroidVersion: "16", + }, + ) + if err != nil { + failures <- err + return + } + results <- result + }() + } + close(start) + wait.Wait() + close(results) + close(failures) + + if len(results) != 1 || len(failures) != 1 { + t.Fatalf( + "concurrent binding successes=%d failures=%d, want 1/1", + len(results), + len(failures), + ) + } + success := <-results + failure := <-failures + assertRepositoryAuthCode(t, failure, "AUTH_FORBIDDEN") + + principal, err := service.AuthenticateAccessToken(ctx, success.Token) + if err != nil { + t.Fatalf("AuthenticateAccessToken() error = %v", err) + } + if principal.DeviceID != device.Device.ID || + principal.UserID != success.User.ID { + t.Fatalf("access principal = %+v", principal) + } + if success.Device.AppVersion == nil || + *success.Device.AppVersion != "0.1.0" || + success.Device.AndroidVersion == nil || + *success.Device.AndroidVersion != "16" { + t.Fatalf("device versions = %+v", success.Device) + } + var appVersion string + var androidVersion string + var pddVersion sql.NullString + if err := db.QueryRow( + `SELECT app_version, android_version, pdd_version + FROM devices WHERE id = ?`, + device.Device.ID, + ).Scan(&appVersion, &androidVersion, &pddVersion); err != nil { + t.Fatalf("query device versions: %v", err) + } + if appVersion != "0.1.0" || + androidVersion != "16" || + pddVersion.Valid { + t.Fatalf( + "stored versions app=%q android=%q pdd=%v", + appVersion, + androidVersion, + pddVersion, + ) + } + var boundUserID string + if err := db.QueryRow( + "SELECT bound_user_id FROM devices WHERE id = ?", + device.Device.ID, + ).Scan(&boundUserID); err != nil { + t.Fatalf("query bound user: %v", err) + } + if boundUserID != success.User.ID { + t.Fatalf("bound user = %s, want %s", boundUserID, success.User.ID) + } + var storedTokenHash string + if err := db.QueryRow( + "SELECT token_hash FROM access_tokens WHERE user_id = ?", + success.User.ID, + ).Scan(&storedTokenHash); err != nil { + t.Fatalf("query access token hash: %v", err) + } + if storedTokenHash == success.Token || len(storedTokenHash) != 64 { + t.Fatal("database contains missing or plaintext access token") + } +} + +func TestAuthRepositoryRechecksCurrentUserDeviceAndExpiryState( + t *testing.T, +) { + db := openDatabase(t) + service := newRepositoryAuthService(t, db) + ctx := context.Background() + user, err := service.ProvisionUser(ctx, usecase.ProvisionUserCommand{ + Username: "buyer", + Password: "buyer-password", + Role: domain.UserRoleBuyer, + Active: true, + }) + if err != nil { + t.Fatalf("ProvisionUser() error = %v", err) + } + device, err := service.ProvisionDevice( + ctx, + usecase.ProvisionDeviceCommand{Name: "Device", Enabled: true}, + ) + if err != nil { + t.Fatalf("ProvisionDevice() error = %v", err) + } + login, err := service.LoginBuyerDevice( + ctx, + usecase.LoginBuyerDeviceCommand{ + Username: user.Username, + Password: "buyer-password", + DeviceID: device.Device.ID, + DeviceToken: device.DeviceToken, + AppVersion: "0.1.0", + AndroidVersion: "16", + }, + ) + if err != nil { + t.Fatalf("LoginBuyerDevice() error = %v", err) + } + + if err := service.SetUserActive( + ctx, + usecase.SetUserActiveCommand{ + Username: user.Username, + Active: false, + }, + ); err != nil { + t.Fatalf("disable user: %v", err) + } + _, err = service.AuthenticateAccessToken(ctx, login.Token) + assertRepositoryAuthCode( + t, + err, + "AUTH_ACCOUNT_OR_DEVICE_DISABLED", + ) + + if err := service.SetUserActive( + ctx, + usecase.SetUserActiveCommand{ + Username: user.Username, + Active: true, + }, + ); err != nil { + t.Fatalf("enable user: %v", err) + } + if err := service.SetDeviceEnabled( + ctx, + usecase.SetDeviceEnabledCommand{ + DeviceID: device.Device.ID, + Enabled: false, + }, + ); err != nil { + t.Fatalf("disable device: %v", err) + } + _, err = service.AuthenticateAccessToken(ctx, login.Token) + assertRepositoryAuthCode( + t, + err, + "AUTH_ACCOUNT_OR_DEVICE_DISABLED", + ) + + if err := service.SetDeviceEnabled( + ctx, + usecase.SetDeviceEnabledCommand{ + DeviceID: device.Device.ID, + Enabled: true, + }, + ); err != nil { + t.Fatalf("enable device: %v", err) + } + if _, err := db.Exec( + "UPDATE access_tokens SET expires_at = ? WHERE user_id = ?", + time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC).Format( + time.RFC3339Nano, + ), + user.ID, + ); err != nil { + t.Fatalf("expire access token: %v", err) + } + _, err = service.AuthenticateAccessToken(ctx, login.Token) + assertRepositoryAuthCode(t, err, "AUTH_SESSION_EXPIRED") + + if _, err := db.Exec( + `UPDATE access_tokens + SET expires_at = ?, revoked_at = ? + WHERE user_id = ?`, + login.ExpiresAt.Format(time.RFC3339Nano), + time.Date(2026, 7, 26, 12, 30, 0, 0, time.UTC).Format( + time.RFC3339Nano, + ), + user.ID, + ); err != nil { + t.Fatalf("revoke access token: %v", err) + } + _, err = service.AuthenticateAccessToken(ctx, login.Token) + assertRepositoryAuthCode(t, err, "AUTH_INVALID_TOKEN") +} + +func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( + t *testing.T, +) { + db := openDatabase(t) + runner, err := migration.New(db) + if err != nil { + t.Fatalf("migration.New() error = %v", err) + } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("Down(v3) error = %v", err) + } + if tableExists(t, db, "users") { + t.Fatal("users table exists after auth migration rollback") + } + if columnExists(t, db, "purchase_tasks", "created_by_user_id") { + t.Fatal("created_by_user_id exists after auth migration rollback") + } + if columnExists(t, db, "task_events", "actor_user_id") { + t.Fatal("actor_user_id exists after auth migration rollback") + } + if !tableExists(t, db, "purchase_tasks") { + t.Fatal("purchase_tasks was lost during auth migration rollback") + } + if applied, err := runner.Up(context.Background()); err != nil { + t.Fatalf("Up(v3) error = %v", err) + } else if applied != 1 { + t.Fatalf("Up(v3) applied = %d, want 1", applied) + } +} + +func newRepositoryAuthService( + t *testing.T, + db *sql.DB, +) *usecase.AuthService { + t.Helper() + store, err := repository.New(db) + if err != nil { + t.Fatalf("repository.New() error = %v", err) + } + passwords, err := password.NewBcrypt(bcrypt.MinCost) + if err != nil { + t.Fatalf("password.NewBcrypt() error = %v", err) + } + service, err := usecase.NewAuthService( + store, + passwords, + repositoryAuthClock{ + now: time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC), + }, + &repositoryAuthIDGenerator{}, + &repositoryAuthTokenGenerator{}, + ) + if err != nil { + t.Fatalf("usecase.NewAuthService() error = %v", err) + } + return service +} + +func assertRepositoryAuthCode(t *testing.T, err error, code string) { + t.Helper() + var authError *usecase.Error + if !errors.As(err, &authError) || authError.Code != code { + t.Fatalf("error = %v, want code %s", err, code) + } +} + +func tableExists( + t *testing.T, + db *sql.DB, + name string, +) bool { + t.Helper() + var count int + if err := db.QueryRow( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", + name, + ).Scan(&count); err != nil { + t.Fatalf("query table %s: %v", name, err) + } + return count == 1 +} + +func columnExists( + t *testing.T, + db *sql.DB, + table string, + column string, +) bool { + t.Helper() + rows, err := db.Query("PRAGMA table_info(" + table + ")") + if err != nil { + t.Fatalf("PRAGMA table_info(%s): %v", table, err) + } + defer rows.Close() + for rows.Next() { + var cid int + var name string + var dataType string + var notNull int + var defaultValue any + var primaryKey int + if err := rows.Scan( + &cid, + &name, + &dataType, + ¬Null, + &defaultValue, + &primaryKey, + ); err != nil { + t.Fatalf("scan table_info(%s): %v", table, err) + } + if name == column { + return true + } + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate table_info(%s): %v", table, err) + } + return false +} + +type repositoryAuthClock struct { + now time.Time +} + +func (clock repositoryAuthClock) Now() time.Time { + return clock.now +} + +type repositoryAuthIDGenerator struct { + mu sync.Mutex + next int +} + +func (generator *repositoryAuthIDGenerator) NewID() (string, error) { + generator.mu.Lock() + defer generator.mu.Unlock() + generator.next++ + return uuid(600 + generator.next), nil +} + +type repositoryAuthTokenGenerator struct { + mu sync.Mutex + next byte +} + +func (generator *repositoryAuthTokenGenerator) NewToken() (string, error) { + generator.mu.Lock() + defer generator.mu.Unlock() + generator.next++ + value := make([]byte, 32) + for index := range value { + value[index] = generator.next + byte(index) + } + return base64.RawURLEncoding.EncodeToString(value), nil +} diff --git a/backend-api/internal/repository/sqlite/helpers.go b/backend-api/internal/repository/sqlite/helpers.go index 773cbc5..e2b3ca0 100644 --- a/backend-api/internal/repository/sqlite/helpers.go +++ b/backend-api/internal/repository/sqlite/helpers.go @@ -49,6 +49,7 @@ func scanAsset(scanner rowScanner) (domain.Asset, error) { func scanTask(scanner rowScanner) (domain.PurchaseTask, error) { var task domain.PurchaseTask + var createdByUserID sql.NullString var sourceRef sql.NullString var maxBudget sql.NullInt64 var cancelReason sql.NullString @@ -58,6 +59,7 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) { err := scanner.Scan( &task.ID, &task.CreatorSubject, + &createdByUserID, &sourceRef, &task.Title, &task.Description, @@ -79,6 +81,9 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) { if sourceRef.Valid { task.SourceRef = &sourceRef.String } + if createdByUserID.Valid { + task.CreatedByUserID = &createdByUserID.String + } if maxBudget.Valid { task.MaxBudgetCents = &maxBudget.Int64 } @@ -137,7 +142,7 @@ func getTaskByID( task, err := scanTask(queryer.QueryRowContext( ctx, `SELECT - id, creator_subject, source_ref, title, description, sku, + id, creator_subject, created_by_user_id, source_ref, title, description, sku, image_asset_id, quantity, max_budget_cents, currency, status, version, cancel_reason, canceled_at, created_at, updated_at FROM purchase_tasks diff --git a/backend-api/internal/repository/sqlite/store_test.go b/backend-api/internal/repository/sqlite/store_test.go index 36c42e6..56e32d5 100644 --- a/backend-api/internal/repository/sqlite/store_test.go +++ b/backend-api/internal/repository/sqlite/store_test.go @@ -321,6 +321,18 @@ func TestStoreEnforcesAssetOwnershipSourceReferenceAndStableCursor( func openStore(t *testing.T) *repository.Store { t.Helper() db := openDatabase(t) + now := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := db.ExecContext( + context.Background(), + `INSERT INTO users ( + id, username, password_hash, role, is_active, created_at, updated_at + ) VALUES (?, 'task-audit-admin', 'test-only-hash', 'ADMIN', 1, ?, ?)`, + uuid(999), + now, + now, + ); err != nil { + t.Fatalf("seed admin user: %v", err) + } store, err := repository.New(db) if err != nil { t.Fatalf("repository.New() error = %v", err) @@ -368,21 +380,23 @@ func testTask( createdAt time.Time, ) domain.PurchaseTask { budget := int64(2000 + index) + actorUserID := uuid(999) return domain.PurchaseTask{ - ID: uuid(index), - CreatorSubject: "local-admin", - SourceRef: &source, - Title: "title " + string(rune('0'+index)), - Description: "description", - SKU: "SKU-" + string(rune('0'+index)), - ImageAssetID: assetID, - Quantity: index, - MaxBudgetCents: &budget, - Currency: domain.CurrencyCNY, - Status: domain.TaskStatusPending, - Version: 1, - CreatedAt: createdAt, - UpdatedAt: createdAt, + ID: uuid(index), + CreatorSubject: "local-admin", + CreatedByUserID: &actorUserID, + SourceRef: &source, + Title: "title " + string(rune('0'+index)), + Description: "description", + SKU: "SKU-" + string(rune('0'+index)), + ImageAssetID: assetID, + Quantity: index, + MaxBudgetCents: &budget, + Currency: domain.CurrencyCNY, + Status: domain.TaskStatusPending, + Version: 1, + CreatedAt: createdAt, + UpdatedAt: createdAt, } } diff --git a/backend-api/internal/repository/sqlite/task_repository.go b/backend-api/internal/repository/sqlite/task_repository.go index bf6fb0a..36f10b8 100644 --- a/backend-api/internal/repository/sqlite/task_repository.go +++ b/backend-api/internal/repository/sqlite/task_repository.go @@ -82,12 +82,13 @@ func (s *Store) CreateTaskIdempotent( _, err = tx.ExecContext( ctx, `INSERT INTO purchase_tasks ( - id, creator_subject, source_ref, title, description, sku, + id, creator_subject, created_by_user_id, source_ref, title, description, sku, image_asset_id, quantity, max_budget_cents, currency, status, version, cancel_reason, canceled_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`, candidate.ID, candidate.CreatorSubject, + nullableString(candidate.CreatedByUserID), nullableString(candidate.SourceRef), candidate.Title, candidate.Description, @@ -142,7 +143,7 @@ func (s *Store) ListTasks( ) ([]domain.PurchaseTask, error) { var query strings.Builder query.WriteString(`SELECT - id, creator_subject, source_ref, title, description, sku, + id, creator_subject, created_by_user_id, source_ref, title, description, sku, image_asset_id, quantity, max_budget_cents, currency, status, version, cancel_reason, canceled_at, created_at, updated_at FROM purchase_tasks @@ -241,7 +242,7 @@ func (s *Store) GetTaskDetail( } rows, err := tx.QueryContext( ctx, - `SELECT id, task_id, event_type, message, occurred_at + `SELECT id, task_id, actor_user_id, event_type, message, occurred_at FROM task_events WHERE task_id = ? ORDER BY occurred_at ASC, id ASC`, @@ -254,16 +255,21 @@ func (s *Store) GetTaskDetail( events := make([]domain.TaskEvent, 0) for rows.Next() { var event domain.TaskEvent + var actorUserID sql.NullString var occurredAt string if err := rows.Scan( &event.ID, &event.TaskID, + &actorUserID, &event.Type, &event.Message, &occurredAt, ); err != nil { return domain.TaskDetail{}, repositoryFailure(err) } + if actorUserID.Valid { + event.ActorUserID = &actorUserID.String + } event.OccurredAt, err = parseTimestamp(occurredAt) if err != nil { return domain.TaskDetail{}, err @@ -357,10 +363,11 @@ func insertTaskEvent( _, err := tx.ExecContext( ctx, `INSERT INTO task_events ( - id, task_id, event_type, message, occurred_at - ) VALUES (?, ?, ?, ?, ?)`, + id, task_id, actor_user_id, event_type, message, occurred_at + ) VALUES (?, ?, ?, ?, ?, ?)`, event.ID, event.TaskID, + nullableString(event.ActorUserID), event.Type, event.Message, formatTimestamp(event.OccurredAt), diff --git a/backend-api/internal/transport/authcommon/attempt_limiter.go b/backend-api/internal/transport/authcommon/attempt_limiter.go new file mode 100644 index 0000000..1c08167 --- /dev/null +++ b/backend-api/internal/transport/authcommon/attempt_limiter.go @@ -0,0 +1,131 @@ +package authcommon + +import ( + "errors" + "math" + "net" + "strings" + "sync" + "time" +) + +type AttemptLimiter interface { + Allow(string) (bool, time.Duration) + Reset(string) +} + +type attemptWindow struct { + count int + resetAt time.Time +} + +type InMemoryAttemptLimiter struct { + mu sync.Mutex + maxAttempts int + window time.Duration + maxKeys int + now func() time.Time + attempts map[string]attemptWindow +} + +func NewAttemptLimiter( + maxAttempts int, + window time.Duration, + maxKeys int, +) (*InMemoryAttemptLimiter, error) { + return newAttemptLimiter(maxAttempts, window, maxKeys, time.Now) +} + +func newAttemptLimiter( + maxAttempts int, + window time.Duration, + maxKeys int, + now func() time.Time, +) (*InMemoryAttemptLimiter, error) { + if maxAttempts < 1 || window <= 0 || maxKeys < 1 || now == nil { + return nil, errors.New("attempt limiter configuration is invalid") + } + return &InMemoryAttemptLimiter{ + maxAttempts: maxAttempts, + window: window, + maxKeys: maxKeys, + now: now, + attempts: make(map[string]attemptWindow), + }, nil +} + +func (limiter *InMemoryAttemptLimiter) Allow( + key string, +) (bool, time.Duration) { + now := limiter.now().UTC() + key = strings.TrimSpace(key) + if key == "" { + key = "unknown" + } + + limiter.mu.Lock() + defer limiter.mu.Unlock() + + current, found := limiter.attempts[key] + if found && !current.resetAt.After(now) { + delete(limiter.attempts, key) + found = false + } + if !found { + if len(limiter.attempts) >= limiter.maxKeys { + limiter.removeExpired(now) + } + if len(limiter.attempts) >= limiter.maxKeys { + return false, limiter.window + } + limiter.attempts[key] = attemptWindow{ + count: 1, + resetAt: now.Add(limiter.window), + } + return true, 0 + } + if current.count >= limiter.maxAttempts { + return false, current.resetAt.Sub(now) + } + current.count++ + limiter.attempts[key] = current + return true, 0 +} + +func (limiter *InMemoryAttemptLimiter) Reset(key string) { + limiter.mu.Lock() + delete(limiter.attempts, strings.TrimSpace(key)) + limiter.mu.Unlock() +} + +func (limiter *InMemoryAttemptLimiter) removeExpired(now time.Time) { + for key, current := range limiter.attempts { + if !current.resetAt.After(now) { + delete(limiter.attempts, key) + } + } +} + +func LoginAttemptKey(scope, remoteAddress string) string { + host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddress)) + if err != nil { + host = strings.TrimSpace(remoteAddress) + } + if parsed := net.ParseIP(host); parsed != nil { + host = parsed.String() + } + if host == "" { + host = "unknown" + } + return scope + ":" + host +} + +func RetryAfterSeconds(wait time.Duration) int { + seconds := int(math.Ceil(wait.Seconds())) + if seconds < 1 { + return 1 + } + return seconds +} + +var _ AttemptLimiter = (*InMemoryAttemptLimiter)(nil) diff --git a/backend-api/internal/transport/authcommon/attempt_limiter_test.go b/backend-api/internal/transport/authcommon/attempt_limiter_test.go new file mode 100644 index 0000000..a5e7471 --- /dev/null +++ b/backend-api/internal/transport/authcommon/attempt_limiter_test.go @@ -0,0 +1,75 @@ +package authcommon + +import ( + "testing" + "time" +) + +func TestAttemptLimiterBlocksUntilWindowExpiresAndCanReset(t *testing.T) { + now := time.Date(2026, 7, 26, 1, 2, 3, 0, time.UTC) + limiter, err := newAttemptLimiter( + 2, + time.Minute, + 10, + func() time.Time { return now }, + ) + if err != nil { + t.Fatalf("newAttemptLimiter() error = %v", err) + } + if allowed, _ := limiter.Allow("admin:127.0.0.1"); !allowed { + t.Fatal("first attempt was blocked") + } + if allowed, _ := limiter.Allow("admin:127.0.0.1"); !allowed { + t.Fatal("second attempt was blocked") + } + if allowed, wait := limiter.Allow("admin:127.0.0.1"); allowed || + wait != time.Minute { + t.Fatalf("third attempt = %v, wait = %v", allowed, wait) + } + + limiter.Reset("admin:127.0.0.1") + if allowed, _ := limiter.Allow("admin:127.0.0.1"); !allowed { + t.Fatal("attempt after reset was blocked") + } + + now = now.Add(time.Minute) + if allowed, _ := limiter.Allow("admin:127.0.0.1"); !allowed { + t.Fatal("attempt after window expiry was blocked") + } +} + +func TestAttemptLimiterBoundsTrackedKeys(t *testing.T) { + now := time.Date(2026, 7, 26, 1, 2, 3, 0, time.UTC) + limiter, err := newAttemptLimiter( + 1, + time.Minute, + 1, + func() time.Time { return now }, + ) + if err != nil { + t.Fatalf("newAttemptLimiter() error = %v", err) + } + if allowed, _ := limiter.Allow("one"); !allowed { + t.Fatal("first key was blocked") + } + if allowed, wait := limiter.Allow("two"); allowed || + wait != time.Minute { + t.Fatalf("second key = %v, wait = %v", allowed, wait) + } + now = now.Add(time.Minute) + if allowed, _ := limiter.Allow("two"); !allowed { + t.Fatal("expired key was not evicted") + } +} + +func TestLoginAttemptKeyUsesRemoteAddressOnly(t *testing.T) { + if got := LoginAttemptKey("buyer", "127.0.0.1:1234"); got != "buyer:127.0.0.1" { + t.Fatalf("IPv4 key = %q", got) + } + if got := LoginAttemptKey("admin", "[2001:db8::1]:443"); got != "admin:2001:db8::1" { + t.Fatalf("IPv6 key = %q", got) + } + if got := RetryAfterSeconds(time.Millisecond); got != 1 { + t.Fatalf("RetryAfterSeconds() = %d", got) + } +} diff --git a/backend-api/internal/transport/authcommon/authcommon.go b/backend-api/internal/transport/authcommon/authcommon.go new file mode 100644 index 0000000..d234eee --- /dev/null +++ b/backend-api/internal/transport/authcommon/authcommon.go @@ -0,0 +1,49 @@ +package authcommon + +import ( + "context" + "crypto/subtle" + "encoding/base64" + "strings" + + "cmroubao/backend-api/internal/domain" +) + +const ( + AdminSessionCookieName = "cmroubao_admin_session" + CSRFCookieName = "cmroubao_admin_csrf" + CSRFFormField = "csrf_token" + CSRFHeader = "X-CSRF-Token" +) + +type principalContextKey struct{} + +func WithPrincipal( + ctx context.Context, + principal domain.AuthPrincipal, +) context.Context { + return context.WithValue(ctx, principalContextKey{}, principal) +} + +func Principal(ctx context.Context) (domain.AuthPrincipal, bool) { + principal, ok := ctx.Value(principalContextKey{}).(domain.AuthPrincipal) + return principal, ok +} + +func ValidCSRFPair(cookieValue, presentedValue string) bool { + cookieValue = strings.TrimSpace(cookieValue) + presentedValue = strings.TrimSpace(presentedValue) + if !ValidOpaqueValue(cookieValue) || + len(cookieValue) != len(presentedValue) { + return false + } + return subtle.ConstantTimeCompare( + []byte(cookieValue), + []byte(presentedValue), + ) == 1 +} + +func ValidOpaqueValue(value string) bool { + decoded, err := base64.RawURLEncoding.DecodeString(value) + return err == nil && len(decoded) == 32 +} diff --git a/backend-api/internal/transport/authcommon/authcommon_test.go b/backend-api/internal/transport/authcommon/authcommon_test.go new file mode 100644 index 0000000..f5cd342 --- /dev/null +++ b/backend-api/internal/transport/authcommon/authcommon_test.go @@ -0,0 +1,41 @@ +package authcommon + +import ( + "context" + "testing" + + "cmroubao/backend-api/internal/domain" +) + +func TestPrincipalRoundTrip(t *testing.T) { + want := domain.AuthPrincipal{ + UserID: "user-1", + Username: "buyer", + Role: domain.UserRoleBuyer, + DeviceID: "device-1", + } + ctx := WithPrincipal(context.Background(), want) + + got, ok := Principal(ctx) + + if !ok || got != want { + t.Fatalf("Principal() = %+v, %t", got, ok) + } +} + +func TestValidCSRFPairRequiresOneCanonical256BitValue(t *testing.T) { + valid := "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE" + if !ValidCSRFPair(valid, valid) { + t.Fatal("valid CSRF pair was rejected") + } + for _, candidate := range []string{ + "", + "short", + valid + "=", + valid[:len(valid)-1] + "b", + } { + if ValidCSRFPair(valid, candidate) { + t.Fatalf("invalid presented value %q was accepted", candidate) + } + } +} diff --git a/backend-api/internal/transport/httpapi/admin_guard.go b/backend-api/internal/transport/httpapi/admin_guard.go deleted file mode 100644 index 851083a..0000000 --- a/backend-api/internal/transport/httpapi/admin_guard.go +++ /dev/null @@ -1,36 +0,0 @@ -package httpapi - -import ( - "net" - "net/http" - - "github.com/gin-gonic/gin" -) - -func loopbackAdminOnly() gin.HandlerFunc { - return func(ctx *gin.Context) { - host, _, err := net.SplitHostPort(ctx.Request.RemoteAddr) - if err != nil { - denyNonLocalAdmin(ctx) - return - } - address := net.ParseIP(host) - if address == nil || !address.IsLoopback() { - denyNonLocalAdmin(ctx) - return - } - ctx.Next() - } -} - -func denyNonLocalAdmin(ctx *gin.Context) { - ctx.Header("Cache-Control", "no-store") - ctx.AbortWithStatusJSON( - http.StatusForbidden, - errorResponse( - ctx, - "ADMIN_SESSION_REQUIRED", - "admin session required", - ), - ) -} diff --git a/backend-api/internal/transport/httpapi/admin_guard_test.go b/backend-api/internal/transport/httpapi/admin_guard_test.go deleted file mode 100644 index 6619a28..0000000 --- a/backend-api/internal/transport/httpapi/admin_guard_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package httpapi - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" -) - -func TestLoopbackAdminOnlyAllowsLoopbackAddresses(t *testing.T) { - for _, remoteAddress := range []string{ - "127.0.0.1:12345", - "[::1]:12345", - } { - t.Run(remoteAddress, func(t *testing.T) { - router := gin.New() - router.Use(requestIDMiddleware(), loopbackAdminOnly()) - router.GET("/tasks", func(ctx *gin.Context) { - ctx.Status(http.StatusNoContent) - }) - request := httptest.NewRequest(http.MethodGet, "/tasks", nil) - request.RemoteAddr = remoteAddress - response := httptest.NewRecorder() - - router.ServeHTTP(response, request) - - if response.Code != http.StatusNoContent { - t.Fatalf("status = %d", response.Code) - } - }) - } -} - -func TestLoopbackAdminOnlyRejectsRemoteOrMalformedAddresses(t *testing.T) { - for _, remoteAddress := range []string{ - "192.0.2.1:12345", - "not-an-address", - } { - t.Run(remoteAddress, func(t *testing.T) { - router := gin.New() - router.Use(requestIDMiddleware(), loopbackAdminOnly()) - router.GET("/tasks", func(ctx *gin.Context) { - ctx.Status(http.StatusNoContent) - }) - request := httptest.NewRequest(http.MethodGet, "/tasks", nil) - request.RemoteAddr = remoteAddress - response := httptest.NewRecorder() - - router.ServeHTTP(response, request) - - if response.Code != http.StatusForbidden { - t.Fatalf("status = %d", response.Code) - } - assertErrorCode(t, response, "ADMIN_SESSION_REQUIRED") - if response.Header().Get("Cache-Control") != "no-store" { - t.Fatalf( - "Cache-Control = %q", - response.Header().Get("Cache-Control"), - ) - } - }) - } -} diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index 9445cb5..9667322 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -11,6 +11,7 @@ import ( "time" "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/transport/authcommon" "cmroubao/backend-api/internal/usecase" "github.com/gin-gonic/gin" @@ -209,6 +210,7 @@ func (h *adminHandlers) createTask(ctx *gin.Context) { ctx.Request.Context(), usecase.CreateTaskCommand{ CreatorSubject: localAdminSubject, + ActorUserID: adminActorUserID(ctx), IdempotencyKey: ctx.GetHeader("Idempotency-Key"), SourceRef: request.SourceRef, Title: request.Title, @@ -226,6 +228,14 @@ func (h *adminHandlers) createTask(ctx *gin.Context) { ctx.JSON(http.StatusCreated, taskSummaryResponse(result.Task)) } +func adminActorUserID(ctx *gin.Context) string { + principal, ok := authcommon.Principal(ctx.Request.Context()) + if !ok || principal.Role != domain.UserRoleAdmin { + return "" + } + return principal.UserID +} + func (h *adminHandlers) listTasks(ctx *gin.Context) { query := usecase.ListTasksQuery{ CreatorSubject: localAdminSubject, @@ -292,10 +302,11 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) { events := make([]gin.H, 0, len(detail.Events)) for _, event := range detail.Events { events = append(events, gin.H{ - "id": event.ID, - "type": event.Type, - "message": event.Message, - "occurred_at": formatTime(event.OccurredAt), + "id": event.ID, + "actor_user_id": event.ActorUserID, + "type": event.Type, + "message": event.Message, + "occurred_at": formatTime(event.OccurredAt), }) } ctx.Header("Cache-Control", "no-store") @@ -355,6 +366,7 @@ func (h *adminHandlers) cancelTask(ctx *gin.Context) { ctx.Request.Context(), usecase.CancelTaskCommand{ CreatorSubject: localAdminSubject, + ActorUserID: adminActorUserID(ctx), TaskID: ctx.Param("id"), Reason: request.Reason, }, diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index 30e4dff..d3c8e78 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strings" "testing" + "time" "cmroubao/backend-api/internal/platform/assetstore" "cmroubao/backend-api/internal/platform/database" @@ -210,6 +211,32 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) { canceled, ) } + canceledDetailResponse := performAdminRequest( + t, + router, + http.MethodGet, + "/api/v1/tasks/"+taskID, + "", + nil, + "", + ) + var canceledDetail map[string]any + decodeResponse(t, canceledDetailResponse, &canceledDetail) + events, _ := canceledDetail["events"].([]any) + if canceledDetailResponse.Code != http.StatusOK || len(events) != 2 { + t.Fatalf( + "canceled detail status/body = %d / %#v", + canceledDetailResponse.Code, + canceledDetail, + ) + } + for _, value := range events { + event, _ := value.(map[string]any) + if event["actor_user_id"] != + "00000000-0000-4000-8000-000000000099" { + t.Fatalf("event actor = %#v", event) + } + } secondCancel := performAdminRequest( t, @@ -229,7 +256,7 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) { } } -func TestAdminRoutesRejectNonLoopbackRequests(t *testing.T) { +func TestAdminRoutesRejectRequestsWithoutAdminSession(t *testing.T) { router := newAdminIntegrationRouter(t) request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil) request.RemoteAddr = "192.0.2.10:3210" @@ -237,7 +264,7 @@ func TestAdminRoutesRejectNonLoopbackRequests(t *testing.T) { router.ServeHTTP(response, request) - if response.Code != http.StatusForbidden { + if response.Code != http.StatusUnauthorized { t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) } var body map[string]any @@ -275,7 +302,7 @@ func TestAdminAssetUploadRequiresIdempotencyKey(t *testing.T) { type emptyAdminWeb struct{} -func (emptyAdminWeb) Register(gin.IRoutes) {} +func (emptyAdminWeb) RegisterProtected(gin.IRoutes) {} func newAdminIntegrationRouter(t *testing.T) http.Handler { t.Helper() @@ -292,6 +319,18 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler { if _, err := runner.Up(ctx); err != nil { t.Fatalf("migration.Up() error = %v", err) } + now := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := db.ExecContext( + ctx, + `INSERT INTO users ( + id, username, password_hash, role, is_active, created_at, updated_at + ) VALUES (?, 'admin', 'test-only-hash', 'ADMIN', 1, ?, ?)`, + "00000000-0000-4000-8000-000000000099", + now, + now, + ); err != nil { + t.Fatalf("seed admin user: %v", err) + } repositories, err := repository.New(db) if err != nil { t.Fatalf("repository.New() error = %v", err) @@ -318,9 +357,11 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler { t.Fatalf("NewAdminRouteRegistrar() error = %v", err) } router, err := NewRouter(RouterDependencies{ - Database: db, - RegisterAdminRoutes: registrar, - LogEvent: discardEvent, + Database: db, + RegisterPublicRoutes: discardRoutes, + RegisterAdminRoutes: registrar, + AdminSessions: allowAdminAuthenticator{}, + LogEvent: discardEvent, }) if err != nil { t.Fatalf("NewRouter() error = %v", err) @@ -375,7 +416,18 @@ func performAdminRequest( ) *httptest.ResponseRecorder { t.Helper() request := httptest.NewRequest(method, target, body) - request.RemoteAddr = "127.0.0.1:3210" + request.AddCookie(&http.Cookie{ + Name: "cmroubao_admin_session", + Value: "test-session", + }) + if method != http.MethodGet && method != http.MethodHead { + const csrfToken = "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE" + request.AddCookie(&http.Cookie{ + Name: "cmroubao_admin_csrf", + Value: csrfToken, + }) + request.Header.Set("X-CSRF-Token", csrfToken) + } if contentType != "" { request.Header.Set("Content-Type", contentType) } diff --git a/backend-api/internal/transport/httpapi/auth_handlers.go b/backend-api/internal/transport/httpapi/auth_handlers.go new file mode 100644 index 0000000..c5ab84f --- /dev/null +++ b/backend-api/internal/transport/httpapi/auth_handlers.go @@ -0,0 +1,355 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "net/url" + "strconv" + "strings" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/transport/authcommon" + "cmroubao/backend-api/internal/usecase" + + "github.com/gin-gonic/gin" +) + +type AdminAuthenticator interface { + AuthenticateAdmin( + context.Context, + string, + ) (domain.AuthPrincipal, error) +} + +type DeviceAuthenticator interface { + AuthenticateAccessToken( + context.Context, + string, + ) (domain.AuthPrincipal, error) +} + +type BuyerTokenService interface { + LoginBuyerDevice( + context.Context, + usecase.LoginBuyerDeviceCommand, + ) (usecase.AccessTokenResult, error) +} + +func NewPublicAuthRegistrar( + service BuyerTokenService, + limiter authcommon.AttemptLimiter, +) (RouteRegistrar, error) { + if service == nil { + return nil, errors.New("buyer token service is required") + } + if limiter == nil { + return nil, errors.New("buyer login limiter is required") + } + handler := &authHandlers{service: service, limiter: limiter} + return func(routes gin.IRoutes) error { + routes.POST("/api/v1/auth/token", handler.issueBuyerToken) + return nil + }, nil +} + +type authHandlers struct { + service BuyerTokenService + limiter authcommon.AttemptLimiter +} + +func (h *authHandlers) issueBuyerToken(ctx *gin.Context) { + if !hasMediaType(ctx, "application/json") { + writePublicError( + ctx, + http.StatusUnsupportedMediaType, + "UNSUPPORTED_MEDIA_TYPE", + "application/json is required", + false, + gin.H{}, + ) + return + } + var request struct { + Username string `json:"username"` + Password string `json:"password"` + DeviceID string `json:"device_id"` + DeviceToken string `json:"device_token"` + AppVersion string `json:"app_version"` + AndroidVersion string `json:"android_version"` + } + if err := decodeJSON(ctx, &request); err != nil { + writePublicError( + ctx, + http.StatusBadRequest, + "INVALID_JSON", + "request body must be valid JSON", + false, + gin.H{}, + ) + return + } + attemptKey := authcommon.LoginAttemptKey( + "buyer", + ctx.Request.RemoteAddr, + ) + if allowed, wait := h.limiter.Allow(attemptKey); !allowed { + ctx.Header( + "Retry-After", + strconv.Itoa(authcommon.RetryAfterSeconds(wait)), + ) + writePublicError( + ctx, + http.StatusTooManyRequests, + "AUTH_RATE_LIMITED", + "too many authentication attempts", + true, + gin.H{}, + ) + return + } + result, err := h.service.LoginBuyerDevice( + ctx.Request.Context(), + usecase.LoginBuyerDeviceCommand{ + Username: request.Username, + Password: request.Password, + DeviceID: request.DeviceID, + DeviceToken: request.DeviceToken, + AppVersion: request.AppVersion, + AndroidVersion: request.AndroidVersion, + }, + ) + if err != nil { + writeAuthError(ctx, err) + return + } + h.limiter.Reset(attemptKey) + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "access_token": result.Token, + "token_type": "Bearer", + "expires_in": int(usecase.AccessTokenLifetime.Seconds()), + "user": gin.H{ + "id": result.User.ID, + "username": result.User.Username, + "role": result.User.Role, + }, + "device": gin.H{ + "id": result.Device.ID, + "enabled": result.Device.IsEnabled, + }, + }) +} + +func requireAdminSession( + authenticator AdminAuthenticator, +) gin.HandlerFunc { + return func(ctx *gin.Context) { + cookie, err := ctx.Request.Cookie( + authcommon.AdminSessionCookieName, + ) + if err != nil || cookie.Value == "" { + denyAdminSession(ctx) + return + } + principal, err := authenticator.AuthenticateAdmin( + ctx.Request.Context(), + cookie.Value, + ) + if err != nil || + principal.Role != domain.UserRoleAdmin || + principal.DeviceID != "" { + denyAdminSession(ctx) + return + } + ctx.Request = ctx.Request.WithContext( + authcommon.WithPrincipal( + ctx.Request.Context(), + principal, + ), + ) + if isUnsafeAdminAPIRequest(ctx.Request) && + !validAPIRequestCSRF(ctx.Request) { + writePublicError( + ctx, + http.StatusForbidden, + "CSRF_INVALID", + "CSRF token is invalid", + false, + gin.H{}, + ) + ctx.Abort() + return + } + ctx.Next() + } +} + +func RequireDeviceAccess( + authenticator DeviceAuthenticator, +) gin.HandlerFunc { + return func(ctx *gin.Context) { + token, ok := bearerToken(ctx.GetHeader("Authorization")) + if !ok { + denyDeviceAccess(ctx) + return + } + principal, err := authenticator.AuthenticateAccessToken( + ctx.Request.Context(), + token, + ) + if err != nil || + principal.Role != domain.UserRoleBuyer || + principal.DeviceID == "" { + denyDeviceAccess(ctx) + return + } + ctx.Request = ctx.Request.WithContext( + authcommon.WithPrincipal( + ctx.Request.Context(), + principal, + ), + ) + ctx.Next() + } +} + +func denyAdminSession(ctx *gin.Context) { + ctx.Header("Cache-Control", "no-store") + if strings.HasPrefix(ctx.Request.URL.Path, "/api/") { + ctx.Abort() + writePublicError( + ctx, + http.StatusUnauthorized, + "ADMIN_SESSION_REQUIRED", + "admin session required", + false, + gin.H{}, + ) + return + } + next := ctx.Request.URL.RequestURI() + if next == "" || + (next != "/tasks" && !strings.HasPrefix(next, "/tasks?") && + !strings.HasPrefix(next, "/tasks/")) { + next = "/tasks" + } + ctx.Abort() + ctx.Redirect( + http.StatusSeeOther, + "/login?next="+url.QueryEscape(next), + ) +} + +func denyDeviceAccess(ctx *gin.Context) { + ctx.Abort() + writePublicError( + ctx, + http.StatusUnauthorized, + "DEVICE_ACCESS_REQUIRED", + "device access token required", + false, + gin.H{}, + ) +} + +func validAPIRequestCSRF(request *http.Request) bool { + cookie, err := request.Cookie(authcommon.CSRFCookieName) + if err != nil { + return false + } + return authcommon.ValidCSRFPair( + cookie.Value, + request.Header.Get(authcommon.CSRFHeader), + ) +} + +func isUnsafeAdminAPIRequest(request *http.Request) bool { + if !strings.HasPrefix(request.URL.Path, "/api/") { + return false + } + switch request.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return false + default: + return true + } +} + +func bearerToken(value string) (string, bool) { + parts := strings.Fields(value) + if len(parts) != 2 || + !strings.EqualFold(parts[0], "Bearer") || + !authcommon.ValidOpaqueValue(parts[1]) { + return "", false + } + return parts[1], true +} + +func writeAuthError(ctx *gin.Context, err error) { + var typed *usecase.Error + if !errors.As(err, &typed) { + writePublicError( + ctx, + http.StatusInternalServerError, + "INTERNAL_ERROR", + "internal server error", + false, + gin.H{}, + ) + return + } + switch typed.Kind { + case usecase.ErrorKindUnauthorized, usecase.ErrorKindInvalid: + writePublicError( + ctx, + http.StatusUnauthorized, + "AUTH_INVALID_CREDENTIALS", + "invalid credentials", + false, + gin.H{}, + ) + case usecase.ErrorKindForbidden: + code := "AUTH_FORBIDDEN" + message := "authentication is not allowed" + if typed.Code == "AUTH_ACCOUNT_OR_DEVICE_DISABLED" { + code = "AUTH_ACCOUNT_OR_DEVICE_DISABLED" + message = "account or device is disabled" + } + writePublicError( + ctx, + http.StatusForbidden, + code, + message, + false, + gin.H{}, + ) + case usecase.ErrorKindConflict: + writePublicError( + ctx, + http.StatusConflict, + "AUTH_RESOURCE_CONFLICT", + "authentication resource conflict", + false, + gin.H{}, + ) + case usecase.ErrorKindUnavailable: + writePublicError( + ctx, + http.StatusServiceUnavailable, + "AUTH_UNAVAILABLE", + "authentication service is unavailable", + true, + gin.H{}, + ) + default: + writePublicError( + ctx, + http.StatusInternalServerError, + "INTERNAL_ERROR", + "internal server error", + false, + gin.H{}, + ) + } +} diff --git a/backend-api/internal/transport/httpapi/auth_handlers_test.go b/backend-api/internal/transport/httpapi/auth_handlers_test.go new file mode 100644 index 0000000..d54892c --- /dev/null +++ b/backend-api/internal/transport/httpapi/auth_handlers_test.go @@ -0,0 +1,405 @@ +package httpapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/transport/authcommon" + "cmroubao/backend-api/internal/usecase" + + "github.com/gin-gonic/gin" +) + +const ( + testOpaqueToken = "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE" + testCSRFOpaqueToken = "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI" +) + +type fakeBuyerTokenService struct { + command usecase.LoginBuyerDeviceCommand + result usecase.AccessTokenResult + err error + calls int +} + +func (service *fakeBuyerTokenService) LoginBuyerDevice( + _ context.Context, + command usecase.LoginBuyerDeviceCommand, +) (usecase.AccessTokenResult, error) { + service.calls++ + service.command = command + return service.result, service.err +} + +func TestIssueBuyerTokenReturnsOnlyPublicIdentityAndAccessToken(t *testing.T) { + service := &fakeBuyerTokenService{ + result: usecase.AccessTokenResult{ + Token: testOpaqueToken, + ExpiresAt: time.Now().Add(time.Hour), + User: domain.User{ + ID: "buyer-1", + Username: "buyer01", + Role: domain.UserRoleBuyer, + }, + Device: domain.Device{ + ID: "device-1", + IsEnabled: true, + }, + }, + } + router := newPublicAuthTestRouter(t, service) + body := `{ + "username":"buyer01", + "password":"private-password", + "device_id":"device-1", + "device_token":"private-device-token", + "app_version":"0.1.0", + "android_version":"15" + }` + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/token", + strings.NewReader(body), + ) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status/body = %d / %s", response.Code, response.Body) + } + if service.command.DeviceID != "device-1" || + service.command.AppVersion != "0.1.0" || + service.command.AndroidVersion != "15" || + service.command.Password != "private-password" { + t.Fatalf("command = %+v", service.command) + } + var decoded map[string]any + if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil { + t.Fatalf("decode response: %v", err) + } + if decoded["access_token"] != testOpaqueToken || + decoded["token_type"] != "Bearer" || + decoded["expires_in"] != float64(3600) { + t.Fatalf("response = %#v", decoded) + } + responseText := response.Body.String() + for _, secret := range []string{ + "private-password", + "private-device-token", + "android_version", + "app_version", + } { + if strings.Contains(responseText, secret) { + t.Fatalf("response leaked %q: %s", secret, responseText) + } + } + if response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("Cache-Control = %q", response.Header().Get("Cache-Control")) + } +} + +func TestIssueBuyerTokenUsesGenericCredentialErrors(t *testing.T) { + service := &fakeBuyerTokenService{ + err: &usecase.Error{ + Kind: usecase.ErrorKindInvalid, + Code: "AUTH_VALIDATION_FAILED", + Message: "private validation detail", + Fields: map[string]string{"password": "private detail"}, + }, + } + router := newPublicAuthTestRouter(t, service) + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/token", + strings.NewReader(`{ + "username":"buyer", + "password":"secret-value", + "device_id":"device", + "device_token":"token", + "app_version":"0.1", + "android_version":"15" + }`), + ) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusUnauthorized { + t.Fatalf("status/body = %d / %s", response.Code, response.Body) + } + assertErrorCode(t, response, "AUTH_INVALID_CREDENTIALS") + for _, privateValue := range []string{ + "secret-value", + "private validation detail", + "private detail", + } { + if strings.Contains(response.Body.String(), privateValue) { + t.Fatalf("error leaked %q", privateValue) + } + } +} + +func TestIssueBuyerTokenRateLimitSkipsAuthenticationWork(t *testing.T) { + service := &fakeBuyerTokenService{ + err: &usecase.Error{ + Kind: usecase.ErrorKindUnauthorized, + Code: "AUTH_INVALID_CREDENTIALS", + }, + } + limiter, err := authcommon.NewAttemptLimiter(1, time.Minute, 10) + if err != nil { + t.Fatalf("NewAttemptLimiter() error = %v", err) + } + registrar, err := NewPublicAuthRegistrar(service, limiter) + if err != nil { + t.Fatalf("NewPublicAuthRegistrar() error = %v", err) + } + router := gin.New() + router.Use(requestIDMiddleware()) + if err := registrar(router); err != nil { + t.Fatalf("register public auth: %v", err) + } + body := `{ + "username":"buyer", + "password":"secret-value", + "device_id":"device", + "device_token":"token", + "app_version":"0.1", + "android_version":"15" + }` + requestToken := func() *httptest.ResponseRecorder { + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/token", + strings.NewReader(body), + ) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response + } + + if response := requestToken(); response.Code != http.StatusUnauthorized { + t.Fatalf("first status = %d", response.Code) + } + response := requestToken() + if response.Code != http.StatusTooManyRequests || + response.Header().Get("Retry-After") == "" || + !strings.Contains(response.Body.String(), `"code":"AUTH_RATE_LIMITED"`) || + service.calls != 1 { + t.Fatalf( + "second status/retry/calls/body = %d / %q / %d / %s", + response.Code, + response.Header().Get("Retry-After"), + service.calls, + response.Body, + ) + } +} + +func TestAdminSessionMiddlewareSeparatesWebAPIAndCSRF(t *testing.T) { + authenticator := &stubAuthenticator{ + adminPrincipal: domain.AuthPrincipal{ + UserID: "admin-1", + Username: "admin", + Role: domain.UserRoleAdmin, + SessionID: "session-1", + }, + } + router := gin.New() + router.Use(requestIDMiddleware()) + protected := router.Group("") + protected.Use(requireAdminSession(authenticator)) + protected.GET("/tasks/item", func(ctx *gin.Context) { + principal, ok := authcommon.Principal(ctx.Request.Context()) + if !ok { + ctx.Status(http.StatusInternalServerError) + return + } + ctx.String(http.StatusOK, principal.UserID) + }) + protected.POST("/api/v1/tasks", func(ctx *gin.Context) { + ctx.Status(http.StatusNoContent) + }) + + webRequest := httptest.NewRequest( + http.MethodGet, + "/tasks/item?q=1", + nil, + ) + webResponse := httptest.NewRecorder() + router.ServeHTTP(webResponse, webRequest) + if webResponse.Code != http.StatusSeeOther || + webResponse.Header().Get("Location") != + "/login?next=%2Ftasks%2Fitem%3Fq%3D1" { + t.Fatalf( + "web status/location = %d / %q", + webResponse.Code, + webResponse.Header().Get("Location"), + ) + } + + apiRequest := httptest.NewRequest( + http.MethodPost, + "/api/v1/tasks", + nil, + ) + apiRequest.Header.Set("Authorization", "Bearer "+testOpaqueToken) + apiResponse := httptest.NewRecorder() + router.ServeHTTP(apiResponse, apiRequest) + if apiResponse.Code != http.StatusUnauthorized { + t.Fatalf("API status/body = %d / %s", apiResponse.Code, apiResponse.Body) + } + assertErrorCode(t, apiResponse, "ADMIN_SESSION_REQUIRED") + + badCSRF := httptest.NewRequest( + http.MethodPost, + "/api/v1/tasks", + nil, + ) + badCSRF.AddCookie(&http.Cookie{ + Name: authcommon.AdminSessionCookieName, + Value: testOpaqueToken, + }) + badCSRFResponse := httptest.NewRecorder() + router.ServeHTTP(badCSRFResponse, badCSRF) + if badCSRFResponse.Code != http.StatusForbidden { + t.Fatalf( + "CSRF status/body = %d / %s", + badCSRFResponse.Code, + badCSRFResponse.Body, + ) + } + assertErrorCode(t, badCSRFResponse, "CSRF_INVALID") + + goodRequest := httptest.NewRequest( + http.MethodPost, + "/api/v1/tasks", + nil, + ) + goodRequest.AddCookie(&http.Cookie{ + Name: authcommon.AdminSessionCookieName, + Value: testOpaqueToken, + }) + goodRequest.AddCookie(&http.Cookie{ + Name: authcommon.CSRFCookieName, + Value: testCSRFOpaqueToken, + }) + goodRequest.Header.Set(authcommon.CSRFHeader, testCSRFOpaqueToken) + goodResponse := httptest.NewRecorder() + router.ServeHTTP(goodResponse, goodRequest) + if goodResponse.Code != http.StatusNoContent { + t.Fatalf("authenticated status = %d", goodResponse.Code) + } +} + +func TestDeviceMiddlewareRejectsCookieAndAcceptsBuyerBearer(t *testing.T) { + authenticator := &stubAuthenticator{ + devicePrincipal: domain.AuthPrincipal{ + UserID: "buyer-1", + Username: "buyer", + Role: domain.UserRoleBuyer, + DeviceID: "device-1", + }, + } + router := gin.New() + router.Use(requestIDMiddleware(), RequireDeviceAccess(authenticator)) + router.GET("/api/v1/device-probe", func(ctx *gin.Context) { + principal, _ := authcommon.Principal(ctx.Request.Context()) + ctx.String(http.StatusOK, principal.DeviceID) + }) + + cookieRequest := httptest.NewRequest( + http.MethodGet, + "/api/v1/device-probe", + nil, + ) + cookieRequest.AddCookie(&http.Cookie{ + Name: authcommon.AdminSessionCookieName, + Value: testOpaqueToken, + }) + cookieResponse := httptest.NewRecorder() + router.ServeHTTP(cookieResponse, cookieRequest) + if cookieResponse.Code != http.StatusUnauthorized { + t.Fatalf("cookie status = %d", cookieResponse.Code) + } + assertErrorCode(t, cookieResponse, "DEVICE_ACCESS_REQUIRED") + + bearerRequest := httptest.NewRequest( + http.MethodGet, + "/api/v1/device-probe", + nil, + ) + bearerRequest.Header.Set("Authorization", "Bearer "+testOpaqueToken) + bearerResponse := httptest.NewRecorder() + router.ServeHTTP(bearerResponse, bearerRequest) + if bearerResponse.Code != http.StatusOK || + bearerResponse.Body.String() != "device-1" { + t.Fatalf( + "bearer status/body = %d / %q", + bearerResponse.Code, + bearerResponse.Body.String(), + ) + } +} + +type stubAuthenticator struct { + adminPrincipal domain.AuthPrincipal + adminErr error + devicePrincipal domain.AuthPrincipal + deviceErr error +} + +func (auth *stubAuthenticator) AuthenticateAdmin( + context.Context, + string, +) (domain.AuthPrincipal, error) { + return auth.adminPrincipal, auth.adminErr +} + +func (auth *stubAuthenticator) AuthenticateAccessToken( + context.Context, + string, +) (domain.AuthPrincipal, error) { + return auth.devicePrincipal, auth.deviceErr +} + +func newPublicAuthTestRouter( + t *testing.T, + service BuyerTokenService, +) http.Handler { + t.Helper() + limiter, err := authcommon.NewAttemptLimiter( + 100, + time.Minute, + 100, + ) + if err != nil { + t.Fatalf("NewAttemptLimiter() error = %v", err) + } + registrar, err := NewPublicAuthRegistrar(service, limiter) + if err != nil { + t.Fatalf("NewPublicAuthRegistrar() error = %v", err) + } + router := gin.New() + router.Use(requestIDMiddleware()) + if err := registrar(router); err != nil { + t.Fatalf("register public auth: %v", err) + } + return router +} + +var ( + _ BuyerTokenService = (*fakeBuyerTokenService)(nil) + _ AdminAuthenticator = (*stubAuthenticator)(nil) + _ DeviceAuthenticator = (*stubAuthenticator)(nil) +) diff --git a/backend-api/internal/transport/httpapi/router.go b/backend-api/internal/transport/httpapi/router.go index cd827ec..935ece0 100644 --- a/backend-api/internal/transport/httpapi/router.go +++ b/backend-api/internal/transport/httpapi/router.go @@ -22,13 +22,15 @@ type EventLogger func(string) type RouteRegistrar func(gin.IRoutes) error type RouterDependencies struct { - Database DatabasePinger - RegisterAdminRoutes RouteRegistrar - LogEvent EventLogger + Database DatabasePinger + RegisterPublicRoutes RouteRegistrar + RegisterAdminRoutes RouteRegistrar + AdminSessions AdminAuthenticator + LogEvent EventLogger } type AdminWeb interface { - Register(gin.IRoutes) + RegisterProtected(gin.IRoutes) } func NewAdminRouteRegistrar( @@ -45,7 +47,7 @@ func NewAdminRouteRegistrar( if err := registerAdminAPI(routes, services); err != nil { return err } - web.Register(routes) + web.RegisterProtected(routes) return nil }, nil } @@ -57,6 +59,12 @@ func NewRouter(dependencies RouterDependencies) (http.Handler, error) { if dependencies.RegisterAdminRoutes == nil { return nil, errors.New("admin route registrar is required") } + if dependencies.RegisterPublicRoutes == nil { + return nil, errors.New("public route registrar is required") + } + if dependencies.AdminSessions == nil { + return nil, errors.New("admin authenticator is required") + } if dependencies.LogEvent == nil { return nil, errors.New("event logger is required") } @@ -70,8 +78,11 @@ func NewRouter(dependencies RouterDependencies) (http.Handler, error) { } router.GET("/healthz", healthHandler(dependencies.Database)) + if err := dependencies.RegisterPublicRoutes(router); err != nil { + return nil, err + } adminRoutes := router.Group("") - adminRoutes.Use(loopbackAdminOnly()) + adminRoutes.Use(requireAdminSession(dependencies.AdminSessions)) if err := dependencies.RegisterAdminRoutes(adminRoutes); err != nil { return nil, err } diff --git a/backend-api/internal/transport/httpapi/router_test.go b/backend-api/internal/transport/httpapi/router_test.go index c1d8ac2..f3d2bbc 100644 --- a/backend-api/internal/transport/httpapi/router_test.go +++ b/backend-api/internal/transport/httpapi/router_test.go @@ -9,6 +9,9 @@ import ( "regexp" "strings" "testing" + "time" + + "cmroubao/backend-api/internal/domain" "github.com/gin-gonic/gin" ) @@ -114,9 +117,11 @@ func TestSafeRecoveryReturnsStableErrorWithoutLoggingRequestHeaders(t *testing.T func TestRouterRequiresDependencies(t *testing.T) { valid := RouterDependencies{ - Database: fakePinger{}, - RegisterAdminRoutes: discardRoutes, - LogEvent: discardEvent, + Database: fakePinger{}, + RegisterPublicRoutes: discardRoutes, + RegisterAdminRoutes: discardRoutes, + AdminSessions: allowAdminAuthenticator{}, + LogEvent: discardEvent, } missingDatabase := valid missingDatabase.Database = nil @@ -128,6 +133,16 @@ func TestRouterRequiresDependencies(t *testing.T) { if _, err := NewRouter(missingRoutes); err == nil { t.Fatal("NewRouter(nil routes) error = nil") } + missingPublicRoutes := valid + missingPublicRoutes.RegisterPublicRoutes = nil + if _, err := NewRouter(missingPublicRoutes); err == nil { + t.Fatal("NewRouter(nil public routes) error = nil") + } + missingAuth := valid + missingAuth.AdminSessions = nil + if _, err := NewRouter(missingAuth); err == nil { + t.Fatal("NewRouter(nil admin auth) error = nil") + } missingLogger := valid missingLogger.LogEvent = nil if _, err := NewRouter(missingLogger); err == nil { @@ -140,9 +155,11 @@ func newTestRouter( logEvent EventLogger, ) (http.Handler, error) { return NewRouter(RouterDependencies{ - Database: database, - RegisterAdminRoutes: discardRoutes, - LogEvent: logEvent, + Database: database, + RegisterPublicRoutes: discardRoutes, + RegisterAdminRoutes: discardRoutes, + AdminSessions: allowAdminAuthenticator{}, + LogEvent: logEvent, }) } @@ -223,6 +240,21 @@ func discardEvent(string) {} func discardRoutes(gin.IRoutes) error { return nil } +type allowAdminAuthenticator struct{} + +func (allowAdminAuthenticator) AuthenticateAdmin( + context.Context, + string, +) (domain.AuthPrincipal, error) { + return domain.AuthPrincipal{ + UserID: "00000000-0000-4000-8000-000000000099", + Username: "admin", + Role: domain.UserRoleAdmin, + SessionID: "admin-session", + ExpiresAt: time.Now().Add(time.Hour), + }, nil +} + var requestIDPattern = regexp.MustCompile( `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, ) diff --git a/backend-api/internal/transport/webui/auth_adapter.go b/backend-api/internal/transport/webui/auth_adapter.go new file mode 100644 index 0000000..ffbfaef --- /dev/null +++ b/backend-api/internal/transport/webui/auth_adapter.go @@ -0,0 +1,75 @@ +package webui + +import ( + "context" + "errors" + + "cmroubao/backend-api/internal/usecase" +) + +type AuthUsecaseAdapter struct { + service *usecase.AuthService +} + +func NewAuthUsecaseAdapter( + service *usecase.AuthService, +) (*AuthUsecaseAdapter, error) { + if service == nil { + return nil, errors.New("admin auth use case is required") + } + return &AuthUsecaseAdapter{service: service}, nil +} + +func (adapter *AuthUsecaseAdapter) LoginAdmin( + ctx context.Context, + input AdminLoginInput, +) (AdminLoginResult, error) { + result, err := adapter.service.LoginAdmin( + ctx, + usecase.LoginAdminCommand{ + Username: input.Username, + Password: input.Password, + }, + ) + if err != nil { + return AdminLoginResult{}, mapAuthUsecaseError(err) + } + return AdminLoginResult{ + Token: result.Token, + ExpiresAt: result.ExpiresAt, + }, nil +} + +func (adapter *AuthUsecaseAdapter) LogoutAdmin( + ctx context.Context, + token string, +) error { + if err := adapter.service.LogoutAdmin(ctx, token); err != nil { + return mapAuthUsecaseError(err) + } + return nil +} + +func mapAuthUsecaseError(err error) error { + var typed *usecase.Error + if !errors.As(err, &typed) { + return err + } + var public error + switch typed.Kind { + case usecase.ErrorKindUnauthorized, + usecase.ErrorKindForbidden, + usecase.ErrorKindInvalid: + public = ErrInvalidCredentials + case usecase.ErrorKindUnavailable: + public = ErrUnavailable + default: + return err + } + return &adapterError{ + public: public, + cause: err, + } +} + +var _ AdminSessionService = (*AuthUsecaseAdapter)(nil) diff --git a/backend-api/internal/transport/webui/auth_handler.go b/backend-api/internal/transport/webui/auth_handler.go new file mode 100644 index 0000000..eabbc9b --- /dev/null +++ b/backend-api/internal/transport/webui/auth_handler.go @@ -0,0 +1,311 @@ +package webui + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "time" + + "cmroubao/backend-api/internal/transport/authcommon" + + "github.com/gin-gonic/gin" +) + +const ( + AdminSessionCookieName = authcommon.AdminSessionCookieName + maxLoginFormBytes = 16 << 10 +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrAccountDisabled = errors.New("account disabled") +) + +type AdminSessionService interface { + LoginAdmin(context.Context, AdminLoginInput) (AdminLoginResult, error) + LogoutAdmin(context.Context, string) error +} + +type AdminLoginInput struct { + Username string + Password string +} + +type AdminLoginResult struct { + Token string + ExpiresAt time.Time +} + +type AuthHandler struct { + sessions AdminSessionService + renderer *Renderer + limiter authcommon.AttemptLimiter +} + +func NewAuthHandler( + sessions AdminSessionService, + renderer *Renderer, + limiter authcommon.AttemptLimiter, +) (*AuthHandler, error) { + if sessions == nil { + return nil, errors.New("admin session service is required") + } + if renderer == nil { + return nil, errors.New("admin auth renderer is required") + } + if limiter == nil { + return nil, errors.New("admin login limiter is required") + } + return &AuthHandler{ + sessions: sessions, + renderer: renderer, + limiter: limiter, + }, nil +} + +func (h *AuthHandler) RegisterPublic(routes gin.IRoutes) { + routes.GET("/login", SecurityHeaders(), h.LoginPage) + routes.POST("/login", SecurityHeaders(), h.Login) +} + +func (h *AuthHandler) RegisterProtected(routes gin.IRoutes) { + routes.POST("/logout", SecurityHeaders(), h.Logout) +} + +func (h *AuthHandler) LoginPage(ctx *gin.Context) { + token, err := csrfToken(ctx) + if err != nil { + h.renderError(ctx, http.StatusInternalServerError) + return + } + h.render(ctx, http.StatusOK, loginPage{ + Page: pageView{Title: "管理端登录"}, + CSRFToken: token, + Next: safeNext(ctx.Query("next")), + }) +} + +func (h *AuthHandler) Login(ctx *gin.Context) { + previousSessionToken := "" + if cookie, err := ctx.Request.Cookie( + authcommon.AdminSessionCookieName, + ); err == nil { + previousSessionToken = cookie.Value + } + ctx.Request.Body = http.MaxBytesReader( + ctx.Writer, + ctx.Request.Body, + maxLoginFormBytes, + ) + if err := ctx.Request.ParseForm(); err != nil { + h.renderError(ctx, http.StatusBadRequest) + return + } + next := safeNext(ctx.PostForm("next")) + page := loginPage{ + Page: pageView{Title: "管理端登录"}, + CSRFToken: strings.TrimSpace( + ctx.PostForm(authcommon.CSRFFormField), + ), + Next: next, + Username: strings.TrimSpace(ctx.PostForm("username")), + } + if !validCSRF(ctx) { + page.Message = "登录页面已失效,请刷新后重试。" + h.render(ctx, http.StatusForbidden, page) + return + } + password := ctx.PostForm("password") + if page.Username == "" { + page.UsernameError = "请输入账号。" + } + if password == "" { + page.PasswordError = "请输入密码。" + } + if page.UsernameError != "" || page.PasswordError != "" { + page.Message = "请检查并补全必填项。" + h.render(ctx, http.StatusUnprocessableEntity, page) + return + } + + attemptKey := authcommon.LoginAttemptKey( + "admin", + ctx.Request.RemoteAddr, + ) + if allowed, wait := h.limiter.Allow(attemptKey); !allowed { + ctx.Header( + "Retry-After", + strconv.Itoa(authcommon.RetryAfterSeconds(wait)), + ) + page.Message = "登录尝试次数过多,请稍后再试。" + h.render(ctx, http.StatusTooManyRequests, page) + return + } + result, err := h.sessions.LoginAdmin( + ctx.Request.Context(), + AdminLoginInput{ + Username: page.Username, + Password: password, + }, + ) + if err != nil { + switch { + case errors.Is(err, ErrInvalidCredentials), + errors.Is(err, ErrAccountDisabled): + page.Message = "账号或密码不正确。" + h.render(ctx, http.StatusUnauthorized, page) + case errors.Is(err, context.DeadlineExceeded), + errors.Is(err, ErrUnavailable): + page.Message = "登录服务暂时不可用,请稍后重试。" + h.render(ctx, http.StatusServiceUnavailable, page) + default: + h.renderError(ctx, http.StatusInternalServerError) + } + return + } + h.limiter.Reset(attemptKey) + + if previousSessionToken != "" && + previousSessionToken != result.Token { + if err := h.sessions.LogoutAdmin( + ctx.Request.Context(), + previousSessionToken, + ); err != nil { + _ = h.sessions.LogoutAdmin( + ctx.Request.Context(), + result.Token, + ) + clearAdminSessionCookie(ctx) + h.renderError(ctx, http.StatusServiceUnavailable) + return + } + } + setAdminSessionCookie(ctx, result) + if _, err := rotateCSRFToken(ctx); err != nil { + _ = h.sessions.LogoutAdmin(ctx.Request.Context(), result.Token) + clearAdminSessionCookie(ctx) + h.renderError(ctx, http.StatusInternalServerError) + return + } + ctx.Redirect(http.StatusSeeOther, next) +} + +func (h *AuthHandler) Logout(ctx *gin.Context) { + if !validCSRF(ctx) { + h.renderError(ctx, http.StatusForbidden) + return + } + cookie, err := ctx.Request.Cookie(authcommon.AdminSessionCookieName) + if err == nil && cookie.Value != "" { + if err := h.sessions.LogoutAdmin( + ctx.Request.Context(), + cookie.Value, + ); err != nil && + !errors.Is(err, ErrNotFound) { + h.renderError(ctx, http.StatusServiceUnavailable) + return + } + } + clearAdminSessionCookie(ctx) + _, _ = rotateCSRFToken(ctx) + ctx.Redirect(http.StatusSeeOther, "/login") +} + +func setAdminSessionCookie( + ctx *gin.Context, + result AdminLoginResult, +) { + maxAge := int(time.Until(result.ExpiresAt).Seconds()) + if maxAge < 1 { + maxAge = 1 + } + http.SetCookie(ctx.Writer, &http.Cookie{ + Name: authcommon.AdminSessionCookieName, + Value: result.Token, + Path: "/", + Expires: result.ExpiresAt.UTC(), + MaxAge: maxAge, + HttpOnly: true, + Secure: ctx.Request.TLS != nil, + SameSite: http.SameSiteLaxMode, + }) +} + +func clearAdminSessionCookie(ctx *gin.Context) { + http.SetCookie(ctx.Writer, &http.Cookie{ + Name: authcommon.AdminSessionCookieName, + Value: "", + Path: "/", + Expires: time.Unix(1, 0).UTC(), + MaxAge: -1, + HttpOnly: true, + Secure: ctx.Request.TLS != nil, + SameSite: http.SameSiteLaxMode, + }) +} + +func safeNext(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "/tasks" + } + if strings.Contains(value, `\`) || + strings.HasPrefix(value, "//") { + return "/tasks" + } + parsed, err := url.Parse(value) + if err != nil || + parsed.IsAbs() || + parsed.Host != "" || + parsed.Fragment != "" || + path.Clean(parsed.Path) != parsed.Path { + return "/tasks" + } + if parsed.Path != "/tasks" && + !strings.HasPrefix(parsed.Path, "/tasks/") { + return "/tasks" + } + return parsed.String() +} + +func (h *AuthHandler) render( + ctx *gin.Context, + status int, + page loginPage, +) { + var output bytes.Buffer + if err := h.renderer.Execute(&output, "login", page); err != nil { + ctx.Data( + http.StatusInternalServerError, + formContentType, + []byte("页面暂时无法显示,请稍后重试。"), + ) + return + } + ctx.Data(status, formContentType, output.Bytes()) +} + +func (h *AuthHandler) renderError(ctx *gin.Context, status int) { + token, _ := csrfToken(ctx) + h.render(ctx, status, loginPage{ + Page: pageView{Title: "管理端登录"}, + CSRFToken: token, + Next: "/tasks", + Message: "操作失败,请刷新页面后重试。", + }) +} + +type loginPage struct { + Page pageView + CSRFToken string + Next string + Username string + Message string + UsernameError string + PasswordError string +} diff --git a/backend-api/internal/transport/webui/auth_handler_test.go b/backend-api/internal/transport/webui/auth_handler_test.go new file mode 100644 index 0000000..b75abed --- /dev/null +++ b/backend-api/internal/transport/webui/auth_handler_test.go @@ -0,0 +1,443 @@ +package webui + +import ( + "context" + "crypto/tls" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "cmroubao/backend-api/internal/transport/authcommon" + + "github.com/gin-gonic/gin" +) + +func TestLoginPageIssuesCSRFAndRendersSafeNext(t *testing.T) { + router, _ := newAuthTestRouter(t, &fakeAdminSessions{}) + + response := performRequest( + t, + router, + http.MethodGet, + "/login?next=%2Ftasks%2Fnew", + nil, + "", + ) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body) + } + cookie := csrfCookie(t, response) + body := response.Body.String() + for _, expected := range []string{ + `name="csrf_token" value="` + cookie.Value + `"`, + `name="next" value="/tasks/new"`, + `autocomplete="username"`, + `autocomplete="current-password"`, + `data-password-toggle`, + } { + if !strings.Contains(body, expected) { + t.Fatalf("login body missing %q", expected) + } + } + assertSecurityHeaders(t, response) +} + +func TestLoginRejectsInvalidCredentialsWithoutLeakingPassword(t *testing.T) { + sessions := &fakeAdminSessions{loginErr: ErrInvalidCredentials} + router, _ := newAuthTestRouter(t, sessions) + csrf := loginCSRF(t, router) + form := url.Values{ + "csrf_token": {csrf.Value}, + "username": {"admin"}, + "password": {"private-password-value"}, + "next": {"/tasks"}, + } + request := httptest.NewRequest( + http.MethodPost, + "/login", + strings.NewReader(form.Encode()), + ) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(csrf) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusUnauthorized || + !strings.Contains(response.Body.String(), "账号或密码不正确") { + t.Fatalf("status/body = %d / %s", response.Code, response.Body) + } + if strings.Contains(response.Body.String(), "private-password-value") { + t.Fatal("login response contains submitted password") + } + if sessions.loginInput.Username != "admin" || + sessions.loginInput.Password != "private-password-value" { + t.Fatalf("login input = %+v", sessions.loginInput) + } +} + +func TestLoginRateLimitBlocksBeforePasswordVerification(t *testing.T) { + sessions := &fakeAdminSessions{loginErr: ErrInvalidCredentials} + limiter, err := authcommon.NewAttemptLimiter(1, time.Minute, 10) + if err != nil { + t.Fatalf("NewAttemptLimiter() error = %v", err) + } + router, _ := newAuthTestRouterWithLimiter(t, sessions, limiter) + csrf := loginCSRF(t, router) + form := url.Values{ + "csrf_token": {csrf.Value}, + "username": {"admin"}, + "password": {"private-password-value"}, + "next": {"/tasks"}, + } + requestLogin := func() *httptest.ResponseRecorder { + request := httptest.NewRequest( + http.MethodPost, + "/login", + strings.NewReader(form.Encode()), + ) + request.Header.Set( + "Content-Type", + "application/x-www-form-urlencoded", + ) + request.AddCookie(csrf) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response + } + + if response := requestLogin(); response.Code != http.StatusUnauthorized { + t.Fatalf("first status = %d", response.Code) + } + response := requestLogin() + if response.Code != http.StatusTooManyRequests || + response.Header().Get("Retry-After") == "" || + !strings.Contains(response.Body.String(), "尝试次数过多") || + sessions.loginCalls != 1 { + t.Fatalf( + "second status/retry/calls = %d / %q / %d", + response.Code, + response.Header().Get("Retry-After"), + sessions.loginCalls, + ) + } +} + +func TestLoginRotatesSessionAndCSRFThenUsesSafeRedirect(t *testing.T) { + expiresAt := time.Now().UTC().Add(8 * time.Hour) + sessions := &fakeAdminSessions{ + loginResult: AdminLoginResult{ + Token: mustToken(t), + ExpiresAt: expiresAt, + }, + } + router, _ := newAuthTestRouter(t, sessions) + csrf := loginCSRF(t, router) + form := url.Values{ + "csrf_token": {csrf.Value}, + "username": {"admin"}, + "password": {"valid-password"}, + "next": {"https://attacker.invalid/private"}, + } + request := httptest.NewRequest( + http.MethodPost, + "/login", + strings.NewReader(form.Encode()), + ) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(csrf) + request.AddCookie(&http.Cookie{ + Name: AdminSessionCookieName, + Value: mustToken(t), + }) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusSeeOther || + response.Header().Get("Location") != "/tasks" { + t.Fatalf( + "status/location = %d / %q", + response.Code, + response.Header().Get("Location"), + ) + } + var sessionCookie, rotatedCSRF *http.Cookie + for _, cookie := range response.Result().Cookies() { + switch cookie.Name { + case AdminSessionCookieName: + sessionCookie = cookie + case csrfCookieName: + rotatedCSRF = cookie + } + } + if sessionCookie == nil || + sessionCookie.Value != sessions.loginResult.Token || + !sessionCookie.HttpOnly || + sessionCookie.SameSite != http.SameSiteLaxMode || + sessionCookie.Path != "/" || + sessionCookie.Secure { + t.Fatalf("session cookie = %+v", sessionCookie) + } + if rotatedCSRF == nil || rotatedCSRF.Value == csrf.Value { + t.Fatalf("CSRF was not rotated: %+v", rotatedCSRF) + } + if sessions.logoutToken == "" || + sessions.logoutToken == sessions.loginResult.Token { + t.Fatalf( + "previous session was not revoked: %q", + sessions.logoutToken, + ) + } +} + +func TestAdminSessionCookieIsSecureForTLSRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/secure-cookie", func(ctx *gin.Context) { + setAdminSessionCookie(ctx, AdminLoginResult{ + Token: mustToken(t), + ExpiresAt: time.Now().UTC().Add(time.Hour), + }) + ctx.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "/secure-cookie", nil) + request.TLS = &tls.ConnectionState{} + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + var sessionCookie *http.Cookie + for _, cookie := range response.Result().Cookies() { + if cookie.Name == AdminSessionCookieName { + sessionCookie = cookie + break + } + } + if sessionCookie == nil || !sessionCookie.Secure { + t.Fatalf("TLS session cookie = %+v", sessionCookie) + } +} + +func TestLoginFailsClosedWhenPreviousSessionCannotBeRevoked( + t *testing.T, +) { + newToken := mustToken(t) + oldToken := mustToken(t) + for oldToken == newToken { + oldToken = mustToken(t) + } + sessions := &fakeAdminSessions{ + loginResult: AdminLoginResult{ + Token: newToken, + ExpiresAt: time.Now().UTC().Add(8 * time.Hour), + }, + logoutErr: ErrUnavailable, + } + router, _ := newAuthTestRouter(t, sessions) + csrf := loginCSRF(t, router) + form := url.Values{ + "csrf_token": {csrf.Value}, + "username": {"admin"}, + "password": {"valid-password"}, + "next": {"/tasks"}, + } + request := httptest.NewRequest( + http.MethodPost, + "/login", + strings.NewReader(form.Encode()), + ) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(csrf) + request.AddCookie(&http.Cookie{ + Name: AdminSessionCookieName, + Value: oldToken, + }) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusServiceUnavailable || + sessions.logoutToken != newToken { + t.Fatalf( + "status/last revoked token = %d / %q", + response.Code, + sessions.logoutToken, + ) + } + for _, cookie := range response.Result().Cookies() { + if cookie.Name == AdminSessionCookieName && + cookie.Value == newToken { + t.Fatal("new session cookie was returned after revoke failure") + } + } +} + +func TestSafeNextRejectsExternalAndAmbiguousPaths(t *testing.T) { + tests := []string{ + "https://attacker.invalid/tasks", + "//attacker.invalid/tasks", + `/tasks\redirect`, + "/tasks/../admin", + "/healthz", + "tasks", + } + for _, candidate := range tests { + if actual := safeNext(candidate); actual != "/tasks" { + t.Fatalf("safeNext(%q) = %q", candidate, actual) + } + } + if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" { + t.Fatalf("safeNext(valid) = %q", actual) + } +} + +func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) { + sessions := &fakeAdminSessions{} + router, _ := newAuthTestRouter(t, sessions) + csrf := loginCSRF(t, router) + sessionValue := mustToken(t) + form := url.Values{"csrf_token": {csrf.Value}} + request := httptest.NewRequest( + http.MethodPost, + "/logout", + strings.NewReader(form.Encode()), + ) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(csrf) + request.AddCookie(&http.Cookie{ + Name: AdminSessionCookieName, + Value: sessionValue, + }) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusSeeOther || + response.Header().Get("Location") != "/login" || + sessions.logoutToken != sessionValue { + t.Fatalf( + "status/location/token = %d / %q / %q", + response.Code, + response.Header().Get("Location"), + sessions.logoutToken, + ) + } + foundCleared := false + for _, cookie := range response.Result().Cookies() { + if cookie.Name == AdminSessionCookieName && cookie.MaxAge < 0 { + foundCleared = true + } + } + if !foundCleared { + t.Fatal("logout did not clear the admin session cookie") + } +} + +func TestLogoutRejectsWrongCSRFWithoutRevoking(t *testing.T) { + sessions := &fakeAdminSessions{} + router, _ := newAuthTestRouter(t, sessions) + request := httptest.NewRequest( + http.MethodPost, + "/logout", + strings.NewReader("csrf_token=wrong"), + ) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(&http.Cookie{ + Name: csrfCookieName, + Value: mustToken(t), + }) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusForbidden || sessions.logoutToken != "" { + t.Fatalf( + "status/logout token = %d / %q", + response.Code, + sessions.logoutToken, + ) + } +} + +type fakeAdminSessions struct { + loginInput AdminLoginInput + loginResult AdminLoginResult + loginErr error + loginCalls int + logoutToken string + logoutErr error +} + +func (service *fakeAdminSessions) LoginAdmin( + _ context.Context, + input AdminLoginInput, +) (AdminLoginResult, error) { + service.loginCalls++ + service.loginInput = input + return service.loginResult, service.loginErr +} + +func (service *fakeAdminSessions) LogoutAdmin( + _ context.Context, + token string, +) error { + service.logoutToken = token + return service.logoutErr +} + +func newAuthTestRouter( + t *testing.T, + sessions AdminSessionService, +) (http.Handler, *AuthHandler) { + t.Helper() + limiter, err := authcommon.NewAttemptLimiter( + 100, + time.Minute, + 100, + ) + if err != nil { + t.Fatalf("NewAttemptLimiter() error = %v", err) + } + return newAuthTestRouterWithLimiter(t, sessions, limiter) +} + +func newAuthTestRouterWithLimiter( + t *testing.T, + sessions AdminSessionService, + limiter authcommon.AttemptLimiter, +) (http.Handler, *AuthHandler) { + t.Helper() + gin.SetMode(gin.TestMode) + renderer, err := NewRenderer() + if err != nil { + t.Fatalf("NewRenderer() error = %v", err) + } + handler, err := NewAuthHandler(sessions, renderer, limiter) + if err != nil { + t.Fatalf("NewAuthHandler() error = %v", err) + } + router := gin.New() + handler.RegisterPublic(router) + handler.RegisterProtected(router) + return router, handler +} + +func loginCSRF(t *testing.T, router http.Handler) *http.Cookie { + t.Helper() + response := performRequest( + t, + router, + http.MethodGet, + "/login", + nil, + "", + ) + return csrfCookie(t, response) +} + +var _ AdminSessionService = (*fakeAdminSessions)(nil) diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index 1028e39..50b63fa 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "crypto/rand" - "crypto/subtle" "encoding/base64" "errors" "io" @@ -14,6 +13,8 @@ import ( "time" "unicode/utf8" + "cmroubao/backend-api/internal/transport/authcommon" + "github.com/gin-gonic/gin" ) @@ -24,8 +25,8 @@ const ( maxTitleBytes = 2048 maxSKUBytes = 512 maxDescriptionBytes = 8192 - csrfCookieName = "cmroubao_admin_csrf" - csrfFormField = "csrf_token" + csrfCookieName = authcommon.CSRFCookieName + csrfFormField = authcommon.CSRFFormField formContentType = "text/html; charset=utf-8" cssContentType = "text/css; charset=utf-8" javascriptContentType = "text/javascript; charset=utf-8" @@ -50,8 +51,16 @@ func NewHandler(service Service, renderer *Renderer) (*Handler, error) { } func (h *Handler) Register(routes gin.IRoutes) { + h.RegisterStatic(routes) + h.RegisterProtected(routes) +} + +func (h *Handler) RegisterStatic(routes gin.IRoutes) { routes.GET("/static/admin.css", SecurityHeaders(), h.Stylesheet) routes.GET("/static/admin.js", SecurityHeaders(), h.Script) +} + +func (h *Handler) RegisterProtected(routes gin.IRoutes) { routes.GET("/tasks", SecurityHeaders(), h.ListTasks) routes.GET("/tasks/new", SecurityHeaders(), h.NewTask) routes.POST("/tasks", SecurityHeaders(), h.CreateTask) @@ -97,6 +106,11 @@ func (h *Handler) serveStatic( } func (h *Handler) ListTasks(ctx *gin.Context) { + token, err := csrfToken(ctx) + if err != nil { + h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") + return + } input := ListTasksInput{ Query: strings.TrimSpace(ctx.Query("q")), Status: strings.TrimSpace(ctx.Query("status")), @@ -127,6 +141,7 @@ func (h *Handler) ListTasks(ctx *gin.Context) { Page: pageView{ Title: "采购任务", TasksCurrent: true, + CSRFToken: token, }, Query: input.Query, Status: input.Status, @@ -138,7 +153,7 @@ func (h *Handler) ListTasks(ctx *gin.Context) { } func (h *Handler) NewTask(ctx *gin.Context) { - token, err := h.csrfToken(ctx) + token, err := csrfToken(ctx) if err != nil { h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") return @@ -167,7 +182,7 @@ func (h *Handler) CreateTask(ctx *gin.Context) { if ctx.Request.MultipartForm != nil { defer ctx.Request.MultipartForm.RemoveAll() } - if !h.validCSRF(ctx) { + if !validCSRF(ctx) { h.renderError( ctx, http.StatusForbidden, @@ -239,7 +254,7 @@ func (h *Handler) TaskDetail(ctx *gin.Context) { h.renderServiceError(ctx, err, "无法加载任务详情,请稍后重试。") return } - token, tokenErr := h.csrfToken(ctx) + token, tokenErr := csrfToken(ctx) if tokenErr != nil { h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") return @@ -253,6 +268,7 @@ func (h *Handler) TaskDetail(ctx *gin.Context) { Page: pageView{ Title: "任务详情", TasksCurrent: true, + CSRFToken: token, }, Task: taskDetailViewFrom(task), CSRFToken: token, @@ -263,7 +279,7 @@ func (h *Handler) TaskDetail(ctx *gin.Context) { } func (h *Handler) CancelTask(ctx *gin.Context) { - if !h.validCSRF(ctx) { + if !validCSRF(ctx) { h.renderError( ctx, http.StatusForbidden, @@ -328,7 +344,7 @@ func (h *Handler) uploadReference( } func (h *Handler) createPageFromRequest(ctx *gin.Context) newTaskPageView { - token, err := h.csrfToken(ctx) + token, err := csrfToken(ctx) if err != nil { token = "" } @@ -338,6 +354,7 @@ func (h *Handler) createPageFromRequest(ctx *gin.Context) newTaskPageView { Page: pageView{ Title: "新建采购任务", NewCurrent: true, + CSRFToken: token, }, CSRFToken: token, UploadKey: strings.TrimSpace(ctx.PostForm("upload_key")), @@ -438,19 +455,35 @@ func validBudget(value string) bool { return units > 0 || fraction > 0 } -func (h *Handler) csrfToken(ctx *gin.Context) (string, error) { - if cookie, err := ctx.Request.Cookie(csrfCookieName); err == nil && - validToken(cookie.Value) { - return cookie.Value, nil +func csrfToken(ctx *gin.Context) (string, error) { + cookies := csrfCookies(ctx.Request) + for index := len(cookies) - 1; index >= 0; index-- { + if validToken(cookies[index].Value) { + return cookies[index].Value, nil + } } + return rotateCSRFToken(ctx) +} + +func rotateCSRFToken(ctx *gin.Context) (string, error) { token, err := newToken() if err != nil { return "", err } http.SetCookie(ctx.Writer, &http.Cookie{ - Name: csrfCookieName, - Value: token, + Name: authcommon.CSRFCookieName, + Value: "", Path: "/tasks", + Expires: time.Unix(1, 0).UTC(), + MaxAge: -1, + HttpOnly: true, + Secure: ctx.Request.TLS != nil, + SameSite: http.SameSiteStrictMode, + }) + http.SetCookie(ctx.Writer, &http.Cookie{ + Name: authcommon.CSRFCookieName, + Value: token, + Path: "/", MaxAge: 3600, HttpOnly: true, Secure: ctx.Request.TLS != nil, @@ -459,24 +492,28 @@ func (h *Handler) csrfToken(ctx *gin.Context) (string, error) { return token, nil } -func (h *Handler) validCSRF(ctx *gin.Context) bool { - cookie, err := ctx.Request.Cookie(csrfCookieName) - if err != nil || !validToken(cookie.Value) { - return false +func validCSRF(ctx *gin.Context) bool { + presented := ctx.PostForm(authcommon.CSRFFormField) + for _, cookie := range csrfCookies(ctx.Request) { + if authcommon.ValidCSRFPair(cookie.Value, presented) { + return true + } } - formToken := strings.TrimSpace(ctx.PostForm(csrfFormField)) - if len(cookie.Value) != len(formToken) { - return false + return false +} + +func csrfCookies(request *http.Request) []*http.Cookie { + result := make([]*http.Cookie, 0, 2) + for _, cookie := range request.Cookies() { + if cookie.Name == authcommon.CSRFCookieName { + result = append(result, cookie) + } } - return subtle.ConstantTimeCompare( - []byte(cookie.Value), - []byte(formToken), - ) == 1 + return result } func validToken(value string) bool { - decoded, err := base64.RawURLEncoding.DecodeString(value) - return err == nil && len(decoded) == 32 + return authcommon.ValidOpaqueValue(value) } func newToken() (string, error) { @@ -500,6 +537,7 @@ func newTaskPage(token string) (newTaskPageView, error) { Page: pageView{ Title: "新建采购任务", NewCurrent: true, + CSRFToken: token, }, CSRFToken: token, UploadKey: uploadKey, @@ -561,9 +599,11 @@ func (h *Handler) renderError( title string, message string, ) { + token, _ := csrfToken(ctx) h.render(ctx, status, "error", errorPage{ Page: pageView{ - Title: title, + Title: title, + CSRFToken: token, }, Heading: title, Message: message, @@ -622,6 +662,7 @@ type pageView struct { Title string TasksCurrent bool NewCurrent bool + CSRFToken string } type statusOption struct { diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index e10078f..a1493fc 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -112,7 +112,7 @@ func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) { cookie := csrfCookie(t, response) if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || - cookie.Path != "/tasks" { + cookie.Path != "/" { t.Fatalf("CSRF cookie = %+v", cookie) } body := response.Body.String() @@ -128,6 +128,7 @@ func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) { `name="quantity"`, `name="max_budget"`, `name="image"`, + `action="/logout"`, "最高总预算", } { if !strings.Contains(body, required) { @@ -141,6 +142,39 @@ func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) { assertSecurityHeaders(t, response) } +func TestNewTaskPrefersRootCSRFCookieDuringLegacyPathMigration( + t *testing.T, +) { + router := newTestRouter(t, &fakeService{}) + legacy := mustToken(t) + root := mustToken(t) + for root == legacy { + root = mustToken(t) + } + request := httptest.NewRequest(http.MethodGet, "/tasks/new", nil) + request.AddCookie(&http.Cookie{ + Name: csrfCookieName, + Value: legacy, + Path: "/tasks", + }) + request.AddCookie(&http.Cookie{ + Name: csrfCookieName, + Value: root, + Path: "/", + }) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusOK || + !strings.Contains( + response.Body.String(), + `name="csrf_token" value="`+root+`"`, + ) { + t.Fatalf("status/body = %d / %s", response.Code, response.Body) + } +} + func TestCreateTaskRejectsCSRFBeforeCallingService(t *testing.T) { service := &fakeService{} router := newTestRouter(t, service) @@ -622,7 +656,9 @@ func csrfCookie( ) *http.Cookie { t.Helper() for _, cookie := range response.Result().Cookies() { - if cookie.Name == csrfCookieName { + if cookie.Name == csrfCookieName && + cookie.Path == "/" && + cookie.Value != "" { return cookie } } diff --git a/backend-api/internal/transport/webui/static/admin.css b/backend-api/internal/transport/webui/static/admin.css index 166b5fb..ef167f0 100644 --- a/backend-api/internal/transport/webui/static/admin.css +++ b/backend-api/internal/transport/webui/static/admin.css @@ -144,6 +144,27 @@ a { font-weight: 700; } +.logout-form { + margin: 0; +} + +.logout-button { + min-height: 44px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--surface); + color: var(--text-muted); + font: inherit; + cursor: pointer; +} + +.logout-button:hover, +.logout-button:focus-visible { + border-color: var(--text-muted); + color: var(--text); +} + .page { width: min(calc(100% - 32px), 1180px); margin: 0 auto; @@ -705,6 +726,65 @@ tbody tr:last-child td { cursor: wait; } +.login-body { + min-height: 100vh; + display: grid; + background: #eef1f0; +} + +.login-page { + width: min(calc(100% - 28px), 430px); + margin: auto; + padding: 32px 0; +} + +.login-brand { + min-height: 44px; + display: inline-flex; + align-items: center; + gap: 10px; + margin-bottom: 18px; + color: var(--ink); + font-weight: 800; + text-decoration: none; +} + +.login-panel { + padding: 28px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +.login-panel h1 { + margin: 0; +} + +.login-panel form { + display: grid; + gap: 16px; + margin-top: 22px; +} + +.password-field { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; +} + +.password-field input { + border-radius: 5px 0 0 5px; +} + +.password-toggle { + min-width: 64px; + border-left: 0; + border-radius: 0 5px 5px 0; +} + +.login-submit { + width: 100%; +} + @media (max-width: 760px) { .page { width: min(calc(100% - 20px), 1180px); @@ -797,6 +877,11 @@ tbody tr:last-child td { font-size: 13px; } + .logout-button { + padding-inline: 8px; + font-size: 13px; + } + .title-row, .detail-title { align-items: stretch; diff --git a/backend-api/internal/transport/webui/static/admin.js b/backend-api/internal/transport/webui/static/admin.js index dfe3482..3b8c6d1 100644 --- a/backend-api/internal/transport/webui/static/admin.js +++ b/backend-api/internal/transport/webui/static/admin.js @@ -9,6 +9,19 @@ if (summary) summary.focus(); } + const passwordToggle = document.querySelector("[data-password-toggle]"); + if (passwordToggle) { + const password = document.getElementById( + passwordToggle.getAttribute("aria-controls"), + ); + passwordToggle.addEventListener("click", () => { + const showing = password.type === "text"; + password.type = showing ? "password" : "text"; + passwordToggle.textContent = showing ? "显示" : "隐藏"; + passwordToggle.setAttribute("aria-pressed", String(!showing)); + }); + } + document.querySelectorAll("[data-loading-form]").forEach((form) => { form.addEventListener("submit", () => { form.setAttribute("aria-busy", "true"); diff --git a/backend-api/internal/transport/webui/templates/login.gohtml b/backend-api/internal/transport/webui/templates/login.gohtml new file mode 100644 index 0000000..8b98fc4 --- /dev/null +++ b/backend-api/internal/transport/webui/templates/login.gohtml @@ -0,0 +1,55 @@ +{{define "login"}} + + + + {{.Page.Title}} - 采购任务管理 + {{template "document-head" .}} + + +
+ + +
+ + +{{end}} diff --git a/backend-api/internal/transport/webui/templates/partials.gohtml b/backend-api/internal/transport/webui/templates/partials.gohtml index 85479f0..707ecc9 100644 --- a/backend-api/internal/transport/webui/templates/partials.gohtml +++ b/backend-api/internal/transport/webui/templates/partials.gohtml @@ -18,5 +18,11 @@ 任务列表 新建任务 + {{if .Page.CSRFToken}} +
+ + +
+ {{end}} {{end}} diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go index 2e89267..d43af47 100644 --- a/backend-api/internal/transport/webui/usecase_adapter.go +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -5,6 +5,7 @@ import ( "errors" "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/transport/authcommon" "cmroubao/backend-api/internal/usecase" ) @@ -104,6 +105,7 @@ func (adapter *UsecaseAdapter) CreateTask( } result, err := adapter.tasks.Create(ctx, usecase.CreateTaskCommand{ CreatorSubject: localAdminSubject, + ActorUserID: actorUserID(ctx), IdempotencyKey: input.IdempotencyKey, Title: input.Title, Description: input.Description, @@ -118,12 +120,21 @@ func (adapter *UsecaseAdapter) CreateTask( return taskFromPurchase(result.Task), nil } +func actorUserID(ctx context.Context) string { + principal, ok := authcommon.Principal(ctx) + if !ok || principal.Role != domain.UserRoleAdmin { + return "" + } + return principal.UserID +} + func (adapter *UsecaseAdapter) CancelPending( ctx context.Context, input CancelPendingInput, ) (Task, error) { task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{ CreatorSubject: localAdminSubject, + ActorUserID: actorUserID(ctx), TaskID: input.TaskID, Reason: "管理员取消", }) diff --git a/backend-api/internal/usecase/auth_encoding.go b/backend-api/internal/usecase/auth_encoding.go new file mode 100644 index 0000000..7d55056 --- /dev/null +++ b/backend-api/internal/usecase/auth_encoding.go @@ -0,0 +1,7 @@ +package usecase + +import "encoding/base64" + +func rawURLDecode(value string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(value) +} diff --git a/backend-api/internal/usecase/auth_errors.go b/backend-api/internal/usecase/auth_errors.go new file mode 100644 index 0000000..a869321 --- /dev/null +++ b/backend-api/internal/usecase/auth_errors.go @@ -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) + } +} diff --git a/backend-api/internal/usecase/auth_ports.go b/backend-api/internal/usecase/auth_ports.go new file mode 100644 index 0000000..ea37d52 --- /dev/null +++ b/backend-api/internal/usecase/auth_ports.go @@ -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") +) diff --git a/backend-api/internal/usecase/auth_runtime.go b/backend-api/internal/usecase/auth_runtime.go new file mode 100644 index 0000000..f1863d3 --- /dev/null +++ b/backend-api/internal/usecase/auth_runtime.go @@ -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 +} diff --git a/backend-api/internal/usecase/auth_service.go b/backend-api/internal/usecase/auth_service.go new file mode 100644 index 0000000..a373ff2 --- /dev/null +++ b/backend-api/internal/usecase/auth_service.go @@ -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, + ) +} diff --git a/backend-api/internal/usecase/auth_service_test.go b/backend-api/internal/usecase/auth_service_test.go new file mode 100644 index 0000000..25bbabb --- /dev/null +++ b/backend-api/internal/usecase/auth_service_test.go @@ -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 +} diff --git a/backend-api/internal/usecase/task_service.go b/backend-api/internal/usecase/task_service.go index 26163b8..0d14fbb 100644 --- a/backend-api/internal/usecase/task_service.go +++ b/backend-api/internal/usecase/task_service.go @@ -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, diff --git a/backend-api/internal/usecase/task_service_test.go b/backend-api/internal/usecase/task_service_test.go index d943887..a9ee021 100644 --- a/backend-api/internal/usecase/task_service_test.go +++ b/backend-api/internal/usecase/task_service_test.go @@ -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 } diff --git a/backend-api/migrations/00003_auth.sql b/backend-api/migrations/00003_auth.sql new file mode 100644 index 0000000..f81ec76 --- /dev/null +++ b/backend-api/migrations/00003_auth.sql @@ -0,0 +1,136 @@ +-- +goose Up +CREATE TABLE users ( + id TEXT PRIMARY KEY NOT NULL + CHECK (length(id) = 36), + username TEXT NOT NULL UNIQUE + CHECK ( + length(trim(username)) > 0 + AND username = lower(trim(username)) + AND length(CAST(username AS BLOB)) <= 128 + ), + password_hash TEXT NOT NULL + CHECK ( + length(password_hash) > 0 + AND length(CAST(password_hash AS BLOB)) <= 255 + ), + role TEXT NOT NULL + CHECK (role IN ('ADMIN', 'BUYER')), + is_active INTEGER NOT NULL + CHECK (is_active IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE devices ( + id TEXT PRIMARY KEY NOT NULL + CHECK (length(id) = 36), + name TEXT NOT NULL + CHECK ( + length(trim(name)) > 0 + AND length(CAST(name AS BLOB)) <= 128 + ), + token_hash TEXT NOT NULL UNIQUE + CHECK ( + length(token_hash) = 64 + AND token_hash NOT GLOB '*[^0-9a-f]*' + ), + bound_user_id TEXT + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + app_version TEXT + CHECK ( + app_version IS NULL + OR ( + length(trim(app_version)) > 0 + AND length(CAST(app_version AS BLOB)) <= 128 + ) + ), + android_version TEXT + CHECK ( + android_version IS NULL + OR ( + length(trim(android_version)) > 0 + AND length(CAST(android_version AS BLOB)) <= 128 + ) + ), + pdd_version TEXT + CHECK ( + pdd_version IS NULL + OR ( + length(trim(pdd_version)) > 0 + AND length(CAST(pdd_version AS BLOB)) <= 128 + ) + ), + last_seen_at TEXT, + is_enabled INTEGER NOT NULL + CHECK (is_enabled IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX devices_bound_user_idx + ON devices (bound_user_id); + +CREATE TABLE admin_sessions ( + id TEXT PRIMARY KEY NOT NULL + CHECK (length(id) = 36), + token_hash TEXT NOT NULL UNIQUE + CHECK ( + length(token_hash) = 64 + AND token_hash NOT GLOB '*[^0-9a-f]*' + ), + user_id TEXT NOT NULL + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE CASCADE, + expires_at TEXT NOT NULL, + revoked_at TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX admin_sessions_user_expires_idx + ON admin_sessions (user_id, expires_at); + +CREATE TABLE access_tokens ( + id TEXT PRIMARY KEY NOT NULL + CHECK (length(id) = 36), + token_hash TEXT NOT NULL UNIQUE + CHECK ( + length(token_hash) = 64 + AND token_hash NOT GLOB '*[^0-9a-f]*' + ), + user_id TEXT NOT NULL + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE CASCADE, + device_id TEXT NOT NULL + REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE CASCADE, + expires_at TEXT NOT NULL, + revoked_at TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX access_tokens_user_device_expires_idx + ON access_tokens (user_id, device_id, expires_at); + +ALTER TABLE purchase_tasks + ADD COLUMN created_by_user_id TEXT + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + +ALTER TABLE task_events + ADD COLUMN actor_user_id TEXT + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + +CREATE INDEX purchase_tasks_created_by_user_idx + ON purchase_tasks (created_by_user_id, created_at DESC, id DESC); + +CREATE INDEX task_events_actor_user_idx + ON task_events (actor_user_id, occurred_at DESC, id DESC); + +-- +goose Down +DROP INDEX IF EXISTS task_events_actor_user_idx; +DROP INDEX IF EXISTS purchase_tasks_created_by_user_idx; +ALTER TABLE task_events DROP COLUMN actor_user_id; +ALTER TABLE purchase_tasks DROP COLUMN created_by_user_id; +DROP INDEX IF EXISTS access_tokens_user_device_expires_idx; +DROP TABLE IF EXISTS access_tokens; +DROP INDEX IF EXISTS admin_sessions_user_expires_idx; +DROP TABLE IF EXISTS admin_sessions; +DROP INDEX IF EXISTS devices_bound_user_idx; +DROP TABLE IF EXISTS devices; +DROP TABLE IF EXISTS users; diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 4c0064a..652c5d8 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -53,9 +53,8 @@ 当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、 动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已 -验证。T-201 后端骨架、T-202 P0 原型和 T-203 任务 API/管理 Web 均已完成。下一步 -按编号开始 T-204,实现正式管理账号、角色、会话和设备身份;在此之前业务路由只 -允许 loopback,不能部署给局域网用户使用。 +验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web 和 T-204 最小鉴权 +均已完成。下一步按编号开始 T-205,实现 App 原子领取、租约和任务状态机。 严格按以下顺序推进: @@ -106,11 +105,15 @@ $env:RUN_START_COMMAND = "1" ``` 脚本先执行 Android `test assembleDebug`,再以 `GOTOOLCHAIN=local` 执行后端 -`go test ./...`、`go vet ./...` 并构建 API 与 migration 两个入口。后端单独运行: +`go test ./...`、`go vet ./...` 并构建 API、migration 与 authctl 三个入口。后端 +单独运行: ```powershell Set-Location backend-api $env:GOTOOLCHAIN = "local" go run ./cmd/migrate up +$env:CMROUBAO_AUTH_PASSWORD = "至少 12 个 UTF-8 字节" +go run ./cmd/authctl create-user ADMIN admin +Remove-Item Env:CMROUBAO_AUTH_PASSWORD go run ./cmd/api ``` diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 9e8581c..2c2a8b1 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -59,6 +59,15 @@ 8. MVP 的“成功”表示完成验证闭环并得到人工确认的候选结果, `order_submitted` 必须为 `false`。 9. 取消和失败不得自动转成新任务;是否重试由人员显式决定。 +10. 管理页面和管理 API 只允许有效 ADMIN 会话;App 执行接口只允许有效 BUYER 与 + 预授权设备联合身份,两种凭证不能互换。 +11. 设备只能由本地管理命令预授权;首次 BUYER 登录可原子绑定空闲设备,客户端 + 不能自助登记、抢占或重新绑定他人设备。 +12. 密码、设备 secret、管理 session 和 App access token 不得以明文持久化或进入 + 普通日志;禁用用户或设备后,已有凭证必须立即失效。 +13. 管理登录和 App token 登录必须在高成本密码校验前按服务端观察到的来源地址限流; + 超限返回通用错误和重试时间,不得泄露账号、角色或设备是否存在。 +14. 管理员创建和取消任务时,任务事件必须记录真实 ADMIN actor;历史事件允许为空。 ## 六、第一层本地样本约定 @@ -113,6 +122,8 @@ T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fi `order_submitted` 固定为 `false`。 - F-007/US-006/IX-008:失败包含稳定错误码、失败步骤、可读说明和必要截图;重新 打开任务后证据仍可查看。 +- US-007/IX-001/IX-004:管理员可登录和退出,采购员可用预授权设备取得 1 小时 + Bearer token;匿名、角色错误、禁用、过期、篡改和跨端凭证替代均被拒绝。 ### 试验验收 @@ -135,7 +146,7 @@ T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fi | 搜索方式 | 先验证关键词搜索;拼多多原生以图搜图作为后续可选路径。 | | 搜索结果 | 最多检查前 5 个可见候选,避免无界遍历。 | | 下单边界 | MVP 停在候选或订单确认页,不提交订单、不支付。 | -| 账号边界 | 验证版为单管理身份 + 设备身份;完整人员 RBAC 后置。 | +| 账号边界 | 验证版为 ADMIN 会话 + BUYER/预授权设备联合身份;完整人员 RBAC 后置。 | | 第一层任务输入 | 从本机私有蝦皮订单文件生成测试任务,不先建设 Go-Gin。 | ## 九、待确认与风险 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 4742d82..6009628 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -23,11 +23,11 @@ | 管理 Web | Gin + `html/template` + `embed` + 少量原生 JS/CSS | MVP 已定 | 不单独引入 SPA 工程,模板和静态资源随服务构建。 | | 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 | | 图片/截图 | 后端受控本地文件目录 + `golang.org/x/image` v0.28.0 | 已验证 | JPEG/PNG/WebP 真解码后白底缩放并编码为 JPEG;数据库只存元数据和随机相对键。 | -| 管理鉴权 | 单个种子管理账号 + 服务端会话 Cookie | MVP 已定 | 密码只保存哈希;完整 RBAC 为 V2。 | -| App 鉴权 | 采购员登录态 + 设备绑定令牌 | 目标已定,细节待实现 | 人员身份与设备身份分离;令牌只保存哈希。 | +| 管理鉴权 | bcrypt + 8 小时 opaque 服务端会话 Cookie | T-204 已验证 | `authctl` 预置 ADMIN;数据库只存密码 hash 与 session SHA-256,完整 RBAC 为 V2。 | +| App 鉴权 | BUYER 密码 + 预授权设备 secret + 1 小时 opaque token | T-204 已验证 | 首次原子绑定空闲设备;数据库只存 token SHA-256,不提供自助登记/refresh。 | | VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | T-103/T-104 使用严格 JSON Schema、单候选单次调用和 2048 px 图片上限;GUI-Owl/MAI-UI 动作模型不具备需求提取能力。 | | 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 | -| 后端测试 | 标准库 `testing` + `httptest` | T-203 已验证 | 当前 99 个测试覆盖配置、迁移、图片限制、领域校验、事务幂等、分页、HTTP 生命周期、CSRF、安全响应头和有界关闭。 | +| 后端测试 | 标准库 `testing` + `httptest` | T-204 已验证 | 当前 142 个测试覆盖配置、迁移、图片限制、任务事务、bcrypt、会话/令牌、设备原子绑定、登录限流、角色/CSRF 隔离、HTTP 生命周期和安全响应。 | | Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | Phase 1 探针已验证 | 166 次测试覆盖 runner、动态页面分类、受控证据、VLM schema、人工确认策略与隐私;OnePlus PKG110 上完成私有 fixture + 本机 mock 的需求提取和 5 候选评估 smoke。 | | 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index c3e5013..0b1f67b 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -73,10 +73,10 @@ SQLite 默认文件为被忽略的 `backend-api/var/cmroubao.db`,连接启用 服务进程内自动改表;T-203 已用 `00002_tasks_and_assets.sql` 建立任务、资产、事件 和幂等记录表。 -T-204 管理会话完成前,T-203 的业务路由只允许 loopback 来源,Web 写操作还校验 -浏览器 Cookie 与表单字段的随机双提交 CSRF token;这不是正式登录,不能据此开放 -局域网监听。模板/API handler 仍调用同一 usecase,T-204 只替换主体和会话门禁, -不重写业务规则。 +T-204 已用正式服务端 session 替换临时来源门禁,不重写 T-203 业务规则。Web 写 +操作校验浏览器 Cookie 与表单字段的随机双提交 CSRF token;Cookie 管理 API 写请求 +校验 `X-CSRF-Token`。明文 HTTP 只能监听 loopback,非 loopback 必须配置服务端 +TLS certificate/key。 ### 2.2 Android App @@ -333,7 +333,7 @@ IDLE | `username` | UNIQUE, NOT NULL | 登录名 | | `password_hash` | NOT NULL | 不保存明文 | | `role` | NOT NULL | MVP 为 `ADMIN` 或 `BUYER` | -| `is_active` | NOT NULL | 禁用后不可建立新会话 | +| `is_active` | NOT NULL | 禁用后现有和新凭证均不可使用 | | `created_at` | NOT NULL | 创建时间 | ### `devices` @@ -345,15 +345,32 @@ IDLE | `token_hash` | NOT NULL | 设备令牌哈希 | | `bound_user_id` | FK, nullable | 当前绑定采购员 | | `app_version` | nullable | App 版本 | +| `android_version` | nullable | Android 系统版本 | | `pdd_version` | nullable | 已验证拼多多版本 | | `last_seen_at` | nullable | 最近心跳 | | `is_enabled` | NOT NULL | 后端开关 | +设备必须先由本地管理命令预授权。首次 BUYER 联合登录可以把 `bound_user_id` 为空的 +设备原子绑定给当前采购员,不支持客户端自助重新登记或抢占其他采购员的设备。 + +### `admin_sessions` / `access_tokens` + +- session/access token 都使用至少 256 bit 的随机 opaque secret,原值只通过 + `Set-Cookie` 或登录响应返回一次,数据库只存 SHA-256。 +- 管理 session 固定 8 小时绝对有效;App access token 固定 1 小时。 +- 每次鉴权都联查用户/设备启用状态,因此禁用立即生效,不等待 token 到期。 +- 管理 Cookie 与 App Bearer token 使用不同 middleware,不能互相替代。 +- T-203 的 `local-admin` 继续作为单管理共享资源 scope;真实 `user_id` 另作 actor, + 避免切换身份后隐藏已有任务。 +- 管理和 App 登录在 bcrypt 前共享一个内存有界限流器,但使用独立 scope + 服务端 + `RemoteAddr` 键;成功登录清零,超限返回 `429` 与 `Retry-After`。 + ### `purchase_tasks` | 字段 | 约束 | 说明 | | --- | --- | --- | | `id` | PK | 任务 ID | +| `created_by_user_id` | FK, nullable | 新任务的真实 ADMIN actor;历史任务允许为空 | | `title` | NOT NULL | 原始标题 | | `description` | NOT NULL | 原始说明,可为空字符串 | | `sku` | NOT NULL | 原始 SKU,不得由模型改写 | @@ -367,6 +384,14 @@ IDLE | `version` | NOT NULL | 乐观锁/状态并发控制 | | `created_at/updated_at` | NOT NULL | 审计时间 | +### `task_events` + +- 事件只追加;T-204 已实现 `TASK_CREATED`、`TASK_CANCELED`。 +- `actor_user_id` 是指向 `users` 的可空外键。新管理操作必须写入真实 ADMIN, + T-203 历史事件保持为空。 +- 管理任务详情 API 可返回 actor;密码、session、设备 secret 和 access token 永不 + 进入事件。 + ### `task_executions` | 字段 | 约束 | 说明 | diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index b23ca7f..6cb6fe1 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -81,6 +81,16 @@ - 状态变化校验当前状态、设备归属、claim token、版本和租约。 - 创建、领取、完成和证据上传的重试路径必须幂等。 - 不向客户端返回 `password_hash`、`token_hash`、存储绝对路径或供应商密钥。 +- 管理 Cookie 与 BUYER 设备 Bearer token 使用独立 middleware,不得互相替代; + 每次鉴权都重新检查用户/设备启用、绑定、撤销和过期状态。 +- 密码使用 bcrypt;session、设备 secret 和 access token 使用至少 256 bit 随机值, + 持久化层只保存 SHA-256。账号和设备只由 `authctl` 显式预置。 +- 管理 Web POST 校验双提交 CSRF;Cookie 管理 API 的非安全方法额外校验 + `X-CSRF-Token`。登录成功同时轮换 session 和 CSRF。 +- 管理/App 登录必须在 bcrypt 前按服务端 `RemoteAddr` 独立限流;不得信任未配置的 + 转发头。限流器必须并发安全、内存有界,超限返回通用 `429` 和 `Retry-After`。 +- 创建和取消任务必须从认证上下文传入真实 ADMIN actor 并写入只追加事件;共享 + `local-admin` 只用于 MVP 资源可见范围,不能冒充操作者。 - 通用错误使用稳定 code 和可读 message;内部堆栈只进受控日志。 - 文件访问通过鉴权接口,防止路径遍历和猜测 URL。 - Go 命令固定 `GOTOOLCHAIN=local`;`go.mod` 不得出现更高 Go 版本或未固定的 @@ -135,5 +145,6 @@ 项目标准验证命令是根目录 `.\init.ps1`;它执行 Android Gradle `test` 和 `assembleDebug`,再以 `GOTOOLCHAIN=local` 执行后端 `go test ./...`、 -`go vet ./...`、`gofmt` 检查和两个入口构建。设置 `RUN_START_COMMAND=1` 时使用 +`go vet ./...`、`gofmt` 检查和 API/migration/authctl 三个入口构建。设置 +`RUN_START_COMMAND=1` 时使用 SDK 内新版 ADB 安装并启动 Android App。 diff --git a/docs/07-user-stories.md b/docs/07-user-stories.md index 3e261b7..3c7b820 100644 --- a/docs/07-user-stories.md +++ b/docs/07-user-stories.md @@ -144,7 +144,7 @@ **范围** -- 包含:登录、退出、设备绑定、会话过期处理。 +- 包含:管理登录/退出、预授权设备首次绑定、会话过期处理。 - 不包含:自助注册、找回密码、完整用户管理和 SSO。 **验收场景** @@ -156,6 +156,7 @@ ## 待确认 -- 管理账号和采购账号的初始化、重置流程。 +- 密码重置流程后置;T-204 使用本地 `authctl` 显式创建种子 ADMIN/BUYER 和预授权 + 设备,不提供管理 UI 或客户端自助登记。 - 谁负责确认测试任务、如何标注“候选可接受”的统一口径。 - 拒绝候选是否允许创建新任务,当前默认只记录结果。 diff --git a/docs/08-interaction-checklist.md b/docs/08-interaction-checklist.md index 8193867..1601ef0 100644 --- a/docs/08-interaction-checklist.md +++ b/docs/08-interaction-checklist.md @@ -111,16 +111,19 @@ **正常路径** -1. 用户输入采购账号并选择/确认设备身份。 +1. 用户输入采购账号并确认后端已预授权的当前设备身份。 2. 成功后令牌存入 Android 安全存储,进入任务页。 3. App 上报版本和就绪能力,不上传敏感设备内容。 **状态与异常** - 账号或设备被禁用:解释联系管理员,不反复重试。 +- 未预授权、token 错误或已绑定其他采购员的设备不能在 App 中“重新登记”,联系 + 管理员重新预置。 - 网络错误:保留非敏感账号字段,密码清空。 - 会话恢复失败:退出到登录页;活跃任务先查询服务端再决定状态。 -- 退出登录:有运行任务时禁止直接退出,先安全停止。 +- 退出登录:有运行任务时禁止直接退出,先安全停止。T-204 只实现服务端 token + 签发和验证;本地清除与服务端撤销接入属于 T-206。 **可访问性** diff --git a/docs/api.md b/docs/api.md index 8664911..e37dc0a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -13,10 +13,9 @@ - 分页使用 `limit` 和不透明 `cursor`;MVP `limit` 最大 100。 - 客户端不得根据 HTTP 超时判断操作失败,必须查询资源最终状态。 -T-204 管理会话完成前,T-203 的管理 Web 和管理 API 只接受来源地址为 loopback 的 -请求,Web 写操作还要求 SameSite Cookie 与表单字段匹配的随机双提交 CSRF token。 -该门禁只用于阻止业务路由被提前暴露,不是正式身份认证;`/login`、管理会话和 -局域网访问仍由 T-204 实现。 +管理 Web/API 使用 ADMIN 服务端会话;App 执行接口使用 BUYER + 设备 Bearer token。 +两种身份不能互换。HTTP 明文只允许 loopback 开发监听,非 loopback 服务必须配置 +certificate/private key 并直接启用 TLS。 通用错误: @@ -71,7 +70,18 @@ HTTP 语义: ### 管理 Web 会话 `POST /login` 接受表单账号密码,成功后设置 `HttpOnly`、`Secure`、`SameSite=Lax` -会话 Cookie。`POST /logout` 清除会话。Web 会话不能调用设备执行接口。 +会话 Cookie。loopback HTTP 开发时不设置 `Secure`,非 loopback 服务必须直接启用 +TLS。管理会话固定 8 小时绝对有效期;`POST /logout` 撤销服务端会话并清除 Cookie。 +Web 会话不能调用设备执行接口。 + +未登录页面请求以 `303` 跳转 `/login?next=...`;`next` 只允许 `/tasks` 及其本站 +子路径。未授权管理 API 返回 `401 ADMIN_SESSION_REQUIRED`。Cookie 认证的管理 API +写请求除原 Content-Type/幂等要求外,还必须携带与 CSRF Cookie 匹配的 +`X-CSRF-Token`。 + +`POST /login` 在通过表单和 CSRF 校验、执行 bcrypt 前,按服务端观察到的来源地址 +限流。5 分钟内最多 10 次;成功登录清零。超限返回 `429`、`Retry-After` 和通用 +中文提示,不暴露账号是否存在。 ### `POST /api/v1/auth/token` @@ -92,7 +102,7 @@ HTTP 语义: ```json { - "access_token": "opaque-or-jwt-token", + "access_token": "opaque-token-returned-once", "token_type": "Bearer", "expires_in": 3600, "user": { @@ -109,6 +119,14 @@ HTTP 语义: 密码和设备 token 不得出现在响应、日志或 execution event 中。 +T-204 固定使用 256 bit 随机 opaque access token,数据库只保存 SHA-256,固定 +1 小时过期。用户必须为有效 `BUYER`,设备必须预授权且启用;未绑定设备在首次成功 +登录时原子绑定当前采购员,已绑定其他采购员时拒绝。T-204 不提供设备自助登记、 +refresh 或 App logout;Android 安全存储接入属于 T-206。 + +App token 登录与管理登录使用独立限流 scope,同样为每个来源地址 5 分钟最多 10 次。 +超限返回 `429`、`Retry-After` 和稳定错误码 `AUTH_RATE_LIMITED`,`retryable=true`。 + ## 资产 ### `POST /api/v1/assets` @@ -222,6 +240,9 @@ T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容 - `execution`:step、outcome、错误、`order_submitted`。 - `events` 和 `assets`:有权限的摘要。 +T-204 已实现的 `TASK_CREATED`、`TASK_CANCELED` 事件包含可空 +`actor_user_id`;新管理操作写入真实 ADMIN 用户 ID,T-203 历史事件返回 `null`。 + ### `POST /api/v1/tasks/{task_id}/cancel` 管理端取消任务。`PENDING` 可立即取消;执行中只设置取消请求,App 在安全检查点确认 diff --git a/docs/current-state.md b/docs/current-state.md index da7ad4a..5b5071a 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,25 +5,30 @@ ## 当前快照 - 日期:2026-07-26 -- 阶段:T-203 任务创建 API 与管理 Web 已完成,准备开始 T-204 +- 阶段:T-204 用户与设备最小鉴权完成,下一步 T-205 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-203 - 均已纳入 Git 历史 + 均已纳入 Git 历史;T-204 与本文同次提交 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 -- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片规范化、任务 - 创建/查询/取消 API 和 SSR 管理 Web,Go Blueprint 只作为一次性骨架输入 +- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、 + SSR 管理 Web、ADMIN 会话、BUYER/预授权设备联合认证和 `authctl` - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器 共 26 份报告、166 次测试,0 failure、0 error、0 skipped -- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 99 个测试通过; - 全包 race、`go vet ./...`、API/migration Windows 构建和根 `init.ps1` 均通过 +- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 142 个测试通过; + 全包 race、`go vet ./...`、API/migration/authctl Windows 构建和根 `init.ps1` + 均通过 - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 脚本错误或外部请求,Android 可见交互控件均不小于 44px - 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和 - 待领取取消;同三种视口无横向溢出,可见操作控件不小于 44px + 待领取取消;ADMIN 登录/退出和安全返回路径已接入,同三种视口无横向溢出, + 可见操作控件不小于 44px +- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret + 均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App + 登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture @@ -59,6 +64,7 @@ | `docs/tasks/T-201.md` | DONE | Go-Gin、SQLite、Goose migration 和健康检查骨架 | | `docs/tasks/T-202.md` | DONE | 生成并确认 P0 Web/App 低保真原型 | | `docs/tasks/T-203.md` | DONE | 图片/任务 API、SQLite 业务层和 SSR 管理 Web | +| `docs/tasks/T-204.md` | DONE | 用户、管理会话和预授权设备联合身份 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -69,9 +75,9 @@ ## 任务摘要 -- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-203。 +- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-204。 - 正在进行:无。 -- 下一个可领取任务:T-204 实现账号、角色、会话和设备身份;完成后 T-205 可开始。 +- 下一个可领取任务:T-205 原子领取、租约和状态机。 ## 当前可运行内容 @@ -84,6 +90,9 @@ $env:RUN_START_COMMAND = "1" Set-Location backend-api $env:GOTOOLCHAIN = "local" go run ./cmd/migrate up +$env:CMROUBAO_AUTH_PASSWORD = "至少 12 个 UTF-8 字节" +go run ./cmd/authctl create-user ADMIN admin +Remove-Item Env:CMROUBAO_AUTH_PASSWORD go run ./cmd/api ``` @@ -115,6 +124,14 @@ SQLite 仓储、管理 API 和 SSR Web。创建/上传幂等,列表稳定分 使用双提交 CSRF;这不是正式鉴权。真实 HTTP/Playwright 和 migration CLI smoke、 99 个 Go 测试、全包 race/vet、Windows 构建与根 `init.ps1` 全部通过。 +2026-07-26 完成 T-204:新增 users/devices/admin_sessions/access_tokens 与任务 actor +migration、bcrypt 密码、opaque 凭证、`authctl`、管理登录/退出和 App 联合认证。 +ADMIN Cookie 与 BUYER 设备 Bearer token 完全隔离;非 loopback 监听必须配置内建 +TLS。真实 SQLite/Gin smoke 验证 migration up/down/up、账号/设备预置、App token、 +管理登录、任务创建 actor 和退出;Playwright 在 1440×900、390×844、360×800 +验证无横向溢出,发现并修复退出路由漏装配及 T-203 旧 CSRF Cookie 路径兼容问题。 +142 个 Go 测试、全包 race/vet、Windows 三入口构建与根 `init.ps1` 均通过。 + ## 维护规则 发生以下变化时覆盖更新本文: diff --git a/docs/design/android-login.html b/docs/design/android-login.html index 98c19b4..3dded8c 100644 --- a/docs/design/android-login.html +++ b/docs/design/android-login.html @@ -44,12 +44,11 @@
- 设备可登记 - 仅上报版本和就绪能力,不上传设备中的敏感内容 + 设备身份待验证 + 未预授权或绑定冲突时请联系管理员,不在 App 内重新登记
diff --git a/docs/routes.md b/docs/routes.md index f566e42..3077a39 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -13,7 +13,8 @@ T-202 的离线 P0 页面入口见[原型索引](design/index.html)。原型仅 | `/tasks/{id}` | 任务详情 | 查看原始输入、时间线、候选和证据 | US-002、US-006 | IX-003、IX-008 | MVP 登录后默认进入 `/tasks`。未登录访问受保护页面时跳转 `/login` 并携带安全的 -站内返回路径。不存在和无权限必须使用不同内部原因,但页面均不得泄露任务内容。 +站内返回路径;只接受 `/tasks` 及其本站子路径,拒绝绝对 URL、`//` 和反斜杠。 +不存在和无权限必须使用不同内部原因,但页面均不得泄露任务内容。 ## Android 页面 diff --git a/docs/tasks/T-204.md b/docs/tasks/T-204.md new file mode 100644 index 0000000..d7a7772 --- /dev/null +++ b/docs/tasks/T-204.md @@ -0,0 +1,172 @@ +--- +id: T-204 +title: 实现用户与设备最小鉴权 +phase: 2 +deps: + - T-201 + - T-202 +status: DONE +created: 2026-07-26 +context_ref: c5d3b21 +work_branch: main +write_paths: + - README.md + - backend-api/** + - init.ps1 + - init.sh + - docs/00-ai-start-here.md + - docs/02-requirements.md + - docs/03-tech-stack.md + - docs/04-architecture.md + - docs/05-coding-rules.md + - docs/07-user-stories.md + - docs/08-interaction-checklist.md + - docs/api.md + - docs/current-state.md + - docs/routes.md + - docs/design/admin-login.html + - docs/design/android-login.html + - docs/design/android-settings.html + - docs/tasks/T-204.md + - progress.md +--- + +## 问题 / 背景 + +T-203 为避免匿名管理接口提前暴露,临时把全部业务路由限制在 loopback 并使用固定 +`local-admin` 主体。现在没有正式登录、角色验证、服务端会话、采购员与预授权设备 +联合认证或凭证初始化命令,T-205 无法安全实现领取和租约,局域网也不能开放。 + +正式文档还留有四个需要在实现前收敛的断点: + +- Android 原型出现“重新登记设备”,但没有安全登记 API,正式前置条件是授权设备。 +- App access token 示例为 1 小时,但 refresh、App logout 和轮换协议尚未定义。 +- 管理 JSON/multipart API 是否接受 Cookie 会话以及写请求 CSRF 规则尚未写清。 +- T-203 任务都在共享 `local-admin` scope,直接换为用户 UUID 会让已有任务不可见。 + +## 已定合约 + +1. MVP 只有 `ADMIN` 和 `BUYER` 两种角色。管理 Web/API 只接受有效 `ADMIN` 会话; + `/api/v1/auth/token` 只为有效 `BUYER` 和已启用的预授权设备建立联合身份。 +2. 不实现自助注册、找回密码、完整用户/设备管理 UI、设备自助登记、token refresh、 + App logout 或多管理租户隔离。账号和设备由本地 `authctl` 显式预置。 +3. 用户密码使用 bcrypt,只保存 hash;密码 12 至 72 个 UTF-8 字节。不存在、密码 + 错误、角色错误和禁用账号的管理登录均返回相同文案,并执行等价 bcrypt 校验。 +4. 管理 session、设备预共享 token 和 App access token 都是至少 256 bit 的随机 + opaque secret;响应/Cookie 只返回原值一次,数据库只保存 SHA-256。 +5. 管理 session 固定 8 小时绝对有效期;App access token 固定 1 小时。禁用用户或 + 设备后,即使 token 尚未到期也立即鉴权失败。 +6. 管理 Cookie 为 `HttpOnly`、`SameSite=Lax`、Path `/`;TLS 请求必须设置 Secure。 + Web 所有 POST 继续要求 SameSite CSRF Cookie 与表单字段匹配;Cookie 认证的管理 + API POST 还要求 `X-CSRF-Token` 匹配。 +7. 未登录 Web 请求以 `303` 跳转 `/login?next=...`;`next` 只允许本站 `/tasks` + 路径。未授权 API 返回稳定 `401`,角色/设备禁用不泄露资源内容。 +8. T-203 的 `local-admin` 保留为单管理共享资源 scope,避免已有任务消失;认证上下文 + 同时携带真实 `user_id`,新任务和后续事件可单独记录 actor。完整多租户/RBAC 后置。 +9. 设备必须由 `authctl create-device` 预授权。首次成功的 BUYER 登录可原子绑定空闲 + 设备;已绑定其他用户、设备 token 错误或设备禁用统一拒绝,不提供“重新登记”。 +10. T-204 交付后端 App 联合认证端点和正式管理登录/退出;Android 安全存储及页面 + 接入属于 T-206,T-204 不把 token 写入当前 App 或普通偏好设置。 +11. HTTP 明文只允许 loopback 开发监听;非 loopback 监听必须同时配置 TLS certificate + 和 private key,由服务直接 `ListenAndServeTLS`。不提供静默降级或“临时局域网 + 明文”开关。 +12. 管理与 App 登录在 bcrypt 前按服务端来源地址独立限流,5 分钟最多 10 次;成功 + 登录清零,超限返回通用 `429` 和 `Retry-After`。 + +## 方案 + +1. 新增 users、devices、admin_sessions、access_tokens 和任务 actor migration, + 约束角色、启用状态、hash、过期时间和外键。 +2. 在 domain/usecase/repository/sqlite 现有分层增加认证实体、密码/令牌端口和事务; + token lookup 必须同时校验用户、设备、绑定、禁用和到期。 +3. 新增 `cmd/authctl`,从非参数秘密输入创建 ADMIN/BUYER 和预授权设备;设备 token + 只输出一次,命令与日志不回显密码,并提供账号/设备启用与禁用命令。 +4. Gin 路由拆成公共认证路由、管理页面/API 与未来设备路由;移除固定 loopback 主体 + 之前先建立管理 session middleware 和稳定上下文。 +5. 实现 `/login`、`POST /login`、`POST /logout` 以及 + `POST /api/v1/auth/token`;管理页复用现有嵌入 CSS/JS 和 CSRF。 +6. 用真实 SQLite/HTTP/浏览器验证角色隔离、会话过期、禁用、设备绑定、token hash、 + open redirect、CSRF 和响应/日志脱敏。 + +## 验收要点 + +- [x] migration 可 up/down/up,密码或 token 原值无法进入数据库。 +- [x] authctl 可创建唯一 ADMIN/BUYER 和设备;弱密码、重复账号与无效输入被拒绝。 +- [x] authctl 可启用/禁用账号和设备,现存 session/token 在下一次请求立即失效。 +- [x] 正确管理凭证建立 8 小时会话;错误/不存在/禁用/BUYER 登录文案一致。 +- [x] 未登录管理 Web 跳转安全登录页,API 返回 401;登录后 T-203 全流程仍可用。 +- [x] 登录 next 不允许绝对 URL、`//`、反斜杠或非 `/tasks` 路径。 +- [x] logout、过期、篡改 session 失效;Cookie 属性和所有写请求 CSRF 正确。 +- [x] BUYER + 正确设备 secret 建立 1 小时 token,并原子绑定未绑定设备。 +- [x] ADMIN、错误密码、错误/禁用/已占用设备不能取得 App token,错误不泄露细节。 +- [x] Bearer token 不能访问管理路由,管理 Cookie 不能替代设备身份。 +- [x] 禁用用户或设备使现存 session/token 立即失效。 +- [x] 日志、错误、HTML、JSON、数据库均不含密码、原始 token 或私有样本。 +- [x] 管理/App 登录在 bcrypt 前独立限流,超限不调用认证服务且返回 429 重试时间。 +- [x] 任务创建与取消事件记录真实 ADMIN actor,历史数据保持可空。 +- [x] Go test/race/vet/gofmt、migration/authctl/HTTP smoke、Playwright 和根脚本通过。 + +## 边界 + +- 不实现完整 RBAC、用户/设备管理页面、密码重置、自助注册、SSO 或审计后台。 +- 不实现 access token refresh、App logout、安全存储 UI 接入;属于 T-206。 +- 不实现 claim token、租约或任务状态机;属于 T-205。 +- 不把 smoke 运行时密码、设备 token、session、数据库或命令输出提交到 Git;单元 + 测试只使用明显脱敏、无复用价值的 sentinel。 + +## 执行记录 + +### 2026-07-26:任务开始 + +- 基于提交 `c5d3b21` 开始,工作区干净。 +- codebase-memory MCP 本轮未暴露 graph 工具,按项目规则回退到 `rg` 和定点读取。 +- 已审计 T-203 路由、固定主体、CSRF、用例/仓储装配与正式 auth/API/IX/原型,先固定 + 上述凭证、设备预授权、共享 scope 和任务边界再编码。 + +### 2026-07-26:认证领域与持久化 + +- 新增 `00003_auth.sql`:users、devices、admin_sessions、access_tokens,以及 + `purchase_tasks.created_by_user_id`、`task_events.actor_user_id`;up/down/up 已用 + 真实 SQLite 验证。 +- 密码限定为 12 至 72 个有效 UTF-8 字节,生产 bcrypt cost 为 12;不存在账号使用 + 同 cost 预计算 dummy hash 校验,管理登录不泄露账号、角色或禁用状态。 +- 管理 session、设备 secret 和 App access token 均为 32 随机字节的 Raw URL token, + 数据库只存 SHA-256。管理 session 为 8 小时,App token 为 1 小时。 +- 每次认证联查用户/设备启用、角色、绑定、撤销和到期。SQLite immediate transaction + 保证两个 BUYER 并发首次绑定同一设备时精确一个成功。 +- 新任务仍使用 `local-admin` 共享可见范围,并从认证上下文另存真实 ADMIN actor; + 创建与取消事件同时记录 actor,历史任务和事件的 actor 允许为空。 + +### 2026-07-26:传输、命令与 TLS + +- 路由拆为公开登录/App token、ADMIN Cookie 管理路由和未来 BUYER 设备路由;删除 + T-203 临时来源地址门禁,Cookie 与 Bearer 不能互换。 +- 管理登录成功轮换 session 与 CSRF,Cookie 为 HttpOnly/SameSite=Lax/Path `/`; + Web POST 校验表单 CSRF,Cookie 管理 API 写请求校验 `X-CSRF-Token`。 +- 实现安全 `next`、通用登录错误、服务端 logout、页头退出入口和旧 `/tasks` 路径 + CSRF Cookie 清理,避免升级后同名 Cookie 导致退出 403。 +- `POST /api/v1/auth/token` 校验 BUYER 密码、预授权设备 secret、App/Android 版本, + 首次原子绑定并只在响应返回 access token。 +- 新增 `authctl create-user`、`create-device` 以及用户/设备 enable/disable 命令。 + 密码只读 `CMROUBAO_AUTH_PASSWORD`,设备 token 只输出一次;根脚本构建第三个入口。 +- 管理与 App 登录在 bcrypt 前使用 scope 隔离、线程安全且内存有界的来源地址限流; + 5 分钟 10 次,成功清零,超限返回通用 `429` 和 `Retry-After`。 +- 配置新增 TLS certificate/private key;两者必须同时设置,非 loopback 监听无 TLS + 时启动前拒绝,配置后由 `ListenAndServeTLS` 直接服务。 + +### 2026-07-26:验证与收尾 + +- `GOTOOLCHAIN=local go test -count=1 -json ./...`:142 个测试通过;覆盖 bcrypt、 + dummy 校验、hash 持久化、过期/撤销/禁用、并发设备绑定、角色隔离、CSRF、迁移、 + 登录限流、authctl 启停、TLS 配置与 Secure Cookie、任务 actor 和入口装配。 +- `go test -race -count=1 ./...`、`go vet ./...`、`gofmt -l cmd internal migrations` + 和 API/migration/authctl Windows 构建通过;根 `init.ps1` 保持 Android 166 次测试 + 与后端全量门禁通过。 +- 真实 CLI/Gin/SQLite smoke 完成 migration up/down/up、ADMIN/BUYER/设备预置、 + App token、匿名 Web 303、匿名 API 401、管理登录、图片/任务创建 actor 和 logout; + 数据库抽查只存在密码/token hash。 +- Playwright 连接真实服务验证错误凭证通用文案、正确登录、任务页、创建和退出; + 1440x900、390x844、360x800 无横向溢出或控件重叠,可见交互控件不小于 44 px。 + 浏览器复验发现并修复 `/logout` 主装配遗漏和旧 CSRF Cookie 路径兼容问题。 +- smoke 数据、临时凭证、数据库、图片和浏览器产物均位于被忽略目录或在提交前删除; + 未读取或提交本地蝦皮订单样本。T-204 状态改为 `DONE`,下一任务为 T-205。 diff --git a/init.ps1 b/init.ps1 index 0c62b09..e950592 100644 --- a/init.ps1 +++ b/init.ps1 @@ -90,6 +90,10 @@ try { if ($LASTEXITCODE -ne 0) { throw "Go migration 构建失败,退出码: $LASTEXITCODE" } + & go build -o "bin/cmroubao-authctl.exe" ./cmd/authctl + if ($LASTEXITCODE -ne 0) { + throw "Go authctl 构建失败,退出码: $LASTEXITCODE" + } } finally { Pop-Location } diff --git a/init.sh b/init.sh index 3c775f0..e475cf1 100755 --- a/init.sh +++ b/init.sh @@ -79,6 +79,7 @@ echo "==> Verifying Go backend" mkdir -p bin go build -o bin/cmroubao-api ./cmd/api go build -o bin/cmroubao-migrate ./cmd/migrate + go build -o bin/cmroubao-authctl ./cmd/authctl ) if [[ "${RUN_START_COMMAND:-0}" == "1" ]]; then diff --git a/progress.md b/progress.md index f248d54..db2cf11 100644 --- a/progress.md +++ b/progress.md @@ -127,3 +127,13 @@ SSR 管理 Web,并用 loopback 与双提交 CSRF 作为 T-204 前的临时暴露门禁。 - 影响:管理人员已能在本机创建、搜索、查看和取消待领取任务;T-204 下一步补正式 账号、角色、会话和设备身份,完成后 T-205 可接入 App 原子领取与租约状态机。 + +## 2026-07-26 用户与设备最小鉴权 + +- 类型:阶段完成 +- 内容:完成 T-204;实现 ADMIN/BUYER、bcrypt、8 小时管理 session、预授权设备 + 首次原子绑定、1 小时 App token、登录限流、账号/设备启停、`authctl`、管理 + 登录/退出、CSRF 和内建 TLS 门禁。 +- 影响:匿名与跨端凭证不能访问任务,新任务保留共享 `local-admin` scope 并单独记录 + 真实 ADMIN actor,创建/取消事件均可审计;T-205 可在稳定 BUYER + device 身份上 + 实现原子领取和租约。