From f56f233704700d43c6733b7f0fa14d67e1feddf7 Mon Sep 17 00:00:00 2001 From: k2safe Date: Wed, 30 Sep 2026 00:09:01 +0800 Subject: [PATCH] fix: stabilize console scrolling and protect administrator login --- CHANGELOG.md | 5 +- README.md | 2 + cmd/orbit/main.go | 24 +- deploy/docker-compose.ha.yml | 8 + deploy/docker-compose.yml | 8 + deploy/nginx/orbit.conf.example | 1 + docs/08-development-plan.md | 7 + docs/console-login-security.md | 65 ++++ docs/console.md | 2 +- docs/reports/console-refresh.md | 16 + internal/config/config.go | 22 +- internal/config/console_login.go | 57 +++ internal/config/console_login_test.go | 30 ++ internal/consoleauth/login_policy.go | 73 ++++ ...tion_resources_upgrade_integration_test.go | 2 +- internal/storage/mysql/baseline.sql | 30 ++ internal/storage/mysql/console_auth.go | 67 +--- internal/storage/mysql/console_login.go | 337 ++++++++++++++++++ .../mysql/console_login_integration_test.go | 262 ++++++++++++++ internal/storage/mysql/migrate.go | 2 +- .../000035_console_login_protection.up.sql | 29 ++ .../mysql/no_foreign_keys_integration_test.go | 2 +- internal/transport/httpserver/console.go | 28 +- internal/transport/httpserver/console/app.js | 21 ++ .../transport/httpserver/console/index.html | 11 +- .../transport/httpserver/console/login.html | 26 +- .../transport/httpserver/console/styles.css | 18 +- .../httpserver/console_applications.go | 14 + .../transport/httpserver/console_auth_test.go | 76 +++- .../transport/httpserver/console_login.go | 57 +++ scripts/test-console-e2e.sh | 24 +- scripts/test-console-login.py | 68 ++++ 32 files changed, 1308 insertions(+), 86 deletions(-) create mode 100644 docs/console-login-security.md create mode 100644 internal/config/console_login.go create mode 100644 internal/config/console_login_test.go create mode 100644 internal/consoleauth/login_policy.go create mode 100644 internal/storage/mysql/console_login.go create mode 100644 internal/storage/mysql/console_login_integration_test.go create mode 100644 internal/storage/mysql/migrations/000035_console_login_protection.up.sql create mode 100644 internal/transport/httpserver/console_login.go create mode 100644 scripts/test-console-login.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 68037fa..ec8cb07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## main — 2026-09-29 +- 登录增加 MySQL 持久失败计数、临时锁定、账号/IP 限流、可信代理解析与审计查询,阈值及时间可配置;迁移 000035,无外键。 +- 补齐根页面和实际滚动区的边界回弹限制,修复侧栏与内容随触控板弹动。 + - 修复长页面滚动体验:固定导航与页头、内容独立滚动,应用详情后台刷新避免重建相同快照及打断交互。 - 筛选范围改为可搜索的浅色菜单;四个资源列表默认每页 10 条,登录页大屏内容居中,统一筛选区与表格间距。 @@ -11,7 +14,7 @@ - 管理端统一浅色工作台、分组导航、紧凑总览、中文状态和移动端布局。 - 任务/流程增加服务端状态、精确业务键和时间筛选;任务、计划、流程、订阅增加游标分页、页大小与 ID 定位。刷新保留查询,迟到响应不覆盖新结果。 - 应用搜索、接入配置集中展示、低频操作折叠;详情支持复制 ID、键盘焦点与返回入口,Token 查看/复制及原有审计规则保持。 -- 本批只更新控制台及说明,不修改 API、SDK 或数据库结构。SDK 继续使用 v0.1.2,Outbox 继续使用 v0.1.1。[验收记录](docs/reports/console-refresh.md)。 +- 此前浅色工作台改版只更新控制台;本次登录保护增加管理接口与迁移 000035,业务 SDK 协议不变。SDK 继续使用 v0.1.2,Outbox 继续使用 v0.1.1。[验收记录](docs/reports/console-refresh.md)。 ## v0.1.2 — 2026-09-27 diff --git a/README.md b/README.md index f3be712..4cb10e7 100644 --- a/README.md +++ b/README.md @@ -282,3 +282,5 @@ make console-e2e # 控制台端到端回归 `v0.1.2` 新增通用资源退役;此前已包含账号密码控制台、归档策略/周期作业、Outbox 公共包、HTTP 生命周期示例及统一示例目录;与上一版本的升级说明和验证结果见 [CHANGELOG](CHANGELOG.md) 与 [本次验证](docs/reports/resource-retirement.md)。当前未提供源数据删除、其他数据库 Outbox 适配,也不把单机或隔离环境多节点测试等同于生产跨故障域高可用验收。 更多资料:[设计文档](docs/README.md) · [持续开发计划](docs/08-development-plan.md) · [接口目录](docs/api/README.md) · [示例目录](examples/README.md) · [发布规则](docs/sdk/release.md)。 + +控制台账号密码登录现已支持连续失败临时锁定、账号/IP 限流、可配置保护窗口及管理员登录审计,详见[登录保护与配置](docs/console-login-security.md)。此能力在 main 迁移 000035 起提供,业务 SDK 协议不变。 diff --git a/cmd/orbit/main.go b/cmd/orbit/main.go index 52891df..a161578 100644 --- a/cmd/orbit/main.go +++ b/cmd/orbit/main.go @@ -93,7 +93,29 @@ func run() error { txStats := mysql.ReadTransactionStats() metrics.RecordTransactionStats(observability.TransactionStats{Retries: txStats.Retries, Exhausted: txStats.Exhausted}) } - consoleAuth := mysql.NewConsoleAuthStore(db) + consoleAuth := mysql.NewConsoleAuthStore(db, cfg.ConsoleLogin) + // 登录安全记录独立保留,每分钟有界清理,关闭时等待清理器退出。 + loginCleanupCtx, stopLoginCleanup := context.WithCancel(root) + loginCleanupDone := make(chan struct{}) + go func() { + defer close(loginCleanupDone) + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-loginCleanupCtx.Done(): + return + case <-ticker.C: + call, cancel := context.WithTimeout(loginCleanupCtx, 5*time.Second) + err := consoleAuth.CleanupLoginSecurity(call) + cancel() + if err != nil && loginCleanupCtx.Err() == nil { + slog.Error("console login security cleanup failed") + } + } + } + }() + defer func() { stopLoginCleanup(); <-loginCleanupDone }() retentionStore := mysql.NewRetentionPolicyStore(db, cfg.RetentionDefaults) server := httpserver.NewWithConsoleAuth(cfg.HTTPAddr, readiness.Check, consoleAuth, service.New(mysql.NewTaskStore(db))) httpserver.RegisterApplicationResources(server, resources.New(mysql.NewTaskStore(db))) diff --git a/deploy/docker-compose.ha.yml b/deploy/docker-compose.ha.yml index 41388e1..a7ce11c 100644 --- a/deploy/docker-compose.ha.yml +++ b/deploy/docker-compose.ha.yml @@ -25,6 +25,14 @@ x-orbit-environment: &orbit-environment ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555} ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-} ORBIT_CONSOLE_APPLICATION_ADMIN: ${ORBIT_CONSOLE_APPLICATION_ADMIN:-} + ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT: ${ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT:-5} + ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW: ${ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW:-15m} + ORBIT_CONSOLE_LOGIN_LOCK_DURATION: ${ORBIT_CONSOLE_LOGIN_LOCK_DURATION:-15m} + ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT: ${ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT:-10} + ORBIT_CONSOLE_LOGIN_IP_LIMIT: ${ORBIT_CONSOLE_LOGIN_IP_LIMIT:-30} + ORBIT_CONSOLE_LOGIN_RATE_WINDOW: ${ORBIT_CONSOLE_LOGIN_RATE_WINDOW:-1m} + ORBIT_CONSOLE_LOGIN_AUDIT_DAYS: ${ORBIT_CONSOLE_LOGIN_AUDIT_DAYS:-90} + ORBIT_CONSOLE_TRUSTED_PROXIES: ${ORBIT_CONSOLE_TRUSTED_PROXIES:-} ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-} services: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 2627fa6..67440a2 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -93,6 +93,14 @@ services: ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90} ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555} ORBIT_CONSOLE_APPLICATION_ADMIN: ${ORBIT_CONSOLE_APPLICATION_ADMIN:-} + ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT: ${ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT:-5} + ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW: ${ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW:-15m} + ORBIT_CONSOLE_LOGIN_LOCK_DURATION: ${ORBIT_CONSOLE_LOGIN_LOCK_DURATION:-15m} + ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT: ${ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT:-10} + ORBIT_CONSOLE_LOGIN_IP_LIMIT: ${ORBIT_CONSOLE_LOGIN_IP_LIMIT:-30} + ORBIT_CONSOLE_LOGIN_RATE_WINDOW: ${ORBIT_CONSOLE_LOGIN_RATE_WINDOW:-1m} + ORBIT_CONSOLE_LOGIN_AUDIT_DAYS: ${ORBIT_CONSOLE_LOGIN_AUDIT_DAYS:-90} + ORBIT_CONSOLE_TRUSTED_PROXIES: ${ORBIT_CONSOLE_TRUSTED_PROXIES:-} ORBIT_CREDENTIAL_KEY_FILE: ${ORBIT_CREDENTIAL_KEY_FILE:-} ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-} ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-} diff --git a/deploy/nginx/orbit.conf.example b/deploy/nginx/orbit.conf.example index 6e68a95..e92ddde 100644 --- a/deploy/nginx/orbit.conf.example +++ b/deploy/nginx/orbit.conf.example @@ -81,6 +81,7 @@ server { } # 控制台登录额外限制来源 IP 的尝试速率;业务任务速率仍由 Orbit 的持久化配额和授权决定。 + # Orbit 另有数据库持久化账号/IP 保护;配置 ORBIT_CONSOLE_TRUSTED_PROXIES 为此代理的实际地址。 location = /console/auth/login { limit_req zone=orbit_console_login burst=5 nodelay; proxy_pass http://orbit_backend; diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index a9ad2b7..7e8cd73 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -344,3 +344,10 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专 - [x] 应用详情相同快照不重建,后台刷新在滚动/编辑/展开详情时让位,检查请求返回时的交互状态。 - [x] 核对登录实现:当前没有失败次数封控或登录限流,明确记录缺口。 - [x] 浏览器滚动、刷新和切页回归,基础检查及持久 Docker 部署;验收见[管理端改版记录](reports/console-refresh.md)。 + +## 2026-09-29:滚动边界补修与登录保护 + +- [ ] 关闭根页面、侧栏及各内容滚动区的边界回弹;验证连续滚动与短屏导航。 +- [ ] MySQL 持久化失败窗口、临时锁定及账号/IP 限流,配置校验、可信代理识别。 +- [ ] 登录成功/失败/拒绝审计,单管理员查询、保留期及有界清理,登录页等待提示。 +- [ ] 真实 MySQL 并发/跨实例/恢复/审计失败回归,基础检查、浏览器验证、Docker 部署与文档同步。 diff --git a/docs/console-login-security.md b/docs/console-login-security.md new file mode 100644 index 0000000..1ae2c76 --- /dev/null +++ b/docs/console-login-security.md @@ -0,0 +1,65 @@ +# 控制台登录保护 + +从 main 的数据库迁移 `000035` 开始启用。仅保护管理端账号密码登录,不改变业务 SDK Token、Worker 或结果订阅认证。保持单管理员,不引入角色管理系统。 + +## 默认行为 + +- 15 分钟失败窗口内连续失败 5 次,临时锁定 15 分钟。成功登录清空失败计数;失败窗口或锁定自然到期后重新计数。锁定期间尝试正确密码也不能登录,继续请求不会延长锁定。 +- 同一账号每分钟最多受理 10 次,同一来源 IP 每分钟最多 30 次。固定窗口从首次尝试开始;成功登录不清空限流计数。任意一项触发就拒绝。 +- 账号不存在、停用、密码错误或绑定凭据失效,正常拒绝均为 401。未知账号也参与相同限流/锁定,使用相同成本的 bcrypt 校验。既有账号按规范用户 ID 计数,避免等价 Unicode 登录名绕过限制。 +- 触发锁定或限流返回 429,响应包含 `Retry-After` 秒数。登录页显示原因和倒计时;刷新页面不能绕过服务端限制。已登录会话不受一次新登录锁定影响,不中断业务服务。 +- 数据库或审计提交失败返回 503,不签发 Cookie。跨站登录请求为 403;非法/过大的 JSON 为 400。 + +所有保护状态存放 MySQL,多节点共用,重启不归零。昂贵的密码校验在事务外执行:先用短事务占用请求预算,校验结束后再次锁定账号保护行;失败计数、锁定、成功会话和最终审计在同一事务提交。并发结果不能越过已经生效的账号锁定。校验进程异常退出的审计保留 PENDING,超过 10 分钟由清理器标 ERROR;不将未知结果当作密码错误。 + +## 部署配置 + +所有 Orbit 节点必须使用一致配置;不允许用 0 关闭保护。修改部署配置并重启服务后生效,不重置已有计数或已记录的锁定截止。降低阈值后按新阈值检查。 + +| 环境变量 | 默认值 | 约束 / 含义 | +| --- | --- | --- | +| `ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT` | `5` | 1–100 次,触发临时锁定 | +| `ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW` | `15m` | 1s–24h,连续失败窗口 | +| `ORBIT_CONSOLE_LOGIN_LOCK_DURATION` | `15m` | 1s–24h,新触发锁定时长 | +| `ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT` | `10` | 1–1000 次 / 账号 / 限流窗口 | +| `ORBIT_CONSOLE_LOGIN_IP_LIMIT` | `30` | 1–10000 次 / IP / 限流窗口 | +| `ORBIT_CONSOLE_LOGIN_RATE_WINDOW` | `1m` | 1s–24h,固定限流窗口 | +| `ORBIT_CONSOLE_LOGIN_AUDIT_DAYS` | `90` | 1–3650 天,登录审计独立保留期 | +| `ORBIT_CONSOLE_TRUSTED_PROXIES` | 空 | 明确信任的代理 CIDR,逗号分隔 | + +普通 Compose 和 HA Compose 均透传这些变量。本地测试环境在 `.local/compose-test/compose.env` 修改所需配置,再执行 `python3 scripts/orbit-local.py up`。此文件含其他私密配置,不提交仓库;不能仅在启动脚本外 export,脚本会隔离外部项目环境变量。 + +默认只使用 TCP 对端 IP,忽略客户端自带的 X-Forwarded-For、X-Real-IP。部署域名反向代理时,将实际代理的地址或精确网段填入 `ORBIT_CONSOLE_TRUSTED_PROXIES`,例如同机代理 `127.0.0.1/32,::1/128`。只在直连对端可信时解析 X-Forwarded-For,从右向左剥离可信代理;遇到第一个非可信地址停止。格式非法或跳数过多时退回 TCP 对端。代理应覆盖或按可信链追加转发头,不能把所有互联网地址都配置为受信代理。未配置时所有经代理的请求共用代理 IP 配额,账号维度仍独立生效。 + +## 查看审计 + +管理端右上角“登录审计”,仅部署指定的 `ORBIT_CONSOLE_APPLICATION_ADMIN` 可查看,默认每页 10 条。显示时间、登录名、来源 IP、结果和可重试时间;记录用户名不代表该账号一定存在。SDK Bearer、未登录或普通控制台账号无权读取全局记录。 + +接口:`GET /console/auth/audit?before=<上页next>`,认证为有效 HttpOnly 管理员会话 Cookie。响应: + +```json +{"data":{"items":[{"id":"42","username":"operator","remote_ip":"192.0.2.1","user_agent":"browser","outcome":"LOCKED","created_at":"2026-09-29T12:00:00Z","finished_at":"2026-09-29T12:00:00Z","retry_at":"2026-09-29T12:15:00Z"}],"next":"42"}} +``` + +`next` 仅在还有下一页时提供,是倒序分页的排他上界;无全库计数、不承诺跨页快照。结果类型:SUCCESS、INVALID、LOCKED、ACCOUNT_LIMIT、IP_LIMIT、PENDING、ERROR。审计从不保存密码、Cookie、Token 或这些秘密的摘要,浏览器列表不会自动拉取接入 Token。 + +登录拒绝示例: + +```http +HTTP/1.1 429 Too Many Requests +Retry-After: 900 +Cache-Control: no-store +Content-Type: application/json + +{"error":{"code":"LOGIN_LOCKED","message":"连续登录失败,已临时锁定,请稍后重试","retry_after_seconds":900}} +``` + +限流代码为 `LOGIN_RATE_LIMITED`。不要让客户端自动循环重试账号密码登录。 + +## 保留与升级 + +迁移只新增 `orbit_console_login_guards` 与 `orbit_console_login_audit`,无外键,不更改已有用户、密码、会话、凭据及业务记录。先备份,停止旧节点,再前向迁移和统一部署;旧版没有保护,不能和新版混跑。 + +每分钟清理一次,每种语句最多处理 500 行:48 小时未更新的保护状态、超过配置天数的登录审计,以及超时 PENDING 的状态归类。有效保护窗口最长 24 小时,48 小时非活动清理不会提前解除有效锁定。清理有索引、有截止时间,失败保留记录并写错误日志;这不会缩小数据库已分配磁盘,也不替代备份。业务“保留策略”管理的是命名空间历史,登录审计的全局保留时间由上述独立配置管理。 + +锁定自然到期后使用正确密码登录即可;不自动改密码、撤销业务 Token 或踢出已有会话。若持续遭受攻击,管理员应按审计中的来源限制访问并核对反向代理配置,不能通过关闭计数绕过保护。 diff --git a/docs/console.md b/docs/console.md index 4e2069f..a928276 100644 --- a/docs/console.md +++ b/docs/console.md @@ -75,7 +75,7 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx' ## 安全边界 -当前登录接口尚未实现连续密码错误计数、临时锁定或账号/IP 登录限流。错误统一返回 401,密码使用 bcrypt 存储;这些措施不能替代防暴力尝试封控。此项是待补能力,不能视为已具备。 +登录已具备持久化失败计数、临时锁定、账号/IP 限流和单管理员登录审计。默认 15 分钟内连续失败 5 次锁定 15 分钟,账号/IP 每分钟分别最多尝试 10/30 次;阈值、窗口、审计保留期和可信代理均可配置。页面显示 429 原因及倒计时,服务重启不清空保护状态。配置与接口见[登录保护](console-login-security.md)。 控制台通过服务端授权访问数据库。登录会话只保存令牌摘要,服务端每次请求都会校验账号、会话、命名空间、应用启停和凭据权限。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。普通账号不自动获得跨命名空间权限;部署指定的单管理员可显式选择应用范围,每个业务事务重新核验原会话及目标应用。各应用分别展示,SDK 权限不变。 diff --git a/docs/reports/console-refresh.md b/docs/reports/console-refresh.md index 2003e3b..5e533a2 100644 --- a/docs/reports/console-refresh.md +++ b/docs/reports/console-refresh.md @@ -64,3 +64,19 @@ SDK、数据库 Schema 34、原应用与凭据保持。部署未重新 bootstrap 登录保护核对:`ConsoleAuthStore.Login` 与 HTTP 登录路由目前没有失败计数、临时锁定和登录限流;本轮如实记录缺口,没有改动账号密码或认证策略。 第三轮持久部署已核验:`/version` 修订 `23db3d8fe0de352be9e756f932b12559ea746ff3`,`/health/ready` 为 200 ready。实际 18080 应用页内容 scrollTop=293 时,document scrollY=0、导航和页头 top=0。服务原账号、SDK、Token 和数据库保持不变。 + +## 2026-09-30:边界回弹与登录保护 + +上一轮键盘翻页和静态位置检查没有覆盖触控板滚到边界的回弹。原 `overscroll-behavior: contain` 只阻止滚动传递,仍允许元素回弹([MDN 属性说明](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overscroll-behavior#values))。本轮在控制台根节点、侧栏、内容、应用列表及弹层的实际滚动容器设置 `none`,并固定根工作区;保留内容正常滚动和短屏菜单可达性。 + +浏览器预览验证: + +- 1440×900,用滚动操作连续冲顶部、底部并反向:根 scrollY=0,侧栏 y=0、品牌 y=28、页头 y=0;内容 scrollTop 在 0–293 内移动。 +- 1100×600,菜单在 0–198 内滚动,内容在 0–593 内独立滚动,另一侧不被拖动,页头 y=0。 +- 390×844,顶部导航固定,内容滚动至 1112;根 scrollY=0、文档宽度 390,无根横向溢出。工具滚动不等同于所有设备的物理触控板惯性手势,以上为实际验证范围。 + +登录保护实现、配置和 API 见[控制台登录保护](../console-login-security.md)。已有真实 MySQL 回归覆盖失败/成功计数重置、两个存储实例与重建实例共用锁定、锁定不续期与自然到期、并发 6 次失败精确计数、账号/IP 配额、未知账号、失败窗口、审计提交失败回滚会话、管理员权限/分页、日志保留和零外键。独立 Docker 双服务实测还覆盖轮流失败、重启服务仍拒绝、已登录会话仍有效、伪造代理头无效、自然到期后登录成功。 + +端到端首轮旧管理查询曾超时;复跑该链路通过,未放宽断言。双节点测试首次重启后因 Docker 重新分配随机发布端口无法就绪,测试工具改为重读实际端口后通过,未修改锁定逻辑或删除状态。最终 `make console-e2e` 全链路通过,含任务执行、重试、定时计划、归档及退出登录。 + +运行中的本地环境将在最终部署核对后补录版本和浏览器证据。 diff --git a/internal/config/config.go b/internal/config/config.go index a6cad41..dc83775 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,17 +10,19 @@ import ( "strconv" "time" + "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" "git.yuntuo.hk/k2safe/orbit/internal/retention" ) type Config struct { - ConsoleApplicationAdmin string // ConsoleApplicationAdmin 可跨应用管理接入的唯一控制台登录名;空时禁用。 - CredentialKeyFile string // CredentialKeyFile 管理员 Token 保管密钥文件路径;所有服务节点共用同一密钥环。 - ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。 - ArchiveStorageID core.ID // ArchiveStorageID 32位小写十六进制卷身份,各节点须挂载同一物理卷。 - ArchiveMaxBytes int64 // ArchiveMaxBytes 全卷文件预算,默认10GiB。 - ArchiveTickInterval time.Duration // ArchiveTickInterval 后台扫描间隔,默认10秒。 - RetentionDefaults retention.Policy // RetentionDefaults 未保存数据库策略时使用的部署默认值。 + ConsoleLogin consoleauth.LoginPolicy // ConsoleLogin 登录保护策略,所有服务节点使用一致配置。 + ConsoleApplicationAdmin string // ConsoleApplicationAdmin 可跨应用管理接入的唯一控制台登录名;空时禁用。 + CredentialKeyFile string // CredentialKeyFile 管理员 Token 保管密钥文件路径;所有服务节点共用同一密钥环。 + ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。 + ArchiveStorageID core.ID // ArchiveStorageID 32位小写十六进制卷身份,各节点须挂载同一物理卷。 + ArchiveMaxBytes int64 // ArchiveMaxBytes 全卷文件预算,默认10GiB。 + ArchiveTickInterval time.Duration // ArchiveTickInterval 后台扫描间隔,默认10秒。 + RetentionDefaults retention.Policy // RetentionDefaults 未保存数据库策略时使用的部署默认值。 HTTPAddr string MySQLDSN string MaxOpen int @@ -39,7 +41,12 @@ type Config struct { func Load() (Config, error) { return FromEnv(os.Getenv) } func FromEnv(get func(string) string) (Config, error) { + loginPolicy, err := loginPolicyFromEnv(get) + if err != nil { + return Config{}, err + } c := Config{ + ConsoleLogin: loginPolicy, ConsoleApplicationAdmin: get("ORBIT_CONSOLE_APPLICATION_ADMIN"), CredentialKeyFile: get("ORBIT_CREDENTIAL_KEY_FILE"), ArchiveRoot: get("ORBIT_ARCHIVE_ROOT"), ArchiveStorageID: core.ID(get("ORBIT_ARCHIVE_STORAGE_ID")), ArchiveMaxBytes: 10 << 30, ArchiveTickInterval: 10 * time.Second, @@ -154,7 +161,6 @@ func FromEnv(get func(string) string) (Config, error) { } c.ArchiveTickInterval = d } - var err error c.RetentionDefaults, err = retention.FromEnv(get) if err != nil { return Config{}, err diff --git a/internal/config/console_login.go b/internal/config/console_login.go new file mode 100644 index 0000000..f40f231 --- /dev/null +++ b/internal/config/console_login.go @@ -0,0 +1,57 @@ +package config + +import ( + "fmt" + "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" + "net/netip" + "strconv" + "strings" + "time" +) + +func loginPolicyFromEnv(get func(string) string) (consoleauth.LoginPolicy, error) { + p := consoleauth.DefaultLoginPolicy() + for _, f := range []struct { + name string + dst *int + }{ + {"ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT", &p.FailureLimit}, + {"ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT", &p.AccountLimit}, + {"ORBIT_CONSOLE_LOGIN_IP_LIMIT", &p.IPLimit}, + {"ORBIT_CONSOLE_LOGIN_AUDIT_DAYS", &p.AuditDays}, + } { + if raw := get(f.name); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil { + return p, fmt.Errorf("invalid %s", f.name) + } + *f.dst = n + } + } + for _, f := range []struct { + name string + dst *time.Duration + }{ + {"ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW", &p.FailureWindow}, + {"ORBIT_CONSOLE_LOGIN_LOCK_DURATION", &p.LockDuration}, + {"ORBIT_CONSOLE_LOGIN_RATE_WINDOW", &p.RateWindow}, + } { + if raw := get(f.name); raw != "" { + d, err := time.ParseDuration(raw) + if err != nil { + return p, fmt.Errorf("invalid %s", f.name) + } + *f.dst = d + } + } + if raw := get("ORBIT_CONSOLE_TRUSTED_PROXIES"); raw != "" { + for _, item := range strings.Split(raw, ",") { + prefix, err := netip.ParsePrefix(strings.TrimSpace(item)) + if err != nil { + return p, fmt.Errorf("invalid ORBIT_CONSOLE_TRUSTED_PROXIES CIDR") + } + p.TrustedProxies = append(p.TrustedProxies, prefix.Masked()) + } + } + return p, p.Validate() +} diff --git a/internal/config/console_login_test.go b/internal/config/console_login_test.go new file mode 100644 index 0000000..98dad07 --- /dev/null +++ b/internal/config/console_login_test.go @@ -0,0 +1,30 @@ +package config + +import ( + "testing" + "time" +) + +func TestConsoleLoginConfiguration(t *testing.T) { + p, err := loginPolicyFromEnv(func(string) string { return "" }) + if err != nil || p.FailureLimit != 5 || p.LockDuration != 15*time.Minute || p.AuditDays != 90 { + t.Fatalf("defaults: %+v %v", p, err) + } + env := map[string]string{"ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT": "3", "ORBIT_CONSOLE_LOGIN_LOCK_DURATION": "20m", "ORBIT_CONSOLE_TRUSTED_PROXIES": "127.0.0.1/32, ::1/128"} + p, err = loginPolicyFromEnv(func(k string) string { return env[k] }) + if err != nil || p.FailureLimit != 3 || len(p.TrustedProxies) != 2 { + t.Fatalf("configured: %+v %v", p, err) + } + for k, v := range map[string]string{"ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT": "0", "ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT": "-1", "ORBIT_CONSOLE_LOGIN_IP_LIMIT": "10001", "ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW": "0s", "ORBIT_CONSOLE_LOGIN_LOCK_DURATION": "25h", "ORBIT_CONSOLE_LOGIN_RATE_WINDOW": "bad", "ORBIT_CONSOLE_LOGIN_AUDIT_DAYS": "0", "ORBIT_CONSOLE_TRUSTED_PROXIES": "all"} { + t.Run(k, func(t *testing.T) { + if _, err := loginPolicyFromEnv(func(name string) string { + if name == k { + return v + } + return "" + }); err == nil { + t.Fatal("invalid policy accepted") + } + }) + } +} diff --git a/internal/consoleauth/login_policy.go b/internal/consoleauth/login_policy.go new file mode 100644 index 0000000..0027c69 --- /dev/null +++ b/internal/consoleauth/login_policy.go @@ -0,0 +1,73 @@ +package consoleauth + +import ( + "context" + "errors" + "git.yuntuo.hk/k2safe/orbit/platform/auth" + "net/netip" + "time" +) + +// LoginPolicy 是所有节点统一配置的登录保护策略;计数及锁定保存在数据库。 +type LoginPolicy struct { + FailureLimit int // FailureLimit 失败窗口内允许的连续失败次数,达到后锁定。 + FailureWindow time.Duration // FailureWindow 连续失败的统计窗口。 + LockDuration time.Duration // LockDuration 临时锁定时长,锁定期间请求不续期。 + AccountLimit int // AccountLimit 每个限流窗口最多接受的账号登录尝试数。 + IPLimit int // IPLimit 每个限流窗口最多接受的来源 IP 登录尝试数。 + RateWindow time.Duration // RateWindow 固定限流窗口;成功登录不清空流量计数。 + AuditDays int // AuditDays 登录审计保留天数,与业务数据策略独立。 + TrustedProxies []netip.Prefix // TrustedProxies 明确信任的反向代理网段;空时忽略所有转发头。 +} + +func DefaultLoginPolicy() LoginPolicy { + return LoginPolicy{FailureLimit: 5, FailureWindow: 15 * time.Minute, LockDuration: 15 * time.Minute, AccountLimit: 10, IPLimit: 30, RateWindow: time.Minute, AuditDays: 90} +} +func (p LoginPolicy) Validate() error { + if p.FailureLimit < 1 || p.FailureLimit > 100 || p.AccountLimit < 1 || p.AccountLimit > 1000 || p.IPLimit < 1 || p.IPLimit > 10000 { + return errors.New("console login limits out of range") + } + for _, d := range []time.Duration{p.FailureWindow, p.LockDuration, p.RateWindow} { + if d < time.Second || d > 24*time.Hour { + return errors.New("console login durations must be 1s–24h") + } + } + if p.AuditDays < 1 || p.AuditDays > 3650 { + return errors.New("console login audit retention must be 1–3650 days") + } + for _, n := range p.TrustedProxies { + if !n.IsValid() { + return errors.New("invalid console trusted proxy CIDR") + } + } + return nil +} + +// LoginBlocked 对外不区分账号是否存在,只给出拒绝类型和最短等待时间。 +type LoginBlocked struct { + Locked bool // Locked 表示临时锁定,否则为请求限流。 + RetryAfter time.Duration // RetryAfter 按数据库时钟计算的剩余等待时间。 +} + +func (*LoginBlocked) Error() string { return "console login temporarily blocked" } + +// LoginAuditEntry 是脱敏的登录审计;从不记录密码、Cookie、Token 或它们的摘要。 +type LoginAuditEntry struct { + ID uint64 `json:"id,string"` // ID 审计游标,按插入序号倒序分页。 + Username string `json:"username"` // Username 提交的有界登录名,不证明账号存在。 + RemoteIP string `json:"remote_ip"` // RemoteIP 经可信代理规则解析的来源地址。 + UserAgent string `json:"user_agent"` // UserAgent 截断为最多 512 字节的浏览器信息。 + Outcome string `json:"outcome"` // Outcome PENDING、SUCCESS、INVALID、LOCKED、ACCOUNT_LIMIT、IP_LIMIT 或 ERROR。 + CreatedAt time.Time `json:"created_at"` // CreatedAt 尝试时间,UTC。 + FinishedAt *time.Time `json:"finished_at,omitempty"` // FinishedAt 完成时间;空表示尚未确认结果。 + RetryAt *time.Time `json:"retry_at,omitempty"` // RetryAt 拒绝时建议最早重试时间,UTC。 +} + +// LoginAuditPage 固定每页 10 条,Next 是下一页排他上界;不承诺跨页快照。 +type LoginAuditPage struct { + Items []LoginAuditEntry `json:"items"` // Items 当前页脱敏记录。 + Next string `json:"next,omitempty"` // Next 下一页游标,无更多时为空。 +} +type LoginAuditor interface { + ListLoginAudit(context.Context, auth.Token, string) (LoginAuditPage, error) +} diff --git a/internal/storage/mysql/application_resources_upgrade_integration_test.go b/internal/storage/mysql/application_resources_upgrade_integration_test.go index 3048306..f515941 100644 --- a/internal/storage/mysql/application_resources_upgrade_integration_test.go +++ b/internal/storage/mysql/application_resources_upgrade_integration_test.go @@ -96,7 +96,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) { f.exec(`ALTER TABLE pulse_worker_sessions DROP COLUMN concurrency_keys`) // 新增的控制台和保留策略迁移也要还原到历史 v9,避免升级测试把 // 已经存在的 v29/v30 表误当成迁移重复执行。 - f.exec(`DROP TABLE orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`) + f.exec(`DROP TABLE orbit_console_login_audit,orbit_console_login_guards,orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`) f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_overlap, DROP COLUMN previous_secret_digest, DROP COLUMN previous_valid_until`) f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_ciphertext, DROP COLUMN secret_key_id, DROP COLUMN secret_ciphertext`) f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`) diff --git a/internal/storage/mysql/baseline.sql b/internal/storage/mysql/baseline.sql index edc4d9f..f66f1a6 100644 --- a/internal/storage/mysql/baseline.sql +++ b/internal/storage/mysql/baseline.sql @@ -943,3 +943,33 @@ ALTER TABLE orbit_subscriptions ADD retired_sequence BIGINT UNSIGNED NULL COMMENT '退役事务锁定的结果流提交水位;NULL表示未退役,之后的事件不再订阅', ADD KEY ix_subscription_active(namespace_id,retired_at), ADD CONSTRAINT ck_subscription_retired CHECK ((retired_at IS NULL AND retired_sequence IS NULL) OR (retired_at IS NOT NULL AND retired_sequence IS NOT NULL AND enabled=FALSE AND pending_count=0)); + +-- 登录保护独立于业务凭据和命名空间;没有外键,不改变已有密码、会话或 Token。 +CREATE TABLE orbit_console_login_guards ( + kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或 ACCOUNT,固定先 IP 后账号加锁', + subject CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或规范账号身份的 SHA-256 十六进制键,不包含密码或 Token', + window_start DATETIME(6) NOT NULL COMMENT '固定请求限流窗口起点,UTC', + requests INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前窗口内已受理的尝试数,成功不归零', + failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败窗口内的失败数,成功后归零', + first_failed_at DATETIME(6) NULL COMMENT '失败窗口起点;空表示无连续失败', + locked_until DATETIME(6) NULL COMMENT '临时锁定截止;锁内请求不延长此时间', + expires_at DATETIME(6) NOT NULL COMMENT '非活动状态清理截止,晚于所有保护窗口', + PRIMARY KEY(kind,subject), + KEY ix_console_guard_expiry(expires_at), + CONSTRAINT ck_console_guard_kind CHECK(kind IN ('IP','ACCOUNT')) +) ENGINE=InnoDB COMMENT='跨节点持久登录限流及临时锁定状态'; + +CREATE TABLE orbit_console_login_audit ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '审计序号,仅用于有界倒序分页', + username VARCHAR(128) NOT NULL COMMENT '请求中的登录名,不证明账号存在', + remote_ip VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '实际连接或受信代理解析出的规范 IP', + user_agent VARCHAR(512) NOT NULL COMMENT '有界浏览器标识,不保存请求正文或认证头', + outcome VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'PENDING/SUCCESS/INVALID/LOCKED/ACCOUNT_LIMIT/IP_LIMIT/ERROR', + created_at DATETIME(6) NOT NULL COMMENT '开始时间,UTC', + finished_at DATETIME(6) NULL COMMENT '结果确认时间,空为未完成;异常中断由清理器标 ERROR', + retry_at DATETIME(6) NULL COMMENT '被锁定或限流时的最早重试时间;否则为空', + PRIMARY KEY(id), + KEY ix_console_login_audit_time(created_at), + KEY ix_console_login_audit_pending(outcome,created_at), + CONSTRAINT ck_console_login_outcome CHECK(outcome IN ('PENDING','SUCCESS','INVALID','LOCKED','ACCOUNT_LIMIT','IP_LIMIT','ERROR')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='全局登录审计,不含密码、Cookie 或 Token,仅管理员可查询'; diff --git a/internal/storage/mysql/console_auth.go b/internal/storage/mysql/console_auth.go index b444181..20c53dc 100644 --- a/internal/storage/mysql/console_auth.go +++ b/internal/storage/mysql/console_auth.go @@ -25,10 +25,20 @@ const ( // ConsoleAuthStore 管理控制台的人类账号和 HttpOnly 会话。 // 账号绑定一个已有的 Orbit 业务凭据,登录后沿用该凭据的权限范围; // 因此控制台登录不会改变 SDK 令牌,也不会绕过原有的 API 授权。 -type ConsoleAuthStore struct{ db *sql.DB } +type ConsoleAuthStore struct { + db *sql.DB + policy consoleauth.LoginPolicy +} // NewConsoleAuthStore 创建控制台认证存储。 -func NewConsoleAuthStore(db *sql.DB) *ConsoleAuthStore { return &ConsoleAuthStore{db: db} } +func NewConsoleAuthStore(db *sql.DB, policies ...consoleauth.LoginPolicy) *ConsoleAuthStore { + policy := consoleauth.DefaultLoginPolicy() + if len(policies) > 0 { + policy = policies[0] + } + return &ConsoleAuthStore{db: db, policy: policy} +} +func (s *ConsoleAuthStore) LoginPolicy() consoleauth.LoginPolicy { return s.policy } var _ consoleauth.Store = (*ConsoleAuthStore)(nil) @@ -108,59 +118,6 @@ func (s *ConsoleAuthStore) CreateConsoleUser(ctx context.Context, in ConsoleUser return ConsoleUser{ID: string(id), Username: in.Username, DisplayName: name}, nil } -// Login 校验账号密码并建立一个独立会话。失败时统一返回未认证,避免泄露账号存在性。 -func (s *ConsoleAuthStore) Login(ctx context.Context, username, password, userAgent, remoteAddr string) (ConsoleLogin, error) { - if s == nil || s.db == nil || username == "" || password == "" || len(password) > 1024 { - return ConsoleLogin{}, fault.Unauthorized - } - var user ConsoleUser - var userID core.ID - var namespace, credential core.ID - var hash string - var enabled bool - err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.display_name,u.password_hash,u.enabled,u.namespace_id,u.credential_id - FROM orbit_console_users u - JOIN orbit_credentials c ON c.namespace_id=u.namespace_id AND c.id=u.credential_id - JOIN orbit_namespaces n ON n.id=u.namespace_id - JOIN orbit_applications a ON a.id=n.application_id - WHERE u.username=? AND c.revoked_at IS NULL - AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)) - AND n.enabled=TRUE AND a.enabled=TRUE`, username). - Scan(&userID, &user.Username, &user.DisplayName, &hash, &enabled, &namespace, &credential) - if err != nil || !enabled || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { - return ConsoleLogin{}, fault.Unauthorized - } - user.ID = string(userID) - token, secretDigest, err := auth.Issue(namespace, credential) - if err != nil { - return ConsoleLogin{}, err - } - var now time.Time - if err := s.db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now); err != nil { - return ConsoleLogin{}, err - } - now = now.UTC() - expires := now.Add(ConsoleSessionLifetime) - sessionID, err := core.NewID() - if err != nil { - return ConsoleLogin{}, err - } - if len(userAgent) > 512 { - userAgent = userAgent[:512] - } - if len(remoteAddr) > 64 { - remoteAddr = remoteAddr[:64] - } - _, err = s.db.ExecContext(ctx, `INSERT INTO orbit_console_sessions - (id,user_id,namespace_id,credential_id,token_digest,expires_at,last_seen_at,user_agent,remote_addr) - VALUES (?,?,?,?,?,?,UTC_TIMESTAMP(6),?,?)`, - sessionID, userID, namespace, credential, secretDigest, expires, userAgent, remoteAddr) - if err != nil { - return ConsoleLogin{}, err - } - return ConsoleLogin{User: user, Token: token, ExpiresAt: expires}, nil -} - // ValidateSession 检查会话是否仍然有效,并更新最近使用时间。 // API 授权还会再次读取该会话对应凭据的权限,不信任浏览器提交的身份字段。 func (s *ConsoleAuthStore) ValidateSession(ctx context.Context, token auth.Token) error { diff --git a/internal/storage/mysql/console_login.go b/internal/storage/mysql/console_login.go new file mode 100644 index 0000000..870ad4e --- /dev/null +++ b/internal/storage/mysql/console_login.go @@ -0,0 +1,337 @@ +package mysql + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "net/netip" + "strconv" + "strings" + "time" + "unicode/utf8" + + "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" + "git.yuntuo.hk/k2safe/orbit/internal/fault" + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" + "golang.org/x/crypto/bcrypt" +) + +// 不存在/停用的账号仍走相同成本校验,限流先于昂贵的密码计算。 +var consoleDummyHash = func() []byte { + hash, err := bcrypt.GenerateFromPassword([]byte("orbit-dummy-login-password"), consolePasswordCost) + if err != nil { + panic(err) + } + return hash +}() + +// loginGuard 对应持久限流行;所有字段的时间均由 MySQL UTC 时钟决定。 +type loginGuard struct { + Kind string // Kind 为 IP 或 ACCOUNT,跨维度固定先 IP 后账号加锁。 + Subject string // Subject 为规范身份的 SHA-256,不含秘密。 + WindowStart time.Time // WindowStart 请求窗口起点。 + Requests int // Requests 已受理请求数,拒绝时不增加以免溢出。 + Failures int // Failures 连续失败计数。 + FirstFailed sql.NullTime // FirstFailed 失败窗口起点,空表示无失败。 + LockedUntil sql.NullTime // LockedUntil 锁定截止,空表示未锁定。 +} + +func loginSubject(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} +func loginText(s string, max int) string { + s = strings.ToValidUTF8(s, "") + if len(s) > max { + s = s[:max] + for !utf8.ValidString(s) { + s = s[:len(s)-1] + } + } + return s +} +func loginNow(ctx context.Context, tx *sql.Tx) (time.Time, error) { + var now time.Time + err := tx.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now) + return now.UTC(), err +} +func loadLoginGuard(ctx context.Context, tx *sql.Tx, kind, key string, now time.Time) (loginGuard, error) { + g := loginGuard{Kind: kind, Subject: key} + _, err := tx.ExecContext(ctx, `INSERT INTO orbit_console_login_guards(kind,subject,window_start,expires_at) VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE subject=VALUES(subject)`, kind, key, now, now.Add(48*time.Hour)) + if err != nil { + return g, err + } + err = tx.QueryRowContext(ctx, `SELECT window_start,requests,failures,first_failed_at,locked_until FROM orbit_console_login_guards WHERE kind=? AND subject=? FOR UPDATE`, kind, key).Scan(&g.WindowStart, &g.Requests, &g.Failures, &g.FirstFailed, &g.LockedUntil) + return g, err +} +func saveLoginGuard(ctx context.Context, tx *sql.Tx, g loginGuard, now time.Time) error { + _, err := tx.ExecContext(ctx, `UPDATE orbit_console_login_guards SET window_start=?,requests=?,failures=?,first_failed_at=?,locked_until=?,expires_at=? WHERE kind=? AND subject=?`, g.WindowStart, g.Requests, g.Failures, g.FirstFailed, g.LockedUntil, now.Add(48*time.Hour), g.Kind, g.Subject) + return err +} +func (g *loginGuard) resetExpired(now time.Time, p consoleauth.LoginPolicy) { + if !now.Before(g.WindowStart.Add(p.RateWindow)) { + g.WindowStart = now + g.Requests = 0 + } + if g.LockedUntil.Valid && !now.Before(g.LockedUntil.Time) { + g.LockedUntil = sql.NullTime{} + g.FirstFailed = sql.NullTime{} + g.Failures = 0 + } + if !g.LockedUntil.Valid && g.FirstFailed.Valid && !now.Before(g.FirstFailed.Time.Add(p.FailureWindow)) { + g.FirstFailed = sql.NullTime{} + g.Failures = 0 + } +} + +// Login 先在短事务中占用账号/IP 请求预算,再在事务外计算 bcrypt。 +// 最终事务重新锁定账号保护行,失败、锁定、成功会话及审计原子提交;并发校验不能穿过已生效的锁定。 +func (s *ConsoleAuthStore) Login(ctx context.Context, username, password, userAgent, remoteAddr string) (ConsoleLogin, error) { + if s == nil || s.db == nil { + return ConsoleLogin{}, errors.New("console authentication unavailable") + } + if err := s.policy.Validate(); err != nil { + return ConsoleLogin{}, err + } + if username == "" || len(username) > 128 || !utf8.ValidString(username) || password == "" || len(password) > 1024 { + return ConsoleLogin{}, fault.Unauthorized + } + ip, err := netip.ParseAddr(remoteAddr) + if err != nil { + return ConsoleLogin{}, fault.Invalid("invalid login source address") + } + remoteAddr = ip.Unmap().String() + userAgent = loginText(userAgent, 512) + var user ConsoleUser + var userID, namespace, credential core.ID + var hash string + var enabled bool + err = s.db.QueryRowContext(ctx, `SELECT id,username,display_name,password_hash,enabled,namespace_id,credential_id FROM orbit_console_users WHERE username=?`, username).Scan(&userID, &user.Username, &user.DisplayName, &hash, &enabled, &namespace, &credential) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return ConsoleLogin{}, err + } + exists := err == nil + // 使用已存在账号的 ID,避免 Unicode 等价拼写绕过 MySQL 登录名匹配对应的限额。 + accountKey := loginSubject("unknown:" + username) + if exists { + accountKey = loginSubject("user:" + string(userID)) + } + var attemptID int64 + var blocked *consoleauth.LoginBlocked + err = WithTx(ctx, s.db, func(tx *sql.Tx) error { + blocked = nil + now, err := loginNow(ctx, tx) + if err != nil { + return err + } + ipGuard, err := loadLoginGuard(ctx, tx, "IP", loginSubject(remoteAddr), now) + if err != nil { + return err + } + ipGuard.resetExpired(now, s.policy) + outcome := "PENDING" + var retryAt *time.Time + reject := func(reason string, until time.Time, locked bool) { + outcome = reason + retryAt = &until + blocked = &consoleauth.LoginBlocked{Locked: locked, RetryAfter: until.Sub(now)} + } + if ipGuard.Requests >= s.policy.IPLimit { + reject("IP_LIMIT", ipGuard.WindowStart.Add(s.policy.RateWindow), false) + } else { + ipGuard.Requests++ + account, err := loadLoginGuard(ctx, tx, "ACCOUNT", accountKey, now) + if err != nil { + return err + } + account.resetExpired(now, s.policy) + if account.LockedUntil.Valid { + reject("LOCKED", account.LockedUntil.Time, true) + } else if account.Requests >= s.policy.AccountLimit { + reject("ACCOUNT_LIMIT", account.WindowStart.Add(s.policy.RateWindow), false) + } else { + account.Requests++ + } + if err = saveLoginGuard(ctx, tx, account, now); err != nil { + return err + } + } + if err = saveLoginGuard(ctx, tx, ipGuard, now); err != nil { + return err + } + var finished *time.Time + if blocked != nil { + finished = &now + } + result, err := tx.ExecContext(ctx, `INSERT INTO orbit_console_login_audit(username,remote_ip,user_agent,outcome,created_at,finished_at,retry_at) VALUES(?,?,?,?,?,?,?)`, username, remoteAddr, userAgent, outcome, now, finished, retryAt) + if err != nil { + return err + } + attemptID, err = result.LastInsertId() + return err + }) + if err != nil { + return ConsoleLogin{}, err + } + if blocked != nil { + return ConsoleLogin{}, blocked + } + checkHash := consoleDummyHash + if exists && enabled { + checkHash = []byte(hash) + } + valid := bcrypt.CompareHashAndPassword(checkHash, []byte(password)) == nil && exists && enabled + var out ConsoleLogin + var rejected error + err = WithTx(ctx, s.db, func(tx *sql.Tx) error { + out = ConsoleLogin{} + rejected = nil + now, err := loginNow(ctx, tx) + if err != nil { + return err + } + guard, err := loadLoginGuard(ctx, tx, "ACCOUNT", accountKey, now) + if err != nil { + return err + } + guard.resetExpired(now, s.policy) + outcome := "INVALID" + var retryAt *time.Time + if guard.LockedUntil.Valid { + outcome = "LOCKED" + until := guard.LockedUntil.Time + retryAt = &until + rejected = &consoleauth.LoginBlocked{Locked: true, RetryAfter: until.Sub(now)} + } else { + stillValid := valid + if stillValid { + var currentHash string + // 密码/账号/关联权限在最终事务中复查;停用、换密和失效凭据不能签发新会话。 + err = tx.QueryRowContext(ctx, `SELECT u.password_hash FROM orbit_console_users u JOIN orbit_credentials c ON c.namespace_id=u.namespace_id AND c.id=u.credential_id JOIN orbit_namespaces n ON n.id=u.namespace_id JOIN orbit_applications a ON a.id=n.application_id WHERE u.id=? AND u.namespace_id=? AND u.credential_id=? AND u.enabled=TRUE AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)) AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, userID, namespace, credential).Scan(¤tHash) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + stillValid = err == nil && currentHash == hash + } + if stillValid { + token, digest, err := auth.Issue(namespace, credential) + if err != nil { + return err + } + sessionID, err := core.NewID() + if err != nil { + return err + } + expires := now.Add(ConsoleSessionLifetime) + _, err = tx.ExecContext(ctx, `INSERT INTO orbit_console_sessions(id,user_id,namespace_id,credential_id,token_digest,expires_at,last_seen_at,user_agent,remote_addr) VALUES(?,?,?,?,?,?,?,?,?)`, sessionID, userID, namespace, credential, digest, expires, now, userAgent, remoteAddr) + if err != nil { + return err + } + user.ID = string(userID) + out = ConsoleLogin{User: user, Token: token, ExpiresAt: expires} + outcome = "SUCCESS" + guard.Failures = 0 + guard.FirstFailed = sql.NullTime{} + guard.LockedUntil = sql.NullTime{} + } else { + rejected = fault.Unauthorized + if !guard.FirstFailed.Valid { + guard.FirstFailed = sql.NullTime{Time: now, Valid: true} + } + guard.Failures++ + if guard.Failures >= s.policy.FailureLimit { + until := now.Add(s.policy.LockDuration) + guard.LockedUntil = sql.NullTime{Time: until, Valid: true} + outcome = "LOCKED" + retryAt = &until + rejected = &consoleauth.LoginBlocked{Locked: true, RetryAfter: s.policy.LockDuration} + } + } + } + if err = saveLoginGuard(ctx, tx, guard, now); err != nil { + return err + } + result, err := tx.ExecContext(ctx, `UPDATE orbit_console_login_audit SET outcome=?,finished_at=?,retry_at=? WHERE id=? AND outcome='PENDING'`, outcome, now, retryAt, attemptID) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n != 1 { + return errors.New("login audit result not confirmed") + } + return nil + }) + if err != nil { + return ConsoleLogin{}, err + } + return out, rejected +} + +// CleanupLoginSecurity 每批最多清理 500 行,过期时间有索引,不触碰业务审计或有效锁定。 +func (s *ConsoleAuthStore) CleanupLoginSecurity(ctx context.Context) error { + if err := s.policy.Validate(); err != nil { + return err + } + for _, q := range []struct { + sql string + args []any + }{ + {`UPDATE orbit_console_login_audit SET outcome='ERROR',finished_at=UTC_TIMESTAMP(6) WHERE outcome='PENDING' AND created_at 10 { + out.Items = out.Items[:10] + out.Next = strconv.FormatUint(out.Items[9].ID, 10) + } + return rows.Err() + }) + return out, err +} diff --git a/internal/storage/mysql/console_login_integration_test.go b/internal/storage/mysql/console_login_integration_test.go new file mode 100644 index 0000000..cef5094 --- /dev/null +++ b/internal/storage/mysql/console_login_integration_test.go @@ -0,0 +1,262 @@ +package mysql + +import ( + "context" + "errors" + "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" + "git.yuntuo.hk/k2safe/orbit/internal/fault" + "git.yuntuo.hk/k2safe/orbit/platform/auth" + "os" + "strings" + "sync" + "testing" + "time" +) + +func TestIntegrationConsoleLoginProtection(t *testing.T) { + dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("requires disposable MySQL") + } + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + db, err := Open(ctx, dsn, 16, 4) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err = Migrate(ctx, db); err != nil { + t.Fatal(err) + } + username := "login-" + string(testID(t)) + password := "login-protection-test-12345" + var boot BootstrapResult + if err = Bootstrap(ctx, db, BootstrapInput{Application: username, Namespace: "default", Environment: "test", Queue: "work", ConsoleUsername: username, ConsolePassword: password}, func(v BootstrapResult) error { boot = v; return nil }); err != nil { + t.Fatal(err) + } + p := consoleauth.DefaultLoginPolicy() + p.FailureLimit = 3 + p.IPLimit = 1000 + p.AccountLimit = 100 + p.LockDuration = time.Second + first, second := NewConsoleAuthStore(db, p), NewConsoleAuthStore(db, p) + exec := func(q string, args ...any) { + t.Helper() + if _, err := db.ExecContext(ctx, q, args...); err != nil { + t.Fatal(err) + } + } + var userID string + if err = db.QueryRowContext(ctx, `SELECT LOWER(HEX(id)) FROM orbit_console_users WHERE username=?`, username).Scan(&userID); err != nil { + t.Fatal(err) + } + key := loginSubject("user:" + userID) + clear := func() { exec(`DELETE FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key) } + login := func(s *ConsoleAuthStore, pass, ip string) error { + t.Helper() + _, err := s.Login(ctx, username, pass, "login-regression", ip) + return err + } + expectBlocked := func(err error, locked bool) { + t.Helper() + var b *consoleauth.LoginBlocked + if !errors.As(err, &b) || b.Locked != locked || b.RetryAfter <= 0 { + t.Fatalf("expected block locked=%v got %v", locked, err) + } + } + t.Run("failure_success_reset_cross_node_and_expiry", func(t *testing.T) { + if err = login(first, "wrong", "192.0.2.10"); !errors.Is(err, fault.Unauthorized) { + t.Fatal(err) + } + if err = login(second, password, "192.0.2.11"); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err = login(first, "wrong", "192.0.2.10"); !errors.Is(err, fault.Unauthorized) { + t.Fatal(err) + } + } + expectBlocked(login(second, "wrong", "192.0.2.11"), true) + var until, after time.Time + if err = db.QueryRowContext(ctx, `SELECT locked_until FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&until); err != nil { + t.Fatal(err) + } + expectBlocked(login(NewConsoleAuthStore(db, p), password, "192.0.2.12"), true) + if err = db.QueryRowContext(ctx, `SELECT locked_until FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&after); err != nil || !until.Equal(after) { + t.Fatal("lock was extended", err) + } + // 使用数据库自身时钟等待真实截止,重建存储实例不能提前解除锁定。 + for { + var elapsed bool + if err = db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)>=?", until).Scan(&elapsed); err != nil { + t.Fatal(err) + } + if elapsed { + break + } + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(25 * time.Millisecond): + } + } + if err = login(second, password, "192.0.2.12"); err != nil { + t.Fatal(err) + } + }) + t.Run("concurrent_failures_serialize_and_lock", func(t *testing.T) { + clear() + policy := p + policy.LockDuration = time.Minute + a, b := NewConsoleAuthStore(db, policy), NewConsoleAuthStore(db, policy) + results := make(chan error, 6) + var wg sync.WaitGroup + start := make(chan struct{}) + for i := 0; i < 6; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + s := a + if i%2 == 1 { + s = b + } + results <- login(s, "wrong", "192.0.2.20") + }(i) + } + close(start) + wg.Wait() + close(results) + invalid, blocked := 0, 0 + for err := range results { + var v *consoleauth.LoginBlocked + if errors.As(err, &v) && v.Locked { + blocked++ + } else if errors.Is(err, fault.Unauthorized) { + invalid++ + } else { + t.Fatal(err) + } + } + if invalid != 2 || blocked != 4 { + t.Fatalf("invalid=%d blocked=%d", invalid, blocked) + } + expectBlocked(login(a, password, "192.0.2.21"), true) + var count int + if err = db.QueryRowContext(ctx, `SELECT failures FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&count); err != nil || count != 3 { + t.Fatalf("failure count=%d %v", count, err) + } + }) + t.Run("account_and_ip_limits", func(t *testing.T) { + clear() + policy := p + policy.AccountLimit = 2 + s := NewConsoleAuthStore(db, policy) + for _, ip := range []string{"192.0.2.30", "192.0.2.31"} { + if err = login(s, password, ip); err != nil { + t.Fatal(err) + } + } + expectBlocked(login(NewConsoleAuthStore(db, policy), password, "192.0.2.32"), false) + clear() + policy.IPLimit = 2 + s = NewConsoleAuthStore(db, policy) + for i := 0; i < 2; i++ { + _, err = s.Login(ctx, "unknown-"+string(testID(t)), "wrong", "test", "192.0.2.40") + if !errors.Is(err, fault.Unauthorized) { + t.Fatal(err) + } + } + expectBlocked(login(s, password, "192.0.2.40"), false) + exec(`UPDATE orbit_console_login_guards SET window_start=UTC_TIMESTAMP(6)-INTERVAL 2 MINUTE WHERE kind='IP' AND subject=?`, loginSubject("192.0.2.40")) + if err = login(s, password, "192.0.2.40"); err != nil { + t.Fatal(err) + } + }) + t.Run("unknown_account_and_failure_window", func(t *testing.T) { + unknown := "unknown-" + string(testID(t)) + policy := p + policy.FailureLimit = 2 + s := NewConsoleAuthStore(db, policy) + _, err = s.Login(ctx, unknown, "wrong", "test", "192.0.2.50") + if !errors.Is(err, fault.Unauthorized) { + t.Fatal(err) + } + _, err = s.Login(ctx, unknown, "wrong", "test", "192.0.2.50") + expectBlocked(err, true) + clear() + if err = login(first, "wrong", "192.0.2.51"); !errors.Is(err, fault.Unauthorized) { + t.Fatal(err) + } + exec(`UPDATE orbit_console_login_guards SET failures=2,first_failed_at=UTC_TIMESTAMP(6)-INTERVAL 1 HOUR WHERE kind='ACCOUNT' AND subject=?`, key) + if err = login(second, "wrong", "192.0.2.51"); !errors.Is(err, fault.Unauthorized) { + t.Fatal(err) + } + }) + t.Run("audit_failure_cannot_issue_session", func(t *testing.T) { + clear() + var before, after int + db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_sessions WHERE user_id=UNHEX(?)`, userID).Scan(&before) + trigger := "login_audit_" + string(testID(t)) + exec(`CREATE TRIGGER ` + trigger + ` BEFORE UPDATE ON orbit_console_login_audit FOR EACH ROW BEGIN IF NEW.username='` + username + `' AND NEW.outcome='SUCCESS' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test audit unavailable'; END IF; END`) + defer exec("DROP TRIGGER " + trigger) + if err = login(first, password, "192.0.2.60"); err == nil { + t.Fatal("audit failure issued session") + } + db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_sessions WHERE user_id=UNHEX(?)`, userID).Scan(&after) + if before != after { + t.Fatal("session survived failed audit") + } + }) + t.Run("audit_admin_pagination_secrets_and_cleanup", func(t *testing.T) { + clear() + result, err := first.Login(ctx, username, password, "test", "192.0.2.70") + if err != nil { + t.Fatal(err) + } + session, _ := auth.Parse(result.Token) + sdk, _ := auth.Parse(boot.Token) + store := NewConsoleApplicationStore(db, username) + if _, err = store.ListLoginAudit(ctx, sdk, ""); !errors.Is(err, fault.Forbidden) { + t.Fatal("SDK read login audit", err) + } + if _, err = NewConsoleApplicationStore(db, "another-admin").ListLoginAudit(ctx, session, ""); !errors.Is(err, fault.Forbidden) { + t.Fatal("non-admin read audit", err) + } + page, err := store.ListLoginAudit(ctx, session, "") + if err != nil || len(page.Items) != 10 || page.Next == "" { + t.Fatalf("page: %+v %v", page, err) + } + next, err := store.ListLoginAudit(ctx, session, page.Next) + if err != nil || len(next.Items) == 0 || next.Items[0].ID >= page.Items[9].ID { + t.Fatal("audit pagination", err) + } + rows, err := db.QueryContext(ctx, `SELECT CONCAT(username,remote_ip,user_agent,outcome) FROM orbit_console_login_audit WHERE username=?`, username) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var value string + if err = rows.Scan(&value); err != nil { + t.Fatal(err) + } + if strings.Contains(value, password) || strings.Contains(value, result.Token) { + t.Fatal("audit contains secret") + } + } + rows.Close() + exec(`UPDATE orbit_console_login_audit SET created_at=UTC_TIMESTAMP(6)-INTERVAL 91 DAY WHERE username=? AND outcome='INVALID'`, username) + exec(`UPDATE orbit_console_login_audit SET created_at=UTC_TIMESTAMP(6)-INTERVAL 11 MINUTE WHERE username=? AND outcome='PENDING'`, username) + exec(`UPDATE orbit_console_login_guards SET expires_at=UTC_TIMESTAMP(6)-INTERVAL 1 DAY WHERE kind='IP' AND subject=?`, loginSubject("192.0.2.70")) + if err = first.CleanupLoginSecurity(ctx); err != nil { + t.Fatal(err) + } + var n int + if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_login_audit WHERE username=? AND (outcome='PENDING' OR created_at toast.classList.remove('show'), 2800); } + let loginAuditCursor = '', loginAuditPage = 1, loginAuditLoading = false; + async function loadLoginAudit(first = false) { + if (loginAuditLoading) return; + loginAuditLoading = true; + $('login-audit-first').disabled = true; $('login-audit-next').disabled = true; + text('login-audit-status', '正在读取…'); + try { + const cursor = first ? '' : loginAuditCursor; + const page = extractData(await request(`/console/auth/audit${cursor ? `?before=${encodeURIComponent(cursor)}` : ''}`)); + const labels = { SUCCESS: '登录成功', INVALID: '账号或密码错误', LOCKED: '临时锁定', ACCOUNT_LIMIT: '账号限流', IP_LIMIT: 'IP 限流', PENDING: '校验中', ERROR: '未完成 / 服务异常' }; + $('login-audit-rows').innerHTML = (page.items || []).map(row => `${escapeHTML(formatTime(row.created_at))}${escapeHTML(row.username)}${escapeHTML(row.remote_ip)}${escapeHTML(labels[row.outcome] || row.outcome)}${escapeHTML(formatTime(row.retry_at))}`).join(''); + loginAuditPage = first ? 1 : loginAuditPage + 1; loginAuditCursor = page.next || ''; + text('login-audit-page', `第 ${loginAuditPage} 页`); text('login-audit-status', page.items?.length ? '' : '暂无登录记录'); + } catch (error) { text('login-audit-status', `读取失败:${error.message}`); } + finally { loginAuditLoading = false; $('login-audit-first').disabled = false; $('login-audit-next').disabled = !loginAuditCursor; } + } + $('login-audit-open').addEventListener('click', () => { $('login-audit-dialog').showModal(); loadLoginAudit(true); }); + $('login-audit-close').addEventListener('click', () => $('login-audit-dialog').close()); + $('login-audit-first').addEventListener('click', () => loadLoginAudit(true)); + $('login-audit-next').addEventListener('click', () => loadLoginAudit()); function formatTime(value) { if (!value) return '—'; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); @@ -137,6 +157,7 @@ appendScopeOptions($('scope-application'), scopeUI.applications, scope.entry.namespace_id, '', 'application'); appendScopeOptions($('scope-queue'), queues, state.queue, scopeUI.nextQueue, 'queue'); $('scope-application').disabled = !scope.can_switch; + $('login-audit-open').hidden = !scope.can_switch; $('scope-queue').disabled = !queues.length; if (scope.can_switch) { try { await loadScopeApplications(); } catch (error) { text('scope-error', '应用目录读取失败:' + error.message); } diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html index 086cde2..4862441 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -1,5 +1,5 @@ - + @@ -42,6 +42,7 @@
本地环境 尚未刷新 +
@@ -357,6 +358,14 @@
+

