fix: stabilize console scrolling and protect administrator login

This commit is contained in:
2026-09-30 00:09:01 +08:00
parent 9a15406f76
commit f56f233704
32 changed files with 1308 additions and 86 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,仅管理员可查询';

View File

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

View File

@@ -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(&currentHash)
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<UTC_TIMESTAMP(6)-INTERVAL 10 MINUTE ORDER BY created_at LIMIT 500`, nil},
{`DELETE FROM orbit_console_login_guards WHERE expires_at<UTC_TIMESTAMP(6) ORDER BY expires_at LIMIT 500`, nil},
{`DELETE FROM orbit_console_login_audit WHERE created_at<UTC_TIMESTAMP(6)-INTERVAL ? DAY ORDER BY created_at LIMIT 500`, []any{s.policy.AuditDays}},
} {
if _, err := s.db.ExecContext(ctx, q.sql, q.args...); err != nil {
return err
}
}
return nil
}
// ListLoginAudit 仅沿用部署指定的单管理员权限;普通会话及 SDK Token 不能读取全局登录记录。
func (s *ConsoleApplicationStore) ListLoginAudit(ctx context.Context, token auth.Token, before string) (consoleauth.LoginAuditPage, error) {
out := consoleauth.LoginAuditPage{Items: []consoleauth.LoginAuditEntry{}}
var cursor uint64
if before != "" {
var err error
cursor, err = strconv.ParseUint(before, 10, 64)
if err != nil || cursor == 0 {
return out, fault.Invalid("invalid login audit cursor")
}
}
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
if _, err := s.admin(ctx, tx, token); err != nil {
return err
}
q := `SELECT id,username,remote_ip,user_agent,outcome,created_at,finished_at,retry_at FROM orbit_console_login_audit`
args := []any{}
if cursor != 0 {
q += ` WHERE id<?`
args = append(args, cursor)
}
rows, err := tx.QueryContext(ctx, q+` ORDER BY id DESC LIMIT 11`, args...)
if err != nil {
return err
}
defer rows.Close()
out.Items = []consoleauth.LoginAuditEntry{}
for rows.Next() {
var e consoleauth.LoginAuditEntry
if err = rows.Scan(&e.ID, &e.Username, &e.RemoteIP, &e.UserAgent, &e.Outcome, &e.CreatedAt, &e.FinishedAt, &e.RetryAt); err != nil {
return err
}
out.Items = append(out.Items, e)
}
if len(out.Items) > 10 {
out.Items = out.Items[:10]
out.Next = strconv.FormatUint(out.Items[9].ID, 10)
}
return rows.Err()
})
return out, err
}

View File

@@ -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<UTC_TIMESTAMP(6)-INTERVAL 90 DAY)`, username).Scan(&n); err != nil || n != 0 {
t.Fatal("audit cleanup", n, err)
}
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&n); err != nil || n != 0 {
t.Fatal("foreign keys", n, err)
}
})
}

View File

