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 }