Files
orbit/internal/storage/mysql/console_login.go

338 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}