@@ -26,7 +26,7 @@ var migrationFiles embed.FS
//go:embed baseline.sql
var freshInstallBaseline string
const freshInstallBaselineVersion uint64 = 34
const freshInstallBaselineVersion uint64 = 35
type Migration struct {
Version uint64

View File

@@ -0,0 +1,29 @@
-- 登录保护独立于业务凭据和命名空间;没有外键,不改变已有密码、会话或 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,仅管理员可查询';

View File

@@ -147,7 +147,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
indexes := func() []string {
t.Helper()
// 排除后续新增/替换索引;原有项在此逐项比较,v24/v25新增项由对应Query/Timeline升级测试核对。
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events','orbit_console_users','orbit_console_sessions','orbit_retention_policies','orbit_archive_jobs','orbit_archive_plans','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_subscription_active','ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_console_login_audit','orbit_console_login_guards','orbit_application_resources','orbit_event_streams','orbit_result_events','orbit_console_users','orbit_console_sessions','orbit_retention_policies','orbit_archive_jobs','orbit_archive_plans','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_subscription_active','ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
if err != nil {
t.Fatal(err)
}

View File

@@ -4,11 +4,14 @@ import (
"context"
"embed"
"encoding/json"
"errors"
"io/fs"
"math"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -64,6 +67,12 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
writeConsoleJSON(w, http.StatusOK, map[string]any{"authenticated": true})
})
mux.HandleFunc("POST /console/auth/login", func(w http.ResponseWriter, r *http.Request) {
if err := http.NewCrossOriginProtection().Check(r); err != nil {
writeConsoleJSON(w, http.StatusForbidden, map[string]any{"error": map[string]string{"code": "PERMISSION_DENIED", "message": "不允许跨站登录请求"}})
return
}
r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
if consoleAuth == nil {
writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "console authentication is not configured"}})
return
@@ -78,10 +87,23 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
login, err := consoleAuth.Login(ctx, in.Username, in.Password, r.UserAgent(), remoteAddress(r))
login, err := consoleAuth.Login(ctx, in.Username, in.Password, r.UserAgent(), loginRemoteAddress(r, consoleAuth))
if err != nil {
// 账号不存在、密码错误、账号停用和绑定凭据失效统一返回,避免账号枚举。
writeConsoleJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]string{"code": "UNAUTHENTICATED", "message": "用户名或密码错误"}})
var blocked *consoleauth.LoginBlocked
if errors.As(err, &blocked) {
seconds := max(1, int(math.Ceil(blocked.RetryAfter.Seconds())))
code, message := "LOGIN_RATE_LIMITED", "登录尝试过于频繁,请稍后重试"
if blocked.Locked {
code, message = "LOGIN_LOCKED", "连续登录失败,已临时锁定,请稍后重试"
}
w.Header().Set("Retry-After", strconv.Itoa(seconds))
writeConsoleJSON(w, http.StatusTooManyRequests, map[string]any{"error": map[string]any{"code": code, "message": message, "retry_after_seconds": seconds}})
} else if errors.Is(err, fault.Unauthorized) {
writeConsoleJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]string{"code": "UNAUTHENTICATED", "message": "用户名或密码错误"}})
} else {
// 存储或审计不可用时不发 Cookie,不将基础设施故障伪装成密码错误。
writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "登录服务暂不可用,请稍后重试"}})
}
return
}
http.SetCookie(w, &http.Cookie{Name: consoleSessionCookie, Value: login.Token, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Expires: login.ExpiresAt, MaxAge: int(time.Until(login.ExpiresAt).Seconds())})

View File

@@ -12,6 +12,26 @@
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
clearTimeout(showToast.timer); showToast.timer = setTimeout(() => 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 => `<tr><td>${escapeHTML(formatTime(row.created_at))}</td><td>${escapeHTML(row.username)}</td><td>${escapeHTML(row.remote_ip)}</td><td>${escapeHTML(labels[row.outcome] || row.outcome)}</td><td>${escapeHTML(formatTime(row.retry_at))}</td></tr>`).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); }

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN" class="console-document">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -42,6 +42,7 @@
<div class="topbar-actions"><label class="auto-refresh"><input type="checkbox" id="auto-refresh" checked>自动刷新</label>
<span class="live-chip"><i></i><span id="live-label">本地环境</span></span>
<span class="last-updated" id="last-updated" aria-live="polite">尚未刷新</span>
<button class="secondary-button compact" id="login-audit-open" hidden>登录审计</button>
<button class="icon-button" id="logout-button" title="退出登录" aria-label="退出登录">↪</button>
<button class="icon-button" id="refresh-button" title="刷新数据" aria-label="刷新数据">↻</button>
</div>
@@ -357,6 +358,14 @@
</aside>
</div>
<div class="toast" id="toast" role="status"></div>
<dialog id="login-audit-dialog" class="access-dialog login-audit-dialog" aria-labelledby="login-audit-title">
<div class="access-dialog-top"><button type="button" class="icon-button" id="login-audit-close" aria-label="关闭登录审计">×</button></div>
<h2 id="login-audit-title">登录审计</h2>
<p class="panel-description">记录登录成功、失败、临时锁定与限流。每页 10 条,时间为当前时区。</p>
<p id="login-audit-status" role="status"></p>
<div class="table-wrap"><table><thead><tr><th>时间</th><th>账号</th><th>来源 IP</th><th>结果</th><th>可重试时间</th></tr></thead><tbody id="login-audit-rows"></tbody></table></div>
<div class="audit-pagination"><button class="secondary-button compact" id="login-audit-first">最新记录</button><span id="login-audit-page">第 1 页</span><button class="secondary-button compact" id="login-audit-next" disabled>下一页 →</button></div>
</dialog>
<dialog id="access-dialog" class="access-dialog">
<div class="access-dialog-top"><button type="button" class="icon-button" id="access-close" aria-label="关闭应用接入">×</button></div>
<h2 id="access-form-title">新建应用</h2><p class="panel-description" id="access-form-note">为应用创建独立的环境、队列与调用凭据。</p>

View File

@@ -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(); }
});
})();
</script>
</body>

View File

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

View File

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

View File

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

View File

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