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

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