新建应用

为应用创建独立的环境、队列与调用凭据。

diff --git a/internal/transport/httpserver/console/login.html b/internal/transport/httpserver/console/login.html index 9189585..a7ff1ba 100644 --- a/internal/transport/httpserver/console/login.html +++ b/internal/transport/httpserver/console/login.html @@ -69,7 +69,31 @@ // 初次访问时 URL 片段不会发给服务端,浏览器会把它保留在跳转后的登录地址上。 if (!returnTo.includes('#') && /^#\/(overview|tasks|schedules|workflows|subscriptions|retention|archives|applications)$/.test(location.hash)) returnTo += location.hash; fetch('/console/auth/session', { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' }).then(response => response.json()).then(body => { if (body.authenticated) location.replace(returnTo); }).catch(() => {}); - form.addEventListener('submit', async event => { event.preventDefault(); error.textContent = ''; submit.disabled = true; submit.textContent = '正在登录…'; try { const response = await fetch('/console/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ username: username.value.trim(), password: password.value }) }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body?.error?.message || '登录失败,请检查用户名和密码'); location.replace(returnTo); } catch (loginError) { error.textContent = loginError.message || '登录失败,请稍后重试'; password.select(); } finally { submit.disabled = false; submit.textContent = '登录 Orbit →'; } }); + let retryUntil = 0, retryTimer; + function showRetry() { + const remaining = Math.max(0, Math.ceil((retryUntil - Date.now()) / 1000)); + submit.disabled = remaining > 0; + submit.textContent = remaining ? `请等待 ${Math.floor(remaining / 60)}分 ${remaining % 60}秒` : '登录 Orbit →'; + if (!remaining) clearInterval(retryTimer); + } + form.addEventListener('submit', async event => { + event.preventDefault(); if (Date.now() < retryUntil) return; + error.textContent = ''; submit.disabled = true; submit.textContent = '正在登录…'; + try { + const response = await fetch('/console/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ username: username.value.trim(), password: password.value }) }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + const seconds = Number(body?.error?.retry_after_seconds); + if (response.status === 429 && Number.isFinite(seconds) && seconds > 0) { + retryUntil = Date.now() + seconds * 1000; + clearInterval(retryTimer); retryTimer = setInterval(showRetry, 1000); + } + throw new Error(body?.error?.message || '登录失败,请稍后重试'); + } + location.replace(returnTo); + } catch (loginError) { error.textContent = loginError.message || '登录失败,请稍后重试'; password.select(); } + finally { showRetry(); } + }); })(); diff --git a/internal/transport/httpserver/console/styles.css b/internal/transport/httpserver/console/styles.css index 999426e..8ae7b40 100644 --- a/internal/transport/httpserver/console/styles.css +++ b/internal/transport/httpserver/console/styles.css @@ -136,14 +136,18 @@ input:focus-visible,textarea:focus-visible { outline:none; } @media(max-width:1200px){.scope-field{flex:1;min-width:160px}.scope-note{margin-left:0}} @media(max-width:920px){.application-scope{gap:12px}.scope-note{flex-basis:100%;margin-top:-4px}.list-filters{grid-template-columns:145px minmax(160px,1fr)}.filter-more{grid-column:1;grid-row:2}.filter-actions{grid-column:2;grid-row:2}.login-brand{padding:40px}.login-intro h1{font-size:40px}} @media(max-width:640px){.application-scope{padding:14px;gap:12px}.scope-field{flex:1 1 100%;min-width:0}.scope-note{display:none}.scope-field>span{margin-bottom:5px}.list-filters{padding:16px;grid-template-columns:1fr 1fr;gap:14px}.list-filters>label{grid-column:1/-1;min-width:0!important}.filter-more{grid-column:1;grid-row:3}.filter-actions{grid-column:2;grid-row:3;gap:7px}.filter-actions button{flex:1;padding:0 11px}.filter-dates{grid-row:4;gap:14px}.filter-dates label{flex:1 1 100%;min-width:0}.filter-dates small{padding:0}.list-pagination{padding:14px 10px;gap:12px}.list-pagination>div{gap:6px}.list-pagination .select-trigger{width:65px;height:32px}.login-brand{padding:28px}.login-brand>*{width:100%;max-width:380px}.login-intro{margin:30px auto 8px}.login-intro h1{font-size:28px}.login-card{width:100%}} -/* 工作台只滚动内容区,导航和页头保持原位;避免整页滚动、回弹及滚动条切换造成错位。 */ -.console-body { height:100dvh; overflow:hidden; overscroll-behavior:none; } -.console-body .app-shell { height:100dvh; min-height:0; overflow:hidden; grid-template-rows:minmax(0,1fr); } -.console-body .sidebar { position:relative; height:100%; min-height:0; overscroll-behavior:contain; } -.console-body .main-content { height:100%; min-height:0; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain; scrollbar-gutter:stable; scroll-padding-top:100px; } +/* none 同时关闭边界回弹和滚动传递;contain 仍会让 macOS 上的菜单和内容弹动。 + 根视口与各实际滚动容器分别设置,保留短屏导航、内容和弹层的正常滚动。 */ +.console-document, .console-body { height:100%; overflow:hidden; overscroll-behavior:none; } +.console-body .app-shell { position:fixed; inset:0; height:100%; min-height:0; overflow:hidden; grid-template-rows:minmax(0,1fr); } +.console-body .sidebar { position:relative; height:100%; min-height:0; overscroll-behavior:none; } +.console-body .main-content { height:100%; min-height:0; overflow-y:auto; overflow-x:hidden; overscroll-behavior:none; scrollbar-gutter:stable; scroll-padding-top:100px; } .console-body .topbar { position:sticky; top:0; z-index:20; background:var(--bg); } -@media(min-width:921px){.access-list-panel{position:sticky;top:100px;max-height:calc(100dvh - 124px);overflow:auto;overscroll-behavior:contain}} -@media(max-width:640px){.console-body .app-shell{display:flex;flex-direction:column}.console-body .sidebar{height:auto;flex:0 0 auto;overflow:visible}.console-body .main-content{flex:1;scroll-padding-top:78px}.nav-list{overscroll-behavior-x:contain}} +.console-body :is(.select-options,.table-wrap,.task-drawer,.access-dialog){overscroll-behavior:none} +@media(min-width:921px){.access-list-panel{position:sticky;top:100px;max-height:calc(100dvh - 124px);overflow:auto;overscroll-behavior:none}} +@media(max-width:640px){.console-body .app-shell{display:flex;flex-direction:column}.console-body .sidebar{height:auto;flex:0 0 auto;overflow:visible}.console-body .main-content{flex:1;scroll-padding-top:78px}.nav-list{overscroll-behavior:none}} @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}} @media(max-width:640px){.data-panel .table-wrap::before{content:"左右滑动查看完整表格 →";display:block;padding:0 15px 10px;color:var(--muted);font-size:10px}.retention-panel{max-width:none}} + +.login-audit-dialog{width:min(960px,calc(100vw - 32px))}.login-audit-dialog .table-wrap{margin:20px 0}.login-audit-dialog th,.login-audit-dialog td{padding:12px;text-align:left;white-space:nowrap}.login-audit-dialog .audit-pagination{display:flex;align-items:center;justify-content:space-between;gap:12px} diff --git a/internal/transport/httpserver/console_applications.go b/internal/transport/httpserver/console_applications.go index f0e55a9..0d4c6a1 100644 --- a/internal/transport/httpserver/console_applications.go +++ b/internal/transport/httpserver/console_applications.go @@ -44,6 +44,20 @@ func RegisterConsoleApplications(server *http.Server, consoleAuth consoleauth.St } return token, true } + if auditor, ok := store.(consoleauth.LoginAuditor); ok { + mux.HandleFunc("GET /console/auth/audit", func(w http.ResponseWriter, r *http.Request) { + token, ok := check(w, r) + if !ok { + return + } + out, err := auditor.ListLoginAudit(r.Context(), token, r.URL.Query().Get("before")) + if err != nil { + writeError(w, "", err) + return + } + writeConsoleJSON(w, 200, map[string]any{"data": out}) + }) + } mux.HandleFunc("GET /console/applications", func(w http.ResponseWriter, r *http.Request) { token, ok := check(w, r) if !ok { diff --git a/internal/transport/httpserver/console_auth_test.go b/internal/transport/httpserver/console_auth_test.go index 0b9e223..78c179e 100644 --- a/internal/transport/httpserver/console_auth_test.go +++ b/internal/transport/httpserver/console_auth_test.go @@ -2,8 +2,11 @@ package httpserver import ( "context" + "errors" + "git.yuntuo.hk/k2safe/orbit/internal/fault" "net/http" "net/http/httptest" + "net/netip" "strings" "testing" "time" @@ -14,12 +17,13 @@ import ( ) type fakeConsoleAuth struct { - valid bool - login consoleauth.LoginResult + valid bool + login consoleauth.LoginResult + loginError error } func (f *fakeConsoleAuth) Login(context.Context, string, string, string, string) (consoleauth.LoginResult, error) { - return f.login, nil + return f.login, f.loginError } func (f *fakeConsoleAuth) ValidateSession(context.Context, auth.Token) error { if !f.valid { @@ -82,3 +86,69 @@ func TestConsoleLoginSetsRootScopedHttpOnlyCookie(t *testing.T) { t.Fatalf("cookie path/httpOnly = %q/%v", cookie.Path, cookie.HttpOnly) } } + +func TestConsoleLoginRejectsWithRetryAndNoCookie(t *testing.T) { + for _, tc := range []struct { + name string + err error + status int + code string + }{ + {"password", fault.Unauthorized, 401, "UNAUTHENTICATED"}, + {"locked", &consoleauth.LoginBlocked{Locked: true, RetryAfter: 1500 * time.Millisecond}, 429, "LOGIN_LOCKED"}, + {"rate", &consoleauth.LoginBlocked{RetryAfter: 1500 * time.Millisecond}, 429, "LOGIN_RATE_LIMITED"}, + {"storage", errors.New("private database error"), 503, "UNAVAILABLE"}, + } { + t.Run(tc.name, func(t *testing.T) { + server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{loginError: tc.err}) + req := httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(`{"username":"admin","password":"password-password"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + server.Handler.ServeHTTP(w, req) + if w.Code != tc.status || !strings.Contains(w.Body.String(), tc.code) || len(w.Result().Cookies()) != 0 || strings.Contains(w.Body.String(), "private database") { + t.Fatalf("response: %d %s", w.Code, w.Body.String()) + } + if w.Code == 429 && w.Header().Get("Retry-After") != "2" { + t.Fatal("missing rounded Retry-After") + } + }) + } +} + +type policyConsoleAuth struct { + fakeConsoleAuth + policy consoleauth.LoginPolicy +} + +func (s *policyConsoleAuth) LoginPolicy() consoleauth.LoginPolicy { return s.policy } +func TestLoginSourceOnlyTrustsConfiguredProxyChain(t *testing.T) { + s := &policyConsoleAuth{policy: consoleauth.DefaultLoginPolicy()} + s.policy.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("::1/128")} + for _, tc := range []struct{ peer, forward, want string }{ + {"192.0.2.1:1234", "198.51.100.7", "192.0.2.1"}, + {"10.0.0.1:1234", "198.51.100.7, 10.0.0.2", "198.51.100.7"}, + {"10.0.0.1:1234", "203.0.113.99, 198.51.100.7", "198.51.100.7"}, + {"10.0.0.1:1234", "garbage, 10.0.0.2", "10.0.0.1"}, + {"[::1]:1234", "::ffff:192.0.2.1", "192.0.2.1"}, + } { + r := httptest.NewRequest("POST", "/console/auth/login", nil) + r.RemoteAddr = tc.peer + r.Header.Set("X-Forwarded-For", tc.forward) + r.Header.Set("X-Real-IP", "203.0.113.123") + if got := loginRemoteAddress(r, s); got != tc.want { + t.Fatalf("%s / %s: got %s", tc.peer, tc.forward, got) + } + } +} + +func TestConsoleLoginRejectsCrossSite(t *testing.T) { + server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{}) + r := httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(`{"username":"admin","password":"wrong"}`)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Origin", "https://evil.invalid") + w := httptest.NewRecorder() + server.Handler.ServeHTTP(w, r) + if w.Code != 403 || len(w.Result().Cookies()) != 0 { + t.Fatalf("cross-site response: %d", w.Code) + } +} diff --git a/internal/transport/httpserver/console_login.go b/internal/transport/httpserver/console_login.go new file mode 100644 index 0000000..8b892b5 --- /dev/null +++ b/internal/transport/httpserver/console_login.go @@ -0,0 +1,57 @@ +package httpserver + +import ( + "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" + "net/http" + "net/netip" + "strings" +) + +// loginRemoteAddress 默认只信任 TCP 对端;仅受信反代可提供 X-Forwarded-For。 +// 从右往左剥离可信跳点,遇到首个非可信地址即停止;不接受任意客户端伪造的首项。 +func loginRemoteAddress(r *http.Request, store consoleauth.Store) string { + peer, err := netip.ParseAddr(remoteAddress(r)) + if err != nil { + return "" + } + peer = peer.Unmap() + policyStore, ok := store.(interface { + LoginPolicy() consoleauth.LoginPolicy + }) + if !ok { + return peer.String() + } + trusted := policyStore.LoginPolicy().TrustedProxies + isTrusted := func(ip netip.Addr) bool { + for _, n := range trusted { + if n.Contains(ip) { + return true + } + } + return false + } + if !isTrusted(peer) { + return peer.String() + } + raw := strings.Join(r.Header.Values("X-Forwarded-For"), ",") + if raw == "" || len(raw) > 2048 { + return peer.String() + } + hops := strings.Split(raw, ",") + if len(hops) > 32 { + return peer.String() + } + addresses := make([]netip.Addr, len(hops)) + for i, h := range hops { + a, e := netip.ParseAddr(strings.TrimSpace(h)) + if e != nil { + return peer.String() + } + addresses[i] = a.Unmap() + } + current := peer + for i := len(addresses) - 1; i >= 0 && isTrusted(current); i-- { + current = addresses[i] + } + return current.String() +} diff --git a/scripts/test-console-e2e.sh b/scripts/test-console-e2e.sh index c5bbbb5..bdd43ce 100755 --- a/scripts/test-console-e2e.sh +++ b/scripts/test-console-e2e.sh @@ -8,13 +8,14 @@ E2E_IMAGE=${ORBIT_E2E_IMAGE:-orbit:e2e} E2E_SUFFIX=$$ E2E_MYSQL="orbit-e2e-mysql-$E2E_SUFFIX" E2E_ORBIT="orbit-e2e-server-$E2E_SUFFIX" +E2E_ORBIT_SECOND="orbit-e2e-login-peer-$E2E_SUFFIX" E2E_DIR=$(mktemp -d "${TMPDIR:-/tmp}/orbit-console-e2e.XXXXXX") E2E_PASSWORD_FILE="$E2E_DIR/console-password" E2E_CREDENTIALS_FILE="$E2E_DIR/credentials.json" cleanup() { - "$E2E_DOCKER" stop "$E2E_ORBIT" "$E2E_MYSQL" >/dev/null 2>&1 || true - "$E2E_DOCKER" container rm "$E2E_ORBIT" "$E2E_MYSQL" >/dev/null 2>&1 || true + "$E2E_DOCKER" stop "$E2E_ORBIT_SECOND" "$E2E_ORBIT" "$E2E_MYSQL" >/dev/null 2>&1 || true + "$E2E_DOCKER" container rm "$E2E_ORBIT_SECOND" "$E2E_ORBIT" "$E2E_MYSQL" >/dev/null 2>&1 || true if [ -d "$E2E_DIR/archive-job" ]; then for e2e_artifact in "$E2E_DIR/archive-job"/.lock "$E2E_DIR/archive-job"/manifest.json "$E2E_DIR/archive-job"/batch-000001.json; do if [ -f "$e2e_artifact" ]; then unlink "$e2e_artifact"; fi @@ -115,6 +116,9 @@ printf '%s\n' "[4/8] start Orbit container" --env ORBIT_MYSQL_DSN="$E2E_DSN" \ --env ORBIT_HTTP_ADDR=0.0.0.0:8080 \ --env ORBIT_CONSOLE_QUEUE_ID="$E2E_QUEUE" \ + --env ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT=3 \ + --env ORBIT_CONSOLE_LOGIN_LOCK_DURATION=10s \ + --env ORBIT_CONSOLE_LOGIN_RATE_WINDOW=2s \ --env ORBIT_CONSOLE_APPLICATION_ADMIN="$E2E_ADMIN" \ --env ORBIT_CREDENTIAL_KEY_FILE=/run/credential-keys.json \ --volume "$E2E_DIR/credential-keys.json:/run/credential-keys.json:ro" \ @@ -199,6 +203,22 @@ assert status == 200, status print("console_login=ok protected_page=ok") PY +printf '%s\n' "[login] cross-node rejection, restart and recovery" +"$E2E_DOCKER" run -d --name "$E2E_ORBIT_SECOND" --network bridge --link "$E2E_MYSQL:mysql" \ + --env ORBIT_MYSQL_DSN="$E2E_DSN" --env ORBIT_HTTP_ADDR=0.0.0.0:8080 \ + --env ORBIT_CONSOLE_APPLICATION_ADMIN="$E2E_ADMIN" \ + --env ORBIT_CREDENTIAL_KEY_FILE=/run/credential-keys.json \ + --env ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT=3 \ + --env ORBIT_CONSOLE_LOGIN_LOCK_DURATION=10s \ + --env ORBIT_CONSOLE_LOGIN_RATE_WINDOW=2s \ + --volume "$E2E_DIR/credential-keys.json:/run/credential-keys.json:ro" \ + --publish 127.0.0.1::8080 "$E2E_IMAGE" >/dev/null +E2E_SECOND_PORT=$("$E2E_DOCKER" port "$E2E_ORBIT_SECOND" 8080/tcp | sed -n 's/.*:\([0-9][0-9]*\)$/\1/p' | head -n 1) +ORBIT_E2E_URL="$E2E_URL" ORBIT_E2E_SECOND_URL="http://127.0.0.1:$E2E_SECOND_PORT" \ + ORBIT_E2E_SECOND_CONTAINER="$E2E_ORBIT_SECOND" ORBIT_E2E_USER="$E2E_ADMIN" \ + ORBIT_E2E_PASSWORD_FILE="$E2E_PASSWORD_FILE" DOCKER="$E2E_DOCKER" python3 scripts/test-console-login.py +"$E2E_DOCKER" stop "$E2E_ORBIT_SECOND" >/dev/null + printf '%s\n' "[6/8] WebSocket Worker task execution" (cd examples && ORBIT_URL="$E2E_URL" ORBIT_CREDENTIALS_FILE="$E2E_CREDENTIALS_FILE" \ "$E2E_GO" run ./worker) | tee "$E2E_DIR/worker.log" diff --git a/scripts/test-console-login.py b/scripts/test-console-login.py new file mode 100644 index 0000000..aefb968 --- /dev/null +++ b/scripts/test-console-login.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""由 console-e2e 调用,在一次性双节点环境核验登录保护;不输出密码或 Cookie。""" +import http.cookiejar +import json +import os +import pathlib +import subprocess +import time +import urllib.error +import urllib.request + +base = os.environ['ORBIT_E2E_URL'] +peer = os.environ['ORBIT_E2E_SECOND_URL'] +username = os.environ['ORBIT_E2E_USER'] +password = pathlib.Path(os.environ['ORBIT_E2E_PASSWORD_FILE']).read_text().strip() +jar = http.cookiejar.CookieJar() +client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) + + +def call(url, path, body=None, headers=None): + req = urllib.request.Request(url + path, data=None if body is None else json.dumps(body).encode(), headers={'Content-Type': 'application/json', **(headers or {})}) + try: + with client.open(req, timeout=8) as response: + return response.status, json.load(response), response.headers + except urllib.error.HTTPError as response: + return response.code, json.load(response), response.headers + + +def ready(url): + for _ in range(30): + try: + if call(url, '/health/ready')[0] == 200: + return + except (OSError, ValueError): + pass + time.sleep(.2) + raise AssertionError('test peer not ready') + + +ready(peer) +assert call(base, '/console/auth/login', {'username': username, 'password': password})[0] == 200 +for url in (base, peer): + status, data, headers = call(url, '/console/auth/login', {'username': username, 'password': 'wrong-test-only'}) + assert status == 401 and not headers.get('Set-Cookie'), status +status, data, headers = call(base, '/console/auth/login', {'username': username, 'password': 'wrong-test-only'}) +assert status == 429 and data['error']['code'] == 'LOGIN_LOCKED' and int(headers['Retry-After']) > 0 +assert not headers.get('Set-Cookie') +assert call(peer, '/console/auth/session')[1]['authenticated'] is True, 'lock killed existing session' +status, body, _ = call(peer, '/console/auth/audit') +assert status == 200 and any(e['outcome'] == 'LOCKED' for e in body['data']['items']) +until = next(e['retry_at'] for e in body['data']['items'] if e['outcome'] == 'LOCKED') +subprocess.run([os.environ.get('DOCKER', 'docker'), 'restart', os.environ['ORBIT_E2E_SECOND_CONTAINER']], check=True, stdout=subprocess.DEVNULL) +# 随机发布端口在 Docker 重启后可能重新分配,读取实际端口再验证同一节点。 +published = subprocess.check_output([os.environ.get('DOCKER', 'docker'), 'port', os.environ['ORBIT_E2E_SECOND_CONTAINER'], '8080/tcp'], text=True).strip().splitlines()[0] +peer = 'http://127.0.0.1:' + published.rsplit(':', 1)[1] +ready(peer) +status, data, headers = call(peer, '/console/auth/login', {'username': username, 'password': password}, {'X-Forwarded-For': '203.0.113.88'}) +assert status == 429 and data['error']['code'] == 'LOGIN_LOCKED', 'restart bypassed lock' +body = call(base, '/console/auth/audit')[1] +latest = body['data']['items'][0] +assert latest['retry_at'] == until and latest['remote_ip'] != '203.0.113.88', 'lock extended or untrusted header accepted' +# 仅按真实返回的截止等待;不修改数据库时间或删除锁定记录。 +time.sleep(int(headers['Retry-After']) + .1) +assert call(peer, '/console/auth/login', {'username': username, 'password': password})[0] == 200 +body = call(base, '/console/auth/audit')[1] +assert body['data']['items'][0]['outcome'] == 'SUCCESS' +assert password not in json.dumps(body) +print('console_login_security=passed cross_node=locked restart=locked existing_session=valid forged_ip=ignored expiry=success audit=confirmed')