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

263 lines
9.4 KiB
Go

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