feat(admin): add isolated external management portal
This commit is contained in:
parent
08a0fec530
commit
ce6b85fb88
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
58
server/admin/docs/外管后台.md
Normal file
58
server/admin/docs/外管后台.md
Normal 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 仍为 active;App 停用后旧会话不能绕过 `/auth/me` 直接调用业务接口。
|
||||
- 同一账号连续失败 5 次后锁定 15 分钟;App、账号和密码错误对外使用统一文案。
|
||||
- Redis 在 MySQL 查询、bcrypt 和登录日志之前执行 60 秒固定窗口限流:系统全局 300、单 App 120、单 IP 30、单 App+账号 10。四层计数在一次 Lua 中原子完成,拒绝请求仍消耗总闸配额。
|
||||
- 登录请求体上限为 8 KiB;限流 Redis 在 200 ms 内不可用时 fail-closed 返回 503,不降级为可绕过的单机计数。
|
||||
- 认证接口统一返回 `Cache-Control: no-store`。
|
||||
|
||||
## 上线步骤
|
||||
|
||||
1. 通过 admin-server 迁移器依次应用 `migrations/098_external_admin_portal.sql` 和 `migrations/099_external_admin_credential_delegation.sql`。098 只新建四张认证/审计表并按唯一索引写入菜单和权限元数据;099 通过角色、权限唯一索引和关联表主键定点撤销旧版 098 可能误授予运营岗位的凭据权限。两者都不扫描用户、房间、钱包等大业务表;不要在高峰期临时改写迁移内容。
|
||||
2. 确认 Redis 已启用且 `/readyz` 为成功。生产配置缺少 Redis 时 admin-server 会拒绝启动,运行中限流 Redis 故障会阻断新登录但不破坏已建立会话。
|
||||
3. 配置 `trusted_proxies` 为真实入口链路的精确 IP/CIDR。默认只信任 loopback;不要信任 `0.0.0.0/0`、`::/0` 或整段未知私网。
|
||||
4. 构建并发布前端 `dist/external-admin/`,保留 Nginx 对 `/external-admin` 的 301 和所有 `/external-admin/*` 深链回退到独立 HTML。
|
||||
5. 在 EdgeOne/WAF 对精确路径 `POST /api/v1/external/auth/login` 配置按真实客户端 IP 的 token-bucket/挑战规则,建议基线 30 次/分钟、burst 10;同时限制源站直连。WAF 负责 Redis 前的大包和连接洪峰,应用层 Redis 负责系统、App 和账号维度的正确性。
|
||||
|
||||
## 上线验收
|
||||
|
||||
- Fami 创建的账号登录后请求 Lalu 的 `X-App-Code` 必须返回 403,省略 header 时仍只能看到 Fami 数据。
|
||||
- 外管账号或 App 停用后,已有会话直接访问任一业务接口必须返回 401。
|
||||
- 临时密码登录后,除会话、退出和修改密码外的业务接口必须返回 403;改密后才可进入授权页面。
|
||||
- 从同一真实 IP 连发超过阈值的登录请求必须返回 429 和 `Retry-After`;伪造 `X-Forwarded-For` 不能改变限流身份。
|
||||
- Redis 临时不可用时新登录应在约 200 ms 后返回 503;恢复后不需要重启服务。
|
||||
- `/external-admin/`、一个桌面深链和一个移动端深链均返回外管独立 HTML,不能回退到主后台入口。
|
||||
- 停用、密码重置、封禁、房间编辑、资源/VIP 发放和 Banner 创建均应在外管操作日志中留下 App、外管账号、请求 ID、资源 ID 和结果。
|
||||
90
server/admin/internal/cache/redis.go
vendored
90
server/admin/internal/cache/redis.go
vendored
@ -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
|
||||
|
||||
34
server/admin/internal/cache/redis_fixed_window_test.go
vendored
Normal file
34
server/admin/internal/cache/redis_fixed_window_test.go
vendored
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
37
server/admin/internal/middleware/cors_external_test.go
Normal file
37
server/admin/internal/middleware/cors_external_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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() }
|
||||
}
|
||||
500
server/admin/internal/modules/externaladmin/handler.go
Normal file
500
server/admin/internal/modules/externaladmin/handler.go
Normal 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
|
||||
}
|
||||
192
server/admin/internal/modules/externaladmin/middleware.go
Normal file
192
server/admin/internal/modules/externaladmin/middleware.go
Normal 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"
|
||||
}
|
||||
271
server/admin/internal/modules/externaladmin/middleware_test.go
Normal file
271
server/admin/internal/modules/externaladmin/middleware_test.go
Normal 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{}
|
||||
}
|
||||
178
server/admin/internal/modules/externaladmin/rate_limit_test.go
Normal file
178
server/admin/internal/modules/externaladmin/rate_limit_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
126
server/admin/internal/modules/externaladmin/routes.go
Normal file
126
server/admin/internal/modules/externaladmin/routes.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
812
server/admin/internal/modules/externaladmin/service.go
Normal file
812
server/admin/internal/modules/externaladmin/service.go
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
155
server/admin/internal/modules/externaladmin/team_test.go
Normal file
155
server/admin/internal/modules/externaladmin/team_test.go
Normal 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
|
||||
}
|
||||
330
server/admin/internal/modules/externaladmin/types.go
Normal file
330
server/admin/internal/modules/externaladmin/types.go
Normal 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
|
||||
}
|
||||
116
server/admin/internal/modules/externaladmin/types_test.go
Normal file
116
server/admin/internal/modules/externaladmin/types_test.go
Normal 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
|
||||
}
|
||||
@ -92,6 +92,10 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.CoinSellerRechargeOrder{},
|
||||
&model.UserWithdrawalApplication{},
|
||||
&model.AdminJob{},
|
||||
&model.ExternalAdminAccount{},
|
||||
&model.ExternalAdminSession{},
|
||||
&model.ExternalAdminLoginLog{},
|
||||
&model.ExternalAdminOperationLog{},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -7,5 +7,8 @@ const (
|
||||
CodeForbidden = 40300
|
||||
CodeNotFound = 40400
|
||||
CodeConflict = 40900
|
||||
CodePayloadLarge = 41300
|
||||
CodeTooMany = 42900
|
||||
CodeServerError = 50000
|
||||
CodeUnavailable = 50300
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
136
server/admin/migrations/098_external_admin_portal.sql
Normal file
136
server/admin/migrations/098_external_admin_portal.sql
Normal 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'
|
||||
);
|
||||
@ -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'
|
||||
);
|
||||
Loading…
x
Reference in New Issue
Block a user