feat(admin): add isolated external management portal

This commit is contained in:
zhx 2026-07-15 16:05:21 +08:00
parent 08a0fec530
commit ce6b85fb88
34 changed files with 3922 additions and 10 deletions

View File

@ -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),

View File

@ -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

View File

@ -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

View File

@ -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 仍为 activeApp 停用后旧会话不能绕过 `/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 和结果。

View File

@ -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

View File

@ -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")
}
}

View File

@ -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")
}

View File

@ -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")

View File

@ -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")
}
}

View File

@ -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")
}

View File

@ -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"`

View File

@ -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)
}
}

View File

@ -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() }
}

View File

@ -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
}

View File

@ -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"
}

View File

@ -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{}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
})
}
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -92,6 +92,10 @@ func (s *Store) AutoMigrate() error {
&model.CoinSellerRechargeOrder{},
&model.UserWithdrawalApplication{},
&model.AdminJob{},
&model.ExternalAdminAccount{},
&model.ExternalAdminSession{},
&model.ExternalAdminLoginLog{},
&model.ExternalAdminOperationLog{},
)
}

View File

@ -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,

View File

@ -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},

View File

@ -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)

View File

@ -7,5 +7,8 @@ const (
CodeForbidden = 40300
CodeNotFound = 40400
CodeConflict = 40900
CodePayloadLarge = 41300
CodeTooMany = 42900
CodeServerError = 50000
CodeUnavailable = 50300
)

View File

@ -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)
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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'
);

View File

@ -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'
);