fix: stabilize console scrolling and protect administrator login
This commit is contained in:
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user