fix: stabilize console scrolling and protect administrator login
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -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,仅管理员可查询';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
337
internal/storage/mysql/console_login.go
Normal file
337
internal/storage/mysql/console_login.go
Normal 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(¤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<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
|
||||
}
|
||||
262
internal/storage/mysql/console_login_integration_test.go
Normal file
262
internal/storage/mysql/console_login_integration_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,仅管理员可查询';
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user