From ce6b85fb88c3be446377f018e03d274b9b6e0d16 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 16:05:21 +0800 Subject: [PATCH] feat(admin): add isolated external management portal --- server/admin/cmd/server/main.go | 21 +- .../admin/configs/config.tencent.example.yaml | 15 + server/admin/configs/config.yaml | 13 + server/admin/docs/外管后台.md | 58 ++ server/admin/internal/cache/redis.go | 90 +- .../internal/cache/redis_fixed_window_test.go | 34 + server/admin/internal/config/config.go | 100 +++ server/admin/internal/config/config_test.go | 45 + .../internal/middleware/cors_external_test.go | 37 + .../admin/internal/middleware/middleware.go | 4 +- server/admin/internal/model/models.go | 79 ++ .../externaladmin/account_security_test.go | 110 +++ .../externaladmin/change_password_test.go | 205 +++++ .../internal/modules/externaladmin/handler.go | 500 +++++++++++ .../modules/externaladmin/middleware.go | 192 +++++ .../modules/externaladmin/middleware_test.go | 271 ++++++ .../modules/externaladmin/rate_limit_test.go | 178 ++++ .../internal/modules/externaladmin/routes.go | 126 +++ .../externaladmin/routes_security_test.go | 45 + .../internal/modules/externaladmin/service.go | 812 ++++++++++++++++++ .../externaladmin/service_login_test.go | 75 ++ .../modules/externaladmin/team_test.go | 155 ++++ .../internal/modules/externaladmin/types.go | 330 +++++++ .../modules/externaladmin/types_test.go | 116 +++ .../admin/internal/repository/repository.go | 4 + .../repository/role_permission_matrix.go | 8 + server/admin/internal/repository/seed.go | 5 + server/admin/internal/repository/seed_test.go | 57 +- server/admin/internal/response/codes.go | 3 + server/admin/internal/response/error.go | 12 + server/admin/internal/router/router.go | 16 + server/admin/internal/router/router_test.go | 67 ++ .../migrations/098_external_admin_portal.sql | 136 +++ ...9_external_admin_credential_delegation.sql | 13 + 34 files changed, 3922 insertions(+), 10 deletions(-) create mode 100644 server/admin/docs/外管后台.md create mode 100644 server/admin/internal/cache/redis_fixed_window_test.go create mode 100644 server/admin/internal/middleware/cors_external_test.go create mode 100644 server/admin/internal/modules/externaladmin/account_security_test.go create mode 100644 server/admin/internal/modules/externaladmin/change_password_test.go create mode 100644 server/admin/internal/modules/externaladmin/handler.go create mode 100644 server/admin/internal/modules/externaladmin/middleware.go create mode 100644 server/admin/internal/modules/externaladmin/middleware_test.go create mode 100644 server/admin/internal/modules/externaladmin/rate_limit_test.go create mode 100644 server/admin/internal/modules/externaladmin/routes.go create mode 100644 server/admin/internal/modules/externaladmin/routes_security_test.go create mode 100644 server/admin/internal/modules/externaladmin/service.go create mode 100644 server/admin/internal/modules/externaladmin/service_login_test.go create mode 100644 server/admin/internal/modules/externaladmin/team_test.go create mode 100644 server/admin/internal/modules/externaladmin/types.go create mode 100644 server/admin/internal/modules/externaladmin/types_test.go create mode 100644 server/admin/migrations/098_external_admin_portal.sql create mode 100644 server/admin/migrations/099_external_admin_credential_delegation.sql diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 5902ca64..414e95b8 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -45,6 +45,7 @@ import ( dailytaskmodule "hyapp-admin-server/internal/modules/dailytask" dashboardmodule "hyapp-admin-server/internal/modules/dashboard" databimodule "hyapp-admin-server/internal/modules/databi" + externaladminmodule "hyapp-admin-server/internal/modules/externaladmin" financeordermodule "hyapp-admin-server/internal/modules/financeorder" financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal" firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward" @@ -103,6 +104,7 @@ import ( "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" + userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" ) @@ -381,7 +383,24 @@ func main() { return paymentmodule.RegionIDForCountryCode(ctx, moneyRegionSources, appCode, countryCode) })), FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler), - FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler), + ExternalAdmin: externaladminmodule.New(db, userDB, externaladminmodule.Config{ + SessionTTL: cfg.ExternalAdmin.SessionTTL, + MaxLoginFailures: cfg.ExternalAdmin.MaxLoginFailures, + LockDuration: cfg.ExternalAdmin.LockDuration, + CookieSecure: cfg.RefreshCookieSecure, + CookieSameSite: cfg.RefreshCookieSameSite, + LoginRateLimit: externaladminmodule.LoginRateLimitConfig{ + Window: cfg.ExternalAdmin.LoginRateLimit.Window, + SystemLimit: cfg.ExternalAdmin.LoginRateLimit.SystemLimit, + AppLimit: cfg.ExternalAdmin.LoginRateLimit.AppLimit, + IPLimit: cfg.ExternalAdmin.LoginRateLimit.IPLimit, + IdentityLimit: cfg.ExternalAdmin.LoginRateLimit.IdentityLimit, + }, + }, auditHandler, + externaladminmodule.WithUserHostClient(userv1.NewUserHostServiceClient(userConn)), + externaladminmodule.WithLoginRateLimiter(redisClient), + ), + FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler), Game: gamemanagementmodule.New( gameclient.NewGRPC(gameConn), userclient.NewGRPC(userConn), diff --git a/server/admin/configs/config.tencent.example.yaml b/server/admin/configs/config.tencent.example.yaml index 1a5f190f..d3158544 100644 --- a/server/admin/configs/config.tencent.example.yaml +++ b/server/admin/configs/config.tencent.example.yaml @@ -8,6 +8,11 @@ log: include_response_body: false max_payload_bytes: 2048 http_addr: "127.0.0.1:13100" +# 示例只信任本机回源代理;上线必须按实际 RemoteAddr 验证 Nginx/Caddy/ +# Docker/EdgeOne 完整链路,仅追加精确 hop,禁止信任整段私网。 +trusted_proxies: + - "127.0.0.0/8" + - "::1" mysql_dsn: "admin_user:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" user_mysql_dsn: "user_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC" wallet_mysql_dsn: "wallet_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC" @@ -80,6 +85,16 @@ cors_allowed_origins: - "https://api-acc.global-interaction.com" refresh_cookie_secure: true refresh_cookie_same_site: "none" +external_admin: + session_ttl: "12h" + max_login_failures: 5 + lock_duration: "15m" + login_rate_limit: + window: "60s" + system_limit: 300 + app_limit: 120 + ip_limit: 30 + identity_limit: 10 bootstrap: # 仅首次初始化或显式 -bootstrap 时打开;生产常驻进程不要每次启动重放 seed。 enabled: false diff --git a/server/admin/configs/config.yaml b/server/admin/configs/config.yaml index 30734e78..766612ec 100644 --- a/server/admin/configs/config.yaml +++ b/server/admin/configs/config.yaml @@ -8,6 +8,9 @@ log: include_response_body: false max_payload_bytes: 2048 http_addr: ":13100" +trusted_proxies: + - "127.0.0.0/8" + - "::1" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" user_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC" wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC" @@ -80,6 +83,16 @@ cors_allowed_origins: - "http://127.0.0.1:7002" refresh_cookie_secure: false refresh_cookie_same_site: "lax" +external_admin: + session_ttl: "12h" + max_login_failures: 5 + lock_duration: "15m" + login_rate_limit: + window: "60s" + system_limit: 300 + app_limit: 120 + ip_limit: 30 + identity_limit: 10 bootstrap: enabled: true seed_demo_data: true diff --git a/server/admin/docs/外管后台.md b/server/admin/docs/外管后台.md new file mode 100644 index 00000000..3510d6f9 --- /dev/null +++ b/server/admin/docs/外管后台.md @@ -0,0 +1,58 @@ +# 外管后台 + +## 系统边界 + +外管后台是独立于主后台、Databi 和 Finance 的浏览器入口: + +- 主后台 `/operations/external-admin-users` 继续使用主后台 JWT、RBAC 和全局 App 选择器,只负责查找 App 用户、签发外管账号、启停账号和重置密码。 +- 外管入口 `/external-admin/` 使用独立账号、服务端 opaque session、CSRF token 和首次改密流程,不读取主后台 token 或 localStorage。 +- 外管账号、会话、登录日志和操作日志分别保存在 `external_admin_accounts`、`external_admin_sessions`、`external_admin_login_logs`、`external_admin_operation_logs`。 +- `app_code` 固化在账号和会话中。Fami 账号只能访问 Fami 数据;请求头缺省时由会话补齐,伪造其他 App 时直接拒绝。 + +## 账号签发和岗位边界 + +创建账号时必须先在当前 App 内用长 ID、短 ID 或靓号精确匹配用户,再提交服务端返回的稳定 `user_id`。昵称不参与绑定,避免重名用户拿到外管凭据。 + +- `platform-admin`:查看、创建、启停和重置密码。创建和重置等同于签发一组固定的高权限外管能力,因此只允许平台管理员执行。 +- `ops-admin`、`operations-specialist`:查看、启停。运营岗位不能通过创建或重置账号间接获得自身权限矩阵中没有的 VIP 发放等能力。 +- 同一 App 内,外管账号名和绑定 App 用户均唯一;不同 App 可以使用同名外管账号。 +- 创建、重置后的临时密码必须首次修改;停用或重置会撤销该账号的全部活跃会话。 + +## 外管能力 + +外管只注册显式白名单路由,不提供主后台的通用代理能力: + +- 用户:用户列表、资料编辑、封禁列表、封禁和解封。 +- 组织:主播、公会、BD、BD Manager、Super Admin 列表和我的团队。 +- 房间:房间列表和房间编辑。 +- 发放:特权道具、靓号、用户称号、房间背景图和财富/VIP 等级。 +- 运营:Banner 创建。 + +底层业务写入仍由各 owner service 或既有后台 Handler 负责;外管模块只做独立认证、App 约束、权限白名单和审计,不复制用户、房间或钱包业务事实。 + +## 登录安全 + +- Session cookie 为 HttpOnly,作用域仅 `/api/v1/external`;写请求同时校验 CSRF cookie、header 和服务端 hash。 +- Session 绝对有效期默认 12 小时。每次请求都会确认账号和 App 仍为 active;App 停用后旧会话不能绕过 `/auth/me` 直接调用业务接口。 +- 同一账号连续失败 5 次后锁定 15 分钟;App、账号和密码错误对外使用统一文案。 +- Redis 在 MySQL 查询、bcrypt 和登录日志之前执行 60 秒固定窗口限流:系统全局 300、单 App 120、单 IP 30、单 App+账号 10。四层计数在一次 Lua 中原子完成,拒绝请求仍消耗总闸配额。 +- 登录请求体上限为 8 KiB;限流 Redis 在 200 ms 内不可用时 fail-closed 返回 503,不降级为可绕过的单机计数。 +- 认证接口统一返回 `Cache-Control: no-store`。 + +## 上线步骤 + +1. 通过 admin-server 迁移器依次应用 `migrations/098_external_admin_portal.sql` 和 `migrations/099_external_admin_credential_delegation.sql`。098 只新建四张认证/审计表并按唯一索引写入菜单和权限元数据;099 通过角色、权限唯一索引和关联表主键定点撤销旧版 098 可能误授予运营岗位的凭据权限。两者都不扫描用户、房间、钱包等大业务表;不要在高峰期临时改写迁移内容。 +2. 确认 Redis 已启用且 `/readyz` 为成功。生产配置缺少 Redis 时 admin-server 会拒绝启动,运行中限流 Redis 故障会阻断新登录但不破坏已建立会话。 +3. 配置 `trusted_proxies` 为真实入口链路的精确 IP/CIDR。默认只信任 loopback;不要信任 `0.0.0.0/0`、`::/0` 或整段未知私网。 +4. 构建并发布前端 `dist/external-admin/`,保留 Nginx 对 `/external-admin` 的 301 和所有 `/external-admin/*` 深链回退到独立 HTML。 +5. 在 EdgeOne/WAF 对精确路径 `POST /api/v1/external/auth/login` 配置按真实客户端 IP 的 token-bucket/挑战规则,建议基线 30 次/分钟、burst 10;同时限制源站直连。WAF 负责 Redis 前的大包和连接洪峰,应用层 Redis 负责系统、App 和账号维度的正确性。 + +## 上线验收 + +- Fami 创建的账号登录后请求 Lalu 的 `X-App-Code` 必须返回 403,省略 header 时仍只能看到 Fami 数据。 +- 外管账号或 App 停用后,已有会话直接访问任一业务接口必须返回 401。 +- 临时密码登录后,除会话、退出和修改密码外的业务接口必须返回 403;改密后才可进入授权页面。 +- 从同一真实 IP 连发超过阈值的登录请求必须返回 429 和 `Retry-After`;伪造 `X-Forwarded-For` 不能改变限流身份。 +- Redis 临时不可用时新登录应在约 200 ms 后返回 503;恢复后不需要重启服务。 +- `/external-admin/`、一个桌面深链和一个移动端深链均返回外管独立 HTML,不能回退到主后台入口。 +- 停用、密码重置、封禁、房间编辑、资源/VIP 发放和 Banner 创建均应在外管操作日志中留下 App、外管账号、请求 ID、资源 ID 和结果。 diff --git a/server/admin/internal/cache/redis.go b/server/admin/internal/cache/redis.go index 94614641..841b8278 100644 --- a/server/admin/internal/cache/redis.go +++ b/server/admin/internal/cache/redis.go @@ -3,6 +3,8 @@ package cache import ( "context" "errors" + "fmt" + "strconv" "strings" "time" @@ -18,14 +20,54 @@ type Redis struct { type UnlockFunc func(context.Context) error +var ErrRedisUnavailable = errors.New("redis is unavailable") + +type FixedWindowRule struct { + Key string + Limit uint64 +} + +type FixedWindowDecision struct { + Allowed bool + RetryAfter time.Duration +} + +// fixedWindowScript consumes every rule in one Redis-side operation. All rate-limit +// keys use one Cluster hash tag at the caller, so EVAL remains atomic without CROSSSLOT. +// Rejected attempts still increment every layer; expiry is assigned only on the first +// increment and is never renewed, preserving true fixed-window behavior. +const fixedWindowScript = ` +local allowed = 1 +local retry_ms = 0 +local window_ms = tonumber(ARGV[1]) +for i = 1, #KEYS do + local count = redis.call("INCR", KEYS[i]) + if count == 1 then + redis.call("PEXPIRE", KEYS[i], window_ms) + end + local limit = tonumber(ARGV[i + 1]) + if count > limit then + allowed = 0 + local ttl = redis.call("PTTL", KEYS[i]) + if ttl > retry_ms then + retry_ms = ttl + end + end +end +if allowed == 0 and retry_ms <= 0 then + retry_ms = window_ms +end +return {allowed, retry_ms}` + func NewRedis(ctx context.Context, cfg config.RedisConfig) (*Redis, error) { if !cfg.Enabled { return nil, nil } client := redis.NewClient(&redis.Options{ - Addr: cfg.Addr, - Password: cfg.Password, - DB: cfg.DB, + Addr: cfg.Addr, + Password: cfg.Password, + DB: cfg.DB, + ContextTimeoutEnabled: true, }) if err := client.Ping(ctx).Err(); err != nil { _ = client.Close() @@ -88,6 +130,48 @@ return 0` return result == 1, err } +// ConsumeFixedWindow atomically increments all supplied counters and decides whether +// every limit still allows the request. A nil/disabled Redis is an availability error, +// not an implicit allow: security callers must fail closed instead of falling back to +// per-process memory that attackers could bypass by switching replicas. +func (r *Redis) ConsumeFixedWindow(ctx context.Context, window time.Duration, rules []FixedWindowRule) (FixedWindowDecision, error) { + if r == nil || r.client == nil { + return FixedWindowDecision{}, ErrRedisUnavailable + } + if window < time.Millisecond || len(rules) == 0 { + return FixedWindowDecision{}, errors.New("positive fixed window and at least one rule are required") + } + keys := make([]string, 0, len(rules)) + args := make([]any, 0, len(rules)+1) + args = append(args, window.Milliseconds()) + seen := make(map[string]struct{}, len(rules)) + for _, rule := range rules { + key := strings.TrimSpace(rule.Key) + if key == "" || rule.Limit == 0 { + return FixedWindowDecision{}, errors.New("fixed-window keys and limits must be non-empty") + } + key = r.key(key) + if _, exists := seen[key]; exists { + return FixedWindowDecision{}, fmt.Errorf("duplicate fixed-window key %q", key) + } + seen[key] = struct{}{} + keys = append(keys, key) + args = append(args, strconv.FormatUint(rule.Limit, 10)) + } + result, err := r.client.Eval(ctx, fixedWindowScript, keys, args...).Int64Slice() + if err != nil { + return FixedWindowDecision{}, fmt.Errorf("%w: %v", ErrRedisUnavailable, err) + } + if len(result) != 2 || (result[0] != 0 && result[0] != 1) { + return FixedWindowDecision{}, fmt.Errorf("%w: invalid fixed-window response", ErrRedisUnavailable) + } + retryAfter := time.Duration(result[1]) * time.Millisecond + if result[0] == 0 && retryAfter <= 0 { + retryAfter = window + } + return FixedWindowDecision{Allowed: result[0] == 1, RetryAfter: retryAfter}, nil +} + func (r *Redis) key(key string) string { if r.prefix == "" { return key diff --git a/server/admin/internal/cache/redis_fixed_window_test.go b/server/admin/internal/cache/redis_fixed_window_test.go new file mode 100644 index 00000000..aa2cf26f --- /dev/null +++ b/server/admin/internal/cache/redis_fixed_window_test.go @@ -0,0 +1,34 @@ +package cache + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func TestConsumeFixedWindowFailsClosedWithoutRedis(t *testing.T) { + _, err := (*Redis)(nil).ConsumeFixedWindow(context.Background(), time.Minute, []FixedWindowRule{{Key: "rate:{external-login}:system", Limit: 300}}) + if !errors.Is(err, ErrRedisUnavailable) { + t.Fatalf("error = %v, want ErrRedisUnavailable", err) + } +} + +func TestFixedWindowLuaConsumesAllKeysWithoutRenewingExistingTTL(t *testing.T) { + // These source-level invariants protect the security semantics even when unit + // tests run without a Redis daemon: all keys increment before the one final + // return, and PEXPIRE remains inside count==1 so rejected requests never slide TTL. + if strings.Count(fixedWindowScript, `redis.call("INCR"`) != 1 { + t.Fatalf("fixed-window script must increment through one loop: %s", fixedWindowScript) + } + if !strings.Contains(fixedWindowScript, `if count == 1 then`) || !strings.Contains(fixedWindowScript, `redis.call("PEXPIRE"`) { + t.Fatal("fixed-window script must assign expiry only on first increment") + } + if strings.Count(fixedWindowScript, "return {") != 1 { + t.Fatal("fixed-window script must not return early before consuming all keys") + } + if strings.Index(fixedWindowScript, `redis.call("INCR"`) > strings.Index(fixedWindowScript, `if count > limit then`) { + t.Fatal("fixed-window script must increment before deciding") + } +} diff --git a/server/admin/internal/config/config.go b/server/admin/internal/config/config.go index 3b2dcd1d..c3eb684c 100644 --- a/server/admin/internal/config/config.go +++ b/server/admin/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "net" "os" "strings" "time" @@ -18,6 +19,7 @@ type Config struct { Environment string `yaml:"environment"` Log logging.Config `yaml:"log"` HTTPAddr string `yaml:"http_addr"` + TrustedProxies []string `yaml:"trusted_proxies"` MySQLDSN string `yaml:"mysql_dsn"` UserMySQLDSN string `yaml:"user_mysql_dsn"` WalletMySQLDSN string `yaml:"wallet_mysql_dsn"` @@ -34,6 +36,7 @@ type Config struct { CORSAllowedOrigins []string `yaml:"cors_allowed_origins"` RefreshCookieSecure bool `yaml:"refresh_cookie_secure"` RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"` + ExternalAdmin ExternalAdminConfig `yaml:"external_admin"` Bootstrap BootstrapConfig `yaml:"bootstrap"` BootstrapPassword string `yaml:"bootstrap_password"` UserService UserServiceConfig `yaml:"user_service"` @@ -57,6 +60,21 @@ type MigrationConfig struct { AllowChecksumRepair bool `yaml:"allow_checksum_repair"` } +type ExternalAdminConfig struct { + SessionTTL time.Duration `yaml:"session_ttl"` + MaxLoginFailures uint `yaml:"max_login_failures"` + LockDuration time.Duration `yaml:"lock_duration"` + LoginRateLimit ExternalAdminLoginRateLimitConfig `yaml:"login_rate_limit"` +} + +type ExternalAdminLoginRateLimitConfig struct { + Window time.Duration `yaml:"window"` + SystemLimit uint64 `yaml:"system_limit"` + AppLimit uint64 `yaml:"app_limit"` + IPLimit uint64 `yaml:"ip_limit"` + IdentityLimit uint64 `yaml:"identity_limit"` +} + type UserServiceConfig struct { Addr string `yaml:"addr"` RequestTimeout time.Duration `yaml:"request_timeout"` @@ -214,6 +232,7 @@ func Default() Config { Environment: "local", Log: logging.DefaultConfig(), HTTPAddr: ":13100", + TrustedProxies: []string{"127.0.0.0/8", "::1"}, MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC", UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC", WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC", @@ -280,6 +299,14 @@ func Default() Config { }, RefreshCookieSecure: false, RefreshCookieSameSite: "lax", + ExternalAdmin: ExternalAdminConfig{ + SessionTTL: 12 * time.Hour, + MaxLoginFailures: 5, + LockDuration: 15 * time.Minute, + LoginRateLimit: ExternalAdminLoginRateLimitConfig{ + Window: 60 * time.Second, SystemLimit: 300, AppLimit: 120, IPLimit: 30, IdentityLimit: 10, + }, + }, Bootstrap: BootstrapConfig{ Enabled: true, SeedDemoData: true, @@ -420,10 +447,51 @@ func (cfg *Config) Normalize() { if cfg.Log.MaxPayloadBytes <= 0 { cfg.Log.MaxPayloadBytes = 2048 } + // Gin must never retain its trust-all proxy default: a forged X-Forwarded-For + // would otherwise bypass IP login limits and corrupt every audit record. Keep + // only explicit ingress hops and preserve an empty list as "trust no proxy". + trustedProxies := make([]string, 0, len(cfg.TrustedProxies)) + seenTrustedProxy := make(map[string]struct{}, len(cfg.TrustedProxies)) + for _, proxy := range cfg.TrustedProxies { + proxy = strings.TrimSpace(proxy) + if proxy == "" { + continue + } + if _, exists := seenTrustedProxy[proxy]; exists { + continue + } + seenTrustedProxy[proxy] = struct{}{} + trustedProxies = append(trustedProxies, proxy) + } + cfg.TrustedProxies = trustedProxies cfg.RefreshCookieSameSite = strings.ToLower(strings.TrimSpace(cfg.RefreshCookieSameSite)) if cfg.RefreshCookieSameSite == "" { cfg.RefreshCookieSameSite = "lax" } + if cfg.ExternalAdmin.SessionTTL <= 0 { + cfg.ExternalAdmin.SessionTTL = 12 * time.Hour + } + if cfg.ExternalAdmin.MaxLoginFailures == 0 { + cfg.ExternalAdmin.MaxLoginFailures = 5 + } + if cfg.ExternalAdmin.LockDuration <= 0 { + cfg.ExternalAdmin.LockDuration = 15 * time.Minute + } + if cfg.ExternalAdmin.LoginRateLimit.Window <= 0 { + cfg.ExternalAdmin.LoginRateLimit.Window = 60 * time.Second + } + if cfg.ExternalAdmin.LoginRateLimit.SystemLimit == 0 { + cfg.ExternalAdmin.LoginRateLimit.SystemLimit = 300 + } + if cfg.ExternalAdmin.LoginRateLimit.AppLimit == 0 { + cfg.ExternalAdmin.LoginRateLimit.AppLimit = 120 + } + if cfg.ExternalAdmin.LoginRateLimit.IPLimit == 0 { + cfg.ExternalAdmin.LoginRateLimit.IPLimit = 30 + } + if cfg.ExternalAdmin.LoginRateLimit.IdentityLimit == 0 { + cfg.ExternalAdmin.LoginRateLimit.IdentityLimit = 10 + } cfg.ServiceName = strings.TrimSpace(cfg.ServiceName) cfg.NodeID = strings.TrimSpace(cfg.NodeID) if cfg.NodeID == "" { @@ -642,6 +710,14 @@ func (cfg Config) Validate() error { if strings.TrimSpace(cfg.HTTPAddr) == "" { return errors.New("http_addr is required") } + for _, proxy := range cfg.TrustedProxies { + if net.ParseIP(proxy) != nil { + continue + } + if _, _, err := net.ParseCIDR(proxy); err != nil { + return fmt.Errorf("trusted_proxies contains invalid IP or CIDR %q", proxy) + } + } if strings.TrimSpace(cfg.MySQLDSN) == "" { return errors.New("mysql_dsn is required") } @@ -666,6 +742,27 @@ func (cfg Config) Validate() error { if cfg.RefreshTokenTTL <= 0 { return errors.New("refresh_token_ttl must be greater than 0") } + if cfg.ExternalAdmin.SessionTTL <= 0 { + return errors.New("external_admin.session_ttl must be greater than 0") + } + if cfg.ExternalAdmin.MaxLoginFailures == 0 || cfg.ExternalAdmin.MaxLoginFailures > 20 { + return errors.New("external_admin.max_login_failures must be between 1 and 20") + } + if cfg.ExternalAdmin.LockDuration <= 0 { + return errors.New("external_admin.lock_duration must be greater than 0") + } + if cfg.ExternalAdmin.LoginRateLimit.Window < time.Second || cfg.ExternalAdmin.LoginRateLimit.Window > time.Hour { + return errors.New("external_admin.login_rate_limit.window must be between 1s and 1h") + } + if cfg.ExternalAdmin.LoginRateLimit.SystemLimit == 0 || cfg.ExternalAdmin.LoginRateLimit.AppLimit == 0 || cfg.ExternalAdmin.LoginRateLimit.IPLimit == 0 || cfg.ExternalAdmin.LoginRateLimit.IdentityLimit == 0 { + return errors.New("external_admin.login_rate_limit limits must be greater than 0") + } + if cfg.ExternalAdmin.LoginRateLimit.SystemLimit < cfg.ExternalAdmin.LoginRateLimit.AppLimit { + return errors.New("external_admin.login_rate_limit.system_limit must be greater than or equal to app_limit") + } + if cfg.ExternalAdmin.LoginRateLimit.AppLimit < cfg.ExternalAdmin.LoginRateLimit.IPLimit || cfg.ExternalAdmin.LoginRateLimit.AppLimit < cfg.ExternalAdmin.LoginRateLimit.IdentityLimit { + return errors.New("external_admin.login_rate_limit.app_limit must cover ip_limit and identity_limit") + } switch strings.ToLower(strings.TrimSpace(cfg.RefreshCookieSameSite)) { case "lax", "strict", "none": default: @@ -872,6 +969,9 @@ func (cfg Config) Validate() error { if cfg.Redis.Enabled && strings.TrimSpace(cfg.Redis.Addr) == "" { return errors.New("redis.addr is required when redis is enabled") } + if isLockedEnvironment(cfg.Environment) && !cfg.Redis.Enabled { + return errors.New("redis.enabled must be true outside local/dev for external login rate limiting") + } if cfg.Redis.DB < 0 { return fmt.Errorf("redis.db must be greater than or equal to 0") } diff --git a/server/admin/internal/config/config_test.go b/server/admin/internal/config/config_test.go index 0e2f1c05..e81c724e 100644 --- a/server/admin/internal/config/config_test.go +++ b/server/admin/internal/config/config_test.go @@ -16,6 +16,9 @@ func TestLoadConfigYAML(t *testing.T) { if cfg.HTTPAddr != ":13100" { t.Fatalf("HTTPAddr = %q", cfg.HTTPAddr) } + if !reflect.DeepEqual(cfg.TrustedProxies, []string{"127.0.0.0/8", "::1"}) { + t.Fatalf("TrustedProxies = %#v", cfg.TrustedProxies) + } if cfg.MySQLDSN != "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" { t.Fatalf("MySQLDSN = %q", cfg.MySQLDSN) } @@ -28,6 +31,12 @@ func TestLoadConfigYAML(t *testing.T) { if cfg.RefreshTokenTTL != 7*24*time.Hour { t.Fatalf("RefreshTokenTTL = %s", cfg.RefreshTokenTTL) } + if cfg.ExternalAdmin.SessionTTL != 12*time.Hour || cfg.ExternalAdmin.MaxLoginFailures != 5 || cfg.ExternalAdmin.LockDuration != 15*time.Minute { + t.Fatalf("ExternalAdmin = %#v", cfg.ExternalAdmin) + } + if limit := cfg.ExternalAdmin.LoginRateLimit; limit.Window != time.Minute || limit.SystemLimit != 300 || limit.AppLimit != 120 || limit.IPLimit != 30 || limit.IdentityLimit != 10 { + t.Fatalf("ExternalAdmin.LoginRateLimit = %#v", limit) + } if !cfg.Migrations.AllowChecksumRepair { t.Fatalf("Migrations.AllowChecksumRepair = false, want true for local config") } @@ -36,6 +45,34 @@ func TestLoadConfigYAML(t *testing.T) { } } +func TestExternalAdminLoginRateLimitDefaultsAndValidation(t *testing.T) { + cfg := Default() + cfg.ExternalAdmin.LoginRateLimit = ExternalAdminLoginRateLimitConfig{} + cfg.Normalize() + if limit := cfg.ExternalAdmin.LoginRateLimit; limit.Window != time.Minute || limit.SystemLimit != 300 || limit.AppLimit != 120 || limit.IPLimit != 30 || limit.IdentityLimit != 10 { + t.Fatalf("normalized login rate limit = %#v", limit) + } + + cfg.ExternalAdmin.LoginRateLimit.AppLimit = 301 + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "system_limit") { + t.Fatalf("Validate() error = %v, want system/app hierarchy error", err) + } +} + +func TestProductionRequiresRedisForExternalLoginRateLimit(t *testing.T) { + cfg := Default() + cfg.Environment = "prod" + cfg.JWTSecret = strings.Repeat("s", 32) + cfg.MySQLAutoMigrate = false + cfg.Migrations.AllowChecksumRepair = false + cfg.Bootstrap.Enabled = false + cfg.RobotProfileSource.MySQLDSN = "robot:test@tcp(example:3306)/robot?parseTime=true" + cfg.Redis.Enabled = false + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "redis.enabled") { + t.Fatalf("Validate() error = %v, want production Redis requirement", err) + } +} + func TestLoadEmptyPathUsesDefault(t *testing.T) { cfg, err := Load("") if err != nil { @@ -47,6 +84,14 @@ func TestLoadEmptyPathUsesDefault(t *testing.T) { } } +func TestValidateRejectsInvalidTrustedProxy(t *testing.T) { + cfg := Default() + cfg.TrustedProxies = []string{"not-a-proxy-range"} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "trusted_proxies") { + t.Fatalf("Validate() error = %v, want trusted_proxies error", err) + } +} + func TestAslanMongoEnvOverride(t *testing.T) { t.Setenv("HYAPP_ADMIN_ASLAN_MONGO_URI", "mongodb://mongouser:secret@example:27017/test?authSource=admin") t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_MONGO_DATABASE", "test_dashboard") diff --git a/server/admin/internal/middleware/cors_external_test.go b/server/admin/internal/middleware/cors_external_test.go new file mode 100644 index 00000000..e92c6dcb --- /dev/null +++ b/server/admin/internal/middleware/cors_external_test.go @@ -0,0 +1,37 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "hyapp-admin-server/internal/config" + + "github.com/gin-gonic/gin" +) + +func TestCORSAllowsAndExposesExternalCSRFHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.Use(CORS(config.Config{CORSAllowedOrigins: []string{"https://admin.example.com"}})) + engine.GET("/api/v1/external/auth/me", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + request := httptest.NewRequest(http.MethodOptions, "/api/v1/external/auth/me", nil) + request.Header.Set("Origin", "https://admin.example.com") + response := httptest.NewRecorder() + engine.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("preflight status = %d", response.Code) + } + if !strings.Contains(response.Header().Get("Access-Control-Allow-Headers"), "X-CSRF-Token") { + t.Fatalf("allow headers = %q", response.Header().Get("Access-Control-Allow-Headers")) + } + if !strings.Contains(response.Header().Get("Access-Control-Expose-Headers"), "X-CSRF-Token") { + t.Fatalf("expose headers = %q", response.Header().Get("Access-Control-Expose-Headers")) + } + if response.Header().Get("Access-Control-Allow-Credentials") != "true" { + t.Fatal("credentialed external session must be allowed") + } +} diff --git a/server/admin/internal/middleware/middleware.go b/server/admin/internal/middleware/middleware.go index 9e6314de..3be22c06 100644 --- a/server/admin/internal/middleware/middleware.go +++ b/server/admin/internal/middleware/middleware.go @@ -38,8 +38,8 @@ func CORS(cfg config.Config) gin.HandlerFunc { c.Header("Access-Control-Allow-Origin", origin) c.Header("Vary", "Origin") c.Header("Access-Control-Allow-Credentials", "true") - c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-ID, "+appctx.HeaderAppCode) - c.Header("Access-Control-Expose-Headers", "X-Request-ID") + c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-ID, "+appctx.HeaderAppCode+", X-CSRF-Token") + c.Header("Access-Control-Expose-Headers", "X-Request-ID, X-CSRF-Token") c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS") } diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index 38c73f63..237d7ed4 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -37,8 +37,87 @@ const ( WithdrawalApplicationStatusPending = "pending" WithdrawalApplicationStatusApproved = "approved" WithdrawalApplicationStatusRejected = "rejected" + + ExternalAdminStatusActive = "active" + ExternalAdminStatusDisabled = "disabled" ) +// ExternalAdminAccount is intentionally not related to admin_users. External operators +// use an isolated credential lifecycle and can never exchange this row for a main-admin JWT. +type ExternalAdminAccount struct { + ID uint64 `gorm:"primaryKey" json:"id"` + AppCode string `gorm:"size:32;uniqueIndex:uk_external_admin_accounts_app_username;uniqueIndex:uk_external_admin_accounts_app_linked_user;index:idx_external_admin_accounts_app_status_updated,priority:1;not null" json:"appCode"` + LinkedAppUserID int64 `gorm:"column:linked_app_user_id;uniqueIndex:uk_external_admin_accounts_app_linked_user;not null" json:"-"` + Username string `gorm:"size:64;uniqueIndex:uk_external_admin_accounts_app_username;not null" json:"username"` + PasswordHash string `gorm:"size:255;not null" json:"-"` + PermissionsJSON string `gorm:"column:permissions_json;type:json;not null" json:"-"` + Status string `gorm:"size:24;index:idx_external_admin_accounts_app_status_updated,priority:2;not null;default:active" json:"status"` + PasswordChangeRequired bool `gorm:"not null;default:true" json:"passwordChangeRequired"` + FailedLoginCount uint `gorm:"not null;default:0" json:"-"` + LockedUntilMS int64 `gorm:"column:locked_until_ms;not null;default:0" json:"-"` + LastLoginAtMS *int64 `gorm:"column:last_login_at_ms" json:"lastLoginAtMs"` + CreatedByAdminID uint `gorm:"column:created_by_admin_id;index;not null" json:"createdByAdminId"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli;index:idx_external_admin_accounts_app_status_updated,priority:3" json:"updatedAtMs"` +} + +func (ExternalAdminAccount) TableName() string { return "external_admin_accounts" } + +// ExternalAdminSession stores only hashes of the session and CSRF secrets. The browser +// receives the opaque values, so a read-only database leak cannot be replayed as a session. +type ExternalAdminSession struct { + ID uint64 `gorm:"primaryKey"` + AccountID uint64 `gorm:"column:account_id;index:idx_external_admin_sessions_account_active,priority:1;not null"` + Account ExternalAdminAccount `gorm:"foreignKey:AccountID;references:ID"` + AppCode string `gorm:"size:32;index:idx_external_admin_sessions_app_expiry,priority:1;not null"` + TokenHash string `gorm:"size:64;uniqueIndex:uk_external_admin_sessions_token_hash;not null"` + CSRFTokenHash string `gorm:"column:csrf_token_hash;size:64;not null"` + IP string `gorm:"size:64;not null;default:''"` + UserAgent string `gorm:"size:512;not null;default:''"` + ExpiresAtMS int64 `gorm:"column:expires_at_ms;index:idx_external_admin_sessions_account_active,priority:3;index:idx_external_admin_sessions_app_expiry,priority:2;not null"` + LastSeenAtMS int64 `gorm:"column:last_seen_at_ms;not null"` + RevokedAtMS int64 `gorm:"column:revoked_at_ms;index:idx_external_admin_sessions_account_active,priority:2;not null;default:0"` + RevokeReason string `gorm:"size:64;not null;default:''"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli"` +} + +func (ExternalAdminSession) TableName() string { return "external_admin_sessions" } + +type ExternalAdminLoginLog struct { + ID uint64 `gorm:"primaryKey"` + AccountID *uint64 `gorm:"column:account_id;index:idx_external_admin_login_logs_account_time,priority:1"` + AppCode string `gorm:"size:32;index:idx_external_admin_login_logs_app_username_time,priority:1;not null"` + Username string `gorm:"size:64;index:idx_external_admin_login_logs_app_username_time,priority:2;not null"` + IP string `gorm:"size:64;not null;default:''"` + UserAgent string `gorm:"size:512;not null;default:''"` + Status string `gorm:"size:24;index:idx_external_admin_login_logs_status_time,priority:1;not null"` + Message string `gorm:"size:128;not null;default:''"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli;index:idx_external_admin_login_logs_app_username_time,priority:3;index:idx_external_admin_login_logs_account_time,priority:2;index:idx_external_admin_login_logs_status_time,priority:2"` +} + +func (ExternalAdminLoginLog) TableName() string { return "external_admin_login_logs" } + +type ExternalAdminOperationLog struct { + ID uint64 `gorm:"primaryKey"` + AccountID uint64 `gorm:"column:account_id;index:idx_external_admin_operation_logs_account_time,priority:1;not null"` + AppCode string `gorm:"size:32;index:idx_external_admin_operation_logs_app_time,priority:1;not null"` + Username string `gorm:"size:64;not null"` + RequestID string `gorm:"size:64;index:idx_external_admin_operation_logs_request;not null;default:''"` + Action string `gorm:"size:160;not null"` + Resource string `gorm:"size:120;not null;default:''"` + ResourceID string `gorm:"size:128;not null;default:''"` + Method string `gorm:"size:12;not null"` + Path string `gorm:"size:255;not null"` + IP string `gorm:"size:64;not null;default:''"` + UserAgent string `gorm:"size:512;not null;default:''"` + Status string `gorm:"size:24;not null"` + HTTPStatus int `gorm:"not null"` + LatencyMS int64 `gorm:"not null;default:0"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli;index:idx_external_admin_operation_logs_account_time,priority:2;index:idx_external_admin_operation_logs_app_time,priority:2"` +} + +func (ExternalAdminOperationLog) TableName() string { return "external_admin_operation_logs" } + type User struct { ID uint `gorm:"primaryKey" json:"id"` Username string `gorm:"size:64;uniqueIndex;not null" json:"account"` diff --git a/server/admin/internal/modules/externaladmin/account_security_test.go b/server/admin/internal/modules/externaladmin/account_security_test.go new file mode 100644 index 00000000..2f8d096c --- /dev/null +++ b/server/admin/internal/modules/externaladmin/account_security_test.go @@ -0,0 +1,110 @@ +package externaladmin + +import ( + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestDisableAccountRevokesEveryActiveSession(t *testing.T) { + service, adminMock, userMock, cleanup := newAccountMutationFixture(t) + defer cleanup() + adminMock.ExpectBegin() + adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). + WithArgs(uint64(7), "fami", 1).WillReturnRows(externalAccountRows()) + adminMock.ExpectExec("UPDATE `external_admin_accounts` SET .*status.* WHERE `id` = \\?"). + WillReturnResult(sqlmock.NewResult(0, 1)) + // No session id predicate is allowed here: disabling an account must revoke + // every still-active session for that account, including other devices. + adminMock.ExpectExec("UPDATE `external_admin_sessions` SET .*revoke_reason.*revoked_at_ms.* WHERE account_id = \\? AND revoked_at_ms = 0"). + WithArgs("account_disabled", sqlmock.AnyArg(), uint64(7)). + WillReturnResult(sqlmock.NewResult(0, 3)) + adminMock.ExpectCommit() + expectReloadedAccountAndLinkedUser(adminMock, userMock) + + account, err := service.SetStatus(t.Context(), "fami", 7, "disabled") + if err != nil { + t.Fatalf("disable account: %v", err) + } + if account.Status != "disabled" { + t.Fatalf("status = %q", account.Status) + } + assertAccountMutationExpectations(t, adminMock, userMock) +} + +func TestResetPasswordRevokesEveryActiveSession(t *testing.T) { + service, adminMock, userMock, cleanup := newAccountMutationFixture(t) + defer cleanup() + adminMock.ExpectBegin() + adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). + WithArgs(uint64(7), "fami", 1).WillReturnRows(externalAccountRows()) + adminMock.ExpectExec("UPDATE `external_admin_accounts` SET .*password_change_required.*password_hash.* WHERE `id` = \\?"). + WillReturnResult(sqlmock.NewResult(0, 1)) + // A reset is an administrator credential replacement, so even the currently + // active browser must re-authenticate with the temporary password. + adminMock.ExpectExec("UPDATE `external_admin_sessions` SET .*revoke_reason.*revoked_at_ms.* WHERE account_id = \\? AND revoked_at_ms = 0"). + WithArgs("password_reset", sqlmock.AnyArg(), uint64(7)). + WillReturnResult(sqlmock.NewResult(0, 3)) + adminMock.ExpectCommit() + expectReloadedAccountAndLinkedUser(adminMock, userMock) + + account, err := service.ResetPassword(t.Context(), "fami", 7, "replacement-password") + if err != nil { + t.Fatalf("reset password: %v", err) + } + if account.LinkedUser.UserID != 9001 { + t.Fatalf("linked user = %d", account.LinkedUser.UserID) + } + assertAccountMutationExpectations(t, adminMock, userMock) +} + +func newAccountMutationFixture(t *testing.T) (*Service, sqlmock.Sqlmock, sqlmock.Sqlmock, func()) { + t.Helper() + adminSQL, adminMock, err := sqlmock.New() + if err != nil { + t.Fatalf("create admin sql mock: %v", err) + } + adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + adminSQL.Close() + t.Fatalf("create admin gorm: %v", err) + } + userDB, userMock, err := sqlmock.New() + if err != nil { + adminSQL.Close() + t.Fatalf("create user sql mock: %v", err) + } + return NewService(adminDB, userDB, Config{}), adminMock, userMock, func() { + _ = userDB.Close() + _ = adminSQL.Close() + } +} + +func externalAccountRows() *sqlmock.Rows { + return sqlmock.NewRows([]string{ + "id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status", + "password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms", + }).AddRow(7, "fami", 9001, "operator", "hash", `[]`, "disabled", true, 0, 0, 1, 1, 1) +} + +func expectReloadedAccountAndLinkedUser(adminMock sqlmock.Sqlmock, userMock sqlmock.Sqlmock) { + adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\?"). + WithArgs(uint64(7), uint64(7), 1).WillReturnRows(externalAccountRows()) + userMock.ExpectQuery("SELECT u.user_id,"). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "fami", int64(9001)). + WillReturnRows(sqlmock.NewRows([]string{ + "user_id", "current_display_user_id", "default_display_user_id", "pretty_display_user_id", "pretty_id", "username", "avatar", "status", + }).AddRow(9001, "123456", "654321", "8888", "8888", "linked-user", "avatar", "active")) +} + +func assertAccountMutationExpectations(t *testing.T, adminMock sqlmock.Sqlmock, userMock sqlmock.Sqlmock) { + t.Helper() + if err := adminMock.ExpectationsWereMet(); err != nil { + t.Fatalf("admin sql expectations: %v", err) + } + if err := userMock.ExpectationsWereMet(); err != nil { + t.Fatalf("user sql expectations: %v", err) + } +} diff --git a/server/admin/internal/modules/externaladmin/change_password_test.go b/server/admin/internal/modules/externaladmin/change_password_test.go new file mode 100644 index 00000000..9bba7698 --- /dev/null +++ b/server/admin/internal/modules/externaladmin/change_password_test.go @@ -0,0 +1,205 @@ +package externaladmin + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "hyapp-admin-server/internal/appctx" + adminmiddleware "hyapp-admin-server/internal/middleware" + "hyapp-admin-server/internal/security" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestChangePasswordRejectsCurrentPasswordWithoutClearingGateOrRevokingSessions(t *testing.T) { + service, mock, cleanup := newPasswordReuseFixture(t, "initial-password") + defer cleanup() + + err := service.ChangePassword(t.Context(), SessionPrincipal{ + AccountID: 7, SessionID: 21, AppCode: "fami", + }, ChangePasswordInput{CurrentPassword: "initial-password", NewPassword: "initial-password"}) + if !errors.Is(err, ErrPasswordReused) { + t.Fatalf("change password error = %v, want ErrPasswordReused", err) + } + // The mock intentionally has no UPDATE expectation. Any attempt to clear + // password_change_required or revoke sessions makes the transaction/test fail. + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("password reuse performed a write: %v", err) + } +} + +func TestChangePasswordHandlerMapsPasswordReuseToExplicitBadRequest(t *testing.T) { + service, mock, cleanup := newPasswordReuseFixture(t, "initial-password") + defer cleanup() + handler := &Handler{service: service} + + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.POST("/change-password", func(c *gin.Context) { + c.Set(contextPrincipal, SessionPrincipal{AccountID: 7, SessionID: 21, AppCode: "fami"}) + c.Next() + }, handler.ChangePassword) + body := bytes.NewBufferString(`{"currentPassword":"initial-password","newPassword":"initial-password"}`) + request := httptest.NewRequest(http.MethodPost, "/change-password", body) + request.Header.Set("Content-Type", "application/json") + responseRecorder := httptest.NewRecorder() + engine.ServeHTTP(responseRecorder, request) + + if responseRecorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String()) + } + var responseBody struct { + Message string `json:"message"` + } + if err := json.Unmarshal(responseRecorder.Body.Bytes(), &responseBody); err != nil { + t.Fatalf("decode response: %v", err) + } + if responseBody.Message != "新密码不能与当前密码相同" { + t.Fatalf("message = %q", responseBody.Message) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("password reuse handler performed a write: %v", err) + } +} + +func TestAllPasswordWritersRejectWhitespaceOnlyCredential(t *testing.T) { + // A non-nil inert DB is enough because all three methods must reject the shared + // validation error before any App/user lookup or database write can run. + service := NewService(&gorm.DB{}, nil, Config{}) + tests := []struct { + name string + call func() error + }{ + { + name: "create", + call: func() error { + _, err := service.CreateAccount(t.Context(), CreateInput{ + AppCode: "fami", TargetUserID: "123456", Username: "operator", Password: " ", CreatedByAdminID: 1, + }) + return err + }, + }, + { + name: "reset", + call: func() error { + _, err := service.ResetPassword(t.Context(), "fami", 7, " ") + return err + }, + }, + { + name: "change", + call: func() error { + return service.ChangePassword(t.Context(), SessionPrincipal{AccountID: 7, SessionID: 21, AppCode: "fami"}, ChangePasswordInput{ + CurrentPassword: "initial-password", NewPassword: " ", + }) + }, + }, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + if err := testCase.call(); !errors.Is(err, ErrPasswordBlank) { + t.Fatalf("error = %v, want ErrPasswordBlank", err) + } + }) + } +} + +func TestPasswordHandlersReturnExplicitWhitespaceValidationMessage(t *testing.T) { + tests := []struct { + name string + path string + body string + wantMessage string + register func(*gin.Engine, *Handler) + }{ + { + name: "create", path: "/create", wantMessage: "密码不能全部为空白字符", + body: `{"targetUserId":"123456","username":"operator","password":" "}`, + register: func(engine *gin.Engine, handler *Handler) { + engine.POST("/create", func(c *gin.Context) { + c.Set(adminmiddleware.ContextUserID, uint(1)) + c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami")) + c.Next() + }, handler.CreateAccount) + }, + }, + { + name: "reset", path: "/reset/7", wantMessage: "密码不能全部为空白字符", + body: `{"password":" "}`, + register: func(engine *gin.Engine, handler *Handler) { + engine.POST("/reset/:id", func(c *gin.Context) { + c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami")) + c.Next() + }, handler.ResetPassword) + }, + }, + { + name: "change", path: "/change", wantMessage: "新密码不能全部为空白字符", + body: `{"currentPassword":"initial-password","newPassword":" "}`, + register: func(engine *gin.Engine, handler *Handler) { + engine.POST("/change", func(c *gin.Context) { + c.Set(contextPrincipal, SessionPrincipal{AccountID: 7, SessionID: 21, AppCode: "fami"}) + c.Next() + }, handler.ChangePassword) + }, + }, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := &Handler{service: NewService(&gorm.DB{}, nil, Config{})} + engine := gin.New() + testCase.register(engine, handler) + request := httptest.NewRequest(http.MethodPost, testCase.path, bytes.NewBufferString(testCase.body)) + request.Header.Set("Content-Type", "application/json") + responseRecorder := httptest.NewRecorder() + engine.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String()) + } + var responseBody struct { + Message string `json:"message"` + } + if err := json.Unmarshal(responseRecorder.Body.Bytes(), &responseBody); err != nil { + t.Fatalf("decode response: %v", err) + } + if responseBody.Message != testCase.wantMessage { + t.Fatalf("message = %q, want %q", responseBody.Message, testCase.wantMessage) + } + }) + } +} + +func newPasswordReuseFixture(t *testing.T, currentPassword string) (*Service, sqlmock.Sqlmock, func()) { + t.Helper() + sqlDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("create sql mock: %v", err) + } + adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + sqlDB.Close() + t.Fatalf("create gorm db: %v", err) + } + passwordHash, err := security.HashPassword(currentPassword) + if err != nil { + sqlDB.Close() + t.Fatalf("hash fixture password: %v", err) + } + mock.ExpectBegin() + mock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). + WithArgs(uint64(7), "fami", 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status", + "password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms", + }).AddRow(7, "fami", 9001, "operator", passwordHash, `[]`, "active", true, 0, 0, 1, 1, 1)) + mock.ExpectCommit() + return NewService(adminDB, nil, Config{}), mock, func() { _ = sqlDB.Close() } +} diff --git a/server/admin/internal/modules/externaladmin/handler.go b/server/admin/internal/modules/externaladmin/handler.go new file mode 100644 index 00000000..594190c4 --- /dev/null +++ b/server/admin/internal/modules/externaladmin/handler.go @@ -0,0 +1,500 @@ +package externaladmin + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/middleware" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/response" + + "github.com/gin-gonic/gin" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gorm.io/gorm" + userv1 "hyapp.local/api/proto/user/v1" +) + +type Handler struct { + service *Service + cfg Config + audit shared.OperationLogger + userHost userv1.UserHostServiceClient +} + +type HandlerOption func(*Handler) + +// WithUserHostClient enables only the external portal's explicitly whitelisted +// owner-service reads. The raw client is not exposed to generic proxy routing. +func WithUserHostClient(client userv1.UserHostServiceClient) HandlerOption { + return func(handler *Handler) { + handler.userHost = client + } +} + +func WithLoginRateLimiter(limiter FixedWindowLimiter) HandlerOption { + return func(handler *Handler) { + handler.service.limiter = limiter + } +} + +func New(db *gorm.DB, userDB *sql.DB, cfg Config, audit shared.OperationLogger, options ...HandlerOption) *Handler { + handler := &Handler{service: NewService(db, userDB, cfg), cfg: cfg, audit: audit} + for _, option := range options { + if option != nil { + option(handler) + } + } + return handler +} + +const ( + myTeamAgencyRPCPageSize = int32(5000) + myTeamAgencyRPCTimeout = 5 * time.Second + maxLoginBodyBytes = int64(8 << 10) +) + +type createAccountRequest struct { + TargetUserID FlexibleString `json:"targetUserId" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type statusRequest struct { + Status string `json:"status" binding:"required"` +} + +type passwordRequest struct { + Password string `json:"password" binding:"required"` +} + +type loginRequest struct { + AppCode string `json:"appCode"` + Account string `json:"account"` + Username string `json:"username"` + Password string `json:"password"` +} + +type changePasswordRequest struct { + CurrentPassword string `json:"currentPassword"` + OldPassword string `json:"oldPassword"` + NewPassword string `json:"newPassword" binding:"required"` +} + +func (h *Handler) ListAccounts(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSizeRaw := c.Query("pageSize") + if pageSizeRaw == "" { + pageSizeRaw = c.DefaultQuery("page_size", "20") + } + pageSize, _ := strconv.Atoi(pageSizeRaw) + result, err := h.service.ListAccounts(c.Request.Context(), ListInput{ + AppCode: appctx.FromContext(c.Request.Context()), Page: page, PageSize: pageSize, + Keyword: c.Query("keyword"), Status: c.Query("status"), + }) + if err != nil { + response.ServerError(c, "获取外管用户失败") + return + } + response.OK(c, result) +} + +func (h *Handler) ResolveTarget(c *gin.Context) { + target := strings.TrimSpace(c.Query("display_user_id")) + if target == "" { + target = strings.TrimSpace(c.Query("displayUserId")) + } + user, err := h.service.ResolveTarget(c.Request.Context(), appctx.FromContext(c.Request.Context()), target) + if errors.Is(err, ErrInvalidInput) { + response.BadRequest(c, "请输入用户短 ID") + return + } + if errors.Is(err, ErrTargetNotFound) { + response.NotFound(c, "当前 App 未找到该用户") + return + } + if err != nil { + response.ServerError(c, "查询用户失败") + return + } + existing, err := h.service.FindAccountByLinkedUser(c.Request.Context(), appctx.FromContext(c.Request.Context()), user) + if err != nil { + response.ServerError(c, "查询外管用户绑定失败") + return + } + response.OK(c, gin.H{"linkedUser": user, "existingExternalAdminUser": existing}) +} + +func (h *Handler) CreateAccount(c *gin.Context) { + var request createAccountRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "创建参数不正确") + return + } + account, err := h.service.CreateAccount(c.Request.Context(), CreateInput{ + AppCode: appctx.FromContext(c.Request.Context()), TargetUserID: string(request.TargetUserID), + Username: request.Username, Password: request.Password, CreatedByAdminID: middleware.CurrentUserID(c), + }) + if errors.Is(err, ErrAccountConflict) { + response.Conflict(c, "当前 App 的账户名称或绑定用户已存在") + return + } + if errors.Is(err, ErrTargetNotFound) { + response.NotFound(c, "当前 App 未找到该用户") + return + } + if errors.Is(err, ErrPasswordBlank) { + response.BadRequest(c, "密码不能全部为空白字符") + return + } + if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") { + response.BadRequest(c, "账户名称格式不正确,密码长度需为 8-72 位") + return + } + if err != nil { + response.ServerError(c, "创建外管用户失败") + return + } + shared.OperationLogWithResourceID(c, h.audit, "create-external-admin-user", "external_admin_accounts", strconv.FormatUint(account.ID, 10), "success", fmt.Sprintf("app_code=%s linked_user_id=%d username=%s", account.AppCode, account.LinkedUser.UserID, account.Username)) + response.Created(c, account) +} + +func (h *Handler) SetStatus(c *gin.Context) { + id, ok := parseUint64Param(c, "id") + if !ok { + return + } + var request statusRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "状态参数不正确") + return + } + account, err := h.service.SetStatus(c.Request.Context(), appctx.FromContext(c.Request.Context()), id, request.Status) + if errors.Is(err, ErrInvalidInput) { + response.BadRequest(c, "状态仅支持 active 或 disabled") + return + } + if errors.Is(err, ErrAccountNotFound) { + response.NotFound(c, "外管用户不存在") + return + } + if err != nil { + response.ServerError(c, "更新外管用户状态失败") + return + } + shared.OperationLogWithResourceID(c, h.audit, "update-external-admin-user-status", "external_admin_accounts", strconv.FormatUint(id, 10), "success", fmt.Sprintf("app_code=%s status=%s sessions_revoked=%t", account.AppCode, account.Status, account.Status == "disabled")) + response.OK(c, account) +} + +func (h *Handler) ResetPassword(c *gin.Context) { + id, ok := parseUint64Param(c, "id") + if !ok { + return + } + var request passwordRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "密码参数不正确") + return + } + account, err := h.service.ResetPassword(c.Request.Context(), appctx.FromContext(c.Request.Context()), id, request.Password) + if errors.Is(err, ErrPasswordBlank) { + response.BadRequest(c, "密码不能全部为空白字符") + return + } + if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") { + response.BadRequest(c, "密码长度需为 8-72 位") + return + } + if errors.Is(err, ErrAccountNotFound) { + response.NotFound(c, "外管用户不存在") + return + } + if err != nil { + response.ServerError(c, "重置外管用户密码失败") + return + } + shared.OperationLogWithResourceID(c, h.audit, "reset-external-admin-user-password", "external_admin_accounts", strconv.FormatUint(id, 10), "success", fmt.Sprintf("app_code=%s sessions_revoked=true", account.AppCode)) + response.OK(c, account) +} + +func (h *Handler) ListApps(c *gin.Context) { + apps, err := h.service.ListApps(c.Request.Context()) + if err != nil { + response.ServerError(c, "获取 App 列表失败") + return + } + response.OK(c, gin.H{"items": apps, "total": len(apps)}) +} + +func (h *Handler) Login(c *gin.Context) { + // Limit parsing memory before decoding untrusted credentials. Binding tags are + // intentionally absent on loginRequest: every syntactically valid JSON attempt, + // including missing fields, must reach the distributed limiter before rejection. + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxLoginBodyBytes) + var request loginRequest + if err := c.ShouldBindJSON(&request); err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + response.PayloadTooLarge(c, "登录请求体不能超过 8 KiB") + return + } + response.BadRequest(c, "登录参数不正确") + return + } + username := strings.TrimSpace(request.Account) + if username == "" { + username = request.Username + } + result, err := h.service.Login(c.Request.Context(), LoginInput{ + AppCode: request.AppCode, Username: username, Password: request.Password, + IP: c.ClientIP(), UserAgent: c.Request.UserAgent(), + }) + var rateLimitErr *LoginRateLimitError + if errors.As(err, &rateLimitErr) { + retrySeconds := int64((rateLimitErr.RetryAfter + time.Second - 1) / time.Second) + if retrySeconds < 1 { + retrySeconds = 1 + } + c.Header("Retry-After", strconv.FormatInt(retrySeconds, 10)) + response.TooManyRequests(c, "登录尝试过于频繁,请稍后重试") + return + } + if errors.Is(err, ErrLoginLimiterFailed) { + response.ServiceUnavailable(c, "登录安全服务暂不可用") + return + } + if errors.Is(err, ErrInvalidCredentials) { + response.Unauthorized(c, "App、账号或密码错误") + return + } + if err != nil { + response.ServerError(c, "登录服务暂不可用") + return + } + h.setSessionCookies(c, result.SessionToken, result.CSRFToken) + c.Header(CSRFHeaderName, result.CSRFToken) + response.OK(c, result.View) +} + +func (h *Handler) Me(c *gin.Context) { + principal, ok := CurrentPrincipal(c) + if !ok { + response.Unauthorized(c, "外管会话已失效") + return + } + csrfToken := CurrentCSRFToken(c) + view, err := h.service.Me(c.Request.Context(), principal, csrfToken) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, sql.ErrNoRows) || errors.Is(err, ErrTargetNotFound) { + h.clearSessionCookies(c) + response.Unauthorized(c, "外管会话已失效") + } else { + response.ServerError(c, "获取外管会话失败") + } + return + } + if csrfToken != "" { + c.Header(CSRFHeaderName, csrfToken) + } + response.OK(c, view) +} + +func (h *Handler) Logout(c *gin.Context) { + principal, _ := CurrentPrincipal(c) + if err := h.service.RevokeSession(c.Request.Context(), principal.SessionID, "logout"); err != nil { + response.ServerError(c, "退出登录失败") + return + } + h.clearSessionCookies(c) + response.OK(c, gin.H{"loggedOut": true}) +} + +func (h *Handler) ChangePassword(c *gin.Context) { + principal, ok := CurrentPrincipal(c) + if !ok { + response.Unauthorized(c, "外管会话已失效") + return + } + var request changePasswordRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "密码参数不正确") + return + } + currentPassword := request.CurrentPassword + if strings.TrimSpace(currentPassword) == "" { + currentPassword = request.OldPassword + } + err := h.service.ChangePassword(c.Request.Context(), principal, ChangePasswordInput{CurrentPassword: currentPassword, NewPassword: request.NewPassword}) + if errors.Is(err, ErrInvalidCredentials) { + response.BadRequest(c, "当前密码不正确") + return + } + if errors.Is(err, ErrPasswordReused) { + response.BadRequest(c, "新密码不能与当前密码相同") + return + } + if errors.Is(err, ErrPasswordBlank) { + response.BadRequest(c, "新密码不能全部为空白字符") + return + } + if errors.Is(err, ErrInvalidInput) { + response.BadRequest(c, "新密码长度需为 8-72 位") + return + } + if err != nil { + response.ServerError(c, "修改密码失败") + return + } + response.OK(c, gin.H{"passwordChanged": true}) +} + +// ListMyTeamAgencies returns only the team rooted at the App user bound to this +// external account. Deliberately do not read manager_user_id/user_id from query or +// path parameters: otherwise a valid external session could enumerate another +// manager's organization tree by changing one client-controlled value. +func (h *Handler) ListMyTeamAgencies(c *gin.Context) { + principal, ok := CurrentPrincipal(c) + if !ok || principal.LinkedAppUserID <= 0 { + response.Unauthorized(c, "外管会话已失效") + return + } + if h.userHost == nil { + response.ServerError(c, "团队服务暂不可用") + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSizeRaw := strings.TrimSpace(c.Query("pageSize")) + if pageSizeRaw == "" { + pageSizeRaw = c.DefaultQuery("page_size", "20") + } + pageSize, _ := strconv.Atoi(pageSizeRaw) + page, pageSize = normalizePage(page, pageSize) + + // The owner service resolves the complete active manager tree up to its + // defensive 5,000-row cap. Local slicing keeps total and pagination mutually + // consistent, while truncated explicitly tells clients when the owner cap hit. + ctx, cancel := context.WithTimeout(c.Request.Context(), myTeamAgencyRPCTimeout) + defer cancel() + result, err := h.userHost.ListManagerTeamAgencies(ctx, &userv1.ListManagerTeamAgenciesRequest{ + Meta: &userv1.RequestMeta{ + RequestId: middleware.CurrentRequestID(c), + Caller: "hyapp-admin-server-external", + SentAtMs: time.Now().UTC().UnixMilli(), + AppCode: principal.AppCode, + ClientIp: c.ClientIP(), + UserAgent: truncateText(c.Request.UserAgent(), 512), + }, + ManagerUserId: principal.LinkedAppUserID, + PageSize: myTeamAgencyRPCPageSize, + }) + if err != nil { + if status.Code(err) == codes.PermissionDenied { + response.Forbidden(c, "当前绑定用户不是有效的团队管理员") + return + } + response.ServerError(c, "获取我的团队失败") + return + } + + allItems := make([]MyTeamAgency, 0, len(result.GetAgencies())) + for _, agency := range result.GetAgencies() { + if agency == nil || agency.GetAgencyId() <= 0 { + continue + } + allItems = append(allItems, MyTeamAgency{ + AgencyID: agency.GetAgencyId(), OwnerUserID: agency.GetOwnerUserId(), + ParentBDUserID: agency.GetParentBdUserId(), Status: "active", + }) + } + // The owner RPC intentionally returns only relation IDs. Honor the UI's keyword + // without widening scope or scanning user tables: match only IDs already present + // in this session-scoped result, then calculate total and pagination from that set. + allItems = filterMyTeamAgencies(allItems, c.Query("keyword")) + items := paginateMyTeamAgencies(allItems, page, pageSize) + response.OK(c, MyTeamAgencyPage{ + Items: items, Page: page, PageSize: pageSize, Total: len(allItems), + TotalBDLeaders: result.GetTotalBdLeaders(), TotalBDs: result.GetTotalBds(), Truncated: result.GetTruncated(), + }) +} + +func filterMyTeamAgencies(items []MyTeamAgency, keyword string) []MyTeamAgency { + keyword = strings.TrimSpace(keyword) + if keyword == "" { + return items + } + filtered := make([]MyTeamAgency, 0, len(items)) + for _, item := range items { + if strings.Contains(strconv.FormatInt(item.AgencyID, 10), keyword) || + strings.Contains(strconv.FormatInt(item.OwnerUserID, 10), keyword) || + strings.Contains(strconv.FormatInt(item.ParentBDUserID, 10), keyword) { + filtered = append(filtered, item) + } + } + return filtered +} + +func paginateMyTeamAgencies(items []MyTeamAgency, page int, pageSize int) []MyTeamAgency { + if len(items) == 0 { + return []MyTeamAgency{} + } + lastPage := (len(items) + pageSize - 1) / pageSize + if page > lastPage { + return []MyTeamAgency{} + } + start := (page - 1) * pageSize + end := start + pageSize + if end > len(items) { + end = len(items) + } + return items[start:end] +} + +func (h *Handler) setSessionCookies(c *gin.Context, sessionToken string, csrfToken string) { + maxAge := int(h.cfg.SessionTTL.Seconds()) + if maxAge <= 0 { + maxAge = int((12 * time.Hour).Seconds()) + } + h.writeCookie(c, SessionCookieName, sessionToken, true, maxAge, time.Now().UTC().Add(time.Duration(maxAge)*time.Second)) + h.writeCookie(c, CSRFCookieName, csrfToken, false, maxAge, time.Now().UTC().Add(time.Duration(maxAge)*time.Second)) +} + +func (h *Handler) clearSessionCookies(c *gin.Context) { + expired := time.Unix(1, 0).UTC() + h.writeCookie(c, SessionCookieName, "", true, -1, expired) + h.writeCookie(c, CSRFCookieName, "", false, -1, expired) +} + +func (h *Handler) writeCookie(c *gin.Context, name string, value string, httpOnly bool, maxAge int, expires time.Time) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, Value: value, Path: CookiePath, MaxAge: maxAge, Expires: expires, + HttpOnly: httpOnly, Secure: h.cfg.CookieSecure, SameSite: parseSameSite(h.cfg.CookieSameSite), + }) +} + +func parseSameSite(value string) http.SameSite { + switch strings.ToLower(strings.TrimSpace(value)) { + case "strict": + return http.SameSiteStrictMode + case "none": + return http.SameSiteNoneMode + default: + return http.SameSiteLaxMode + } +} + +func parseUint64Param(c *gin.Context, name string) (uint64, bool) { + id, err := strconv.ParseUint(c.Param(name), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "ID 参数不正确") + return 0, false + } + return id, true +} diff --git a/server/admin/internal/modules/externaladmin/middleware.go b/server/admin/internal/modules/externaladmin/middleware.go new file mode 100644 index 00000000..807c7bd9 --- /dev/null +++ b/server/admin/internal/modules/externaladmin/middleware.go @@ -0,0 +1,192 @@ +package externaladmin + +import ( + "crypto/subtle" + "errors" + "net/http" + "strings" + "time" + + "hyapp-admin-server/internal/appctx" + adminmiddleware "hyapp-admin-server/internal/middleware" + "hyapp-admin-server/internal/model" + "hyapp-admin-server/internal/response" + "hyapp-admin-server/internal/security" + + "github.com/gin-gonic/gin" +) + +const ( + contextPrincipal = "externalAdminPrincipal" + contextCSRFToken = "externalAdminCSRFToken" +) + +func (h *Handler) AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + cookie, err := c.Request.Cookie(SessionCookieName) + if err != nil || strings.TrimSpace(cookie.Value) == "" { + response.Unauthorized(c, "缺少外管会话") + c.Abort() + return + } + principal, err := h.service.Authenticate(c.Request.Context(), cookie.Value) + if err != nil { + if errors.Is(err, ErrInvalidSession) { + h.clearSessionCookies(c) + response.Unauthorized(c, "外管会话已失效") + } else { + response.ServerError(c, "外管会话服务暂不可用") + } + c.Abort() + return + } + + // The session is the tenant authority. A caller may omit X-App-Code, but it can never + // switch tenants by sending a header that differs from the App fixed at login. + headerAppCode := strings.ToLower(strings.TrimSpace(c.GetHeader(appctx.HeaderAppCode))) + if headerAppCode != "" && headerAppCode != principal.AppCode { + response.Forbidden(c, "请求 App 与外管会话不一致") + c.Abort() + return + } + c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), principal.AppCode)) + c.Header(appctx.HeaderAppCode, principal.AppCode) + c.Set(contextPrincipal, principal) + // Existing business handlers forward these generic actor fields to owner services. Use a + // collision-free synthetic identity there; the real account remains in the external audit log. + c.Set(adminmiddleware.ContextUserID, principal.OperatorID) + c.Set(adminmiddleware.ContextUsername, operatorUsername(principal.AppCode, principal.Username)) + c.Set(adminmiddleware.ContextPermissions, principal.Permissions) + + // The raw CSRF secret is never persisted server-side. Echo it only when the browser's + // double-submit cookie still matches the hash stored in this exact session. + if csrfCookie, cookieErr := c.Request.Cookie(CSRFCookieName); cookieErr == nil && secureEqualHash(csrfCookie.Value, principal.CSRFTokenHash) { + c.Set(contextCSRFToken, csrfCookie.Value) + } + c.Next() + } +} + +func (h *Handler) RequireCSRF() gin.HandlerFunc { + return func(c *gin.Context) { + if isSafeMethod(c.Request.Method) { + c.Next() + return + } + principal, ok := CurrentPrincipal(c) + if !ok { + response.Unauthorized(c, "外管会话已失效") + c.Abort() + return + } + csrfCookie, cookieErr := c.Request.Cookie(CSRFCookieName) + headerToken := strings.TrimSpace(c.GetHeader(CSRFHeaderName)) + if cookieErr != nil || headerToken == "" || !secureEqual(csrfCookie.Value, headerToken) || !secureEqualHash(headerToken, principal.CSRFTokenHash) { + response.Forbidden(c, "CSRF 校验失败") + c.Abort() + return + } + c.Set(contextCSRFToken, headerToken) + c.Next() + } +} + +func (h *Handler) RequirePasswordChanged() gin.HandlerFunc { + return func(c *gin.Context) { + principal, ok := CurrentPrincipal(c) + if !ok { + response.Unauthorized(c, "外管会话已失效") + c.Abort() + return + } + if principal.PasswordChangeRequired { + response.Forbidden(c, "请先修改初始密码") + c.Abort() + return + } + c.Next() + } +} + +func (h *Handler) Audit() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + if isSafeMethod(c.Request.Method) { + return + } + principal, ok := CurrentPrincipal(c) + if !ok { + return + } + status := "success" + if c.Writer.Status() >= http.StatusBadRequest { + status = "failed" + } + fullPath := c.FullPath() + if fullPath == "" { + fullPath = c.Request.URL.Path + } + _ = h.service.CreateOperationLog(c.Request.Context(), model.ExternalAdminOperationLog{ + AccountID: principal.AccountID, AppCode: principal.AppCode, Username: principal.Username, + RequestID: adminmiddleware.CurrentRequestID(c), Action: strings.ToLower(c.Request.Method) + " " + fullPath, + Resource: externalAuditResource(fullPath), ResourceID: externalAuditResourceID(c), + Method: c.Request.Method, Path: c.Request.URL.Path, IP: c.ClientIP(), UserAgent: c.Request.UserAgent(), + Status: status, HTTPStatus: c.Writer.Status(), LatencyMS: time.Since(start).Milliseconds(), + }) + } +} + +func CurrentPrincipal(c *gin.Context) (SessionPrincipal, bool) { + value, ok := c.Get(contextPrincipal) + if !ok { + return SessionPrincipal{}, false + } + principal, ok := value.(SessionPrincipal) + return principal, ok +} + +func CurrentCSRFToken(c *gin.Context) string { + value, _ := c.Get(contextCSRFToken) + token, _ := value.(string) + return token +} + +func secureEqual(left string, right string) bool { + if len(left) == 0 || len(left) != len(right) { + return false + } + return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1 +} + +func secureEqualHash(raw string, expectedHash string) bool { + return secureEqual(security.HashToken(raw), expectedHash) +} + +func isSafeMethod(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + default: + return false + } +} + +func externalAuditResourceID(c *gin.Context) string { + for _, name := range []string{"id", "user_id", "room_id", "resource_id", "grant_id", "pretty_id", "banner_id"} { + if value := c.Param(name); value != "" { + return value + } + } + return "" +} + +func externalAuditResource(path string) string { + parts := strings.Split(strings.Trim(path, "/"), "/") + for index, part := range parts { + if part == "external" && index+1 < len(parts) { + return parts[index+1] + } + } + return "external-admin" +} diff --git a/server/admin/internal/modules/externaladmin/middleware_test.go b/server/admin/internal/modules/externaladmin/middleware_test.go new file mode 100644 index 00000000..1c6ed3e0 --- /dev/null +++ b/server/admin/internal/modules/externaladmin/middleware_test.go @@ -0,0 +1,271 @@ +package externaladmin + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "hyapp-admin-server/internal/appctx" + adminmiddleware "hyapp-admin-server/internal/middleware" + "hyapp-admin-server/internal/security" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestRequireCSRFAcceptsOnlyMatchingCookieHeaderAndSessionHash(t *testing.T) { + gin.SetMode(gin.TestMode) + csrfToken := "csrf-secret" + handler := &Handler{} + engine := gin.New() + engine.POST("/write", func(c *gin.Context) { + c.Set(contextPrincipal, SessionPrincipal{CSRFTokenHash: security.HashToken(csrfToken)}) + }, handler.RequireCSRF(), func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + validRequest := httptest.NewRequest(http.MethodPost, "/write", nil) + validRequest.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: csrfToken}) + validRequest.Header.Set(CSRFHeaderName, csrfToken) + validResponse := httptest.NewRecorder() + engine.ServeHTTP(validResponse, validRequest) + if validResponse.Code != http.StatusNoContent { + t.Fatalf("valid csrf status = %d body=%s", validResponse.Code, validResponse.Body.String()) + } + + invalidRequest := httptest.NewRequest(http.MethodPost, "/write", nil) + invalidRequest.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: csrfToken}) + invalidRequest.Header.Set(CSRFHeaderName, "different") + invalidResponse := httptest.NewRecorder() + engine.ServeHTTP(invalidResponse, invalidRequest) + if invalidResponse.Code != http.StatusForbidden { + t.Fatalf("invalid csrf status = %d body=%s", invalidResponse.Code, invalidResponse.Body.String()) + } +} + +func TestExternalSessionCookiesAreHostOnlyAndPathScoped(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := &Handler{cfg: Config{SessionTTL: time.Hour, CookieSecure: true, CookieSameSite: "none"}} + engine := gin.New() + engine.POST("/login", func(c *gin.Context) { + handler.setSessionCookies(c, "session-token", "csrf-token") + c.Status(http.StatusNoContent) + }) + response := httptest.NewRecorder() + engine.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/login", nil)) + + cookies := response.Result().Cookies() + if len(cookies) != 2 { + t.Fatalf("cookie count = %d, want 2", len(cookies)) + } + for _, cookie := range cookies { + if cookie.Path != CookiePath || cookie.Domain != "" || !cookie.Secure || cookie.SameSite != http.SameSiteNoneMode { + t.Fatalf("unsafe cookie attributes: %+v", cookie) + } + } + if !cookieByName(cookies, SessionCookieName).HttpOnly { + t.Fatal("session cookie must be HttpOnly") + } + if cookieByName(cookies, CSRFCookieName).HttpOnly { + t.Fatal("double-submit CSRF cookie must be readable") + } +} + +func TestExternalSessionCookiesAllowLocalHTTPWithLaxSameSite(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := &Handler{cfg: Config{SessionTTL: time.Hour, CookieSecure: false, CookieSameSite: "lax"}} + engine := gin.New() + engine.POST("/login", func(c *gin.Context) { + handler.setSessionCookies(c, "session-token", "csrf-token") + c.Status(http.StatusNoContent) + }) + response := httptest.NewRecorder() + engine.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/login", nil)) + for _, cookie := range response.Result().Cookies() { + if cookie.Secure || cookie.SameSite != http.SameSiteLaxMode { + t.Fatalf("local cookie must work over HTTP with Lax SameSite: %+v", cookie) + } + } +} + +func TestAuthRequiredRejectsMismatchedAppHeaderAndBindsOmittedHeaderToSessionApp(t *testing.T) { + for _, testCase := range []struct { + name string + headerApp string + wantStatus int + wantBound bool + }{ + {name: "mismatch rejected", headerApp: "lalu", wantStatus: http.StatusForbidden}, + {name: "omitted header bound", headerApp: "", wantStatus: http.StatusNoContent, wantBound: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + handler, adminMock, userMock, cleanup := newAuthRequiredFixture(t, "session-secret", "active") + defer cleanup() + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.GET("/protected", handler.AuthRequired(), func(c *gin.Context) { + if testCase.wantBound { + principal, ok := CurrentPrincipal(c) + if !ok || principal.SessionID != 11 { + t.Fatalf("session principal = %+v ok=%t", principal, ok) + } + if got := appctx.FromContext(c.Request.Context()); got != "fami" { + t.Fatalf("request app = %q, want session app fami", got) + } + if got := c.Writer.Header().Get(appctx.HeaderAppCode); got != "fami" { + t.Fatalf("response app header = %q", got) + } + if got := adminmiddleware.CurrentUserID(c); uint64(got) != externalOperatorNamespace|7 { + t.Fatalf("synthetic operator = %d", got) + } + } + c.Status(http.StatusNoContent) + }) + + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "session-secret"}) + if testCase.headerApp != "" { + request.Header.Set(appctx.HeaderAppCode, testCase.headerApp) + } + responseRecorder := httptest.NewRecorder() + engine.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != testCase.wantStatus { + t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String()) + } + assertAuthFixtureExpectations(t, adminMock, userMock) + }) + } +} + +func TestAuthRequiredBlocksBusinessDirectlyWhenSessionAppWasDisabled(t *testing.T) { + handler, adminMock, userMock, cleanup := newAuthRequiredFixture(t, "session-secret", "disabled") + defer cleanup() + gin.SetMode(gin.TestMode) + engine := gin.New() + reachedBusiness := false + // Intentionally omit /auth/me: this proves a stale browser cannot bypass the + // App-state check by navigating straight to a business API. + engine.GET("/business", handler.AuthRequired(), func(c *gin.Context) { + reachedBusiness = true + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "/business", nil) + request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "session-secret"}) + responseRecorder := httptest.NewRecorder() + engine.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != http.StatusUnauthorized || reachedBusiness { + t.Fatalf("status=%d reached_business=%t body=%s", responseRecorder.Code, reachedBusiness, responseRecorder.Body.String()) + } + assertAuthFixtureExpectations(t, adminMock, userMock) +} + +func TestAuthRequiredFailsClosedWithoutRevokingOnAppDatabaseError(t *testing.T) { + handler, adminMock, userMock, cleanup := newAuthRequiredFixture(t, "session-secret", "error") + defer cleanup() + gin.SetMode(gin.TestMode) + engine := gin.New() + reachedBusiness := false + engine.GET("/business", handler.AuthRequired(), func(c *gin.Context) { + reachedBusiness = true + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "/business", nil) + request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "session-secret"}) + responseRecorder := httptest.NewRecorder() + engine.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != http.StatusInternalServerError || reachedBusiness { + t.Fatalf("status=%d reached_business=%t body=%s", responseRecorder.Code, reachedBusiness, responseRecorder.Body.String()) + } + // The fixture has no revocation UPDATE expectation. A transient owner-DB error + // must fail this request closed without converting a recoverable session to 401. + assertAuthFixtureExpectations(t, adminMock, userMock) +} + +func TestRequirePasswordChangedBlocksBusinessRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := &Handler{} + engine := gin.New() + engine.GET("/business", func(c *gin.Context) { + c.Set(contextPrincipal, SessionPrincipal{PasswordChangeRequired: true}) + c.Next() + }, handler.RequirePasswordChanged(), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + responseRecorder := httptest.NewRecorder() + engine.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/business", nil)) + if responseRecorder.Code != http.StatusForbidden { + t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String()) + } +} + +func newAuthRequiredFixture(t *testing.T, rawToken string, appState string) (*Handler, sqlmock.Sqlmock, sqlmock.Sqlmock, func()) { + t.Helper() + adminSQL, adminMock, err := sqlmock.New() + if err != nil { + t.Fatalf("create admin sql mock: %v", err) + } + adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + adminSQL.Close() + t.Fatalf("create gorm db: %v", err) + } + userDB, userMock, err := sqlmock.New() + if err != nil { + adminSQL.Close() + t.Fatalf("create user sql mock: %v", err) + } + nowMS := time.Now().UTC().UnixMilli() + adminMock.ExpectQuery("SELECT \\* FROM `external_admin_sessions` WHERE token_hash = \\? AND revoked_at_ms = 0 AND expires_at_ms > \\?"). + WithArgs(security.HashToken(rawToken), sqlmock.AnyArg(), 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "account_id", "app_code", "token_hash", "csrf_token_hash", "ip", "user_agent", "expires_at_ms", "last_seen_at_ms", "revoked_at_ms", "revoke_reason", "created_at_ms", + }).AddRow(11, 7, "fami", security.HashToken(rawToken), security.HashToken("csrf"), "127.0.0.1", "browser", nowMS+3600000, nowMS, 0, "", nowMS)) + adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE `external_admin_accounts`.`id` = \\?"). + WithArgs(uint64(7)). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status", + "password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms", + }).AddRow(7, "fami", 9001, "operator", "hash", `["bd:view"]`, "active", false, 0, 0, 1, nowMS, nowMS)) + appQuery := userMock.ExpectQuery("SELECT app_code, app_name, logo_url FROM apps WHERE app_code = \\? AND status = 'active' LIMIT 1").WithArgs("fami") + switch appState { + case "active": + appQuery.WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}).AddRow("fami", "Fami", "logo")) + case "disabled": + appQuery.WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"})) + adminMock.ExpectBegin() + adminMock.ExpectExec("UPDATE `external_admin_sessions`"). + WithArgs("app_disabled", sqlmock.AnyArg(), uint64(11)). + WillReturnResult(sqlmock.NewResult(0, 1)) + adminMock.ExpectCommit() + case "error": + appQuery.WillReturnError(errors.New("user database temporarily unavailable")) + default: + t.Fatalf("unknown app state %q", appState) + } + return &Handler{service: NewService(adminDB, userDB, Config{})}, adminMock, userMock, func() { + _ = userDB.Close() + _ = adminSQL.Close() + } +} + +func assertAuthFixtureExpectations(t *testing.T, adminMock sqlmock.Sqlmock, userMock sqlmock.Sqlmock) { + t.Helper() + if err := adminMock.ExpectationsWereMet(); err != nil { + t.Fatalf("admin auth sql expectations: %v", err) + } + if err := userMock.ExpectationsWereMet(); err != nil { + t.Fatalf("user auth sql expectations: %v", err) + } +} + +func cookieByName(cookies []*http.Cookie, name string) *http.Cookie { + for _, cookie := range cookies { + if cookie.Name == name { + return cookie + } + } + return &http.Cookie{} +} diff --git a/server/admin/internal/modules/externaladmin/rate_limit_test.go b/server/admin/internal/modules/externaladmin/rate_limit_test.go new file mode 100644 index 00000000..941a38e4 --- /dev/null +++ b/server/admin/internal/modules/externaladmin/rate_limit_test.go @@ -0,0 +1,178 @@ +package externaladmin + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "hyapp-admin-server/internal/cache" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type allowFixedWindowLimiter struct{} + +func (allowFixedWindowLimiter) ConsumeFixedWindow(context.Context, time.Duration, []cache.FixedWindowRule) (cache.FixedWindowDecision, error) { + return cache.FixedWindowDecision{Allowed: true}, nil +} + +type recordingFixedWindowLimiter struct { + decision cache.FixedWindowDecision + err error + calls int + window time.Duration + rules []cache.FixedWindowRule + deadlineSet bool + deadlineIn time.Duration +} + +func (limiter *recordingFixedWindowLimiter) ConsumeFixedWindow(ctx context.Context, window time.Duration, rules []cache.FixedWindowRule) (cache.FixedWindowDecision, error) { + limiter.calls++ + limiter.window = window + limiter.rules = append([]cache.FixedWindowRule(nil), rules...) + deadline, ok := ctx.Deadline() + limiter.deadlineSet = ok + if ok { + limiter.deadlineIn = time.Until(deadline) + } + return limiter.decision, limiter.err +} + +func TestLoginRateLimiterUsesFourHashedClusterSafeLayersBeforeDatabase(t *testing.T) { + limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: true}} + service := NewService(&gorm.DB{}, nil, Config{LoginRateLimit: LoginRateLimitConfig{ + Window: time.Minute, SystemLimit: 300, AppLimit: 120, IPLimit: 30, IdentityLimit: 10, + }}) + service.limiter = limiter + + // Empty password is invalid, but this syntactically valid attempt must consume + // distributed counters before dummy bcrypt/input rejection. + _, err := service.Login(t.Context(), LoginInput{AppCode: "FAMI", Username: "Operator", IP: "203.0.113.44"}) + if !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("login error = %v", err) + } + if limiter.calls != 1 || limiter.window != time.Minute || len(limiter.rules) != 4 { + t.Fatalf("limiter call=%d window=%s rules=%+v", limiter.calls, limiter.window, limiter.rules) + } + if !limiter.deadlineSet || limiter.deadlineIn <= 0 || limiter.deadlineIn > loginLimiterTimeout+25*time.Millisecond { + t.Fatalf("independent limiter deadline = %s set=%t", limiter.deadlineIn, limiter.deadlineSet) + } + wantLimits := []uint64{300, 120, 30, 10} + for index, rule := range limiter.rules { + if rule.Limit != wantLimits[index] { + t.Fatalf("rule %d limit=%d", index, rule.Limit) + } + if !strings.Contains(rule.Key, loginLimiterClusterTag) { + t.Fatalf("rule %d is not Redis Cluster-safe: %s", index, rule.Key) + } + for _, secret := range []string{"fami", "operator", "203.0.113.44"} { + if strings.Contains(rule.Key, secret) { + t.Fatalf("rule %d leaks %q: %s", index, secret, rule.Key) + } + } + } + if limiter.rules[0].Key != "rate:{external-login}:system" { + t.Fatalf("system-global key must not vary by App: %s", limiter.rules[0].Key) + } + if len(loginRateLimitDigest("fami")) != 32 || loginRateLimitDigest("fami") == loginRateLimitDigest("lalu") { + t.Fatal("rate-limit identifiers must use a truncated SHA-256 digest") + } +} + +func TestLoginRateLimitDenialAndRedisFailurePrecedeAllDatabaseWork(t *testing.T) { + t.Run("limited", func(t *testing.T) { + limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: false, RetryAfter: 1500 * time.Millisecond}} + service := NewService(nil, nil, Config{}) + service.limiter = limiter + _, err := service.Login(t.Context(), LoginInput{AppCode: "fami", Username: "operator", Password: "password", IP: "127.0.0.1"}) + var rateErr *LoginRateLimitError + if !errors.As(err, &rateErr) || rateErr.RetryAfter != 1500*time.Millisecond { + t.Fatalf("rate error = %#v", err) + } + }) + + t.Run("redis unavailable", func(t *testing.T) { + limiter := &recordingFixedWindowLimiter{err: errors.New("redis down")} + service := NewService(nil, nil, Config{}) + service.limiter = limiter + _, err := service.Login(t.Context(), LoginInput{AppCode: "fami", Username: "operator", Password: "password", IP: "127.0.0.1"}) + if !errors.Is(err, ErrLoginLimiterFailed) { + t.Fatalf("login error = %v", err) + } + }) + + t.Run("missing redis", func(t *testing.T) { + service := NewService(nil, nil, Config{}) + _, err := service.Login(t.Context(), LoginInput{AppCode: "fami", Username: "operator", Password: "password", IP: "127.0.0.1"}) + if !errors.Is(err, ErrLoginLimiterFailed) { + t.Fatalf("login error = %v", err) + } + }) +} + +func TestLoginHandlerReturns429RetryAfterAnd503OnLimiterFailure(t *testing.T) { + for _, testCase := range []struct { + name string + limiter *recordingFixedWindowLimiter + wantStatus int + wantRetry string + body string + }{ + { + name: "limited", limiter: &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: false, RetryAfter: 1500 * time.Millisecond}}, + wantStatus: http.StatusTooManyRequests, wantRetry: "2", body: `{"appCode":"fami","account":"operator","password":"password"}`, + }, + { + name: "valid empty json still counted", limiter: &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: false, RetryAfter: time.Second}}, + wantStatus: http.StatusTooManyRequests, wantRetry: "1", body: `{}`, + }, + { + name: "redis unavailable", limiter: &recordingFixedWindowLimiter{err: errors.New("redis down")}, + wantStatus: http.StatusServiceUnavailable, body: `{"appCode":"fami","account":"operator","password":"password"}`, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + handler := New(nil, nil, Config{}, nil, WithLoginRateLimiter(testCase.limiter)) + RegisterExternalRoutes(engine.Group("/api/v1"), handler, BusinessHandlers{}) + request := httptest.NewRequest(http.MethodPost, "/api/v1/external/auth/login", strings.NewReader(testCase.body)) + request.Header.Set("Content-Type", "application/json") + responseRecorder := httptest.NewRecorder() + engine.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != testCase.wantStatus { + t.Fatalf("status=%d body=%s", responseRecorder.Code, responseRecorder.Body.String()) + } + if got := responseRecorder.Header().Get("Retry-After"); got != testCase.wantRetry { + t.Fatalf("Retry-After=%q want=%q", got, testCase.wantRetry) + } + if responseRecorder.Header().Get("Cache-Control") != "no-store" { + t.Fatal("rate-limit auth responses must remain non-cacheable") + } + }) + } +} + +func TestLoginHandlerCapsBodyBeforeDistributedLimiter(t *testing.T) { + limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: true}} + gin.SetMode(gin.TestMode) + engine := gin.New() + handler := New(nil, nil, Config{}, nil, WithLoginRateLimiter(limiter)) + RegisterExternalRoutes(engine.Group("/api/v1"), handler, BusinessHandlers{}) + body := `{"appCode":"fami","account":"operator","password":"` + strings.Repeat("x", int(maxLoginBodyBytes)) + `"}` + request := httptest.NewRequest(http.MethodPost, "/api/v1/external/auth/login", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + responseRecorder := httptest.NewRecorder() + engine.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status=%d body=%s", responseRecorder.Code, responseRecorder.Body.String()) + } + if limiter.calls != 0 { + t.Fatalf("oversized, unparseable body reached limiter %d times", limiter.calls) + } +} diff --git a/server/admin/internal/modules/externaladmin/routes.go b/server/admin/internal/modules/externaladmin/routes.go new file mode 100644 index 00000000..b1726140 --- /dev/null +++ b/server/admin/internal/modules/externaladmin/routes.go @@ -0,0 +1,126 @@ +package externaladmin + +import ( + "hyapp-admin-server/internal/middleware" + "hyapp-admin-server/internal/modules/appconfig" + "hyapp-admin-server/internal/modules/appuser" + "hyapp-admin-server/internal/modules/hostorg" + "hyapp-admin-server/internal/modules/prettyid" + "hyapp-admin-server/internal/modules/resource" + "hyapp-admin-server/internal/modules/roomadmin" + "hyapp-admin-server/internal/modules/upload" + "hyapp-admin-server/internal/modules/vipconfig" + + "github.com/gin-gonic/gin" +) + +type BusinessHandlers struct { + AppUser *appuser.Handler + HostOrg *hostorg.Handler + RoomAdmin *roomadmin.Handler + Resource *resource.Handler + PrettyID *prettyid.Handler + AppConfig *appconfig.Handler + VIPConfig *vipconfig.Handler + Upload *upload.Handler +} + +func RegisterAdminRoutes(protected *gin.RouterGroup, h *Handler) { + if protected == nil || h == nil { + return + } + protected.GET("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:view"), h.ListAccounts) + protected.GET("/admin/external-admin-users/target", middleware.RequirePermission("external-admin-user:create"), h.ResolveTarget) + protected.POST("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:create"), h.CreateAccount) + protected.PATCH("/admin/external-admin-users/:id/status", middleware.RequirePermission("external-admin-user:status"), h.SetStatus) + protected.POST("/admin/external-admin-users/:id/password", middleware.RequirePermission("external-admin-user:reset-password"), h.ResetPassword) +} + +func RegisterExternalRoutes(api *gin.RouterGroup, h *Handler, handlers BusinessHandlers) { + if api == nil || h == nil { + return + } + external := api.Group("/external") + auth := external.Group("/auth") + // Credentials, session state and CSRF secrets must never be cached by browsers, + // service workers or intermediary proxies. This middleware runs before session + // auth so 4xx/5xx responses carry the same protection as successful responses. + auth.Use(noStore()) + auth.GET("/apps", h.ListApps) + auth.POST("/login", h.Login) + + protectedAuth := auth.Group("") + // Audit wraps CSRF so forged writes are recorded as failed attempts instead of disappearing + // before the audit middleware gets control. + protectedAuth.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF()) + protectedAuth.GET("/me", h.Me) + protectedAuth.POST("/logout", h.Logout) + protectedAuth.POST("/change-password", h.ChangePassword) + + // Initial/reset credentials are deliberately limited to me/logout/change-password. + // Only after changing the temporary password can the account reach business handlers. + business := external.Group("") + business.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF(), h.RequirePasswordChanged()) + registerBusinessWhitelist(business, h, handlers) +} + +func noStore() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Cache-Control", "no-store") + c.Header("Pragma", "no-cache") + c.Next() + } +} + +func registerBusinessWhitelist(group *gin.RouterGroup, h *Handler, handlers BusinessHandlers) { + // "My team" is not a generic manager-list alias. Its handler derives the root + // manager from the external session and never accepts a client-selected user ID. + group.GET("/admin/my-team/agencies", middleware.RequirePermission("bd:view"), h.ListMyTeamAgencies) + if handlers.AppUser != nil { + group.GET("/app/users", middleware.RequirePermission("app-user:view"), handlers.AppUser.ListUsers) + group.GET("/app/users/bans", middleware.RequirePermission("app-user:view"), handlers.AppUser.ListBannedUsers) + group.GET("/app/users/:id", middleware.RequirePermission("app-user:view"), handlers.AppUser.GetUser) + group.PATCH("/app/users/:id", middleware.RequirePermission("app-user:update"), handlers.AppUser.UpdateUser) + group.PUT("/app/users/:id/levels", middleware.RequirePermission("app-user:level"), handlers.AppUser.AdjustTemporaryLevels) + group.POST("/app/users/:id/ban", middleware.RequirePermission("app-user:status"), handlers.AppUser.BanUser) + group.POST("/app/users/:id/unban", middleware.RequirePermission("app-user:status"), handlers.AppUser.UnbanUser) + } + if handlers.HostOrg != nil { + group.GET("/admin/hosts", middleware.RequirePermission("host:view"), handlers.HostOrg.ListHosts) + group.GET("/admin/agencies", middleware.RequirePermission("agency:view"), handlers.HostOrg.ListAgencies) + group.GET("/admin/bds", middleware.RequirePermission("bd:view"), handlers.HostOrg.ListBDs) + group.GET("/admin/bd-leaders", middleware.RequirePermission("bd:view"), handlers.HostOrg.ListBDLeaders) + group.GET("/admin/managers", middleware.RequirePermission("bd:view"), handlers.HostOrg.ListManagers) + } + if handlers.RoomAdmin != nil { + group.GET("/admin/rooms", middleware.RequirePermission("room:view"), handlers.RoomAdmin.ListRooms) + group.PATCH("/admin/rooms/:room_id", middleware.RequirePermission("room:update"), handlers.RoomAdmin.UpdateRoom) + } + if handlers.Resource != nil { + group.GET("/admin/resources", middleware.RequirePermission("resource:view"), handlers.Resource.ListResources) + group.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), handlers.Resource.ListResourceGrants) + group.GET("/admin/resource-grants/target", middleware.RequirePermission("resource-grant:create"), handlers.Resource.LookupResourceGrantTarget) + group.POST("/admin/resource-grants/resource", middleware.RequirePermission("resource-grant:create"), handlers.Resource.GrantResource) + group.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), handlers.Resource.GrantResourceGroup) + } + if handlers.PrettyID != nil { + group.GET("/admin/users/pretty-ids", middleware.RequirePermission("pretty-id:view"), handlers.PrettyID.ListIDs) + group.POST("/admin/users/pretty-ids/grant", middleware.RequirePermission("pretty-id:grant"), handlers.PrettyID.Grant) + } + if handlers.AppConfig != nil { + group.GET("/admin/app-config/banners", middleware.RequirePermission("app-config:view"), handlers.AppConfig.ListBanners) + group.POST("/admin/app-config/banners", middleware.RequirePermission("app-config:update"), handlers.AppConfig.CreateBanner) + } + if handlers.VIPConfig != nil { + // Grant mode and level IDs are owner-service facts. Read them with the same narrowly scoped + // grant permission so the portal can select direct-VIP vs trial-card without config write access. + group.GET("/admin/activity/vip-program", middleware.RequirePermission("vip-config:grant"), handlers.VIPConfig.GetProgram) + group.GET("/admin/activity/vip-levels", middleware.RequirePermission("vip-config:grant"), handlers.VIPConfig.ListLevels) + group.POST("/admin/activity/vip-trial-card-grants", middleware.RequirePermission("vip-config:grant"), handlers.VIPConfig.GrantVIPTrialCard) + group.POST("/admin/activity/vip-grants", middleware.RequirePermission("vip-config:grant"), handlers.VIPConfig.GrantVIP) + } + if handlers.Upload != nil { + // Banner/avatar forms need a real upload endpoint; no other file-management route is exposed. + group.POST("/admin/files/image/upload", middleware.RequirePermission("upload:create"), handlers.Upload.UploadImage) + } +} diff --git a/server/admin/internal/modules/externaladmin/routes_security_test.go b/server/admin/internal/modules/externaladmin/routes_security_test.go new file mode 100644 index 00000000..e55ffb63 --- /dev/null +++ b/server/admin/internal/modules/externaladmin/routes_security_test.go @@ -0,0 +1,45 @@ +package externaladmin + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestExternalAuthResponsesAlwaysDisableCachingIncludingErrors(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + RegisterExternalRoutes(engine.Group("/api/v1"), New(nil, nil, Config{}, nil), BusinessHandlers{}) + + tests := []struct { + name string + method string + path string + body string + }{ + {name: "login validation error", method: http.MethodPost, path: "/api/v1/external/auth/login", body: `{}`}, + {name: "me auth error", method: http.MethodGet, path: "/api/v1/external/auth/me"}, + {name: "logout auth error", method: http.MethodPost, path: "/api/v1/external/auth/logout"}, + {name: "change password auth error", method: http.MethodPost, path: "/api/v1/external/auth/change-password", body: `{}`}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + request := httptest.NewRequest(testCase.method, testCase.path, bytes.NewBufferString(testCase.body)) + request.Header.Set("Content-Type", "application/json") + responseRecorder := httptest.NewRecorder() + engine.ServeHTTP(responseRecorder, request) + if responseRecorder.Code < http.StatusBadRequest { + t.Fatalf("expected error response, status=%d body=%s", responseRecorder.Code, responseRecorder.Body.String()) + } + if got := responseRecorder.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q", got) + } + if got := responseRecorder.Header().Get("Pragma"); got != "no-cache" { + t.Fatalf("Pragma = %q", got) + } + }) + } +} diff --git a/server/admin/internal/modules/externaladmin/service.go b/server/admin/internal/modules/externaladmin/service.go new file mode 100644 index 00000000..0ef51b43 --- /dev/null +++ b/server/admin/internal/modules/externaladmin/service.go @@ -0,0 +1,812 @@ +package externaladmin + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/cache" + "hyapp-admin-server/internal/model" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/security" + + mysqlDriver "github.com/go-sql-driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + lastSeenWriteInterval = 5 * time.Minute + loginLimiterTimeout = 200 * time.Millisecond + loginLimiterClusterTag = "{external-login}" +) + +type Service struct { + db *gorm.DB + userDB *sql.DB + cfg Config + dummyHash string + limiter FixedWindowLimiter +} + +func NewService(db *gorm.DB, userDB *sql.DB, cfg Config) *Service { + if cfg.SessionTTL <= 0 { + cfg.SessionTTL = 12 * time.Hour + } + if cfg.MaxLoginFailures == 0 { + cfg.MaxLoginFailures = 5 + } + if cfg.LockDuration <= 0 { + cfg.LockDuration = 15 * time.Minute + } + if cfg.LoginRateLimit.Window <= 0 { + cfg.LoginRateLimit.Window = 60 * time.Second + } + if cfg.LoginRateLimit.SystemLimit == 0 { + cfg.LoginRateLimit.SystemLimit = 300 + } + if cfg.LoginRateLimit.AppLimit == 0 { + cfg.LoginRateLimit.AppLimit = 120 + } + if cfg.LoginRateLimit.IPLimit == 0 { + cfg.LoginRateLimit.IPLimit = 30 + } + if cfg.LoginRateLimit.IdentityLimit == 0 { + cfg.LoginRateLimit.IdentityLimit = 10 + } + dummyHash, _ := security.HashPassword("external-login-dummy-password") + return &Service{db: db, userDB: userDB, cfg: cfg, dummyHash: dummyHash} +} + +func (s *Service) ListApps(ctx context.Context) ([]App, error) { + if s.userDB == nil { + return nil, errors.New("user mysql is not configured") + } + rows, err := s.userDB.QueryContext(ctx, ` + SELECT app_code, app_name, logo_url + FROM apps + WHERE status = 'active' + ORDER BY app_name ASC, app_code ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + apps := []App{} + for rows.Next() { + var app App + if err := rows.Scan(&app.AppCode, &app.AppName, &app.LogoURL); err != nil { + return nil, err + } + apps = append(apps, app) + } + return apps, rows.Err() +} + +func (s *Service) ResolveTarget(ctx context.Context, appCode string, target string) (LinkedUser, error) { + appCode = normalizeAppCode(appCode) + target = strings.TrimSpace(target) + if appCode == "" || target == "" || s.userDB == nil { + return LinkedUser{}, ErrInvalidInput + } + userID, matched, err := shared.ResolveExactUserID(ctx, s.userDB, appCode, target, time.Now().UTC().UnixMilli()) + if err != nil { + return LinkedUser{}, err + } + if !matched { + return LinkedUser{}, ErrTargetNotFound + } + users, err := s.loadLinkedUsers(ctx, appCode, []int64{userID}) + if err != nil { + return LinkedUser{}, err + } + user, ok := users[userID] + if !ok { + return LinkedUser{}, ErrTargetNotFound + } + // ResolveExactUserID keeps nickname fallback for generic admin forms. External-account creation + // accepts only an exact long/short/pretty ID so an ambiguous nickname can never bind credentials. + if !matchesExactIdentity(user, target) { + return LinkedUser{}, ErrTargetNotFound + } + return user, nil +} + +func (s *Service) ListAccounts(ctx context.Context, input ListInput) (ListResult, error) { + if s.db == nil { + return ListResult{}, errors.New("admin mysql is not configured") + } + input.AppCode = normalizeAppCode(input.AppCode) + input.Page, input.PageSize = normalizePage(input.Page, input.PageSize) + query := s.db.WithContext(ctx).Model(&model.ExternalAdminAccount{}).Where("app_code = ?", input.AppCode) + if status := normalizeStatus(input.Status); status != "" { + query = query.Where("status = ?", status) + } + if keyword := strings.TrimSpace(input.Keyword); keyword != "" { + like := "%" + keyword + "%" + linkedUserID := int64(0) + if user, resolveErr := s.ResolveTarget(ctx, input.AppCode, keyword); resolveErr == nil { + linkedUserID = user.UserID + } else if userID, parseErr := strconv.ParseInt(keyword, 10, 64); parseErr == nil && userID > 0 { + linkedUserID = userID + } + if linkedUserID > 0 { + query = query.Where("username LIKE ? OR linked_app_user_id = ?", like, linkedUserID) + } else { + query = query.Where("username LIKE ?", like) + } + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return ListResult{}, err + } + accounts := []model.ExternalAdminAccount{} + if err := query.Order("updated_at_ms DESC, id DESC").Offset((input.Page - 1) * input.PageSize).Limit(input.PageSize).Find(&accounts).Error; err != nil { + return ListResult{}, err + } + userIDs := make([]int64, 0, len(accounts)) + for _, account := range accounts { + userIDs = append(userIDs, account.LinkedAppUserID) + } + users, err := s.loadLinkedUsers(ctx, input.AppCode, userIDs) + if err != nil { + return ListResult{}, err + } + items := make([]AccountDTO, 0, len(accounts)) + for _, account := range accounts { + items = append(items, accountDTO(account, users[account.LinkedAppUserID])) + } + return ListResult{Items: items, Page: input.Page, PageSize: input.PageSize, Total: total}, nil +} + +func (s *Service) FindAccountByLinkedUser(ctx context.Context, appCode string, linkedUser LinkedUser) (*AccountDTO, error) { + if s.db == nil || linkedUser.UserID <= 0 { + return nil, ErrInvalidInput + } + var account model.ExternalAdminAccount + err := s.db.WithContext(ctx).Where("app_code = ? AND linked_app_user_id = ?", normalizeAppCode(appCode), linkedUser.UserID).First(&account).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + dto := accountDTO(account, linkedUser) + return &dto, nil +} + +func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (AccountDTO, error) { + if s.db == nil { + return AccountDTO{}, errors.New("admin mysql is not configured") + } + input.AppCode = normalizeAppCode(input.AppCode) + input.Username = normalizeUsername(input.Username) + if input.AppCode == "" || !validUsername(input.Username) || input.CreatedByAdminID == 0 { + return AccountDTO{}, ErrInvalidInput + } + if err := validatePassword(input.Password); err != nil { + return AccountDTO{}, err + } + if _, err := s.findActiveApp(ctx, input.AppCode); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return AccountDTO{}, ErrInvalidInput + } + return AccountDTO{}, err + } + linkedUser, err := s.ResolveTarget(ctx, input.AppCode, input.TargetUserID) + if err != nil { + return AccountDTO{}, err + } + passwordHash, err := security.HashPassword(input.Password) + if err != nil { + return AccountDTO{}, ErrInvalidInput + } + permissionsJSON, err := encodePermissions(DefaultPermissionSnapshot()) + if err != nil { + return AccountDTO{}, err + } + account := model.ExternalAdminAccount{ + AppCode: input.AppCode, + LinkedAppUserID: linkedUser.UserID, + Username: input.Username, + PasswordHash: passwordHash, + PermissionsJSON: permissionsJSON, + Status: model.ExternalAdminStatusActive, + PasswordChangeRequired: true, + CreatedByAdminID: input.CreatedByAdminID, + } + if err := s.db.WithContext(ctx).Create(&account).Error; err != nil { + if isDuplicateKey(err) { + return AccountDTO{}, ErrAccountConflict + } + return AccountDTO{}, err + } + return accountDTO(account, linkedUser), nil +} + +func (s *Service) SetStatus(ctx context.Context, appCode string, id uint64, status string) (AccountDTO, error) { + appCode = normalizeAppCode(appCode) + status = normalizeStatus(status) + if id == 0 || status == "" { + return AccountDTO{}, ErrInvalidInput + } + var account model.ExternalAdminAccount + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil { + return err + } + updates := map[string]any{"status": status} + if status == model.ExternalAdminStatusActive { + updates["failed_login_count"] = 0 + updates["locked_until_ms"] = 0 + } + if err := tx.Model(&account).Updates(updates).Error; err != nil { + return err + } + if status == model.ExternalAdminStatusDisabled { + return revokeAllSessions(tx, account.ID, "account_disabled", time.Now().UTC().UnixMilli()) + } + return nil + }) + if errors.Is(err, gorm.ErrRecordNotFound) { + return AccountDTO{}, ErrAccountNotFound + } + if err != nil { + return AccountDTO{}, err + } + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&account).Error; err != nil { + return AccountDTO{}, err + } + users, err := s.loadLinkedUsers(ctx, appCode, []int64{account.LinkedAppUserID}) + return accountDTO(account, users[account.LinkedAppUserID]), err +} + +func (s *Service) ResetPassword(ctx context.Context, appCode string, id uint64, password string) (AccountDTO, error) { + appCode = normalizeAppCode(appCode) + if id == 0 { + return AccountDTO{}, ErrInvalidInput + } + if err := validatePassword(password); err != nil { + return AccountDTO{}, err + } + hash, err := security.HashPassword(password) + if err != nil { + return AccountDTO{}, ErrInvalidInput + } + var account model.ExternalAdminAccount + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil { + return err + } + if err := tx.Model(&account).Updates(map[string]any{ + "password_hash": hash, + "password_change_required": true, + "failed_login_count": 0, + "locked_until_ms": 0, + }).Error; err != nil { + return err + } + return revokeAllSessions(tx, account.ID, "password_reset", time.Now().UTC().UnixMilli()) + }) + if errors.Is(err, gorm.ErrRecordNotFound) { + return AccountDTO{}, ErrAccountNotFound + } + if err != nil { + return AccountDTO{}, err + } + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&account).Error; err != nil { + return AccountDTO{}, err + } + users, err := s.loadLinkedUsers(ctx, appCode, []int64{account.LinkedAppUserID}) + return accountDTO(account, users[account.LinkedAppUserID]), err +} + +func (s *Service) Login(ctx context.Context, input LoginInput) (LoginResult, error) { + input.AppCode = normalizeAppCode(input.AppCode) + input.Username = normalizeUsername(input.Username) + input.IP = truncateText(strings.TrimSpace(input.IP), 64) + input.UserAgent = truncateText(strings.TrimSpace(input.UserAgent), 512) + // Rate limiting runs before any MySQL lookup, login-log write or bcrypt work. + // This includes syntactically valid attempts with missing/invalid fields, so + // attackers cannot bypass distributed counters by varying malformed identities. + if err := s.consumeLoginRateLimit(ctx, input); err != nil { + return LoginResult{}, err + } + if s.db == nil { + return LoginResult{}, errors.New("admin mysql is not configured") + } + if !validAppCode(input.AppCode) || !validUsername(input.Username) || input.Password == "" { + security.CheckPassword(s.dummyHash, input.Password) + return LoginResult{}, ErrInvalidCredentials + } + app, err := s.findActiveApp(ctx, input.AppCode) + if err != nil { + // Keep unknown/disabled App, unknown account and wrong password indistinguishable. + security.CheckPassword(s.dummyHash, input.Password) + if errors.Is(err, sql.ErrNoRows) { + s.writeUnknownLoginLog(ctx, input, "invalid_credentials") + return LoginResult{}, ErrInvalidCredentials + } + return LoginResult{}, err + } + + nowMS := time.Now().UTC().UnixMilli() + var account model.ExternalAdminAccount + var session model.ExternalAdminSession + var sessionToken string + var csrfToken string + var authErr error + // Lock the account row while checking/updating the failure counter. Concurrent bad passwords + // must serialize on one counter; otherwise attackers could race requests below the lock threshold. + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("app_code = ? AND username = ?", input.AppCode, input.Username).First(&account).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + security.CheckPassword(s.dummyHash, input.Password) + authErr = ErrInvalidCredentials + return tx.Create(&model.ExternalAdminLoginLog{ + AppCode: input.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, + Status: "failed", Message: "invalid_credentials", + }).Error + } + if err != nil { + return err + } + + accountID := account.ID + validPassword := security.CheckPassword(account.PasswordHash, input.Password) + if account.Status != model.ExternalAdminStatusActive { + authErr = ErrInvalidCredentials + return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: input.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: "account_disabled"}).Error + } + if account.LockedUntilMS > nowMS { + authErr = ErrInvalidCredentials + return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: input.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: "account_locked"}).Error + } + if !validPassword { + failedCount := account.FailedLoginCount + if account.LockedUntilMS > 0 && account.LockedUntilMS <= nowMS { + failedCount = 0 + } + failedCount++ + lockedUntilMS := int64(0) + message := "invalid_credentials" + if failedCount >= s.cfg.MaxLoginFailures { + lockedUntilMS = time.Now().UTC().Add(s.cfg.LockDuration).UnixMilli() + failedCount = 0 + message = "failure_limit_locked" + } + if err := tx.Model(&account).Updates(map[string]any{"failed_login_count": failedCount, "locked_until_ms": lockedUntilMS}).Error; err != nil { + return err + } + authErr = ErrInvalidCredentials + return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: input.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "failed", Message: message}).Error + } + + var tokenHash string + sessionToken, tokenHash, err = security.NewRefreshToken() + if err != nil { + return err + } + var csrfHash string + csrfToken, csrfHash, err = security.NewRefreshToken() + if err != nil { + return err + } + session = model.ExternalAdminSession{ + AccountID: account.ID, AppCode: account.AppCode, TokenHash: tokenHash, CSRFTokenHash: csrfHash, + IP: input.IP, UserAgent: input.UserAgent, ExpiresAtMS: time.Now().UTC().Add(s.cfg.SessionTTL).UnixMilli(), LastSeenAtMS: nowMS, + } + if err := tx.Create(&session).Error; err != nil { + return err + } + if err := tx.Model(&account).Updates(map[string]any{"failed_login_count": 0, "locked_until_ms": 0, "last_login_at_ms": nowMS}).Error; err != nil { + return err + } + account.LastLoginAtMS = &nowMS + return tx.Create(&model.ExternalAdminLoginLog{AccountID: &accountID, AppCode: input.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, Status: "success", Message: "login_success"}).Error + }) + if err != nil { + return LoginResult{}, err + } + if authErr != nil { + return LoginResult{}, authErr + } + view, err := s.sessionView(ctx, account, app, csrfToken) + if err != nil { + _ = s.RevokeSession(ctx, session.ID, "profile_load_failed") + return LoginResult{}, err + } + return LoginResult{SessionToken: sessionToken, CSRFToken: csrfToken, View: view}, nil +} + +func (s *Service) consumeLoginRateLimit(ctx context.Context, input LoginInput) error { + if s.limiter == nil { + return ErrLoginLimiterFailed + } + appDigest := loginRateLimitDigest(input.AppCode) + ipDigest := loginRateLimitDigest(input.IP) + identityDigest := loginRateLimitDigest(input.AppCode + "\x00" + input.Username) + rules := []cache.FixedWindowRule{ + // The system key is intentionally independent of request App. It is the + // non-bypassable circuit breaker across tenant/IP/account rotations. + {Key: "rate:" + loginLimiterClusterTag + ":system", Limit: s.cfg.LoginRateLimit.SystemLimit}, + {Key: "rate:" + loginLimiterClusterTag + ":app:" + appDigest, Limit: s.cfg.LoginRateLimit.AppLimit}, + {Key: "rate:" + loginLimiterClusterTag + ":ip:" + ipDigest, Limit: s.cfg.LoginRateLimit.IPLimit}, + {Key: "rate:" + loginLimiterClusterTag + ":identity:" + identityDigest, Limit: s.cfg.LoginRateLimit.IdentityLimit}, + } + limitCtx, cancel := context.WithTimeout(ctx, loginLimiterTimeout) + defer cancel() + decision, err := s.limiter.ConsumeFixedWindow(limitCtx, s.cfg.LoginRateLimit.Window, rules) + if err != nil { + return fmt.Errorf("%w: %v", ErrLoginLimiterFailed, err) + } + if !decision.Allowed { + retryAfter := decision.RetryAfter + if retryAfter <= 0 { + retryAfter = s.cfg.LoginRateLimit.Window + } + return &LoginRateLimitError{RetryAfter: retryAfter} + } + return nil +} + +func loginRateLimitDigest(value string) string { + digest := sha256.Sum256([]byte(value)) + // 128 bits of the SHA-256 output keeps keys compact while retaining ample + // collision resistance; no App, IP or account identifier appears in Redis. + return hex.EncodeToString(digest[:16]) +} + +func (s *Service) Authenticate(ctx context.Context, rawToken string) (SessionPrincipal, error) { + if strings.TrimSpace(rawToken) == "" || s.db == nil { + return SessionPrincipal{}, ErrInvalidSession + } + nowMS := time.Now().UTC().UnixMilli() + var session model.ExternalAdminSession + err := s.db.WithContext(ctx).Preload("Account").Where("token_hash = ? AND revoked_at_ms = 0 AND expires_at_ms > ?", security.HashToken(rawToken), nowMS).First(&session).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return SessionPrincipal{}, ErrInvalidSession + } + if err != nil { + return SessionPrincipal{}, err + } + if session.Account.Status != model.ExternalAdminStatusActive || session.Account.AppCode != session.AppCode { + _ = s.RevokeSession(ctx, session.ID, "account_invalid") + return SessionPrincipal{}, ErrInvalidSession + } + // Login-time App validation is insufficient because an already authenticated + // browser can skip /auth/me and call a business route directly after operators + // disable the App. Re-read the indexed App row on every opaque-session auth. + // Only a confirmed missing/inactive App revokes the session; transient user DB + // failures fail closed for this request without destroying a recoverable session. + if _, err := s.findActiveApp(ctx, session.AppCode); err != nil { + if errors.Is(err, sql.ErrNoRows) { + _ = s.RevokeSession(ctx, session.ID, "app_disabled") + return SessionPrincipal{}, ErrInvalidSession + } + return SessionPrincipal{}, err + } + permissions, err := decodePermissions(session.Account.PermissionsJSON) + if err != nil { + _ = s.RevokeSession(ctx, session.ID, "permission_snapshot_invalid") + return SessionPrincipal{}, ErrInvalidSession + } + if nowMS-session.LastSeenAtMS >= lastSeenWriteInterval.Milliseconds() { + // last_seen is intentionally throttled so read-heavy external pages do not turn every request into a write. + _ = s.db.WithContext(ctx).Model(&model.ExternalAdminSession{}).Where("id = ? AND revoked_at_ms = 0", session.ID).Update("last_seen_at_ms", nowMS).Error + } + operatorID, err := operatorIDForAccount(session.Account.ID) + if err != nil { + _ = s.RevokeSession(ctx, session.ID, "account_id_out_of_range") + return SessionPrincipal{}, ErrInvalidSession + } + return SessionPrincipal{ + SessionID: session.ID, AccountID: session.Account.ID, OperatorID: operatorID, AppCode: session.AppCode, + LinkedAppUserID: session.Account.LinkedAppUserID, Username: session.Account.Username, + Permissions: permissions, PasswordChangeRequired: session.Account.PasswordChangeRequired, + CSRFTokenHash: session.CSRFTokenHash, ExpiresAtMS: session.ExpiresAtMS, + }, nil +} + +func (s *Service) Me(ctx context.Context, principal SessionPrincipal, csrfToken string) (SessionView, error) { + var account model.ExternalAdminAccount + if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", principal.AccountID, principal.AppCode).First(&account).Error; err != nil { + return SessionView{}, err + } + app, err := s.findActiveApp(ctx, principal.AppCode) + if err != nil { + return SessionView{}, err + } + return s.sessionView(ctx, account, app, csrfToken) +} + +func (s *Service) ChangePassword(ctx context.Context, principal SessionPrincipal, input ChangePasswordInput) error { + if err := validatePassword(input.NewPassword); err != nil { + return err + } + if strings.TrimSpace(input.CurrentPassword) == "" { + return ErrInvalidInput + } + hash, err := security.HashPassword(input.NewPassword) + if err != nil { + return ErrInvalidInput + } + var passwordErr error + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var account model.ExternalAdminAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", principal.AccountID, principal.AppCode).First(&account).Error; err != nil { + return err + } + if !security.CheckPassword(account.PasswordHash, input.CurrentPassword) { + passwordErr = ErrInvalidCredentials + return nil + } + // This comparison must use the hash read under the account row lock. Otherwise + // two concurrent password changes could both compare against a stale hash and + // one request could clear password_change_required without changing the secret. + // Returning before both Updates calls also guarantees rejection neither clears + // the first-login gate nor revokes the account's other active sessions. + if security.CheckPassword(account.PasswordHash, input.NewPassword) { + passwordErr = ErrPasswordReused + return nil + } + if err := tx.Model(&account).Updates(map[string]any{ + "password_hash": hash, "password_change_required": false, + "failed_login_count": 0, "locked_until_ms": 0, + }).Error; err != nil { + return err + } + nowMS := time.Now().UTC().UnixMilli() + return tx.Model(&model.ExternalAdminSession{}). + Where("account_id = ? AND id <> ? AND revoked_at_ms = 0", principal.AccountID, principal.SessionID). + Updates(map[string]any{"revoked_at_ms": nowMS, "revoke_reason": "password_changed"}).Error + }) + if err != nil { + return err + } + return passwordErr +} + +func (s *Service) RevokeSession(ctx context.Context, sessionID uint64, reason string) error { + if sessionID == 0 || s.db == nil { + return nil + } + return s.db.WithContext(ctx).Model(&model.ExternalAdminSession{}). + Where("id = ? AND revoked_at_ms = 0", sessionID). + Updates(map[string]any{"revoked_at_ms": time.Now().UTC().UnixMilli(), "revoke_reason": strings.TrimSpace(reason)}).Error +} + +func (s *Service) CreateOperationLog(ctx context.Context, log model.ExternalAdminOperationLog) error { + if s.db == nil { + return errors.New("admin mysql is not configured") + } + return s.db.WithContext(ctx).Create(&log).Error +} + +func (s *Service) sessionView(ctx context.Context, account model.ExternalAdminAccount, app App, csrfToken string) (SessionView, error) { + users, err := s.loadLinkedUsers(ctx, account.AppCode, []int64{account.LinkedAppUserID}) + if err != nil { + return SessionView{}, err + } + user, ok := users[account.LinkedAppUserID] + if !ok { + return SessionView{}, ErrTargetNotFound + } + permissions, err := decodePermissions(account.PermissionsJSON) + if err != nil { + return SessionView{}, err + } + return SessionView{ + User: user, + Account: AccountSummary{ID: account.ID, Username: account.Username, Status: account.Status}, + App: app, AppCode: account.AppCode, Permissions: permissions, Capabilities: capabilitiesFor(permissions), + PasswordChangeRequired: account.PasswordChangeRequired, CSRFToken: csrfToken, + }, nil +} + +func (s *Service) findActiveApp(ctx context.Context, appCode string) (App, error) { + if s.userDB == nil { + return App{}, errors.New("user mysql is not configured") + } + var app App + err := s.userDB.QueryRowContext(ctx, `SELECT app_code, app_name, logo_url FROM apps WHERE app_code = ? AND status = 'active' LIMIT 1`, normalizeAppCode(appCode)).Scan(&app.AppCode, &app.AppName, &app.LogoURL) + return app, err +} + +func (s *Service) loadLinkedUsers(ctx context.Context, appCode string, userIDs []int64) (map[int64]LinkedUser, error) { + users := make(map[int64]LinkedUser, len(userIDs)) + if len(userIDs) == 0 { + return users, nil + } + if s.userDB == nil { + return nil, errors.New("user mysql is not configured") + } + uniqueIDs := make([]int64, 0, len(userIDs)) + seen := map[int64]struct{}{} + for _, userID := range userIDs { + if userID <= 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + uniqueIDs = append(uniqueIDs, userID) + } + if len(uniqueIDs) == 0 { + return users, nil + } + placeholders := strings.TrimRight(strings.Repeat("?,", len(uniqueIDs)), ",") + nowMS := time.Now().UTC().UnixMilli() + args := []any{nowMS, nowMS, normalizeAppCode(appCode)} + for _, userID := range uniqueIDs { + args = append(args, userID) + } + rows, err := s.userDB.QueryContext(ctx, ` + SELECT u.user_id, + COALESCE(u.current_display_user_id, ''), + COALESCE(u.default_display_user_id, ''), + COALESCE(( + SELECT lease.display_user_id + FROM pretty_display_user_id_leases lease + WHERE lease.app_code = u.app_code AND lease.user_id = u.user_id + AND lease.status = 'active' + AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?) + ORDER BY lease.created_at_ms DESC, lease.lease_id DESC LIMIT 1 + ), ''), + COALESCE(( + SELECT pdi.pretty_id + FROM pretty_display_user_id_leases lease + JOIN pretty_display_ids pdi ON pdi.app_code = lease.app_code + AND pdi.assigned_lease_id = lease.lease_id AND pdi.status = 'assigned' + WHERE lease.app_code = u.app_code AND lease.user_id = u.user_id + AND lease.status = 'active' + AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?) + ORDER BY lease.created_at_ms DESC, lease.lease_id DESC LIMIT 1 + ), ''), + COALESCE(u.username, ''), COALESCE(u.avatar, ''), COALESCE(u.status, '') + FROM users u + WHERE u.app_code = ? AND u.user_id IN (`+placeholders+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var user LinkedUser + if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.DefaultDisplayUserID, &user.PrettyDisplayUserID, &user.PrettyID, &user.Username, &user.Avatar, &user.Status); err != nil { + return nil, err + } + users[user.UserID] = user + } + return users, rows.Err() +} + +func (s *Service) writeUnknownLoginLog(ctx context.Context, input LoginInput, message string) { + if s.db == nil { + return + } + _ = s.db.WithContext(ctx).Create(&model.ExternalAdminLoginLog{ + AppCode: input.AppCode, Username: input.Username, IP: input.IP, UserAgent: input.UserAgent, + Status: "failed", Message: message, + }).Error +} + +func accountDTO(account model.ExternalAdminAccount, linkedUser LinkedUser) AccountDTO { + permissions, _ := decodePermissions(account.PermissionsJSON) + if linkedUser.UserID == 0 { + linkedUser.UserID = account.LinkedAppUserID + } + return AccountDTO{ + ID: account.ID, AppCode: account.AppCode, Username: account.Username, Status: account.Status, + LinkedUser: linkedUser, Permissions: permissions, CreatedByAdminID: account.CreatedByAdminID, + CreatedAtMS: account.CreatedAtMS, UpdatedAtMS: account.UpdatedAtMS, LastLoginAtMS: account.LastLoginAtMS, + } +} + +func revokeAllSessions(tx *gorm.DB, accountID uint64, reason string, nowMS int64) error { + return tx.Model(&model.ExternalAdminSession{}).Where("account_id = ? AND revoked_at_ms = 0", accountID). + Updates(map[string]any{"revoked_at_ms": nowMS, "revoke_reason": reason}).Error +} + +func matchesExactIdentity(user LinkedUser, target string) bool { + target = strings.TrimSpace(target) + return target == strconv.FormatInt(user.UserID, 10) || + target == user.DisplayUserID || target == user.DefaultDisplayUserID || target == user.PrettyDisplayUserID || target == user.PrettyID +} + +func normalizePage(page int, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +func normalizeAppCode(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + return appctx.Normalize(value) +} + +func normalizeUsername(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func normalizeStatus(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case model.ExternalAdminStatusActive: + return model.ExternalAdminStatusActive + case model.ExternalAdminStatusDisabled: + return model.ExternalAdminStatusDisabled + default: + return "" + } +} + +func validUsername(value string) bool { + if len(value) < 3 || len(value) > 64 { + return false + } + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '.' || char == '_' || char == '-' { + continue + } + return false + } + return true +} + +func validAppCode(value string) bool { + if len(value) == 0 || len(value) > 32 { + return false + } + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' || char == '-' { + continue + } + return false + } + return true +} + +func truncateText(value string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= maxRunes { + return value + } + return string(runes[:maxRunes]) +} + +func validatePassword(password string) error { + // Length alone would accept eight spaces. Such an account cannot complete the + // first-login flow because currentPassword is intentionally required to contain + // a non-whitespace character, so reject the unusable credential at every writer. + if strings.TrimSpace(password) == "" { + return ErrPasswordBlank + } + if len(password) < 8 || len(password) > 72 { + return fmt.Errorf("%w: password length must be between 8 and 72", ErrInvalidInput) + } + return nil +} + +func isDuplicateKey(err error) bool { + var mysqlErr *mysqlDriver.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 +} diff --git a/server/admin/internal/modules/externaladmin/service_login_test.go b/server/admin/internal/modules/externaladmin/service_login_test.go new file mode 100644 index 00000000..11475dee --- /dev/null +++ b/server/admin/internal/modules/externaladmin/service_login_test.go @@ -0,0 +1,75 @@ +package externaladmin + +import ( + "database/sql/driver" + "errors" + "testing" + "time" + + "hyapp-admin-server/internal/security" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestLoginFailureLimitPersistsTimedLockAndUnifiedError(t *testing.T) { + adminSQL, adminMock, err := sqlmock.New() + if err != nil { + t.Fatalf("create admin sql mock: %v", err) + } + defer adminSQL.Close() + adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + t.Fatalf("create admin gorm db: %v", err) + } + userDB, userMock, err := sqlmock.New() + if err != nil { + t.Fatalf("create user sql mock: %v", err) + } + defer userDB.Close() + + passwordHash, err := security.HashPassword("correct-password") + if err != nil { + t.Fatalf("hash fixture password: %v", err) + } + userMock.ExpectQuery("SELECT app_code, app_name, logo_url FROM apps"). + WithArgs("fami"). + WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}).AddRow("fami", "Fami", "logo")) + adminMock.ExpectBegin() + adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE app_code = \\? AND username = \\?"). + WithArgs("fami", "operator", 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status", + "password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms", + }).AddRow(7, "fami", 9001, "operator", passwordHash, `[]`, "active", false, 0, 0, 1, 1, 1)) + adminMock.ExpectExec("UPDATE `external_admin_accounts` SET"). + WithArgs(uint(0), futureMillis{}, sqlmock.AnyArg(), uint64(7)). + WillReturnResult(sqlmock.NewResult(0, 1)) + adminMock.ExpectExec("INSERT INTO `external_admin_login_logs`"). + WithArgs(sqlmock.AnyArg(), "fami", "operator", "127.0.0.1", "browser", "failed", "failure_limit_locked", sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + adminMock.ExpectCommit() + + service := NewService(adminDB, userDB, Config{MaxLoginFailures: 1, LockDuration: 10 * time.Minute}) + service.limiter = allowFixedWindowLimiter{} + _, err = service.Login(t.Context(), LoginInput{ + AppCode: "FAMI", Username: "Operator", Password: "wrong-password", IP: "127.0.0.1", UserAgent: "browser", + }) + if !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("login error = %v, want unified invalid credentials", err) + } + if err := adminMock.ExpectationsWereMet(); err != nil { + t.Fatalf("admin sql expectations: %v", err) + } + if err := userMock.ExpectationsWereMet(); err != nil { + t.Fatalf("user sql expectations: %v", err) + } +} + +type futureMillis struct{} + +func (futureMillis) Match(value driver.Value) bool { + millis, ok := value.(int64) + return ok && millis > time.Now().UTC().UnixMilli() +} diff --git a/server/admin/internal/modules/externaladmin/team_test.go b/server/admin/internal/modules/externaladmin/team_test.go new file mode 100644 index 00000000..e7be5e60 --- /dev/null +++ b/server/admin/internal/modules/externaladmin/team_test.go @@ -0,0 +1,155 @@ +package externaladmin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + adminmiddleware "hyapp-admin-server/internal/middleware" + + "github.com/gin-gonic/gin" + "google.golang.org/grpc" + userv1 "hyapp.local/api/proto/user/v1" +) + +type managerTeamHostClient struct { + userv1.UserHostServiceClient + request *userv1.ListManagerTeamAgenciesRequest + calls int +} + +func (client *managerTeamHostClient) ListManagerTeamAgencies(_ context.Context, request *userv1.ListManagerTeamAgenciesRequest, _ ...grpc.CallOption) (*userv1.ListManagerTeamAgenciesResponse, error) { + client.calls++ + client.request = request + return &userv1.ListManagerTeamAgenciesResponse{ + Agencies: []*userv1.ManagerTeamAgency{ + {AgencyId: 101, OwnerUserId: 1001, ParentBdUserId: 2001}, + {AgencyId: 102, OwnerUserId: 1002, ParentBdUserId: 2002}, + {AgencyId: 103, OwnerUserId: 1003, ParentBdUserId: 2003}, + }, + TotalBdLeaders: 4, + TotalBds: 9, + Truncated: true, + }, nil +} + +func TestMyTeamRouteUsesOnlySessionLinkedUserAndPaginates(t *testing.T) { + gin.SetMode(gin.TestMode) + client := &managerTeamHostClient{} + handler := New(nil, nil, Config{}, nil, WithUserHostClient(client)) + engine := gin.New() + group := engine.Group("/api/v1/external") + group.Use(func(c *gin.Context) { + // This fixture models fields populated by AuthRequired. Query parameters below + // intentionally carry attacker-selected IDs and must never override the principal. + c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42}) + c.Set(adminmiddleware.ContextPermissions, []string{"bd:view"}) + c.Next() + }) + registerBusinessWhitelist(group, handler, BusinessHandlers{}) + + request := httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies?page=2&page_size=2&manager_user_id=999&user_id=888", nil) + responseRecorder := httptest.NewRecorder() + engine.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String()) + } + if client.calls != 1 || client.request == nil { + t.Fatalf("owner service calls = %d request=%v", client.calls, client.request) + } + if client.request.GetManagerUserId() != 42 { + t.Fatalf("manager user id = %d, want session linked user 42", client.request.GetManagerUserId()) + } + if client.request.GetMeta().GetAppCode() != "fami" || client.request.GetPageSize() != myTeamAgencyRPCPageSize { + t.Fatalf("owner request is not session scoped: %+v", client.request) + } + + var body struct { + Data MyTeamAgencyPage `json:"data"` + } + if err := json.Unmarshal(responseRecorder.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Data.Page != 2 || body.Data.PageSize != 2 || body.Data.Total != 3 || len(body.Data.Items) != 1 { + t.Fatalf("unexpected page: %+v", body.Data) + } + item := body.Data.Items[0] + if item.AgencyID != 103 || item.OwnerUserID != 1003 || item.ParentBDUserID != 2003 || item.Status != "active" { + t.Fatalf("unexpected agency projection: %+v", item) + } + if body.Data.TotalBDLeaders != 4 || body.Data.TotalBDs != 9 || !body.Data.Truncated { + t.Fatalf("owner totals not preserved: %+v", body.Data) + } + if got := responseRecorder.Body.String(); !containsAll(got, `"agencyId":"103"`, `"ownerUserId":"1003"`, `"parentBdUserId":"2003"`) { + t.Fatalf("int64 ids must be JSON strings: %s", got) + } +} + +func TestMyTeamRouteRequiresBDViewPermissionBeforeOwnerCall(t *testing.T) { + gin.SetMode(gin.TestMode) + client := &managerTeamHostClient{} + handler := New(nil, nil, Config{}, nil, WithUserHostClient(client)) + engine := gin.New() + group := engine.Group("/api/v1/external") + group.Use(func(c *gin.Context) { + c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42}) + c.Set(adminmiddleware.ContextPermissions, []string{"agency:view"}) + c.Next() + }) + registerBusinessWhitelist(group, handler, BusinessHandlers{}) + + responseRecorder := httptest.NewRecorder() + engine.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies", nil)) + if responseRecorder.Code != http.StatusForbidden { + t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String()) + } + if client.calls != 0 { + t.Fatalf("owner service called %d times without bd:view", client.calls) + } +} + +func TestMyTeamRouteFiltersScopedIDProjectionBeforeTotalAndPagination(t *testing.T) { + gin.SetMode(gin.TestMode) + client := &managerTeamHostClient{} + handler := New(nil, nil, Config{}, nil, WithUserHostClient(client)) + engine := gin.New() + group := engine.Group("/api/v1/external") + group.Use(func(c *gin.Context) { + c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42}) + c.Set(adminmiddleware.ContextPermissions, []string{"bd:view"}) + c.Next() + }) + registerBusinessWhitelist(group, handler, BusinessHandlers{}) + + // "002" matches owner 1002 and parent BD 2002. Filtering happens only over + // the already session-scoped RPC projection and must precede total/pagination. + responseRecorder := httptest.NewRecorder() + engine.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies?keyword=002&page=1&page_size=1", nil)) + if responseRecorder.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String()) + } + var body struct { + Data MyTeamAgencyPage `json:"data"` + } + if err := json.Unmarshal(responseRecorder.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Data.Total != 1 || len(body.Data.Items) != 1 || body.Data.Items[0].AgencyID != 102 { + t.Fatalf("keyword filter/page = %+v", body.Data) + } + if client.request.GetManagerUserId() != 42 { + t.Fatalf("keyword must not change team scope: manager=%d", client.request.GetManagerUserId()) + } +} + +func containsAll(value string, targets ...string) bool { + for _, target := range targets { + if !strings.Contains(value, target) { + return false + } + } + return true +} diff --git a/server/admin/internal/modules/externaladmin/types.go b/server/admin/internal/modules/externaladmin/types.go new file mode 100644 index 00000000..f99003e7 --- /dev/null +++ b/server/admin/internal/modules/externaladmin/types.go @@ -0,0 +1,330 @@ +package externaladmin + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "hyapp-admin-server/internal/cache" +) + +const ( + SessionCookieName = "hyapp_external_session" + CSRFCookieName = "hyapp_external_csrf" + CSRFHeaderName = "X-CSRF-Token" + CookiePath = "/api/v1/external" +) + +var ( + ErrInvalidCredentials = errors.New("invalid external admin credentials") + ErrAccountConflict = errors.New("external admin account conflict") + ErrAccountNotFound = errors.New("external admin account not found") + ErrTargetNotFound = errors.New("linked app user not found") + ErrInvalidInput = errors.New("invalid external admin input") + ErrInvalidSession = errors.New("invalid external admin session") + ErrPasswordReused = errors.New("new external admin password matches current password") + ErrPasswordBlank = errors.New("external admin password cannot be only whitespace") + ErrLoginRateLimited = errors.New("external admin login rate limited") + ErrLoginLimiterFailed = errors.New("external admin login rate limiter unavailable") +) + +const externalOperatorNamespace uint64 = 1 << 62 + +// Config contains only external-portal controls. Cookie transport attributes are inherited +// from the already validated admin cookie settings so local/prod deployments stay consistent. +type Config struct { + SessionTTL time.Duration + MaxLoginFailures uint + LockDuration time.Duration + CookieSecure bool + CookieSameSite string + LoginRateLimit LoginRateLimitConfig +} + +type LoginRateLimitConfig struct { + Window time.Duration + SystemLimit uint64 + AppLimit uint64 + IPLimit uint64 + IdentityLimit uint64 +} + +type FixedWindowLimiter interface { + ConsumeFixedWindow(context.Context, time.Duration, []cache.FixedWindowRule) (cache.FixedWindowDecision, error) +} + +type LoginRateLimitError struct { + RetryAfter time.Duration +} + +func (err *LoginRateLimitError) Error() string { + return ErrLoginRateLimited.Error() +} + +func (err *LoginRateLimitError) Unwrap() error { + return ErrLoginRateLimited +} + +type App struct { + AppCode string `json:"appCode"` + AppName string `json:"appName"` + LogoURL string `json:"logoUrl"` +} + +type LinkedUser struct { + UserID int64 `json:"userId,string"` + DisplayUserID string `json:"displayUserId"` + DefaultDisplayUserID string `json:"defaultDisplayUserId"` + PrettyDisplayUserID string `json:"prettyDisplayUserId"` + PrettyID string `json:"prettyId"` + Username string `json:"username"` + Avatar string `json:"avatar"` + Status string `json:"status"` +} + +type AccountDTO struct { + ID uint64 `json:"id"` + AppCode string `json:"appCode"` + Username string `json:"username"` + Status string `json:"status"` + LinkedUser LinkedUser `json:"linkedUser"` + Permissions []string `json:"permissions"` + CreatedByAdminID uint `json:"createdByAdminId"` + CreatedAtMS int64 `json:"createdAtMs"` + UpdatedAtMS int64 `json:"updatedAtMs"` + LastLoginAtMS *int64 `json:"lastLoginAtMs"` +} + +type AccountSummary struct { + ID uint64 `json:"id"` + Username string `json:"username"` + Status string `json:"status"` +} + +type SessionView struct { + User LinkedUser `json:"user"` + Account AccountSummary `json:"account"` + App App `json:"app"` + AppCode string `json:"appCode"` + Permissions []string `json:"permissions"` + Capabilities []string `json:"capabilities"` + PasswordChangeRequired bool `json:"passwordChangeRequired"` + CSRFToken string `json:"csrfToken,omitempty"` +} + +type LoginInput struct { + AppCode string + Username string + Password string + IP string + UserAgent string +} + +type LoginResult struct { + SessionToken string + CSRFToken string + View SessionView +} + +type ListInput struct { + AppCode string + Page int + PageSize int + Keyword string + Status string +} + +type ListResult struct { + Items []AccountDTO `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` +} + +type CreateInput struct { + AppCode string + TargetUserID string + Username string + Password string + CreatedByAdminID uint +} + +type ChangePasswordInput struct { + CurrentPassword string + NewPassword string +} + +type SessionPrincipal struct { + SessionID uint64 + AccountID uint64 + OperatorID uint + AppCode string + LinkedAppUserID int64 + Username string + Permissions []string + PasswordChangeRequired bool + CSRFTokenHash string + ExpiresAtMS int64 +} + +// MyTeamAgency is intentionally a narrow projection of the owner service's active +// manager-team relation. IDs are encoded as strings so browser clients cannot lose +// precision when HYApp's int64 identifiers exceed JavaScript's safe integer range. +type MyTeamAgency struct { + AgencyID int64 `json:"agencyId,string"` + OwnerUserID int64 `json:"ownerUserId,string"` + ParentBDUserID int64 `json:"parentBdUserId,string"` + Status string `json:"status"` +} + +type MyTeamAgencyPage struct { + Items []MyTeamAgency `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` + TotalBDLeaders int32 `json:"totalBdLeaders"` + TotalBDs int32 `json:"totalBds"` + Truncated bool `json:"truncated"` +} + +func operatorIDForAccount(accountID uint64) (uint, error) { + if accountID == 0 || accountID >= externalOperatorNamespace { + return 0, ErrInvalidSession + } + // Reused business handlers accept only a positive admin/operator integer. A high-bit namespace + // prevents external account #1 from being recorded as main admin #1 while staying <= MaxInt64 + // for signed downstream protos and positive for UNSIGNED audit columns. + return uint(externalOperatorNamespace | accountID), nil +} + +func operatorUsername(appCode string, username string) string { + return truncateText("external:"+normalizeAppCode(appCode)+":"+normalizeUsername(username), 64) +} + +// FlexibleString accepts either a JSON string or number. Short IDs must remain textual in +// storage, but accepting numeric JSON prevents clients from silently losing older contracts. +type FlexibleString string + +func (value *FlexibleString) UnmarshalJSON(body []byte) error { + body = bytes.TrimSpace(body) + if len(body) == 0 || bytes.Equal(body, []byte("null")) { + *value = "" + return nil + } + if body[0] == '"' { + var text string + if err := json.Unmarshal(body, &text); err != nil { + return err + } + *value = FlexibleString(strings.TrimSpace(text)) + return nil + } + var number json.Number + if err := json.Unmarshal(body, &number); err != nil { + return fmt.Errorf("targetUserId must be a string or integer: %w", err) + } + if _, err := strconv.ParseInt(number.String(), 10, 64); err != nil { + return fmt.Errorf("targetUserId must be an integer: %w", err) + } + *value = FlexibleString(number.String()) + return nil +} + +var defaultPermissionSnapshot = []string{ + "app-user:view", + "app-user:update", + "app-user:status", + "app-user:level", + "host:view", + "agency:view", + "bd:view", + "room:view", + "room:update", + "resource:view", + "resource-grant:view", + "resource-grant:create", + "pretty-id:view", + "pretty-id:grant", + "app-config:view", + "app-config:update", + "vip-config:grant", + "upload:create", +} + +func DefaultPermissionSnapshot() []string { + return append([]string(nil), defaultPermissionSnapshot...) +} + +func encodePermissions(permissions []string) (string, error) { + normalized := normalizePermissions(permissions) + body, err := json.Marshal(normalized) + return string(body), err +} + +func decodePermissions(raw string) ([]string, error) { + permissions := []string{} + if err := json.Unmarshal([]byte(raw), &permissions); err != nil { + return nil, err + } + return normalizePermissions(permissions), nil +} + +func normalizePermissions(permissions []string) []string { + seen := make(map[string]struct{}, len(permissions)) + out := make([]string, 0, len(permissions)) + for _, permission := range permissions { + permission = strings.TrimSpace(permission) + if permission == "" { + continue + } + if _, ok := seen[permission]; ok { + continue + } + seen[permission] = struct{}{} + out = append(out, permission) + } + sort.Strings(out) + return out +} + +func capabilitiesFor(permissions []string) []string { + permissionSet := make(map[string]struct{}, len(permissions)) + for _, permission := range permissions { + permissionSet[permission] = struct{}{} + } + aliases := map[string][]string{ + "app-user:view": {"user:list", "user-ban:list"}, + "app-user:update": {"user:update"}, + "app-user:status": {"user:ban", "user:unban"}, + "app-user:level": {"user-level:grant"}, + "host:view": {"host:list"}, + "agency:view": {"agency:list"}, + "bd:view": {"bd:list", "bd-manager:list", "super-admin:list", "team:view"}, + "room:view": {"room:list"}, + "room:update": {"room:update"}, + "resource:view": {"privilege:list"}, + "resource-grant:view": {"privilege:list"}, + "resource-grant:create": {"privilege:grant", "user-title:grant", "room-background:grant"}, + "pretty-id:grant": {"pretty-id:grant"}, + "app-config:update": {"banner:create"}, + } + seen := map[string]struct{}{} + capabilities := []string{} + for permission := range permissionSet { + for _, capability := range aliases[permission] { + if _, ok := seen[capability]; ok { + continue + } + seen[capability] = struct{}{} + capabilities = append(capabilities, capability) + } + } + sort.Strings(capabilities) + return capabilities +} diff --git a/server/admin/internal/modules/externaladmin/types_test.go b/server/admin/internal/modules/externaladmin/types_test.go new file mode 100644 index 00000000..d44274dd --- /dev/null +++ b/server/admin/internal/modules/externaladmin/types_test.go @@ -0,0 +1,116 @@ +package externaladmin + +import ( + "encoding/json" + "errors" + "reflect" + "sort" + "strings" + "testing" +) + +func TestExternalOperatorIdentityUsesStablePositiveNamespace(t *testing.T) { + first, err := operatorIDForAccount(1) + if err != nil { + t.Fatalf("operator id for first account: %v", err) + } + second, err := operatorIDForAccount(2) + if err != nil { + t.Fatalf("operator id for second account: %v", err) + } + if first == 1 || second == 2 || first == second { + t.Fatalf("operator ids must be namespaced and unique: first=%d second=%d", first, second) + } + if uint64(first) > uint64(1<<63-1) || uint64(second) > uint64(1<<63-1) { + t.Fatalf("operator ids must fit signed downstream fields: first=%d second=%d", first, second) + } + if _, err := operatorIDForAccount(externalOperatorNamespace); !errors.Is(err, ErrInvalidSession) { + t.Fatalf("out-of-range account id error = %v", err) + } + if got := operatorUsername("FAMI", "Operator"); got != "external:fami:operator" { + t.Fatalf("operator username = %q", got) + } + if got := operatorUsername("fami", strings.Repeat("x", 80)); len([]rune(got)) != 64 || !strings.HasPrefix(got, "external:fami:") { + t.Fatalf("long operator username is not safely prefixed/truncated: %q", got) + } +} + +func TestLinkedUserMarshalsLongIDAsString(t *testing.T) { + body, err := json.Marshal(LinkedUser{UserID: 9223372036854770000}) + if err != nil { + t.Fatalf("marshal linked user: %v", err) + } + if string(body) != `{"userId":"9223372036854770000","displayUserId":"","defaultDisplayUserId":"","prettyDisplayUserId":"","prettyId":"","username":"","avatar":"","status":""}` { + t.Fatalf("linked user json = %s", body) + } +} + +func TestDefaultPermissionSnapshotProducesCompleteCapabilityContract(t *testing.T) { + permissions := DefaultPermissionSnapshot() + wantPermissions := []string{ + "app-user:view", "app-user:update", "app-user:status", "app-user:level", + "host:view", "agency:view", "bd:view", "room:view", "room:update", + "resource:view", "resource-grant:view", "resource-grant:create", + "pretty-id:view", "pretty-id:grant", "app-config:view", "app-config:update", + "vip-config:grant", "upload:create", + } + for _, permission := range wantPermissions { + if !contains(permissions, permission) { + t.Fatalf("default permission snapshot missing %s", permission) + } + } + + capabilities := capabilitiesFor(permissions) + wantCapabilities := []string{ + "user:list", "user:update", "user-ban:list", "user:ban", "user:unban", + "host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list", + "room:list", "room:update", "privilege:list", "privilege:grant", "banner:create", + "pretty-id:grant", "user-title:grant", "room-background:grant", "user-level:grant", "team:view", + } + sort.Strings(wantCapabilities) + if !reflect.DeepEqual(capabilities, wantCapabilities) { + t.Fatalf("capabilities = %#v, want %#v", capabilities, wantCapabilities) + } +} + +func TestFlexibleStringAcceptsShortIDStringAndInteger(t *testing.T) { + for _, testCase := range []struct { + body string + want string + }{ + {body: `"001234"`, want: "001234"}, + {body: `123456`, want: "123456"}, + } { + var value FlexibleString + if err := json.Unmarshal([]byte(testCase.body), &value); err != nil { + t.Fatalf("unmarshal %s: %v", testCase.body, err) + } + if string(value) != testCase.want { + t.Fatalf("value = %q, want %q", value, testCase.want) + } + } +} + +func TestExactIdentityDoesNotAcceptNicknameFallback(t *testing.T) { + user := LinkedUser{ + UserID: 77, DisplayUserID: "1234567", DefaultDisplayUserID: "7654321", + PrettyDisplayUserID: "8888", PrettyID: "8888", Username: "nickname", + } + for _, accepted := range []string{"77", "1234567", "7654321", "8888"} { + if !matchesExactIdentity(user, accepted) { + t.Fatalf("exact identity %s rejected", accepted) + } + } + if matchesExactIdentity(user, "nickname") { + t.Fatal("nickname must not bind an external credential") + } +} + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/server/admin/internal/repository/repository.go b/server/admin/internal/repository/repository.go index 95733b16..84650eb2 100644 --- a/server/admin/internal/repository/repository.go +++ b/server/admin/internal/repository/repository.go @@ -92,6 +92,10 @@ func (s *Store) AutoMigrate() error { &model.CoinSellerRechargeOrder{}, &model.UserWithdrawalApplication{}, &model.AdminJob{}, + &model.ExternalAdminAccount{}, + &model.ExternalAdminSession{}, + &model.ExternalAdminLoginLog{}, + &model.ExternalAdminOperationLog{}, ) } diff --git a/server/admin/internal/repository/role_permission_matrix.go b/server/admin/internal/repository/role_permission_matrix.go index 8b7f8e02..b20560fb 100644 --- a/server/admin/internal/repository/role_permission_matrix.go +++ b/server/admin/internal/repository/role_permission_matrix.go @@ -158,6 +158,12 @@ var ( geoRead = []string{"country:view", "region:view"} geoManage = []string{"country:create", "country:update", "country:status", "region:create", "region:update", "region:status"} + + // 运营岗位只能查看和停用外管账号。创建、重置密码会签发或接管一组固定的 + // 高权限外管能力,必须收口到 platform-admin,避免运营角色委派自身并不具备的权限。 + externalAdminUserOperate = []string{ + "external-admin-user:view", "external-admin-user:status", + } ) // defaultRolePermissionCodes 只组合表格中明确勾选的模块。相同页面曾复用 game:* 或 @@ -173,6 +179,7 @@ func defaultRolePermissionCodes(code string) []string { []string{"app-config:view"}, appConfigManage, resourceRead, resourceManage, operationsRead, operationsLeadManage, + externalAdminUserOperate, paymentRead, paymentBillExport, paymentBillRefresh, paymentOperationsManage, activityRead, activityManage, gameRead, gameManage, @@ -187,6 +194,7 @@ func defaultRolePermissionCodes(code string) []string { []string{"app-config:view"}, appConfigManage, resourceRead, resourceManage, operationsRead, + externalAdminUserOperate, paymentRead, paymentBillExport, paymentBillRefresh, activityRead, gameRead, gameOperationsSpecialistManage, diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index 8c1385ab..4813d23e 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -16,6 +16,10 @@ var defaultPermissions = []model.Permission{ {Name: "用户状态", Code: "user:status", Kind: "button"}, {Name: "用户重置密码", Code: "user:reset-password", Kind: "button"}, {Name: "用户导出", Code: "user:export", Kind: "button"}, + {Name: "外管用户查看", Code: "external-admin-user:view", Kind: "menu", Description: "查看当前 App 的外管用户"}, + {Name: "外管用户创建", Code: "external-admin-user:create", Kind: "button", Description: "按当前 App 用户短 ID 创建外管账号"}, + {Name: "外管用户状态", Code: "external-admin-user:status", Kind: "button", Description: "启用或停用当前 App 的外管账号"}, + {Name: "外管用户重置密码", Code: "external-admin-user:reset-password", Kind: "button", Description: "重置当前 App 的外管账号密码并撤销全部会话"}, {Name: "团队查看", Code: "team:view", Kind: "menu"}, {Name: "团队创建", Code: "team:create", Kind: "button"}, {Name: "团队更新", Code: "team:update", Kind: "button"}, @@ -362,6 +366,7 @@ func (s *Store) seedMenus() error { {ParentID: &operationsID, Title: "币商流水", Code: "operation-coin-seller-ledger", Path: "/operations/coin-seller-ledger", Icon: "receipt", PermissionCode: "coin-seller-ledger:view", Sort: 69, Visible: true}, {ParentID: &operationsID, Title: "金币增减", Code: "operation-coin-adjustment", Path: "/operations/coin-adjustments", Icon: "wallet", PermissionCode: "coin-adjustment:view", Sort: 70, Visible: true}, {ParentID: &operationsID, Title: "幸运礼物", Code: "lucky-gift", Path: "/operations/lucky-gift", Icon: "redeem", PermissionCode: "lucky-gift:view", Sort: 71, Visible: true}, + {ParentID: &operationsID, Title: "外管用户", Code: "operation-external-admin-users", Path: "/operations/external-admin-users", Icon: "shield", PermissionCode: "external-admin-user:view", Sort: 72, Visible: true}, {ParentID: &operationsID, Title: "举报列表", Code: "operation-reports", Path: "/operations/reports", Icon: "flag", PermissionCode: "report:view", Sort: 73, Visible: true}, {ParentID: &operationsID, Title: "礼物钻石", Code: "operation-gift-diamond", Path: "/operations/gift-diamonds", Icon: "diamond", PermissionCode: "gift-diamond:view", Sort: 74, Visible: true}, {ParentID: &operationsID, Title: "收益政策模板", Code: "policy-template", Path: "/policy/templates", Icon: "settings", PermissionCode: "policy-template:view", Sort: 88, Visible: true}, diff --git a/server/admin/internal/repository/seed_test.go b/server/admin/internal/repository/seed_test.go index a06e42ff..4f12349a 100644 --- a/server/admin/internal/repository/seed_test.go +++ b/server/admin/internal/repository/seed_test.go @@ -126,12 +126,12 @@ func TestManagedDefaultRolePermissionMatrix(t *testing.T) { func TestManagedDefaultRoleModuleBoundaries(t *testing.T) { assertRolePermissions(t, roleCodeOperationsLead, - []string{"app-config:update", "payment-third-party:sync-methods", "game-catalog:update", "country:update"}, - []string{"app-version:view", "finance:view", "payment-third-party:update-rate", "role:view"}, + []string{"app-config:update", "payment-third-party:sync-methods", "game-catalog:update", "country:update", "external-admin-user:view", "external-admin-user:status"}, + []string{"app-version:view", "finance:view", "payment-third-party:update-rate", "role:view", "external-admin-user:create", "external-admin-user:reset-password"}, ) assertRolePermissions(t, roleCodeOperationsSpecialist, - []string{"room:update", "game-robot:update", "room-rps-config:update", "payment-bill:export"}, - []string{"activity:update", "daily-task:update", "game-catalog:update", "self-game:update", "coin-adjustment:create"}, + []string{"room:update", "game-robot:update", "room-rps-config:update", "payment-bill:export", "external-admin-user:view", "external-admin-user:status"}, + []string{"activity:update", "daily-task:update", "game-catalog:update", "self-game:update", "coin-adjustment:create", "external-admin-user:create", "external-admin-user:reset-password"}, ) assertRolePermissions(t, roleCodeProductLead, []string{"app-version:update", "daily-task:update", "game-catalog:update", "resource:update"}, @@ -221,6 +221,54 @@ func TestActivityTemplatePermissionMigrationExtendsManagedRoles(t *testing.T) { } } +func TestExternalAdminMigrationSeedsIsolatedPortalAndOperationsMenu(t *testing.T) { + content, err := os.ReadFile("../../migrations/098_external_admin_portal.sql") + if err != nil { + t.Fatalf("read external admin migration: %v", err) + } + sqlText := string(content) + for _, token := range []string{ + "external_admin_accounts", "external_admin_sessions", "external_admin_login_logs", "external_admin_operation_logs", + "uk_external_admin_accounts_app_username", "uk_external_admin_accounts_app_linked_user", + "'external-admin-user:view'", "'external-admin-user:create'", "'external-admin-user:status'", "'external-admin-user:reset-password'", + "'operation-external-admin-users'", "'/operations/external-admin-users'", + "WHERE role.code = 'platform-admin'", "WHERE role.code IN ('ops-admin', 'operations-specialist')", + } { + if !strings.Contains(sqlText, token) { + t.Fatalf("external admin migration missing %s", token) + } + } + // 创建和重置密码会让操作者控制固定的全能力外管账号;迁移必须把这两项 + // 仅授予平台管理员,运营岗位只保留日常查看和启停能力。 + platformBlock := regexp.MustCompile(`(?s)WHERE role\.code = 'platform-admin'\s+AND permission\.code IN \((.*?)\);`).FindStringSubmatch(sqlText) + if len(platformBlock) != 2 || !strings.Contains(platformBlock[1], "'external-admin-user:create'") || !strings.Contains(platformBlock[1], "'external-admin-user:reset-password'") { + t.Fatal("external admin credential permissions must be granted to platform-admin") + } + opsBlock := regexp.MustCompile(`(?s)WHERE role\.code IN \('ops-admin', 'operations-specialist'\)\s+AND permission\.code IN \((.*?)\);`).FindStringSubmatch(sqlText) + if len(opsBlock) != 2 || !strings.Contains(opsBlock[1], "'external-admin-user:view'") || !strings.Contains(opsBlock[1], "'external-admin-user:status'") { + t.Fatal("external admin operations permissions missing for operations roles") + } + if strings.Contains(opsBlock[1], "'external-admin-user:create'") || strings.Contains(opsBlock[1], "'external-admin-user:reset-password'") { + t.Fatal("operations roles must not receive external admin credential permissions") + } +} + +func TestExternalAdminCredentialDelegationCorrectionMigration(t *testing.T) { + content, err := os.ReadFile("../../migrations/099_external_admin_credential_delegation.sql") + if err != nil { + t.Fatalf("read external admin delegation migration: %v", err) + } + sqlText := string(content) + for _, token := range []string{ + "DELETE role_permission", "admin_role_permissions", "'ops-admin'", "'operations-specialist'", + "'external-admin-user:create'", "'external-admin-user:reset-password'", + } { + if !strings.Contains(sqlText, token) { + t.Fatalf("external admin delegation migration missing %s", token) + } + } +} + func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) { t.Helper() quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`) @@ -236,6 +284,7 @@ func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList strin "gift-record:view", "activity-template:view", "activity-template:create", "activity-template:update", "activity-template:publish", "activity-template:delete", "activity-template:data", "activity-template:export", "activity-template:retry", + "external-admin-user:view", "external-admin-user:create", "external-admin-user:status", "external-admin-user:reset-password", }) sort.Strings(actual) sort.Strings(expected) diff --git a/server/admin/internal/response/codes.go b/server/admin/internal/response/codes.go index c6b16195..1277997e 100644 --- a/server/admin/internal/response/codes.go +++ b/server/admin/internal/response/codes.go @@ -7,5 +7,8 @@ const ( CodeForbidden = 40300 CodeNotFound = 40400 CodeConflict = 40900 + CodePayloadLarge = 41300 + CodeTooMany = 42900 CodeServerError = 50000 + CodeUnavailable = 50300 ) diff --git a/server/admin/internal/response/error.go b/server/admin/internal/response/error.go index 47662d8b..b1140239 100644 --- a/server/admin/internal/response/error.go +++ b/server/admin/internal/response/error.go @@ -30,6 +30,18 @@ func Conflict(c *gin.Context, message string) { Fail(c, http.StatusConflict, CodeConflict, message) } +func PayloadTooLarge(c *gin.Context, message string) { + Fail(c, http.StatusRequestEntityTooLarge, CodePayloadLarge, message) +} + +func TooManyRequests(c *gin.Context, message string) { + Fail(c, http.StatusTooManyRequests, CodeTooMany, message) +} + func ServerError(c *gin.Context, message string) { Fail(c, http.StatusInternalServerError, CodeServerError, message) } + +func ServiceUnavailable(c *gin.Context, message string) { + Fail(c, http.StatusServiceUnavailable, CodeUnavailable, message) +} diff --git a/server/admin/internal/router/router.go b/server/admin/internal/router/router.go index d1b3b74e..36f59211 100644 --- a/server/admin/internal/router/router.go +++ b/server/admin/internal/router/router.go @@ -1,6 +1,8 @@ package router import ( + "fmt" + "hyapp-admin-server/internal/config" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/achievementconfig" @@ -20,6 +22,7 @@ import ( "hyapp-admin-server/internal/modules/dailytask" "hyapp-admin-server/internal/modules/dashboard" "hyapp-admin-server/internal/modules/databi" + "hyapp-admin-server/internal/modules/externaladmin" "hyapp-admin-server/internal/modules/financeorder" "hyapp-admin-server/internal/modules/financewithdrawal" "hyapp-admin-server/internal/modules/firstrechargereward" @@ -89,6 +92,7 @@ type Handlers struct { FirstRechargeReward *firstrechargereward.Handler FinanceOrder *financeorder.Handler FinanceWithdrawal *financewithdrawal.Handler + ExternalAdmin *externaladmin.Handler FullServerNotice *fullservernotice.Handler Game *gamemanagement.Handler GiftDiamond *giftdiamond.Handler @@ -131,6 +135,13 @@ type Handlers struct { func New(cfg config.Config, auth *service.AuthService, store *repository.Store, h Handlers) *gin.Engine { engine := gin.New() + // SetTrustedProxies must be called even for an empty list; otherwise Gin 1.10 + // trusts every proxy by default and client-controlled XFF can defeat IP limits. + // Config validation rejects malformed entries before startup; panic here catches + // only tests/manual constructors that bypass the normal validated load path. + if err := engine.SetTrustedProxies(cfg.TrustedProxies); err != nil { + panic(fmt.Sprintf("configure trusted proxies: %v", err)) + } engine.Use(middleware.RequestID(), middleware.AppCode(), logging.GinAccessLogger(), gin.Recovery(), middleware.CORS(cfg)) engine.GET("/healthz", h.Health.Health) @@ -143,6 +154,10 @@ func New(cfg config.Config, auth *service.AuthService, store *repository.Store, appProtected.Use(middleware.RequireAppScope(store)) authroutes.RegisterRoutes(api, protected, h.Auth) + externaladmin.RegisterExternalRoutes(api, h.ExternalAdmin, externaladmin.BusinessHandlers{ + AppUser: h.AppUser, HostOrg: h.HostOrg, RoomAdmin: h.RoomAdmin, Resource: h.Resource, + PrettyID: h.PrettyID, AppConfig: h.AppConfig, VIPConfig: h.VIPConfig, Upload: h.Upload, + }) agencyopening.RegisterRoutes(appProtected, h.AgencyOpening) activitytemplate.RegisterRoutes(appProtected, h.ActivityTemplate) achievementconfig.RegisterRoutes(appProtected, h.AchievementConfig) @@ -153,6 +168,7 @@ func New(cfg config.Config, auth *service.AuthService, store *repository.Store, reportmodule.RegisterRoutes(appProtected, h.Report) resourcemodule.RegisterRoutes(appProtected, h.Resource) adminuser.RegisterRoutes(protected, h.AdminUser) + externaladmin.RegisterAdminRoutes(appProtected, h.ExternalAdmin) appuser.RegisterRoutes(appProtected, h.AppUser) appregistry.RegisterRoutes(protected, h.AppRegistry) appconfig.RegisterRoutes(appProtected, h.AppConfig) diff --git a/server/admin/internal/router/router_test.go b/server/admin/internal/router/router_test.go index 7f8fc91d..1345527e 100644 --- a/server/admin/internal/router/router_test.go +++ b/server/admin/internal/router/router_test.go @@ -8,6 +8,8 @@ import ( "time" "hyapp-admin-server/internal/config" + "hyapp-admin-server/internal/modules/appuser" + "hyapp-admin-server/internal/modules/externaladmin" "hyapp-admin-server/internal/modules/opscenter" "hyapp-admin-server/internal/service" @@ -44,3 +46,68 @@ func TestOpsCenterAppBootstrapDoesNotRequireAppCode(t *testing.T) { t.Fatalf("unauthorized status = %d body=%s", unauthorized.Code, unauthorized.Body.String()) } } + +func TestExternalPortalRegistersOnlyExplicitAppUserWhitelist(t *testing.T) { + gin.SetMode(gin.TestMode) + auth := service.NewAuthService("external-router-test-secret", time.Hour) + externalHandler := externaladmin.New(nil, nil, externaladmin.Config{}, nil) + engine := New(config.Config{}, auth, nil, Handlers{ + ExternalAdmin: externalHandler, + AppUser: &appuser.Handler{}, + }) + + // The listed route exists and is protected by the isolated opaque session middleware. + allowed := httptest.NewRecorder() + engine.ServeHTTP(allowed, httptest.NewRequest(http.MethodGet, "/api/v1/external/app/users", nil)) + if allowed.Code != http.StatusUnauthorized { + t.Fatalf("whitelisted route status = %d body=%s", allowed.Code, allowed.Body.String()) + } + team := httptest.NewRecorder() + engine.ServeHTTP(team, httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies", nil)) + if team.Code != http.StatusUnauthorized { + t.Fatalf("my-team route must exist behind external session auth: status=%d body=%s", team.Code, team.Body.String()) + } + + for _, path := range []string{ + "/api/v1/external/app/users/1/password", + "/api/v1/external/app/users/1/access-token", + "/api/v1/external/exports/app-users", + } { + request := httptest.NewRequest(http.MethodPost, path, nil) + response := httptest.NewRecorder() + engine.ServeHTTP(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("non-whitelisted route %s status = %d body=%s", path, response.Code, response.Body.String()) + } + } +} + +func TestRouterDoesNotTrustForwardedIPFromDirectClient(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := New(config.Config{TrustedProxies: []string{"127.0.0.0/8", "::1"}}, nil, nil, Handlers{}) + engine.GET("/__test_client_ip", func(c *gin.Context) { + c.String(http.StatusOK, c.ClientIP()) + }) + + // A direct caller is outside the configured ingress hop. Its XFF value must be + // ignored, otherwise it can rotate a forged address to bypass external-login limits. + directRequest := httptest.NewRequest(http.MethodGet, "/__test_client_ip", nil) + directRequest.RemoteAddr = "198.51.100.24:43210" + directRequest.Header.Set("X-Forwarded-For", "203.0.113.99") + directResponse := httptest.NewRecorder() + engine.ServeHTTP(directResponse, directRequest) + if got := directResponse.Body.String(); got != "198.51.100.24" { + t.Fatalf("direct client IP = %q, want socket peer", got) + } + + // The local Nginx hop is trusted, but Gin must stop at the right-most untrusted + // address. A prepended attacker-controlled value therefore cannot become identity. + proxiedRequest := httptest.NewRequest(http.MethodGet, "/__test_client_ip", nil) + proxiedRequest.RemoteAddr = "127.0.0.1:43210" + proxiedRequest.Header.Set("X-Forwarded-For", "203.0.113.99, 198.51.100.24") + proxiedResponse := httptest.NewRecorder() + engine.ServeHTTP(proxiedResponse, proxiedRequest) + if got := proxiedResponse.Body.String(); got != "198.51.100.24" { + t.Fatalf("proxied client IP = %q, want right-most untrusted hop", got) + } +} diff --git a/server/admin/migrations/098_external_admin_portal.sql b/server/admin/migrations/098_external_admin_portal.sql new file mode 100644 index 00000000..cc19d5d0 --- /dev/null +++ b/server/admin/migrations/098_external_admin_portal.sql @@ -0,0 +1,136 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 外管账号与主后台账号、App 用户账号完全隔离;所有唯一键都带 app_code,避免 Fami/Lalu 同名账号互相占用。 +-- 本迁移只创建小型认证/审计表并按唯一索引写权限元数据,不扫描 users、房间或钱包等大业务表。 +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +CREATE TABLE IF NOT EXISTS external_admin_accounts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '外管账号 ID', + app_code VARCHAR(32) NOT NULL COMMENT '固定 App 租户', + linked_app_user_id BIGINT NOT NULL COMMENT '绑定的 App 内部用户 ID', + username VARCHAR(64) NOT NULL COMMENT '外管登录账号,服务端统一转小写', + password_hash VARCHAR(255) NOT NULL COMMENT 'bcrypt 密码摘要', + permissions_json JSON NOT NULL COMMENT '允许进入外管白名单路由的权限快照', + status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled', + password_change_required TINYINT(1) NOT NULL DEFAULT 1 COMMENT '管理员创建或重置后要求首次改密', + failed_login_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续登录失败次数', + locked_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '失败锁定截止时间,UTC epoch ms', + last_login_at_ms BIGINT NULL COMMENT '最近成功登录时间,UTC epoch ms', + created_by_admin_id BIGINT UNSIGNED NOT NULL COMMENT '创建该账号的主后台管理员 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (id), + UNIQUE KEY uk_external_admin_accounts_app_username (app_code, username), + UNIQUE KEY uk_external_admin_accounts_app_linked_user (app_code, linked_app_user_id), + KEY idx_external_admin_accounts_app_status_updated (app_code, status, updated_at_ms), + KEY idx_external_admin_accounts_creator (created_by_admin_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='独立外管账号'; + +CREATE TABLE IF NOT EXISTS external_admin_sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '会话 ID', + account_id BIGINT UNSIGNED NOT NULL COMMENT '外管账号 ID', + app_code VARCHAR(32) NOT NULL COMMENT '创建时固化的 App 租户', + token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'opaque session token SHA-256', + csrf_token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'CSRF token SHA-256', + ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录 IP', + user_agent VARCHAR(512) NOT NULL DEFAULT '' COMMENT '登录客户端', + expires_at_ms BIGINT NOT NULL COMMENT '过期时间,UTC epoch ms', + last_seen_at_ms BIGINT NOT NULL COMMENT '最近访问时间,UTC epoch ms', + revoked_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '撤销时间,0 表示有效', + revoke_reason VARCHAR(64) NOT NULL DEFAULT '' COMMENT '撤销原因', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + PRIMARY KEY (id), + UNIQUE KEY uk_external_admin_sessions_token_hash (token_hash), + KEY idx_external_admin_sessions_account_active (account_id, revoked_at_ms, expires_at_ms), + KEY idx_external_admin_sessions_app_expiry (app_code, expires_at_ms), + CONSTRAINT fk_external_admin_sessions_account FOREIGN KEY (account_id) REFERENCES external_admin_accounts(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外管服务端 opaque 会话'; + +CREATE TABLE IF NOT EXISTS external_admin_login_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '登录日志 ID', + account_id BIGINT UNSIGNED NULL COMMENT '命中账号时记录;未知账号保持 NULL 防止伪造关联', + app_code VARCHAR(32) NOT NULL COMMENT '请求 App', + username VARCHAR(64) NOT NULL COMMENT '请求账号', + ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求 IP', + user_agent VARCHAR(512) NOT NULL DEFAULT '' COMMENT '请求客户端', + status VARCHAR(24) NOT NULL COMMENT 'success/failed', + message VARCHAR(128) NOT NULL DEFAULT '' COMMENT '内部审计原因,不回传客户端', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + PRIMARY KEY (id), + KEY idx_external_admin_login_logs_app_username_time (app_code, username, created_at_ms), + KEY idx_external_admin_login_logs_account_time (account_id, created_at_ms), + KEY idx_external_admin_login_logs_status_time (status, created_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外管登录日志'; + +CREATE TABLE IF NOT EXISTS external_admin_operation_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '操作日志 ID', + account_id BIGINT UNSIGNED NOT NULL COMMENT '外管账号 ID', + app_code VARCHAR(32) NOT NULL COMMENT '会话固定 App', + username VARCHAR(64) NOT NULL COMMENT '外管账号', + request_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求链路 ID', + action VARCHAR(160) NOT NULL COMMENT 'HTTP 方法与路由模板', + resource VARCHAR(120) NOT NULL DEFAULT '' COMMENT '资源类型', + resource_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目标资源 ID', + method VARCHAR(12) NOT NULL COMMENT 'HTTP 方法', + path VARCHAR(255) NOT NULL COMMENT '实际请求路径', + ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求 IP', + user_agent VARCHAR(512) NOT NULL DEFAULT '' COMMENT '请求客户端', + status VARCHAR(24) NOT NULL COMMENT 'success/failed', + http_status INT NOT NULL COMMENT 'HTTP 状态码', + latency_ms BIGINT NOT NULL DEFAULT 0 COMMENT '处理耗时', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + PRIMARY KEY (id), + KEY idx_external_admin_operation_logs_account_time (account_id, created_at_ms), + KEY idx_external_admin_operation_logs_app_time (app_code, created_at_ms), + KEY idx_external_admin_operation_logs_request (request_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外管业务操作审计'; + +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES + ('外管用户查看', 'external-admin-user:view', 'menu', '查看当前 App 的外管用户', @now_ms, @now_ms), + ('外管用户创建', 'external-admin-user:create', 'button', '按当前 App 用户短 ID 创建外管账号', @now_ms, @now_ms), + ('外管用户状态', 'external-admin-user:status', 'button', '启用或停用当前 App 的外管账号', @now_ms, @now_ms), + ('外管用户重置密码', 'external-admin-user:reset-password', 'button', '重置当前 App 的外管账号密码并撤销全部会话', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) +SELECT parent.id, '外管用户', 'operation-external-admin-users', '/operations/external-admin-users', 'shield', 'external-admin-user:view', 72, TRUE, @now_ms, @now_ms +FROM admin_menus parent +WHERE parent.code = 'operations' +ON DUPLICATE KEY UPDATE + parent_id = VALUES(parent_id), + title = VALUES(title), + path = VALUES(path), + icon = VALUES(icon), + permission_code = VALUES(permission_code), + sort = VALUES(sort), + visible = VALUES(visible), + updated_at_ms = @now_ms; + +-- 创建或重置密码等同于签发/接管固定的全能力外管账号,只允许平台管理员执行, +-- 避免运营角色通过委派获得自身权限矩阵中不存在的 VIP 发放等高风险能力。 +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT role.id, permission.id +FROM admin_roles role +JOIN admin_permissions permission +WHERE role.code = 'platform-admin' + AND permission.code IN ( + 'external-admin-user:view', + 'external-admin-user:create', + 'external-admin-user:status', + 'external-admin-user:reset-password' + ); + +-- 运营负责人和运营专员负责日常查看、启用和停用,不持有账号凭据签发能力。 +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT role.id, permission.id +FROM admin_roles role +JOIN admin_permissions permission +WHERE role.code IN ('ops-admin', 'operations-specialist') + AND permission.code IN ( + 'external-admin-user:view', + 'external-admin-user:status' + ); diff --git a/server/admin/migrations/099_external_admin_credential_delegation.sql b/server/admin/migrations/099_external_admin_credential_delegation.sql new file mode 100644 index 00000000..28d802d9 --- /dev/null +++ b/server/admin/migrations/099_external_admin_credential_delegation.sql @@ -0,0 +1,13 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 098 尚未发布时本迁移为空操作;若某个环境曾执行过早期 098(曾把四项权限都给运营岗位), +-- 这里通过角色 code、权限 code 的唯一索引和关联表主键定点撤销,不扫描任何业务数据表。 +DELETE role_permission +FROM admin_role_permissions role_permission +JOIN admin_roles role ON role.id = role_permission.role_id +JOIN admin_permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('ops-admin', 'operations-specialist') + AND permission.code IN ( + 'external-admin-user:create', + 'external-admin-user:reset-password' + );