feat(auth): implement user and device authentication
This commit is contained in:
@@ -30,7 +30,8 @@ cmroubao/
|
|||||||
```
|
```
|
||||||
|
|
||||||
管理人员使用 Web 管理端,采购人员使用 Android App;二者共享同一后端、任务数据
|
管理人员使用 Web 管理端,采购人员使用 Android App;二者共享同一后端、任务数据
|
||||||
和权限体系。验证版先使用单管理账号和设备身份,完整 RBAC 放到验证通过后。
|
和权限体系。验证版已实现 ADMIN 服务端会话、BUYER 账号和预授权设备联合身份,
|
||||||
|
完整 RBAC 放到验证通过后。
|
||||||
|
|
||||||
后端采用 Go 1.23.0 + Gin 1.11.0,以 Go Blueprint v0.10.11 为一次性骨架参考,
|
后端采用 Go 1.23.0 + Gin 1.11.0,以 Go Blueprint v0.10.11 为一次性骨架参考,
|
||||||
已收敛出 SQLite、Goose 迁移、健康检查和可关闭的 HTTP Server;业务 API 按 Phase 2
|
已收敛出 SQLite、Goose 迁移、健康检查和可关闭的 HTTP Server;业务 API 按 Phase 2
|
||||||
@@ -61,6 +62,9 @@ fixture,默认 APK 不携带真实订单资料。
|
|||||||
Set-Location backend-api
|
Set-Location backend-api
|
||||||
$env:GOTOOLCHAIN = "local"
|
$env:GOTOOLCHAIN = "local"
|
||||||
go run ./cmd/migrate up
|
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
|
go run ./cmd/api
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -80,5 +84,5 @@ go run ./cmd/api
|
|||||||
- [当前实现状态](docs/current-state.md)
|
- [当前实现状态](docs/current-state.md)
|
||||||
- [完整文档导航](docs/README.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) 为准。
|
[`docs/current-state.md`](docs/current-state.md) 为准。
|
||||||
|
|||||||
+30
-4
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Go 1.23.0、Gin 1.11.0 和 SQLite 构成的单进程采购任务服务。当前提供参考图上传与
|
Go 1.23.0、Gin 1.11.0 和 SQLite 构成的单进程采购任务服务。当前提供参考图上传与
|
||||||
规范化、任务创建/列表/详情/取消 API、服务端渲染管理页面、健康检查和显式数据库
|
规范化、任务创建/列表/详情/取消 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_DATABASE_PATH` | `var/cmroubao.db` | SQLite 文件路径 |
|
||||||
| `CMROUBAO_ASSET_DIR` | `var/assets` | 规范化参考图片的受控本地目录 |
|
| `CMROUBAO_ASSET_DIR` | `var/assets` | 规范化参考图片的受控本地目录 |
|
||||||
|
| `CMROUBAO_TLS_CERT_FILE` | 无 | TLS certificate;必须和 private key 同时设置 |
|
||||||
|
| `CMROUBAO_TLS_KEY_FILE` | 无 | TLS private key;非 loopback 监听必须设置 |
|
||||||
|
|
||||||
不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。
|
不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。
|
||||||
|
|
||||||
@@ -28,20 +30,44 @@ go test ./...
|
|||||||
go vet ./...
|
go vet ./...
|
||||||
go build -o bin/cmroubao-api.exe ./cmd/api
|
go build -o bin/cmroubao-api.exe ./cmd/api
|
||||||
go build -o bin/cmroubao-migrate.exe ./cmd/migrate
|
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 up
|
||||||
go run ./cmd/migrate status
|
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 <device-id>
|
||||||
|
# 恢复时使用对应的 enable-user / enable-device
|
||||||
go run ./cmd/api
|
go run ./cmd/api
|
||||||
```
|
```
|
||||||
|
|
||||||
API 启动前会检查全部 migration 已应用;发现 pending migration 会拒绝启动并提示先
|
API 启动前会检查全部 migration 已应用;发现 pending migration 会拒绝启动并提示先
|
||||||
执行 `go run ./cmd/migrate up`,不会在服务进程内自动改表。
|
执行 `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` 和
|
服务启动后,`GET /healthz` 在数据库可用时返回 `200` 和
|
||||||
`{"status":"ok"}`,不可用时返回 `503` 和 `{"status":"unavailable"}`。管理页面:
|
`{"status":"ok"}`,不可用时返回 `503` 和 `{"status":"unavailable"}`。管理页面:
|
||||||
|
|
||||||
- `GET /tasks`:任务列表、搜索和状态筛选。
|
- `GET /tasks`:任务列表、搜索和状态筛选。
|
||||||
- `GET /tasks/new`:上传参考图并创建任务。
|
- `GET /tasks/new`:上传参考图并创建任务。
|
||||||
- `GET /tasks/{id}`:查看原始约束和任务状态,待领取任务可取消。
|
- `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)。
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ import (
|
|||||||
"cmroubao/backend-api/internal/platform/assetstore"
|
"cmroubao/backend-api/internal/platform/assetstore"
|
||||||
"cmroubao/backend-api/internal/platform/database"
|
"cmroubao/backend-api/internal/platform/database"
|
||||||
"cmroubao/backend-api/internal/platform/migration"
|
"cmroubao/backend-api/internal/platform/migration"
|
||||||
|
"cmroubao/backend-api/internal/platform/password"
|
||||||
repository "cmroubao/backend-api/internal/repository/sqlite"
|
repository "cmroubao/backend-api/internal/repository/sqlite"
|
||||||
|
"cmroubao/backend-api/internal/transport/authcommon"
|
||||||
"cmroubao/backend-api/internal/transport/httpapi"
|
"cmroubao/backend-api/internal/transport/httpapi"
|
||||||
"cmroubao/backend-api/internal/transport/webui"
|
"cmroubao/backend-api/internal/transport/webui"
|
||||||
"cmroubao/backend-api/internal/usecase"
|
"cmroubao/backend-api/internal/usecase"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -67,6 +71,13 @@ func run() error {
|
|||||||
|
|
||||||
serverErrors := make(chan error, 1)
|
serverErrors := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
|
if cfg.TLSCertificate != "" {
|
||||||
|
serverErrors <- server.ListenAndServeTLS(
|
||||||
|
cfg.TLSCertificate,
|
||||||
|
cfg.TLSPrivateKey,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
serverErrors <- server.ListenAndServe()
|
serverErrors <- server.ListenAndServe()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -158,6 +169,20 @@ func buildRouter(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
webService, err := webui.NewUsecaseAdapter(tasks, assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -171,6 +196,33 @@ func buildRouter(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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(
|
registerAdminRoutes, err := httpapi.NewAdminRouteRegistrar(
|
||||||
httpapi.AdminServices{
|
httpapi.AdminServices{
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
@@ -181,9 +233,22 @@ func buildRouter(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{
|
return httpapi.NewRouter(httpapi.RouterDependencies{
|
||||||
Database: db,
|
Database: db,
|
||||||
RegisterAdminRoutes: registerAdminRoutes,
|
RegisterPublicRoutes: func(routes gin.IRoutes) error {
|
||||||
|
webHandler.RegisterStatic(routes)
|
||||||
|
authWebHandler.RegisterPublic(routes)
|
||||||
|
return registerPublicAuth(routes)
|
||||||
|
},
|
||||||
|
RegisterAdminRoutes: registerProtectedRoutes,
|
||||||
|
AdminSessions: auth,
|
||||||
LogEvent: func(event string) {
|
LogEvent: func(event string) {
|
||||||
log.Print(event)
|
log.Print(event)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/config"
|
||||||
|
"cmroubao/backend-api/internal/platform/database"
|
||||||
"cmroubao/backend-api/internal/platform/migration"
|
"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 {
|
type stubMigrationStatusReader struct {
|
||||||
statuses []migration.Status
|
statuses []migration.Status
|
||||||
err error
|
err error
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/config"
|
||||||
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
"cmroubao/backend-api/internal/platform/database"
|
||||||
|
"cmroubao/backend-api/internal/platform/migration"
|
||||||
|
"cmroubao/backend-api/internal/platform/password"
|
||||||
|
repository "cmroubao/backend-api/internal/repository/sqlite"
|
||||||
|
"cmroubao/backend-api/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
const passwordEnvironment = "CMROUBAO_AUTH_PASSWORD"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(
|
||||||
|
os.Args[1:],
|
||||||
|
os.LookupEnv,
|
||||||
|
os.Stdout,
|
||||||
|
); err != nil {
|
||||||
|
log.Printf("auth command failed: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(
|
||||||
|
arguments []string,
|
||||||
|
lookup config.LookupEnvironment,
|
||||||
|
output io.Writer,
|
||||||
|
) error {
|
||||||
|
command, value, role, err := parseArguments(arguments)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
databasePath, err := config.LoadDatabasePath(lookup)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var plainPassword string
|
||||||
|
if command == "create-user" {
|
||||||
|
var exists bool
|
||||||
|
plainPassword, exists = lookup(passwordEnvironment)
|
||||||
|
if !exists || plainPassword == "" {
|
||||||
|
return errors.New(
|
||||||
|
passwordEnvironment + " must be set for create-user",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
db, err := database.Open(ctx, databasePath)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("database startup failed")
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
log.Print("database close failed")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
runner, err := migration.New(db)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("migration setup failed")
|
||||||
|
}
|
||||||
|
if err := requireCurrentMigrations(ctx, runner); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store, err := repository.New(db)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("auth repository setup failed")
|
||||||
|
}
|
||||||
|
passwords, err := password.NewBcrypt(12)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("password manager setup failed")
|
||||||
|
}
|
||||||
|
service, err := usecase.NewAuthService(
|
||||||
|
store,
|
||||||
|
passwords,
|
||||||
|
usecase.SystemClock{},
|
||||||
|
usecase.UUIDGenerator{},
|
||||||
|
usecase.CryptoTokenGenerator{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("auth service setup failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch command {
|
||||||
|
case "create-user":
|
||||||
|
user, err := service.ProvisionUser(
|
||||||
|
ctx,
|
||||||
|
usecase.ProvisionUserCommand{
|
||||||
|
Username: value,
|
||||||
|
Password: plainPassword,
|
||||||
|
Role: role,
|
||||||
|
Active: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return publicProvisioningError(err)
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(
|
||||||
|
output,
|
||||||
|
"user_id=%s username=%s role=%s\n",
|
||||||
|
user.ID,
|
||||||
|
user.Username,
|
||||||
|
user.Role,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
case "create-device":
|
||||||
|
result, err := service.ProvisionDevice(
|
||||||
|
ctx,
|
||||||
|
usecase.ProvisionDeviceCommand{
|
||||||
|
Name: value,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return publicProvisioningError(err)
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(
|
||||||
|
output,
|
||||||
|
"device_id=%s\ndevice_token=%s\n",
|
||||||
|
result.Device.ID,
|
||||||
|
result.DeviceToken,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
case "enable-user", "disable-user":
|
||||||
|
active := command == "enable-user"
|
||||||
|
if err := service.SetUserActive(
|
||||||
|
ctx,
|
||||||
|
usecase.SetUserActiveCommand{
|
||||||
|
Username: value,
|
||||||
|
Active: active,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return publicProvisioningError(err)
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(
|
||||||
|
output,
|
||||||
|
"username=%s active=%t\n",
|
||||||
|
domain.NormalizeUsername(value),
|
||||||
|
active,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
case "enable-device", "disable-device":
|
||||||
|
enabled := command == "enable-device"
|
||||||
|
if err := service.SetDeviceEnabled(
|
||||||
|
ctx,
|
||||||
|
usecase.SetDeviceEnabledCommand{
|
||||||
|
DeviceID: value,
|
||||||
|
Enabled: enabled,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return publicProvisioningError(err)
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(
|
||||||
|
output,
|
||||||
|
"device_id=%s enabled=%t\n",
|
||||||
|
strings.TrimSpace(value),
|
||||||
|
enabled,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
return errors.New("unsupported auth command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseArguments(
|
||||||
|
arguments []string,
|
||||||
|
) (string, string, domain.UserRole, error) {
|
||||||
|
if len(arguments) == 2 && arguments[0] == "create-device" {
|
||||||
|
return "create-device", arguments[1], "", nil
|
||||||
|
}
|
||||||
|
if len(arguments) == 2 {
|
||||||
|
switch arguments[0] {
|
||||||
|
case "enable-user", "disable-user",
|
||||||
|
"enable-device", "disable-device":
|
||||||
|
return arguments[0], arguments[1], "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(arguments) == 3 && arguments[0] == "create-user" {
|
||||||
|
role := domain.UserRole(strings.ToUpper(arguments[1]))
|
||||||
|
if role != domain.UserRoleAdmin && role != domain.UserRoleBuyer {
|
||||||
|
return "", "", "", errors.New(
|
||||||
|
"role must be ADMIN or BUYER",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return "create-user", arguments[2], role, nil
|
||||||
|
}
|
||||||
|
return "", "", "", errors.New(
|
||||||
|
"usage: authctl create-user <ADMIN|BUYER> <username> | " +
|
||||||
|
"authctl create-device <name> | " +
|
||||||
|
"authctl <enable-user|disable-user> <username> | " +
|
||||||
|
"authctl <enable-device|disable-device> <device-id>",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type migrationStatusReader interface {
|
||||||
|
Status(context.Context) ([]migration.Status, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireCurrentMigrations(
|
||||||
|
ctx context.Context,
|
||||||
|
reader migrationStatusReader,
|
||||||
|
) error {
|
||||||
|
statuses, err := reader.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("database migration status failed")
|
||||||
|
}
|
||||||
|
if len(statuses) == 0 {
|
||||||
|
return errors.New("database has no known migrations")
|
||||||
|
}
|
||||||
|
for _, status := range statuses {
|
||||||
|
if !status.Applied {
|
||||||
|
return errors.New(
|
||||||
|
"database migrations are pending; run migrate up",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicProvisioningError(err error) error {
|
||||||
|
var typed *usecase.Error
|
||||||
|
if !errors.As(err, &typed) {
|
||||||
|
return errors.New("authentication provisioning failed")
|
||||||
|
}
|
||||||
|
switch typed.Kind {
|
||||||
|
case usecase.ErrorKindInvalid:
|
||||||
|
return errors.New("authentication input is invalid")
|
||||||
|
case usecase.ErrorKindConflict:
|
||||||
|
return errors.New("authentication resource already exists")
|
||||||
|
case usecase.ErrorKindUnavailable:
|
||||||
|
return errors.New("authentication storage is unavailable")
|
||||||
|
case usecase.ErrorKindNotFound:
|
||||||
|
return errors.New("authentication resource was not found")
|
||||||
|
default:
|
||||||
|
return errors.New("authentication provisioning failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.48
|
github.com/mattn/go-sqlite3 v1.14.48
|
||||||
github.com/pressly/goose/v3 v3.26.0
|
github.com/pressly/goose/v3 v3.26.0
|
||||||
|
golang.org/x/crypto v0.40.0
|
||||||
golang.org/x/image v0.28.0
|
golang.org/x/image v0.28.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,7 +37,6 @@ require (
|
|||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.20.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/mod v0.25.0 // indirect
|
||||||
golang.org/x/net v0.42.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const (
|
|||||||
HTTPAddressEnvironment = "CMROUBAO_HTTP_ADDR"
|
HTTPAddressEnvironment = "CMROUBAO_HTTP_ADDR"
|
||||||
DatabasePathEnvironment = "CMROUBAO_DATABASE_PATH"
|
DatabasePathEnvironment = "CMROUBAO_DATABASE_PATH"
|
||||||
AssetDirectoryEnvironment = "CMROUBAO_ASSET_DIR"
|
AssetDirectoryEnvironment = "CMROUBAO_ASSET_DIR"
|
||||||
|
TLSCertificateEnvironment = "CMROUBAO_TLS_CERT_FILE"
|
||||||
|
TLSPrivateKeyEnvironment = "CMROUBAO_TLS_KEY_FILE"
|
||||||
|
|
||||||
defaultHTTPAddress = "127.0.0.1:8080"
|
defaultHTTPAddress = "127.0.0.1:8080"
|
||||||
defaultDatabasePath = "var/cmroubao.db"
|
defaultDatabasePath = "var/cmroubao.db"
|
||||||
@@ -25,6 +27,8 @@ type Config struct {
|
|||||||
HTTPAddress string
|
HTTPAddress string
|
||||||
DatabasePath string
|
DatabasePath string
|
||||||
AssetDirectory string
|
AssetDirectory string
|
||||||
|
TLSCertificate string
|
||||||
|
TLSPrivateKey string
|
||||||
ReadHeaderTimeout time.Duration
|
ReadHeaderTimeout time.Duration
|
||||||
ReadTimeout time.Duration
|
ReadTimeout time.Duration
|
||||||
WriteTimeout time.Duration
|
WriteTimeout time.Duration
|
||||||
@@ -68,11 +72,39 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
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{
|
return Config{
|
||||||
HTTPAddress: httpAddress,
|
HTTPAddress: httpAddress,
|
||||||
DatabasePath: filepath.Clean(databasePath),
|
DatabasePath: filepath.Clean(databasePath),
|
||||||
AssetDirectory: assetDirectory,
|
AssetDirectory: assetDirectory,
|
||||||
|
TLSCertificate: cleanOptionalPath(tlsCertificate),
|
||||||
|
TLSPrivateKey: cleanOptionalPath(tlsPrivateKey),
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
@@ -82,6 +114,43 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
|||||||
}, nil
|
}, 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) {
|
func validatedAssetDirectory(path string) (string, error) {
|
||||||
if strings.ContainsRune(path, '\x00') {
|
if strings.ContainsRune(path, '\x00') {
|
||||||
return "", errors.New(
|
return "", errors.New(
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
|||||||
HTTPAddressEnvironment: "192.0.2.10:9090",
|
HTTPAddressEnvironment: "192.0.2.10:9090",
|
||||||
DatabasePathEnvironment: "tmp/test.db",
|
DatabasePathEnvironment: "tmp/test.db",
|
||||||
AssetDirectoryEnvironment: "tmp/assets",
|
AssetDirectoryEnvironment: "tmp/assets",
|
||||||
|
TLSCertificateEnvironment: "tmp/server.crt",
|
||||||
|
TLSPrivateKeyEnvironment: "tmp/server.key",
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := Load(mapEnvironment(values))
|
cfg, err := Load(mapEnvironment(values))
|
||||||
@@ -55,6 +57,14 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
|||||||
if cfg.AssetDirectory != filepath.Clean(values[AssetDirectoryEnvironment]) {
|
if cfg.AssetDirectory != filepath.Clean(values[AssetDirectoryEnvironment]) {
|
||||||
t.Fatalf("AssetDirectory = %q", cfg.AssetDirectory)
|
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) {
|
func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
|
||||||
@@ -116,6 +126,25 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
|
|||||||
AssetDirectoryEnvironment: ".",
|
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 {
|
for _, test := range tests {
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserRoleAdmin UserRole = "ADMIN"
|
||||||
|
UserRoleBuyer UserRole = "BUYER"
|
||||||
|
|
||||||
|
MaxUsernameBytes = 128
|
||||||
|
MaxDeviceNameBytes = 128
|
||||||
|
MaxVersionBytes = 128
|
||||||
|
MinPasswordBytes = 12
|
||||||
|
MaxPasswordBytes = 72
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
PasswordHash string
|
||||||
|
Role UserRole
|
||||||
|
IsActive bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Device struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
TokenHash string
|
||||||
|
BoundUserID *string
|
||||||
|
AppVersion *string
|
||||||
|
AndroidVersion *string
|
||||||
|
PDDVersion *string
|
||||||
|
LastSeenAt *time.Time
|
||||||
|
IsEnabled bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminSession struct {
|
||||||
|
ID string
|
||||||
|
TokenHash string
|
||||||
|
UserID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccessToken struct {
|
||||||
|
ID string
|
||||||
|
TokenHash string
|
||||||
|
UserID string
|
||||||
|
DeviceID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthPrincipal struct {
|
||||||
|
UserID string
|
||||||
|
Username string
|
||||||
|
Role UserRole
|
||||||
|
SessionID string
|
||||||
|
DeviceID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthValidationError struct {
|
||||||
|
Fields map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AuthValidationError) Error() string {
|
||||||
|
return "authentication input validation failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeUsername(value string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateUserInput(
|
||||||
|
username string,
|
||||||
|
password string,
|
||||||
|
role UserRole,
|
||||||
|
) error {
|
||||||
|
fields := make(map[string]string)
|
||||||
|
normalized := NormalizeUsername(username)
|
||||||
|
switch {
|
||||||
|
case normalized == "":
|
||||||
|
fields["username"] = "required"
|
||||||
|
case !utf8.ValidString(normalized):
|
||||||
|
fields["username"] = "must be valid UTF-8"
|
||||||
|
case len([]byte(normalized)) > MaxUsernameBytes:
|
||||||
|
fields["username"] = fmt.Sprintf(
|
||||||
|
"must not exceed %d UTF-8 bytes",
|
||||||
|
MaxUsernameBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case password == "":
|
||||||
|
fields["password"] = "required"
|
||||||
|
case !utf8.ValidString(password):
|
||||||
|
fields["password"] = "must be valid UTF-8"
|
||||||
|
case len([]byte(password)) < MinPasswordBytes:
|
||||||
|
fields["password"] = fmt.Sprintf(
|
||||||
|
"must be at least %d UTF-8 bytes",
|
||||||
|
MinPasswordBytes,
|
||||||
|
)
|
||||||
|
case len([]byte(password)) > MaxPasswordBytes:
|
||||||
|
fields["password"] = fmt.Sprintf(
|
||||||
|
"must not exceed %d UTF-8 bytes",
|
||||||
|
MaxPasswordBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if role != UserRoleAdmin && role != UserRoleBuyer {
|
||||||
|
fields["role"] = "must be ADMIN or BUYER"
|
||||||
|
}
|
||||||
|
if len(fields) > 0 {
|
||||||
|
return &AuthValidationError{Fields: fields}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDeviceName(name string) error {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
switch {
|
||||||
|
case name == "":
|
||||||
|
return errors.New("device name is required")
|
||||||
|
case !utf8.ValidString(name):
|
||||||
|
return errors.New("device name must be valid UTF-8")
|
||||||
|
case len([]byte(name)) > MaxDeviceNameBytes:
|
||||||
|
return fmt.Errorf(
|
||||||
|
"device name must not exceed %d UTF-8 bytes",
|
||||||
|
MaxDeviceNameBytes,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeUsernameAndValidateUserInput(t *testing.T) {
|
||||||
|
if got := NormalizeUsername(" Admin.User "); got != "admin.user" {
|
||||||
|
t.Fatalf("NormalizeUsername() = %q", got)
|
||||||
|
}
|
||||||
|
if err := ValidateUserInput(
|
||||||
|
"admin.user",
|
||||||
|
"strong-password",
|
||||||
|
UserRoleAdmin,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("ValidateUserInput(valid) error = %v", err)
|
||||||
|
}
|
||||||
|
for name, input := range map[string]struct {
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
role UserRole
|
||||||
|
}{
|
||||||
|
"blank username": {" ", "password", UserRoleAdmin},
|
||||||
|
"blank password": {"admin", "", UserRoleAdmin},
|
||||||
|
"short password": {"admin", "short", UserRoleAdmin},
|
||||||
|
"invalid password UTF-8": {
|
||||||
|
"admin",
|
||||||
|
string([]byte{0xff, 0xfe, 0xfd}),
|
||||||
|
UserRoleAdmin,
|
||||||
|
},
|
||||||
|
"oversized password": {
|
||||||
|
"admin",
|
||||||
|
strings.Repeat("x", MaxPasswordBytes+1),
|
||||||
|
UserRoleAdmin,
|
||||||
|
},
|
||||||
|
"invalid role": {"admin", "password", "AUDITOR"},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if err := ValidateUserInput(
|
||||||
|
input.username,
|
||||||
|
input.password,
|
||||||
|
input.role,
|
||||||
|
); err == nil {
|
||||||
|
t.Fatal("ValidateUserInput() error = nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDeviceName(t *testing.T) {
|
||||||
|
if err := ValidateDeviceName("Test device"); err != nil {
|
||||||
|
t.Fatalf("ValidateDeviceName(valid) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidateDeviceName(" "); err == nil {
|
||||||
|
t.Fatal("ValidateDeviceName(blank) error = nil")
|
||||||
|
}
|
||||||
|
if err := ValidateDeviceName(
|
||||||
|
strings.Repeat("x", MaxDeviceNameBytes+1),
|
||||||
|
); err == nil {
|
||||||
|
t.Fatal("ValidateDeviceName(oversized) error = nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,30 +33,32 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PurchaseTask struct {
|
type PurchaseTask struct {
|
||||||
ID string
|
ID string
|
||||||
CreatorSubject string
|
CreatorSubject string
|
||||||
SourceRef *string
|
CreatedByUserID *string
|
||||||
Title string
|
SourceRef *string
|
||||||
Description string
|
Title string
|
||||||
SKU string
|
Description string
|
||||||
ImageAssetID string
|
SKU string
|
||||||
Quantity int
|
ImageAssetID string
|
||||||
MaxBudgetCents *int64
|
Quantity int
|
||||||
Currency string
|
MaxBudgetCents *int64
|
||||||
Status TaskStatus
|
Currency string
|
||||||
Version int64
|
Status TaskStatus
|
||||||
CancelReason *string
|
Version int64
|
||||||
CanceledAt *time.Time
|
CancelReason *string
|
||||||
CreatedAt time.Time
|
CanceledAt *time.Time
|
||||||
UpdatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskEvent struct {
|
type TaskEvent struct {
|
||||||
ID string
|
ID string
|
||||||
TaskID string
|
TaskID string
|
||||||
Type string
|
ActorUserID *string
|
||||||
Message string
|
Type string
|
||||||
OccurredAt time.Time
|
Message string
|
||||||
|
OccurredAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskDetail struct {
|
type TaskDetail struct {
|
||||||
|
|||||||
@@ -27,12 +27,13 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Up() error = %v", err)
|
t.Fatalf("Up() error = %v", err)
|
||||||
}
|
}
|
||||||
if applied != 2 {
|
if applied != 3 {
|
||||||
t.Fatalf("Up() applied = %d, want 2", applied)
|
t.Fatalf("Up() applied = %d, want 3", applied)
|
||||||
}
|
}
|
||||||
assertStatuses(t, runner, map[int64]bool{
|
assertStatuses(t, runner, map[int64]bool{
|
||||||
1: true,
|
1: true,
|
||||||
2: true,
|
2: true,
|
||||||
|
3: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
applied, err = runner.Up(context.Background())
|
applied, err = runner.Up(context.Background())
|
||||||
@@ -48,7 +49,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assertStatuses(t, runner, map[int64]bool{
|
assertStatuses(t, runner, map[int64]bool{
|
||||||
1: true,
|
1: true,
|
||||||
2: false,
|
2: true,
|
||||||
|
3: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
applied, err = runner.Up(context.Background())
|
applied, err = runner.Up(context.Background())
|
||||||
@@ -61,6 +63,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
assertStatuses(t, runner, map[int64]bool{
|
assertStatuses(t, runner, map[int64]bool{
|
||||||
1: true,
|
1: true,
|
||||||
2: true,
|
2: true,
|
||||||
|
3: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ func scanAsset(scanner rowScanner) (domain.Asset, error) {
|
|||||||
|
|
||||||
func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
|
func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
|
||||||
var task domain.PurchaseTask
|
var task domain.PurchaseTask
|
||||||
|
var createdByUserID sql.NullString
|
||||||
var sourceRef sql.NullString
|
var sourceRef sql.NullString
|
||||||
var maxBudget sql.NullInt64
|
var maxBudget sql.NullInt64
|
||||||
var cancelReason sql.NullString
|
var cancelReason sql.NullString
|
||||||
@@ -58,6 +59,7 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
|
|||||||
err := scanner.Scan(
|
err := scanner.Scan(
|
||||||
&task.ID,
|
&task.ID,
|
||||||
&task.CreatorSubject,
|
&task.CreatorSubject,
|
||||||
|
&createdByUserID,
|
||||||
&sourceRef,
|
&sourceRef,
|
||||||
&task.Title,
|
&task.Title,
|
||||||
&task.Description,
|
&task.Description,
|
||||||
@@ -79,6 +81,9 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
|
|||||||
if sourceRef.Valid {
|
if sourceRef.Valid {
|
||||||
task.SourceRef = &sourceRef.String
|
task.SourceRef = &sourceRef.String
|
||||||
}
|
}
|
||||||
|
if createdByUserID.Valid {
|
||||||
|
task.CreatedByUserID = &createdByUserID.String
|
||||||
|
}
|
||||||
if maxBudget.Valid {
|
if maxBudget.Valid {
|
||||||
task.MaxBudgetCents = &maxBudget.Int64
|
task.MaxBudgetCents = &maxBudget.Int64
|
||||||
}
|
}
|
||||||
@@ -137,7 +142,7 @@ func getTaskByID(
|
|||||||
task, err := scanTask(queryer.QueryRowContext(
|
task, err := scanTask(queryer.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT
|
`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,
|
image_asset_id, quantity, max_budget_cents, currency, status,
|
||||||
version, cancel_reason, canceled_at, created_at, updated_at
|
version, cancel_reason, canceled_at, created_at, updated_at
|
||||||
FROM purchase_tasks
|
FROM purchase_tasks
|
||||||
|
|||||||
@@ -321,6 +321,18 @@ func TestStoreEnforcesAssetOwnershipSourceReferenceAndStableCursor(
|
|||||||
func openStore(t *testing.T) *repository.Store {
|
func openStore(t *testing.T) *repository.Store {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db := openDatabase(t)
|
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)
|
store, err := repository.New(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("repository.New() error = %v", err)
|
t.Fatalf("repository.New() error = %v", err)
|
||||||
@@ -368,21 +380,23 @@ func testTask(
|
|||||||
createdAt time.Time,
|
createdAt time.Time,
|
||||||
) domain.PurchaseTask {
|
) domain.PurchaseTask {
|
||||||
budget := int64(2000 + index)
|
budget := int64(2000 + index)
|
||||||
|
actorUserID := uuid(999)
|
||||||
return domain.PurchaseTask{
|
return domain.PurchaseTask{
|
||||||
ID: uuid(index),
|
ID: uuid(index),
|
||||||
CreatorSubject: "local-admin",
|
CreatorSubject: "local-admin",
|
||||||
SourceRef: &source,
|
CreatedByUserID: &actorUserID,
|
||||||
Title: "title " + string(rune('0'+index)),
|
SourceRef: &source,
|
||||||
Description: "description",
|
Title: "title " + string(rune('0'+index)),
|
||||||
SKU: "SKU-" + string(rune('0'+index)),
|
Description: "description",
|
||||||
ImageAssetID: assetID,
|
SKU: "SKU-" + string(rune('0'+index)),
|
||||||
Quantity: index,
|
ImageAssetID: assetID,
|
||||||
MaxBudgetCents: &budget,
|
Quantity: index,
|
||||||
Currency: domain.CurrencyCNY,
|
MaxBudgetCents: &budget,
|
||||||
Status: domain.TaskStatusPending,
|
Currency: domain.CurrencyCNY,
|
||||||
Version: 1,
|
Status: domain.TaskStatusPending,
|
||||||
CreatedAt: createdAt,
|
Version: 1,
|
||||||
UpdatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
|
UpdatedAt: createdAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,12 +82,13 @@ func (s *Store) CreateTaskIdempotent(
|
|||||||
_, err = tx.ExecContext(
|
_, err = tx.ExecContext(
|
||||||
ctx,
|
ctx,
|
||||||
`INSERT INTO purchase_tasks (
|
`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,
|
image_asset_id, quantity, max_budget_cents, currency, status,
|
||||||
version, cancel_reason, canceled_at, created_at, updated_at
|
version, cancel_reason, canceled_at, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`,
|
||||||
candidate.ID,
|
candidate.ID,
|
||||||
candidate.CreatorSubject,
|
candidate.CreatorSubject,
|
||||||
|
nullableString(candidate.CreatedByUserID),
|
||||||
nullableString(candidate.SourceRef),
|
nullableString(candidate.SourceRef),
|
||||||
candidate.Title,
|
candidate.Title,
|
||||||
candidate.Description,
|
candidate.Description,
|
||||||
@@ -142,7 +143,7 @@ func (s *Store) ListTasks(
|
|||||||
) ([]domain.PurchaseTask, error) {
|
) ([]domain.PurchaseTask, error) {
|
||||||
var query strings.Builder
|
var query strings.Builder
|
||||||
query.WriteString(`SELECT
|
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,
|
image_asset_id, quantity, max_budget_cents, currency, status,
|
||||||
version, cancel_reason, canceled_at, created_at, updated_at
|
version, cancel_reason, canceled_at, created_at, updated_at
|
||||||
FROM purchase_tasks
|
FROM purchase_tasks
|
||||||
@@ -241,7 +242,7 @@ func (s *Store) GetTaskDetail(
|
|||||||
}
|
}
|
||||||
rows, err := tx.QueryContext(
|
rows, err := tx.QueryContext(
|
||||||
ctx,
|
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
|
FROM task_events
|
||||||
WHERE task_id = ?
|
WHERE task_id = ?
|
||||||
ORDER BY occurred_at ASC, id ASC`,
|
ORDER BY occurred_at ASC, id ASC`,
|
||||||
@@ -254,16 +255,21 @@ func (s *Store) GetTaskDetail(
|
|||||||
events := make([]domain.TaskEvent, 0)
|
events := make([]domain.TaskEvent, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var event domain.TaskEvent
|
var event domain.TaskEvent
|
||||||
|
var actorUserID sql.NullString
|
||||||
var occurredAt string
|
var occurredAt string
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&event.ID,
|
&event.ID,
|
||||||
&event.TaskID,
|
&event.TaskID,
|
||||||
|
&actorUserID,
|
||||||
&event.Type,
|
&event.Type,
|
||||||
&event.Message,
|
&event.Message,
|
||||||
&occurredAt,
|
&occurredAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return domain.TaskDetail{}, repositoryFailure(err)
|
return domain.TaskDetail{}, repositoryFailure(err)
|
||||||
}
|
}
|
||||||
|
if actorUserID.Valid {
|
||||||
|
event.ActorUserID = &actorUserID.String
|
||||||
|
}
|
||||||
event.OccurredAt, err = parseTimestamp(occurredAt)
|
event.OccurredAt, err = parseTimestamp(occurredAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.TaskDetail{}, err
|
return domain.TaskDetail{}, err
|
||||||
@@ -357,10 +363,11 @@ func insertTaskEvent(
|
|||||||
_, err := tx.ExecContext(
|
_, err := tx.ExecContext(
|
||||||
ctx,
|
ctx,
|
||||||
`INSERT INTO task_events (
|
`INSERT INTO task_events (
|
||||||
id, task_id, event_type, message, occurred_at
|
id, task_id, actor_user_id, event_type, message, occurred_at
|
||||||
) VALUES (?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
event.ID,
|
event.ID,
|
||||||
event.TaskID,
|
event.TaskID,
|
||||||
|
nullableString(event.ActorUserID),
|
||||||
event.Type,
|
event.Type,
|
||||||
event.Message,
|
event.Message,
|
||||||
formatTimestamp(event.OccurredAt),
|
formatTimestamp(event.OccurredAt),
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cmroubao/backend-api/internal/domain"
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
"cmroubao/backend-api/internal/transport/authcommon"
|
||||||
"cmroubao/backend-api/internal/usecase"
|
"cmroubao/backend-api/internal/usecase"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -209,6 +210,7 @@ func (h *adminHandlers) createTask(ctx *gin.Context) {
|
|||||||
ctx.Request.Context(),
|
ctx.Request.Context(),
|
||||||
usecase.CreateTaskCommand{
|
usecase.CreateTaskCommand{
|
||||||
CreatorSubject: localAdminSubject,
|
CreatorSubject: localAdminSubject,
|
||||||
|
ActorUserID: adminActorUserID(ctx),
|
||||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||||
SourceRef: request.SourceRef,
|
SourceRef: request.SourceRef,
|
||||||
Title: request.Title,
|
Title: request.Title,
|
||||||
@@ -226,6 +228,14 @@ func (h *adminHandlers) createTask(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusCreated, taskSummaryResponse(result.Task))
|
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) {
|
func (h *adminHandlers) listTasks(ctx *gin.Context) {
|
||||||
query := usecase.ListTasksQuery{
|
query := usecase.ListTasksQuery{
|
||||||
CreatorSubject: localAdminSubject,
|
CreatorSubject: localAdminSubject,
|
||||||
@@ -292,10 +302,11 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
|
|||||||
events := make([]gin.H, 0, len(detail.Events))
|
events := make([]gin.H, 0, len(detail.Events))
|
||||||
for _, event := range detail.Events {
|
for _, event := range detail.Events {
|
||||||
events = append(events, gin.H{
|
events = append(events, gin.H{
|
||||||
"id": event.ID,
|
"id": event.ID,
|
||||||
"type": event.Type,
|
"actor_user_id": event.ActorUserID,
|
||||||
"message": event.Message,
|
"type": event.Type,
|
||||||
"occurred_at": formatTime(event.OccurredAt),
|
"message": event.Message,
|
||||||
|
"occurred_at": formatTime(event.OccurredAt),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ctx.Header("Cache-Control", "no-store")
|
ctx.Header("Cache-Control", "no-store")
|
||||||
@@ -355,6 +366,7 @@ func (h *adminHandlers) cancelTask(ctx *gin.Context) {
|
|||||||
ctx.Request.Context(),
|
ctx.Request.Context(),
|
||||||
usecase.CancelTaskCommand{
|
usecase.CancelTaskCommand{
|
||||||
CreatorSubject: localAdminSubject,
|
CreatorSubject: localAdminSubject,
|
||||||
|
ActorUserID: adminActorUserID(ctx),
|
||||||
TaskID: ctx.Param("id"),
|
TaskID: ctx.Param("id"),
|
||||||
Reason: request.Reason,
|
Reason: request.Reason,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"cmroubao/backend-api/internal/platform/assetstore"
|
"cmroubao/backend-api/internal/platform/assetstore"
|
||||||
"cmroubao/backend-api/internal/platform/database"
|
"cmroubao/backend-api/internal/platform/database"
|
||||||
@@ -210,6 +211,32 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
|
|||||||
canceled,
|
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(
|
secondCancel := performAdminRequest(
|
||||||
t,
|
t,
|
||||||
@@ -229,7 +256,7 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminRoutesRejectNonLoopbackRequests(t *testing.T) {
|
func TestAdminRoutesRejectRequestsWithoutAdminSession(t *testing.T) {
|
||||||
router := newAdminIntegrationRouter(t)
|
router := newAdminIntegrationRouter(t)
|
||||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
|
||||||
request.RemoteAddr = "192.0.2.10:3210"
|
request.RemoteAddr = "192.0.2.10:3210"
|
||||||
@@ -237,7 +264,7 @@ func TestAdminRoutesRejectNonLoopbackRequests(t *testing.T) {
|
|||||||
|
|
||||||
router.ServeHTTP(response, request)
|
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())
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
var body map[string]any
|
var body map[string]any
|
||||||
@@ -275,7 +302,7 @@ func TestAdminAssetUploadRequiresIdempotencyKey(t *testing.T) {
|
|||||||
|
|
||||||
type emptyAdminWeb struct{}
|
type emptyAdminWeb struct{}
|
||||||
|
|
||||||
func (emptyAdminWeb) Register(gin.IRoutes) {}
|
func (emptyAdminWeb) RegisterProtected(gin.IRoutes) {}
|
||||||
|
|
||||||
func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -292,6 +319,18 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
|||||||
if _, err := runner.Up(ctx); err != nil {
|
if _, err := runner.Up(ctx); err != nil {
|
||||||
t.Fatalf("migration.Up() error = %v", err)
|
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)
|
repositories, err := repository.New(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("repository.New() error = %v", err)
|
t.Fatalf("repository.New() error = %v", err)
|
||||||
@@ -318,9 +357,11 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
|||||||
t.Fatalf("NewAdminRouteRegistrar() error = %v", err)
|
t.Fatalf("NewAdminRouteRegistrar() error = %v", err)
|
||||||
}
|
}
|
||||||
router, err := NewRouter(RouterDependencies{
|
router, err := NewRouter(RouterDependencies{
|
||||||
Database: db,
|
Database: db,
|
||||||
RegisterAdminRoutes: registrar,
|
RegisterPublicRoutes: discardRoutes,
|
||||||
LogEvent: discardEvent,
|
RegisterAdminRoutes: registrar,
|
||||||
|
AdminSessions: allowAdminAuthenticator{},
|
||||||
|
LogEvent: discardEvent,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewRouter() error = %v", err)
|
t.Fatalf("NewRouter() error = %v", err)
|
||||||
@@ -375,7 +416,18 @@ func performAdminRequest(
|
|||||||
) *httptest.ResponseRecorder {
|
) *httptest.ResponseRecorder {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
request := httptest.NewRequest(method, target, body)
|
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 != "" {
|
if contentType != "" {
|
||||||
request.Header.Set("Content-Type", contentType)
|
request.Header.Set("Content-Type", contentType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -22,13 +22,15 @@ type EventLogger func(string)
|
|||||||
type RouteRegistrar func(gin.IRoutes) error
|
type RouteRegistrar func(gin.IRoutes) error
|
||||||
|
|
||||||
type RouterDependencies struct {
|
type RouterDependencies struct {
|
||||||
Database DatabasePinger
|
Database DatabasePinger
|
||||||
RegisterAdminRoutes RouteRegistrar
|
RegisterPublicRoutes RouteRegistrar
|
||||||
LogEvent EventLogger
|
RegisterAdminRoutes RouteRegistrar
|
||||||
|
AdminSessions AdminAuthenticator
|
||||||
|
LogEvent EventLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminWeb interface {
|
type AdminWeb interface {
|
||||||
Register(gin.IRoutes)
|
RegisterProtected(gin.IRoutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAdminRouteRegistrar(
|
func NewAdminRouteRegistrar(
|
||||||
@@ -45,7 +47,7 @@ func NewAdminRouteRegistrar(
|
|||||||
if err := registerAdminAPI(routes, services); err != nil {
|
if err := registerAdminAPI(routes, services); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
web.Register(routes)
|
web.RegisterProtected(routes)
|
||||||
return nil
|
return nil
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,12 @@ func NewRouter(dependencies RouterDependencies) (http.Handler, error) {
|
|||||||
if dependencies.RegisterAdminRoutes == nil {
|
if dependencies.RegisterAdminRoutes == nil {
|
||||||
return nil, errors.New("admin route registrar is required")
|
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 {
|
if dependencies.LogEvent == nil {
|
||||||
return nil, errors.New("event logger is required")
|
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))
|
router.GET("/healthz", healthHandler(dependencies.Database))
|
||||||
|
if err := dependencies.RegisterPublicRoutes(router); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
adminRoutes := router.Group("")
|
adminRoutes := router.Group("")
|
||||||
adminRoutes.Use(loopbackAdminOnly())
|
adminRoutes.Use(requireAdminSession(dependencies.AdminSessions))
|
||||||
if err := dependencies.RegisterAdminRoutes(adminRoutes); err != nil {
|
if err := dependencies.RegisterAdminRoutes(adminRoutes); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -114,9 +117,11 @@ func TestSafeRecoveryReturnsStableErrorWithoutLoggingRequestHeaders(t *testing.T
|
|||||||
|
|
||||||
func TestRouterRequiresDependencies(t *testing.T) {
|
func TestRouterRequiresDependencies(t *testing.T) {
|
||||||
valid := RouterDependencies{
|
valid := RouterDependencies{
|
||||||
Database: fakePinger{},
|
Database: fakePinger{},
|
||||||
RegisterAdminRoutes: discardRoutes,
|
RegisterPublicRoutes: discardRoutes,
|
||||||
LogEvent: discardEvent,
|
RegisterAdminRoutes: discardRoutes,
|
||||||
|
AdminSessions: allowAdminAuthenticator{},
|
||||||
|
LogEvent: discardEvent,
|
||||||
}
|
}
|
||||||
missingDatabase := valid
|
missingDatabase := valid
|
||||||
missingDatabase.Database = nil
|
missingDatabase.Database = nil
|
||||||
@@ -128,6 +133,16 @@ func TestRouterRequiresDependencies(t *testing.T) {
|
|||||||
if _, err := NewRouter(missingRoutes); err == nil {
|
if _, err := NewRouter(missingRoutes); err == nil {
|
||||||
t.Fatal("NewRouter(nil routes) error = 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 := valid
|
||||||
missingLogger.LogEvent = nil
|
missingLogger.LogEvent = nil
|
||||||
if _, err := NewRouter(missingLogger); err == nil {
|
if _, err := NewRouter(missingLogger); err == nil {
|
||||||
@@ -140,9 +155,11 @@ func newTestRouter(
|
|||||||
logEvent EventLogger,
|
logEvent EventLogger,
|
||||||
) (http.Handler, error) {
|
) (http.Handler, error) {
|
||||||
return NewRouter(RouterDependencies{
|
return NewRouter(RouterDependencies{
|
||||||
Database: database,
|
Database: database,
|
||||||
RegisterAdminRoutes: discardRoutes,
|
RegisterPublicRoutes: discardRoutes,
|
||||||
LogEvent: logEvent,
|
RegisterAdminRoutes: discardRoutes,
|
||||||
|
AdminSessions: allowAdminAuthenticator{},
|
||||||
|
LogEvent: logEvent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +240,21 @@ func discardEvent(string) {}
|
|||||||
|
|
||||||
func discardRoutes(gin.IRoutes) error { return nil }
|
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(
|
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}$`,
|
`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
@@ -14,6 +13,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/transport/authcommon"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,8 +25,8 @@ const (
|
|||||||
maxTitleBytes = 2048
|
maxTitleBytes = 2048
|
||||||
maxSKUBytes = 512
|
maxSKUBytes = 512
|
||||||
maxDescriptionBytes = 8192
|
maxDescriptionBytes = 8192
|
||||||
csrfCookieName = "cmroubao_admin_csrf"
|
csrfCookieName = authcommon.CSRFCookieName
|
||||||
csrfFormField = "csrf_token"
|
csrfFormField = authcommon.CSRFFormField
|
||||||
formContentType = "text/html; charset=utf-8"
|
formContentType = "text/html; charset=utf-8"
|
||||||
cssContentType = "text/css; charset=utf-8"
|
cssContentType = "text/css; charset=utf-8"
|
||||||
javascriptContentType = "text/javascript; 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) {
|
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.css", SecurityHeaders(), h.Stylesheet)
|
||||||
routes.GET("/static/admin.js", SecurityHeaders(), h.Script)
|
routes.GET("/static/admin.js", SecurityHeaders(), h.Script)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
||||||
routes.GET("/tasks", SecurityHeaders(), h.ListTasks)
|
routes.GET("/tasks", SecurityHeaders(), h.ListTasks)
|
||||||
routes.GET("/tasks/new", SecurityHeaders(), h.NewTask)
|
routes.GET("/tasks/new", SecurityHeaders(), h.NewTask)
|
||||||
routes.POST("/tasks", SecurityHeaders(), h.CreateTask)
|
routes.POST("/tasks", SecurityHeaders(), h.CreateTask)
|
||||||
@@ -97,6 +106,11 @@ func (h *Handler) serveStatic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ListTasks(ctx *gin.Context) {
|
func (h *Handler) ListTasks(ctx *gin.Context) {
|
||||||
|
token, err := csrfToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||||
|
return
|
||||||
|
}
|
||||||
input := ListTasksInput{
|
input := ListTasksInput{
|
||||||
Query: strings.TrimSpace(ctx.Query("q")),
|
Query: strings.TrimSpace(ctx.Query("q")),
|
||||||
Status: strings.TrimSpace(ctx.Query("status")),
|
Status: strings.TrimSpace(ctx.Query("status")),
|
||||||
@@ -127,6 +141,7 @@ func (h *Handler) ListTasks(ctx *gin.Context) {
|
|||||||
Page: pageView{
|
Page: pageView{
|
||||||
Title: "采购任务",
|
Title: "采购任务",
|
||||||
TasksCurrent: true,
|
TasksCurrent: true,
|
||||||
|
CSRFToken: token,
|
||||||
},
|
},
|
||||||
Query: input.Query,
|
Query: input.Query,
|
||||||
Status: input.Status,
|
Status: input.Status,
|
||||||
@@ -138,7 +153,7 @@ func (h *Handler) ListTasks(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) NewTask(ctx *gin.Context) {
|
func (h *Handler) NewTask(ctx *gin.Context) {
|
||||||
token, err := h.csrfToken(ctx)
|
token, err := csrfToken(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||||
return
|
return
|
||||||
@@ -167,7 +182,7 @@ func (h *Handler) CreateTask(ctx *gin.Context) {
|
|||||||
if ctx.Request.MultipartForm != nil {
|
if ctx.Request.MultipartForm != nil {
|
||||||
defer ctx.Request.MultipartForm.RemoveAll()
|
defer ctx.Request.MultipartForm.RemoveAll()
|
||||||
}
|
}
|
||||||
if !h.validCSRF(ctx) {
|
if !validCSRF(ctx) {
|
||||||
h.renderError(
|
h.renderError(
|
||||||
ctx,
|
ctx,
|
||||||
http.StatusForbidden,
|
http.StatusForbidden,
|
||||||
@@ -239,7 +254,7 @@ func (h *Handler) TaskDetail(ctx *gin.Context) {
|
|||||||
h.renderServiceError(ctx, err, "无法加载任务详情,请稍后重试。")
|
h.renderServiceError(ctx, err, "无法加载任务详情,请稍后重试。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, tokenErr := h.csrfToken(ctx)
|
token, tokenErr := csrfToken(ctx)
|
||||||
if tokenErr != nil {
|
if tokenErr != nil {
|
||||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||||
return
|
return
|
||||||
@@ -253,6 +268,7 @@ func (h *Handler) TaskDetail(ctx *gin.Context) {
|
|||||||
Page: pageView{
|
Page: pageView{
|
||||||
Title: "任务详情",
|
Title: "任务详情",
|
||||||
TasksCurrent: true,
|
TasksCurrent: true,
|
||||||
|
CSRFToken: token,
|
||||||
},
|
},
|
||||||
Task: taskDetailViewFrom(task),
|
Task: taskDetailViewFrom(task),
|
||||||
CSRFToken: token,
|
CSRFToken: token,
|
||||||
@@ -263,7 +279,7 @@ func (h *Handler) TaskDetail(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) CancelTask(ctx *gin.Context) {
|
func (h *Handler) CancelTask(ctx *gin.Context) {
|
||||||
if !h.validCSRF(ctx) {
|
if !validCSRF(ctx) {
|
||||||
h.renderError(
|
h.renderError(
|
||||||
ctx,
|
ctx,
|
||||||
http.StatusForbidden,
|
http.StatusForbidden,
|
||||||
@@ -328,7 +344,7 @@ func (h *Handler) uploadReference(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) createPageFromRequest(ctx *gin.Context) newTaskPageView {
|
func (h *Handler) createPageFromRequest(ctx *gin.Context) newTaskPageView {
|
||||||
token, err := h.csrfToken(ctx)
|
token, err := csrfToken(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
token = ""
|
token = ""
|
||||||
}
|
}
|
||||||
@@ -338,6 +354,7 @@ func (h *Handler) createPageFromRequest(ctx *gin.Context) newTaskPageView {
|
|||||||
Page: pageView{
|
Page: pageView{
|
||||||
Title: "新建采购任务",
|
Title: "新建采购任务",
|
||||||
NewCurrent: true,
|
NewCurrent: true,
|
||||||
|
CSRFToken: token,
|
||||||
},
|
},
|
||||||
CSRFToken: token,
|
CSRFToken: token,
|
||||||
UploadKey: strings.TrimSpace(ctx.PostForm("upload_key")),
|
UploadKey: strings.TrimSpace(ctx.PostForm("upload_key")),
|
||||||
@@ -438,19 +455,35 @@ func validBudget(value string) bool {
|
|||||||
return units > 0 || fraction > 0
|
return units > 0 || fraction > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) csrfToken(ctx *gin.Context) (string, error) {
|
func csrfToken(ctx *gin.Context) (string, error) {
|
||||||
if cookie, err := ctx.Request.Cookie(csrfCookieName); err == nil &&
|
cookies := csrfCookies(ctx.Request)
|
||||||
validToken(cookie.Value) {
|
for index := len(cookies) - 1; index >= 0; index-- {
|
||||||
return cookie.Value, nil
|
if validToken(cookies[index].Value) {
|
||||||
|
return cookies[index].Value, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return rotateCSRFToken(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rotateCSRFToken(ctx *gin.Context) (string, error) {
|
||||||
token, err := newToken()
|
token, err := newToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
http.SetCookie(ctx.Writer, &http.Cookie{
|
http.SetCookie(ctx.Writer, &http.Cookie{
|
||||||
Name: csrfCookieName,
|
Name: authcommon.CSRFCookieName,
|
||||||
Value: token,
|
Value: "",
|
||||||
Path: "/tasks",
|
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,
|
MaxAge: 3600,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: ctx.Request.TLS != nil,
|
Secure: ctx.Request.TLS != nil,
|
||||||
@@ -459,24 +492,28 @@ func (h *Handler) csrfToken(ctx *gin.Context) (string, error) {
|
|||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) validCSRF(ctx *gin.Context) bool {
|
func validCSRF(ctx *gin.Context) bool {
|
||||||
cookie, err := ctx.Request.Cookie(csrfCookieName)
|
presented := ctx.PostForm(authcommon.CSRFFormField)
|
||||||
if err != nil || !validToken(cookie.Value) {
|
for _, cookie := range csrfCookies(ctx.Request) {
|
||||||
return false
|
if authcommon.ValidCSRFPair(cookie.Value, presented) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
formToken := strings.TrimSpace(ctx.PostForm(csrfFormField))
|
return false
|
||||||
if len(cookie.Value) != len(formToken) {
|
}
|
||||||
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(
|
return result
|
||||||
[]byte(cookie.Value),
|
|
||||||
[]byte(formToken),
|
|
||||||
) == 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validToken(value string) bool {
|
func validToken(value string) bool {
|
||||||
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
return authcommon.ValidOpaqueValue(value)
|
||||||
return err == nil && len(decoded) == 32
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newToken() (string, error) {
|
func newToken() (string, error) {
|
||||||
@@ -500,6 +537,7 @@ func newTaskPage(token string) (newTaskPageView, error) {
|
|||||||
Page: pageView{
|
Page: pageView{
|
||||||
Title: "新建采购任务",
|
Title: "新建采购任务",
|
||||||
NewCurrent: true,
|
NewCurrent: true,
|
||||||
|
CSRFToken: token,
|
||||||
},
|
},
|
||||||
CSRFToken: token,
|
CSRFToken: token,
|
||||||
UploadKey: uploadKey,
|
UploadKey: uploadKey,
|
||||||
@@ -561,9 +599,11 @@ func (h *Handler) renderError(
|
|||||||
title string,
|
title string,
|
||||||
message string,
|
message string,
|
||||||
) {
|
) {
|
||||||
|
token, _ := csrfToken(ctx)
|
||||||
h.render(ctx, status, "error", errorPage{
|
h.render(ctx, status, "error", errorPage{
|
||||||
Page: pageView{
|
Page: pageView{
|
||||||
Title: title,
|
Title: title,
|
||||||
|
CSRFToken: token,
|
||||||
},
|
},
|
||||||
Heading: title,
|
Heading: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
@@ -622,6 +662,7 @@ type pageView struct {
|
|||||||
Title string
|
Title string
|
||||||
TasksCurrent bool
|
TasksCurrent bool
|
||||||
NewCurrent bool
|
NewCurrent bool
|
||||||
|
CSRFToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
type statusOption struct {
|
type statusOption struct {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) {
|
|||||||
cookie := csrfCookie(t, response)
|
cookie := csrfCookie(t, response)
|
||||||
if !cookie.HttpOnly ||
|
if !cookie.HttpOnly ||
|
||||||
cookie.SameSite != http.SameSiteStrictMode ||
|
cookie.SameSite != http.SameSiteStrictMode ||
|
||||||
cookie.Path != "/tasks" {
|
cookie.Path != "/" {
|
||||||
t.Fatalf("CSRF cookie = %+v", cookie)
|
t.Fatalf("CSRF cookie = %+v", cookie)
|
||||||
}
|
}
|
||||||
body := response.Body.String()
|
body := response.Body.String()
|
||||||
@@ -128,6 +128,7 @@ func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) {
|
|||||||
`name="quantity"`,
|
`name="quantity"`,
|
||||||
`name="max_budget"`,
|
`name="max_budget"`,
|
||||||
`name="image"`,
|
`name="image"`,
|
||||||
|
`action="/logout"`,
|
||||||
"最高总预算",
|
"最高总预算",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, required) {
|
if !strings.Contains(body, required) {
|
||||||
@@ -141,6 +142,39 @@ func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) {
|
|||||||
assertSecurityHeaders(t, response)
|
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) {
|
func TestCreateTaskRejectsCSRFBeforeCallingService(t *testing.T) {
|
||||||
service := &fakeService{}
|
service := &fakeService{}
|
||||||
router := newTestRouter(t, service)
|
router := newTestRouter(t, service)
|
||||||
@@ -622,7 +656,9 @@ func csrfCookie(
|
|||||||
) *http.Cookie {
|
) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, cookie := range response.Result().Cookies() {
|
for _, cookie := range response.Result().Cookies() {
|
||||||
if cookie.Name == csrfCookieName {
|
if cookie.Name == csrfCookieName &&
|
||||||
|
cookie.Path == "/" &&
|
||||||
|
cookie.Value != "" {
|
||||||
return cookie
|
return cookie
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,27 @@ a {
|
|||||||
font-weight: 700;
|
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 {
|
.page {
|
||||||
width: min(calc(100% - 32px), 1180px);
|
width: min(calc(100% - 32px), 1180px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -705,6 +726,65 @@ tbody tr:last-child td {
|
|||||||
cursor: wait;
|
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) {
|
@media (max-width: 760px) {
|
||||||
.page {
|
.page {
|
||||||
width: min(calc(100% - 20px), 1180px);
|
width: min(calc(100% - 20px), 1180px);
|
||||||
@@ -797,6 +877,11 @@ tbody tr:last-child td {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
padding-inline: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.title-row,
|
.title-row,
|
||||||
.detail-title {
|
.detail-title {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@@ -9,6 +9,19 @@
|
|||||||
if (summary) summary.focus();
|
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) => {
|
document.querySelectorAll("[data-loading-form]").forEach((form) => {
|
||||||
form.addEventListener("submit", () => {
|
form.addEventListener("submit", () => {
|
||||||
form.setAttribute("aria-busy", "true");
|
form.setAttribute("aria-busy", "true");
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{{define "login"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||||
|
{{template "document-head" .}}
|
||||||
|
</head>
|
||||||
|
<body class="login-body">
|
||||||
|
<main id="main-content" class="login-page">
|
||||||
|
<a class="login-brand" href="/login" aria-label="采购任务管理登录">
|
||||||
|
<span class="brand-mark" aria-hidden="true">采</span>
|
||||||
|
<span>采购任务管理</span>
|
||||||
|
</a>
|
||||||
|
<section class="login-panel" aria-labelledby="login-title">
|
||||||
|
<h1 id="login-title">管理端登录</h1>
|
||||||
|
<p class="subtitle">使用采购管理员账号继续</p>
|
||||||
|
|
||||||
|
{{if .Message}}
|
||||||
|
<div class="notice notice-error" role="alert" tabindex="-1" data-error-summary>
|
||||||
|
{{.Message}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/login" novalidate data-loading-form>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<input type="hidden" name="next" value="{{.Next}}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="username">账号</label>
|
||||||
|
<input id="username" name="username" value="{{.Username}}"
|
||||||
|
autocomplete="username" autofocus required
|
||||||
|
aria-describedby="username-error"
|
||||||
|
{{if .UsernameError}}aria-invalid="true" data-error-field{{end}}>
|
||||||
|
<p id="username-error" class="field-error">{{.UsernameError}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="login-password">密码</label>
|
||||||
|
<div class="password-field">
|
||||||
|
<input id="login-password" name="password" type="password"
|
||||||
|
autocomplete="current-password" required
|
||||||
|
aria-describedby="password-error"
|
||||||
|
{{if .PasswordError}}aria-invalid="true" data-error-field{{end}}>
|
||||||
|
<button class="button password-toggle" type="button"
|
||||||
|
aria-controls="login-password" aria-pressed="false"
|
||||||
|
data-password-toggle>显示</button>
|
||||||
|
</div>
|
||||||
|
<p id="password-error" class="field-error">{{.PasswordError}}</p>
|
||||||
|
</div>
|
||||||
|
<button class="button primary login-submit" type="submit"
|
||||||
|
data-loading-label="正在登录…">登录</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -18,5 +18,11 @@
|
|||||||
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
|
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
|
||||||
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
|
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
{{if .Page.CSRFToken}}
|
||||||
|
<form class="logout-form" method="post" action="/logout">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
|
||||||
|
<button class="logout-button" type="submit">退出</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
</header>
|
</header>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"cmroubao/backend-api/internal/domain"
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
"cmroubao/backend-api/internal/transport/authcommon"
|
||||||
"cmroubao/backend-api/internal/usecase"
|
"cmroubao/backend-api/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,6 +105,7 @@ func (adapter *UsecaseAdapter) CreateTask(
|
|||||||
}
|
}
|
||||||
result, err := adapter.tasks.Create(ctx, usecase.CreateTaskCommand{
|
result, err := adapter.tasks.Create(ctx, usecase.CreateTaskCommand{
|
||||||
CreatorSubject: localAdminSubject,
|
CreatorSubject: localAdminSubject,
|
||||||
|
ActorUserID: actorUserID(ctx),
|
||||||
IdempotencyKey: input.IdempotencyKey,
|
IdempotencyKey: input.IdempotencyKey,
|
||||||
Title: input.Title,
|
Title: input.Title,
|
||||||
Description: input.Description,
|
Description: input.Description,
|
||||||
@@ -118,12 +120,21 @@ func (adapter *UsecaseAdapter) CreateTask(
|
|||||||
return taskFromPurchase(result.Task), nil
|
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(
|
func (adapter *UsecaseAdapter) CancelPending(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
input CancelPendingInput,
|
input CancelPendingInput,
|
||||||
) (Task, error) {
|
) (Task, error) {
|
||||||
task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{
|
task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{
|
||||||
CreatorSubject: localAdminSubject,
|
CreatorSubject: localAdminSubject,
|
||||||
|
ActorUserID: actorUserID(ctx),
|
||||||
TaskID: input.TaskID,
|
TaskID: input.TaskID,
|
||||||
Reason: "管理员取消",
|
Reason: "管理员取消",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import "encoding/base64"
|
||||||
|
|
||||||
|
func rawURLDecode(value string) ([]byte, error) {
|
||||||
|
return base64.RawURLEncoding.DecodeString(value)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ type TaskService struct {
|
|||||||
|
|
||||||
type CreateTaskCommand struct {
|
type CreateTaskCommand struct {
|
||||||
CreatorSubject string
|
CreatorSubject string
|
||||||
|
ActorUserID string
|
||||||
IdempotencyKey string
|
IdempotencyKey string
|
||||||
SourceRef *string
|
SourceRef *string
|
||||||
Title string
|
Title string
|
||||||
@@ -61,6 +62,7 @@ type TaskPage struct {
|
|||||||
|
|
||||||
type CancelTaskCommand struct {
|
type CancelTaskCommand struct {
|
||||||
CreatorSubject string
|
CreatorSubject string
|
||||||
|
ActorUserID string
|
||||||
TaskID string
|
TaskID string
|
||||||
Reason string
|
Reason string
|
||||||
}
|
}
|
||||||
@@ -91,6 +93,7 @@ func (s *TaskService) Create(
|
|||||||
return CreateTaskResult{}, err
|
return CreateTaskResult{}, err
|
||||||
}
|
}
|
||||||
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
|
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
|
||||||
|
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
|
||||||
command.Title = strings.TrimSpace(command.Title)
|
command.Title = strings.TrimSpace(command.Title)
|
||||||
command.SKU = strings.TrimSpace(command.SKU)
|
command.SKU = strings.TrimSpace(command.SKU)
|
||||||
command.ImageAssetID = strings.TrimSpace(command.ImageAssetID)
|
command.ImageAssetID = strings.TrimSpace(command.ImageAssetID)
|
||||||
@@ -105,6 +108,13 @@ func (s *TaskService) Create(
|
|||||||
map[string]string{"image_asset_id": "must be a UUID"},
|
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(
|
if err := domain.ValidateTaskInput(
|
||||||
command.CreatorSubject,
|
command.CreatorSubject,
|
||||||
command.SourceRef,
|
command.SourceRef,
|
||||||
@@ -156,28 +166,31 @@ func (s *TaskService) Create(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
now := s.clock.Now().UTC()
|
now := s.clock.Now().UTC()
|
||||||
|
actorUserID := command.ActorUserID
|
||||||
task := domain.PurchaseTask{
|
task := domain.PurchaseTask{
|
||||||
ID: taskID,
|
ID: taskID,
|
||||||
CreatorSubject: command.CreatorSubject,
|
CreatorSubject: command.CreatorSubject,
|
||||||
SourceRef: command.SourceRef,
|
CreatedByUserID: &actorUserID,
|
||||||
Title: command.Title,
|
SourceRef: command.SourceRef,
|
||||||
Description: command.Description,
|
Title: command.Title,
|
||||||
SKU: command.SKU,
|
Description: command.Description,
|
||||||
ImageAssetID: command.ImageAssetID,
|
SKU: command.SKU,
|
||||||
Quantity: command.Quantity,
|
ImageAssetID: command.ImageAssetID,
|
||||||
MaxBudgetCents: budget,
|
Quantity: command.Quantity,
|
||||||
Currency: domain.CurrencyCNY,
|
MaxBudgetCents: budget,
|
||||||
Status: domain.TaskStatusPending,
|
Currency: domain.CurrencyCNY,
|
||||||
Version: 1,
|
Status: domain.TaskStatusPending,
|
||||||
CreatedAt: now,
|
Version: 1,
|
||||||
UpdatedAt: now,
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
event := domain.TaskEvent{
|
event := domain.TaskEvent{
|
||||||
ID: eventID,
|
ID: eventID,
|
||||||
TaskID: taskID,
|
TaskID: taskID,
|
||||||
Type: "TASK_CREATED",
|
ActorUserID: &actorUserID,
|
||||||
Message: "task created",
|
Type: "TASK_CREATED",
|
||||||
OccurredAt: now,
|
Message: "task created",
|
||||||
|
OccurredAt: now,
|
||||||
}
|
}
|
||||||
requestHash, err := hashCreateTaskCommand(command, budget)
|
requestHash, err := hashCreateTaskCommand(command, budget)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -323,6 +336,7 @@ func (s *TaskService) Cancel(
|
|||||||
command CancelTaskCommand,
|
command CancelTaskCommand,
|
||||||
) (domain.PurchaseTask, error) {
|
) (domain.PurchaseTask, error) {
|
||||||
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
|
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
|
||||||
|
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
|
||||||
command.TaskID = strings.TrimSpace(command.TaskID)
|
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||||
command.Reason = strings.TrimSpace(command.Reason)
|
command.Reason = strings.TrimSpace(command.Reason)
|
||||||
fields := make(map[string]string)
|
fields := make(map[string]string)
|
||||||
@@ -332,6 +346,9 @@ func (s *TaskService) Cancel(
|
|||||||
if !isUUID(command.TaskID) {
|
if !isUUID(command.TaskID) {
|
||||||
fields["task_id"] = "must be a UUID"
|
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 {
|
if len([]byte(command.Reason)) > domain.MaxCancelReasonBytes {
|
||||||
fields["reason"] = "too long"
|
fields["reason"] = "too long"
|
||||||
}
|
}
|
||||||
@@ -352,12 +369,14 @@ func (s *TaskService) Cancel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
now := s.clock.Now().UTC()
|
now := s.clock.Now().UTC()
|
||||||
|
actorUserID := command.ActorUserID
|
||||||
event := domain.TaskEvent{
|
event := domain.TaskEvent{
|
||||||
ID: eventID,
|
ID: eventID,
|
||||||
TaskID: command.TaskID,
|
TaskID: command.TaskID,
|
||||||
Type: "TASK_CANCELED",
|
ActorUserID: &actorUserID,
|
||||||
Message: "task canceled",
|
Type: "TASK_CANCELED",
|
||||||
OccurredAt: now,
|
Message: "task canceled",
|
||||||
|
OccurredAt: now,
|
||||||
}
|
}
|
||||||
task, err := s.repository.CancelPendingTask(
|
task, err := s.repository.CancelPendingTask(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -378,6 +397,7 @@ func hashCreateTaskCommand(
|
|||||||
budget *int64,
|
budget *int64,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
payload := struct {
|
payload := struct {
|
||||||
|
ActorUserID string `json:"actor_user_id"`
|
||||||
SourceRef *string `json:"source_ref"`
|
SourceRef *string `json:"source_ref"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
@@ -386,6 +406,7 @@ func hashCreateTaskCommand(
|
|||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
MaxBudgetCents *int64 `json:"max_budget_cents"`
|
MaxBudgetCents *int64 `json:"max_budget_cents"`
|
||||||
}{
|
}{
|
||||||
|
ActorUserID: command.ActorUserID,
|
||||||
SourceRef: command.SourceRef,
|
SourceRef: command.SourceRef,
|
||||||
Title: command.Title,
|
Title: command.Title,
|
||||||
Description: command.Description,
|
Description: command.Description,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) {
|
|||||||
|
|
||||||
result, err := service.Create(context.Background(), CreateTaskCommand{
|
result, err := service.Create(context.Background(), CreateTaskCommand{
|
||||||
CreatorSubject: " local-admin ",
|
CreatorSubject: " local-admin ",
|
||||||
|
ActorUserID: "00000000-0000-4000-8000-000000000099",
|
||||||
IdempotencyKey: " create-1 ",
|
IdempotencyKey: " create-1 ",
|
||||||
SourceRef: &sourceRef,
|
SourceRef: &sourceRef,
|
||||||
Title: " Demo title ",
|
Title: " Demo title ",
|
||||||
@@ -34,6 +35,9 @@ func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) {
|
|||||||
result.Task.SKU != "SKU-1" ||
|
result.Task.SKU != "SKU-1" ||
|
||||||
result.Task.SourceRef == nil ||
|
result.Task.SourceRef == nil ||
|
||||||
*result.Task.SourceRef != "source-1" ||
|
*result.Task.SourceRef != "source-1" ||
|
||||||
|
result.Task.CreatedByUserID == nil ||
|
||||||
|
*result.Task.CreatedByUserID !=
|
||||||
|
"00000000-0000-4000-8000-000000000099" ||
|
||||||
result.Task.MaxBudgetCents == nil ||
|
result.Task.MaxBudgetCents == nil ||
|
||||||
*result.Task.MaxBudgetCents != 2000 {
|
*result.Task.MaxBudgetCents != 2000 {
|
||||||
t.Fatalf("created task = %+v", result.Task)
|
t.Fatalf("created task = %+v", result.Task)
|
||||||
@@ -46,7 +50,10 @@ func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if repository.event.Type != "TASK_CREATED" ||
|
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)
|
t.Fatalf("event = %+v", repository.event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +63,7 @@ func TestTaskServiceCreateMapsValidationAndRepositoryErrors(t *testing.T) {
|
|||||||
service := mustTaskService(t, repository)
|
service := mustTaskService(t, repository)
|
||||||
_, err := service.Create(context.Background(), CreateTaskCommand{
|
_, err := service.Create(context.Background(), CreateTaskCommand{
|
||||||
CreatorSubject: "local-admin",
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: "00000000-0000-4000-8000-000000000099",
|
||||||
IdempotencyKey: "create-1",
|
IdempotencyKey: "create-1",
|
||||||
Title: "title",
|
Title: "title",
|
||||||
SKU: "sku",
|
SKU: "sku",
|
||||||
@@ -66,6 +74,7 @@ func TestTaskServiceCreateMapsValidationAndRepositoryErrors(t *testing.T) {
|
|||||||
|
|
||||||
_, err = service.Create(context.Background(), CreateTaskCommand{
|
_, err = service.Create(context.Background(), CreateTaskCommand{
|
||||||
CreatorSubject: "local-admin",
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: "00000000-0000-4000-8000-000000000099",
|
||||||
IdempotencyKey: "create-2",
|
IdempotencyKey: "create-2",
|
||||||
Title: "",
|
Title: "",
|
||||||
SKU: "",
|
SKU: "",
|
||||||
@@ -112,10 +121,16 @@ func TestTaskServiceCancelMapsStateConflict(t *testing.T) {
|
|||||||
service := mustTaskService(t, repository)
|
service := mustTaskService(t, repository)
|
||||||
_, err := service.Cancel(context.Background(), CancelTaskCommand{
|
_, err := service.Cancel(context.Background(), CancelTaskCommand{
|
||||||
CreatorSubject: "local-admin",
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: "00000000-0000-4000-8000-000000000099",
|
||||||
TaskID: "00000000-0000-4000-8000-000000000001",
|
TaskID: "00000000-0000-4000-8000-000000000001",
|
||||||
Reason: "no longer needed",
|
Reason: "no longer needed",
|
||||||
})
|
})
|
||||||
assertUsecaseError(t, err, ErrorKindConflict, "TASK_STATE_CONFLICT")
|
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{}
|
type fakeClock struct{}
|
||||||
@@ -194,8 +209,9 @@ func (repository *fakeTaskRepository) CancelPendingTask(
|
|||||||
_ string,
|
_ string,
|
||||||
_ string,
|
_ string,
|
||||||
_ time.Time,
|
_ time.Time,
|
||||||
_ domain.TaskEvent,
|
event domain.TaskEvent,
|
||||||
) (domain.PurchaseTask, error) {
|
) (domain.PurchaseTask, error) {
|
||||||
|
repository.event = event
|
||||||
return domain.PurchaseTask{}, repository.cancelErr
|
return domain.PurchaseTask{}, repository.cancelErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -53,9 +53,8 @@
|
|||||||
|
|
||||||
当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、
|
当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、
|
||||||
动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已
|
动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已
|
||||||
验证。T-201 后端骨架、T-202 P0 原型和 T-203 任务 API/管理 Web 均已完成。下一步
|
验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web 和 T-204 最小鉴权
|
||||||
按编号开始 T-204,实现正式管理账号、角色、会话和设备身份;在此之前业务路由只
|
均已完成。下一步按编号开始 T-205,实现 App 原子领取、租约和任务状态机。
|
||||||
允许 loopback,不能部署给局域网用户使用。
|
|
||||||
|
|
||||||
严格按以下顺序推进:
|
严格按以下顺序推进:
|
||||||
|
|
||||||
@@ -106,11 +105,15 @@ $env:RUN_START_COMMAND = "1"
|
|||||||
```
|
```
|
||||||
|
|
||||||
脚本先执行 Android `test assembleDebug`,再以 `GOTOOLCHAIN=local` 执行后端
|
脚本先执行 Android `test assembleDebug`,再以 `GOTOOLCHAIN=local` 执行后端
|
||||||
`go test ./...`、`go vet ./...` 并构建 API 与 migration 两个入口。后端单独运行:
|
`go test ./...`、`go vet ./...` 并构建 API、migration 与 authctl 三个入口。后端
|
||||||
|
单独运行:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Set-Location backend-api
|
Set-Location backend-api
|
||||||
$env:GOTOOLCHAIN = "local"
|
$env:GOTOOLCHAIN = "local"
|
||||||
go run ./cmd/migrate up
|
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
|
go run ./cmd/api
|
||||||
```
|
```
|
||||||
|
|||||||
+12
-1
@@ -59,6 +59,15 @@
|
|||||||
8. MVP 的“成功”表示完成验证闭环并得到人工确认的候选结果,
|
8. MVP 的“成功”表示完成验证闭环并得到人工确认的候选结果,
|
||||||
`order_submitted` 必须为 `false`。
|
`order_submitted` 必须为 `false`。
|
||||||
9. 取消和失败不得自动转成新任务;是否重试由人员显式决定。
|
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`。
|
`order_submitted` 固定为 `false`。
|
||||||
- F-007/US-006/IX-008:失败包含稳定错误码、失败步骤、可读说明和必要截图;重新
|
- 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 个可见候选,避免无界遍历。 |
|
| 搜索结果 | 最多检查前 5 个可见候选,避免无界遍历。 |
|
||||||
| 下单边界 | MVP 停在候选或订单确认页,不提交订单、不支付。 |
|
| 下单边界 | MVP 停在候选或订单确认页,不提交订单、不支付。 |
|
||||||
| 账号边界 | 验证版为单管理身份 + 设备身份;完整人员 RBAC 后置。 |
|
| 账号边界 | 验证版为 ADMIN 会话 + BUYER/预授权设备联合身份;完整人员 RBAC 后置。 |
|
||||||
| 第一层任务输入 | 从本机私有蝦皮订单文件生成测试任务,不先建设 Go-Gin。 |
|
| 第一层任务输入 | 从本机私有蝦皮订单文件生成测试任务,不先建设 Go-Gin。 |
|
||||||
|
|
||||||
## 九、待确认与风险
|
## 九、待确认与风险
|
||||||
|
|||||||
@@ -23,11 +23,11 @@
|
|||||||
| 管理 Web | Gin + `html/template` + `embed` + 少量原生 JS/CSS | MVP 已定 | 不单独引入 SPA 工程,模板和静态资源随服务构建。 |
|
| 管理 Web | Gin + `html/template` + `embed` + 少量原生 JS/CSS | MVP 已定 | 不单独引入 SPA 工程,模板和静态资源随服务构建。 |
|
||||||
| 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 |
|
| 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 |
|
||||||
| 图片/截图 | 后端受控本地文件目录 + `golang.org/x/image` v0.28.0 | 已验证 | JPEG/PNG/WebP 真解码后白底缩放并编码为 JPEG;数据库只存元数据和随机相对键。 |
|
| 图片/截图 | 后端受控本地文件目录 + `golang.org/x/image` v0.28.0 | 已验证 | JPEG/PNG/WebP 真解码后白底缩放并编码为 JPEG;数据库只存元数据和随机相对键。 |
|
||||||
| 管理鉴权 | 单个种子管理账号 + 服务端会话 Cookie | MVP 已定 | 密码只保存哈希;完整 RBAC 为 V2。 |
|
| 管理鉴权 | bcrypt + 8 小时 opaque 服务端会话 Cookie | T-204 已验证 | `authctl` 预置 ADMIN;数据库只存密码 hash 与 session SHA-256,完整 RBAC 为 V2。 |
|
||||||
| App 鉴权 | 采购员登录态 + 设备绑定令牌 | 目标已定,细节待实现 | 人员身份与设备身份分离;令牌只保存哈希。 |
|
| 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 动作模型不具备需求提取能力。 |
|
| VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | T-103/T-104 使用严格 JSON Schema、单候选单次调用和 2048 px 图片上限;GUI-Owl/MAI-UI 动作模型不具备需求提取能力。 |
|
||||||
| 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 |
|
| 通知 | 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。 |
|
| 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 或受控测试网络访问。 |
|
| 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 |
|
||||||
|
|
||||||
|
|||||||
+30
-5
@@ -73,10 +73,10 @@ SQLite 默认文件为被忽略的 `backend-api/var/cmroubao.db`,连接启用
|
|||||||
服务进程内自动改表;T-203 已用 `00002_tasks_and_assets.sql` 建立任务、资产、事件
|
服务进程内自动改表;T-203 已用 `00002_tasks_and_assets.sql` 建立任务、资产、事件
|
||||||
和幂等记录表。
|
和幂等记录表。
|
||||||
|
|
||||||
T-204 管理会话完成前,T-203 的业务路由只允许 loopback 来源,Web 写操作还校验
|
T-204 已用正式服务端 session 替换临时来源门禁,不重写 T-203 业务规则。Web 写
|
||||||
浏览器 Cookie 与表单字段的随机双提交 CSRF token;这不是正式登录,不能据此开放
|
操作校验浏览器 Cookie 与表单字段的随机双提交 CSRF token;Cookie 管理 API 写请求
|
||||||
局域网监听。模板/API handler 仍调用同一 usecase,T-204 只替换主体和会话门禁,
|
校验 `X-CSRF-Token`。明文 HTTP 只能监听 loopback,非 loopback 必须配置服务端
|
||||||
不重写业务规则。
|
TLS certificate/key。
|
||||||
|
|
||||||
### 2.2 Android App
|
### 2.2 Android App
|
||||||
|
|
||||||
@@ -333,7 +333,7 @@ IDLE
|
|||||||
| `username` | UNIQUE, NOT NULL | 登录名 |
|
| `username` | UNIQUE, NOT NULL | 登录名 |
|
||||||
| `password_hash` | NOT NULL | 不保存明文 |
|
| `password_hash` | NOT NULL | 不保存明文 |
|
||||||
| `role` | NOT NULL | MVP 为 `ADMIN` 或 `BUYER` |
|
| `role` | NOT NULL | MVP 为 `ADMIN` 或 `BUYER` |
|
||||||
| `is_active` | NOT NULL | 禁用后不可建立新会话 |
|
| `is_active` | NOT NULL | 禁用后现有和新凭证均不可使用 |
|
||||||
| `created_at` | NOT NULL | 创建时间 |
|
| `created_at` | NOT NULL | 创建时间 |
|
||||||
|
|
||||||
### `devices`
|
### `devices`
|
||||||
@@ -345,15 +345,32 @@ IDLE
|
|||||||
| `token_hash` | NOT NULL | 设备令牌哈希 |
|
| `token_hash` | NOT NULL | 设备令牌哈希 |
|
||||||
| `bound_user_id` | FK, nullable | 当前绑定采购员 |
|
| `bound_user_id` | FK, nullable | 当前绑定采购员 |
|
||||||
| `app_version` | nullable | App 版本 |
|
| `app_version` | nullable | App 版本 |
|
||||||
|
| `android_version` | nullable | Android 系统版本 |
|
||||||
| `pdd_version` | nullable | 已验证拼多多版本 |
|
| `pdd_version` | nullable | 已验证拼多多版本 |
|
||||||
| `last_seen_at` | nullable | 最近心跳 |
|
| `last_seen_at` | nullable | 最近心跳 |
|
||||||
| `is_enabled` | NOT NULL | 后端开关 |
|
| `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`
|
### `purchase_tasks`
|
||||||
|
|
||||||
| 字段 | 约束 | 说明 |
|
| 字段 | 约束 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | PK | 任务 ID |
|
| `id` | PK | 任务 ID |
|
||||||
|
| `created_by_user_id` | FK, nullable | 新任务的真实 ADMIN actor;历史任务允许为空 |
|
||||||
| `title` | NOT NULL | 原始标题 |
|
| `title` | NOT NULL | 原始标题 |
|
||||||
| `description` | NOT NULL | 原始说明,可为空字符串 |
|
| `description` | NOT NULL | 原始说明,可为空字符串 |
|
||||||
| `sku` | NOT NULL | 原始 SKU,不得由模型改写 |
|
| `sku` | NOT NULL | 原始 SKU,不得由模型改写 |
|
||||||
@@ -367,6 +384,14 @@ IDLE
|
|||||||
| `version` | NOT NULL | 乐观锁/状态并发控制 |
|
| `version` | NOT NULL | 乐观锁/状态并发控制 |
|
||||||
| `created_at/updated_at` | 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`
|
### `task_executions`
|
||||||
|
|
||||||
| 字段 | 约束 | 说明 |
|
| 字段 | 约束 | 说明 |
|
||||||
|
|||||||
+12
-1
@@ -81,6 +81,16 @@
|
|||||||
- 状态变化校验当前状态、设备归属、claim token、版本和租约。
|
- 状态变化校验当前状态、设备归属、claim token、版本和租约。
|
||||||
- 创建、领取、完成和证据上传的重试路径必须幂等。
|
- 创建、领取、完成和证据上传的重试路径必须幂等。
|
||||||
- 不向客户端返回 `password_hash`、`token_hash`、存储绝对路径或供应商密钥。
|
- 不向客户端返回 `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;内部堆栈只进受控日志。
|
- 通用错误使用稳定 code 和可读 message;内部堆栈只进受控日志。
|
||||||
- 文件访问通过鉴权接口,防止路径遍历和猜测 URL。
|
- 文件访问通过鉴权接口,防止路径遍历和猜测 URL。
|
||||||
- Go 命令固定 `GOTOOLCHAIN=local`;`go.mod` 不得出现更高 Go 版本或未固定的
|
- Go 命令固定 `GOTOOLCHAIN=local`;`go.mod` 不得出现更高 Go 版本或未固定的
|
||||||
@@ -135,5 +145,6 @@
|
|||||||
|
|
||||||
项目标准验证命令是根目录 `.\init.ps1`;它执行 Android Gradle `test` 和
|
项目标准验证命令是根目录 `.\init.ps1`;它执行 Android Gradle `test` 和
|
||||||
`assembleDebug`,再以 `GOTOOLCHAIN=local` 执行后端 `go 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。
|
SDK 内新版 ADB 安装并启动 Android App。
|
||||||
|
|||||||
@@ -144,7 +144,7 @@
|
|||||||
|
|
||||||
**范围**
|
**范围**
|
||||||
|
|
||||||
- 包含:登录、退出、设备绑定、会话过期处理。
|
- 包含:管理登录/退出、预授权设备首次绑定、会话过期处理。
|
||||||
- 不包含:自助注册、找回密码、完整用户管理和 SSO。
|
- 不包含:自助注册、找回密码、完整用户管理和 SSO。
|
||||||
|
|
||||||
**验收场景**
|
**验收场景**
|
||||||
@@ -156,6 +156,7 @@
|
|||||||
|
|
||||||
## 待确认
|
## 待确认
|
||||||
|
|
||||||
- 管理账号和采购账号的初始化、重置流程。
|
- 密码重置流程后置;T-204 使用本地 `authctl` 显式创建种子 ADMIN/BUYER 和预授权
|
||||||
|
设备,不提供管理 UI 或客户端自助登记。
|
||||||
- 谁负责确认测试任务、如何标注“候选可接受”的统一口径。
|
- 谁负责确认测试任务、如何标注“候选可接受”的统一口径。
|
||||||
- 拒绝候选是否允许创建新任务,当前默认只记录结果。
|
- 拒绝候选是否允许创建新任务,当前默认只记录结果。
|
||||||
|
|||||||
@@ -111,16 +111,19 @@
|
|||||||
|
|
||||||
**正常路径**
|
**正常路径**
|
||||||
|
|
||||||
1. 用户输入采购账号并选择/确认设备身份。
|
1. 用户输入采购账号并确认后端已预授权的当前设备身份。
|
||||||
2. 成功后令牌存入 Android 安全存储,进入任务页。
|
2. 成功后令牌存入 Android 安全存储,进入任务页。
|
||||||
3. App 上报版本和就绪能力,不上传敏感设备内容。
|
3. App 上报版本和就绪能力,不上传敏感设备内容。
|
||||||
|
|
||||||
**状态与异常**
|
**状态与异常**
|
||||||
|
|
||||||
- 账号或设备被禁用:解释联系管理员,不反复重试。
|
- 账号或设备被禁用:解释联系管理员,不反复重试。
|
||||||
|
- 未预授权、token 错误或已绑定其他采购员的设备不能在 App 中“重新登记”,联系
|
||||||
|
管理员重新预置。
|
||||||
- 网络错误:保留非敏感账号字段,密码清空。
|
- 网络错误:保留非敏感账号字段,密码清空。
|
||||||
- 会话恢复失败:退出到登录页;活跃任务先查询服务端再决定状态。
|
- 会话恢复失败:退出到登录页;活跃任务先查询服务端再决定状态。
|
||||||
- 退出登录:有运行任务时禁止直接退出,先安全停止。
|
- 退出登录:有运行任务时禁止直接退出,先安全停止。T-204 只实现服务端 token
|
||||||
|
签发和验证;本地清除与服务端撤销接入属于 T-206。
|
||||||
|
|
||||||
**可访问性**
|
**可访问性**
|
||||||
|
|
||||||
|
|||||||
+27
-6
@@ -13,10 +13,9 @@
|
|||||||
- 分页使用 `limit` 和不透明 `cursor`;MVP `limit` 最大 100。
|
- 分页使用 `limit` 和不透明 `cursor`;MVP `limit` 最大 100。
|
||||||
- 客户端不得根据 HTTP 超时判断操作失败,必须查询资源最终状态。
|
- 客户端不得根据 HTTP 超时判断操作失败,必须查询资源最终状态。
|
||||||
|
|
||||||
T-204 管理会话完成前,T-203 的管理 Web 和管理 API 只接受来源地址为 loopback 的
|
管理 Web/API 使用 ADMIN 服务端会话;App 执行接口使用 BUYER + 设备 Bearer token。
|
||||||
请求,Web 写操作还要求 SameSite Cookie 与表单字段匹配的随机双提交 CSRF token。
|
两种身份不能互换。HTTP 明文只允许 loopback 开发监听,非 loopback 服务必须配置
|
||||||
该门禁只用于阻止业务路由被提前暴露,不是正式身份认证;`/login`、管理会话和
|
certificate/private key 并直接启用 TLS。
|
||||||
局域网访问仍由 T-204 实现。
|
|
||||||
|
|
||||||
通用错误:
|
通用错误:
|
||||||
|
|
||||||
@@ -71,7 +70,18 @@ HTTP 语义:
|
|||||||
### 管理 Web 会话
|
### 管理 Web 会话
|
||||||
|
|
||||||
`POST /login` 接受表单账号密码,成功后设置 `HttpOnly`、`Secure`、`SameSite=Lax`
|
`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`
|
### `POST /api/v1/auth/token`
|
||||||
|
|
||||||
@@ -92,7 +102,7 @@ HTTP 语义:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"access_token": "opaque-or-jwt-token",
|
"access_token": "opaque-token-returned-once",
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expires_in": 3600,
|
"expires_in": 3600,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -109,6 +119,14 @@ HTTP 语义:
|
|||||||
|
|
||||||
密码和设备 token 不得出现在响应、日志或 execution event 中。
|
密码和设备 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`
|
### `POST /api/v1/assets`
|
||||||
@@ -222,6 +240,9 @@ T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容
|
|||||||
- `execution`:step、outcome、错误、`order_submitted`。
|
- `execution`:step、outcome、错误、`order_submitted`。
|
||||||
- `events` 和 `assets`:有权限的摘要。
|
- `events` 和 `assets`:有权限的摘要。
|
||||||
|
|
||||||
|
T-204 已实现的 `TASK_CREATED`、`TASK_CANCELED` 事件包含可空
|
||||||
|
`actor_user_id`;新管理操作写入真实 ADMIN 用户 ID,T-203 历史事件返回 `null`。
|
||||||
|
|
||||||
### `POST /api/v1/tasks/{task_id}/cancel`
|
### `POST /api/v1/tasks/{task_id}/cancel`
|
||||||
|
|
||||||
管理端取消任务。`PENDING` 可立即取消;执行中只设置取消请求,App 在安全检查点确认
|
管理端取消任务。`PENDING` 可立即取消;执行中只设置取消请求,App 在安全检查点确认
|
||||||
|
|||||||
+26
-9
@@ -5,25 +5,30 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-26
|
- 日期: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:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-203
|
||||||
均已纳入 Git 历史
|
均已纳入 Git 历史;T-204 与本文同次提交
|
||||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||||
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
|
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
|
||||||
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片规范化、任务
|
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、
|
||||||
创建/查询/取消 API 和 SSR 管理 Web,Go Blueprint 只作为一次性骨架输入
|
SSR 管理 Web、ADMIN 会话、BUYER/预授权设备联合认证和 `authctl`
|
||||||
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
||||||
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
||||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||||
- 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器
|
- 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器
|
||||||
共 26 份报告、166 次测试,0 failure、0 error、0 skipped
|
共 26 份报告、166 次测试,0 failure、0 error、0 skipped
|
||||||
- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 99 个测试通过;
|
- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 142 个测试通过;
|
||||||
全包 race、`go vet ./...`、API/migration Windows 构建和根 `init.ps1` 均通过
|
全包 race、`go vet ./...`、API/migration/authctl Windows 构建和根 `init.ps1`
|
||||||
|
均通过
|
||||||
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
||||||
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
||||||
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
||||||
- 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和
|
- 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和
|
||||||
待领取取消;同三种视口无横向溢出,可见操作控件不小于 44px
|
待领取取消;ADMIN 登录/退出和安全返回路径已接入,同三种视口无横向溢出,
|
||||||
|
可见操作控件不小于 44px
|
||||||
|
- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret
|
||||||
|
均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App
|
||||||
|
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
|
||||||
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
||||||
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
||||||
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
||||||
@@ -59,6 +64,7 @@
|
|||||||
| `docs/tasks/T-201.md` | DONE | Go-Gin、SQLite、Goose migration 和健康检查骨架 |
|
| `docs/tasks/T-201.md` | DONE | Go-Gin、SQLite、Goose migration 和健康检查骨架 |
|
||||||
| `docs/tasks/T-202.md` | DONE | 生成并确认 P0 Web/App 低保真原型 |
|
| `docs/tasks/T-202.md` | DONE | 生成并确认 P0 Web/App 低保真原型 |
|
||||||
| `docs/tasks/T-203.md` | DONE | 图片/任务 API、SQLite 业务层和 SSR 管理 Web |
|
| `docs/tasks/T-203.md` | DONE | 图片/任务 API、SQLite 业务层和 SSR 管理 Web |
|
||||||
|
| `docs/tasks/T-204.md` | DONE | 用户、管理会话和预授权设备联合身份 |
|
||||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
| `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
|
Set-Location backend-api
|
||||||
$env:GOTOOLCHAIN = "local"
|
$env:GOTOOLCHAIN = "local"
|
||||||
go run ./cmd/migrate up
|
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
|
go run ./cmd/api
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -115,6 +124,14 @@ SQLite 仓储、管理 API 和 SSR Web。创建/上传幂等,列表稳定分
|
|||||||
使用双提交 CSRF;这不是正式鉴权。真实 HTTP/Playwright 和 migration CLI smoke、
|
使用双提交 CSRF;这不是正式鉴权。真实 HTTP/Playwright 和 migration CLI smoke、
|
||||||
99 个 Go 测试、全包 race/vet、Windows 构建与根 `init.ps1` 全部通过。
|
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` 均通过。
|
||||||
|
|
||||||
## 维护规则
|
## 维护规则
|
||||||
|
|
||||||
发生以下变化时覆盖更新本文:
|
发生以下变化时覆盖更新本文:
|
||||||
|
|||||||
@@ -44,12 +44,11 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="device">设备身份</label>
|
<label for="device">设备身份</label>
|
||||||
<select id="device">
|
<select id="device">
|
||||||
<option>测试设备 A · 本机</option>
|
<option>测试设备 A · 后端已预授权</option>
|
||||||
<option>重新登记此设备</option>
|
|
||||||
</select>
|
</select>
|
||||||
<div class="device" aria-label="设备状态">
|
<div class="device" aria-label="设备状态">
|
||||||
<strong>设备可登记</strong>
|
<strong>设备身份待验证</strong>
|
||||||
<span>仅上报版本和就绪能力,不上传设备中的敏感内容</span>
|
<span>未预授权或绑定冲突时请联系管理员,不在 App 内重新登记</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,8 @@ T-202 的离线 P0 页面入口见[原型索引](design/index.html)。原型仅
|
|||||||
| `/tasks/{id}` | 任务详情 | 查看原始输入、时间线、候选和证据 | US-002、US-006 | IX-003、IX-008 |
|
| `/tasks/{id}` | 任务详情 | 查看原始输入、时间线、候选和证据 | US-002、US-006 | IX-003、IX-008 |
|
||||||
|
|
||||||
MVP 登录后默认进入 `/tasks`。未登录访问受保护页面时跳转 `/login` 并携带安全的
|
MVP 登录后默认进入 `/tasks`。未登录访问受保护页面时跳转 `/login` 并携带安全的
|
||||||
站内返回路径。不存在和无权限必须使用不同内部原因,但页面均不得泄露任务内容。
|
站内返回路径;只接受 `/tasks` 及其本站子路径,拒绝绝对 URL、`//` 和反斜杠。
|
||||||
|
不存在和无权限必须使用不同内部原因,但页面均不得泄露任务内容。
|
||||||
|
|
||||||
## Android 页面
|
## Android 页面
|
||||||
|
|
||||||
|
|||||||
@@ -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。
|
||||||
@@ -90,6 +90,10 @@ try {
|
|||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "Go migration 构建失败,退出码: $LASTEXITCODE"
|
throw "Go migration 构建失败,退出码: $LASTEXITCODE"
|
||||||
}
|
}
|
||||||
|
& go build -o "bin/cmroubao-authctl.exe" ./cmd/authctl
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Go authctl 构建失败,退出码: $LASTEXITCODE"
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ echo "==> Verifying Go backend"
|
|||||||
mkdir -p bin
|
mkdir -p bin
|
||||||
go build -o bin/cmroubao-api ./cmd/api
|
go build -o bin/cmroubao-api ./cmd/api
|
||||||
go build -o bin/cmroubao-migrate ./cmd/migrate
|
go build -o bin/cmroubao-migrate ./cmd/migrate
|
||||||
|
go build -o bin/cmroubao-authctl ./cmd/authctl
|
||||||
)
|
)
|
||||||
|
|
||||||
if [[ "${RUN_START_COMMAND:-0}" == "1" ]]; then
|
if [[ "${RUN_START_COMMAND:-0}" == "1" ]]; then
|
||||||
|
|||||||
+10
@@ -127,3 +127,13 @@
|
|||||||
SSR 管理 Web,并用 loopback 与双提交 CSRF 作为 T-204 前的临时暴露门禁。
|
SSR 管理 Web,并用 loopback 与双提交 CSRF 作为 T-204 前的临时暴露门禁。
|
||||||
- 影响:管理人员已能在本机创建、搜索、查看和取消待领取任务;T-204 下一步补正式
|
- 影响:管理人员已能在本机创建、搜索、查看和取消待领取任务;T-204 下一步补正式
|
||||||
账号、角色、会话和设备身份,完成后 T-205 可接入 App 原子领取与租约状态机。
|
账号、角色、会话和设备身份,完成后 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 身份上
|
||||||
|
实现原子领取和租约。
|
||||||
|
|||||||
Reference in New Issue
Block a user