package user import ( "context" "encoding/json" "errors" "fmt" "net/http" "sync" "sync/atomic" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/controllers" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" ) // TestLoginGates pins the registry of login gates (D-02) without a // database: a nil or empty registry allows, gates run in the order they were // added, the first result wins and stops the chain, an error stops the chain // and is returned, nil gates are ignored, and Add and Check are safe for // concurrent use (run with -race). CheckLoginGates allows a sign-in when no // app or no registry is published. func TestLoginGates(t *testing.T) { ctx := context.Background() attempt := classes.LoginAttempt{Source: classes.LoginSourcePassword} t.Run("nil_and_empty_allow", func(t *testing.T) { var nilGates *classes.LoginGates if res, err := nilGates.Check(ctx, attempt); res != nil || err != nil { t.Fatalf("nil registry = %v, %v", res, err) } nilGates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { t.Fatal("a gate added to a nil registry ran") return nil, nil })) if res, err := classes.NewLoginGates().Check(ctx, attempt); res != nil || err != nil { t.Fatalf("empty registry = %v, %v", res, err) } g := classes.NewLoginGates() g.Add(nil) if res, err := g.Check(ctx, attempt); res != nil || err != nil { t.Fatalf("registry with a nil gate = %v, %v", res, err) } }) t.Run("order_and_first_result_wins", func(t *testing.T) { var order []string gate := func(name string, res *classes.LoginGateResult) classes.LoginGate { return gateFunc(func(_ context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) { if a.Source != classes.LoginSourcePassword { t.Fatalf("gate %s saw source %q", name, a.Source) } order = append(order, name) return res, nil }) } first := &classes.LoginGateResult{Status: http.StatusOK, Body: map[string]any{"two_factor_required": true}} second := &classes.LoginGateResult{Status: http.StatusTooManyRequests, Body: map[string]any{"error": "later"}} g := classes.NewLoginGates() g.Add(gate("a", nil)) g.Add(gate("b", nil)) g.Add(gate("c", first)) g.Add(gate("d", second)) res, err := g.Check(ctx, attempt) if err != nil || res != first { t.Fatalf("Check = %v, %v; want the first result", res, err) } if fmt.Sprint(order) != "[a b c]" { t.Fatalf("gates ran %v, want [a b c]", order) } order = nil all := classes.NewLoginGates() all.Add(gate("x", nil)) all.Add(gate("y", nil)) if res, err := all.Check(ctx, attempt); res != nil || err != nil { t.Fatalf("allowing gates = %v, %v", res, err) } if fmt.Sprint(order) != "[x y]" { t.Fatalf("allowing gates ran %v, want [x y]", order) } }) t.Run("error_stops_the_chain", func(t *testing.T) { boom := errors.New("challenge store down") later := 0 g := classes.NewLoginGates() g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return nil, nil })) g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { // A gate that answers both a result and an error fails closed. return &classes.LoginGateResult{Body: map[string]any{"ignored": true}}, boom })) g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { later++ return nil, nil })) res, err := g.Check(ctx, attempt) if res != nil || !errors.Is(err, boom) { t.Fatalf("erroring gate = %v, %v; want nil and the error", res, err) } if later != 0 { t.Fatal("a gate after the erroring one ran") } }) t.Run("concurrent_add_and_check", func(t *testing.T) { g := classes.NewLoginGates() var calls atomic.Int64 allow := gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { calls.Add(1) return nil, nil }) var wg sync.WaitGroup for i := range 64 { wg.Add(1) go func() { defer wg.Done() if i%2 == 0 { g.Add(allow) return } if res, err := g.Check(ctx, attempt); res != nil || err != nil { t.Errorf("concurrent Check = %v, %v", res, err) } }() } wg.Wait() calls.Store(0) if res, err := g.Check(ctx, attempt); res != nil || err != nil { t.Fatalf("Check after the adds = %v, %v", res, err) } if calls.Load() != 32 { t.Fatalf("Check ran %d gates, want the 32 added", calls.Load()) } }) t.Run("check_login_gates_lookup", func(t *testing.T) { if res, err := controllers.CheckLoginGates(ctx, nil, attempt); res != nil || err != nil { t.Fatalf("no app = %v, %v", res, err) } app := backpack.New(sessionConfig(t)) if res, err := controllers.CheckLoginGates(ctx, app, attempt); res != nil || err != nil { t.Fatalf("no published registry = %v, %v", res, err) } gates := classes.NewLoginGates() if err := app.Publish(gates); err != nil { t.Fatal(err) } want := &classes.LoginGateResult{Body: map[string]any{"two_factor_required": true}} // A gate added after publishing is seen: the registry is looked up // on every call. gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return want, nil })) if res, err := controllers.CheckLoginGates(ctx, app, attempt); res != want || err != nil { t.Fatalf("published registry = %v, %v", res, err) } }) } // countingGate counts its calls, records the attempts it saw and answers // with result (nil allows). type countingGate struct { mu sync.Mutex calls int seen []classes.LoginAttempt result *classes.LoginGateResult } func (g *countingGate) CheckLogin(_ context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) { g.mu.Lock() defer g.mu.Unlock() g.calls++ snapshot := a if a.User != nil { user := *a.User snapshot.User = &user } g.seen = append(g.seen, snapshot) return g.result, nil } func (g *countingGate) count() int { g.mu.Lock() defer g.mu.Unlock() return g.calls } // gatedEnv boots the plugin through the admin harness with one counting // gate added to the registry its Register published. func gatedEnv(t *testing.T, result *classes.LoginGateResult) (*adminEnv, *countingGate) { t.Helper() env := newAdminEnv(t) gates, ok := env.app.Lookup[*classes.LoginGates]() if !ok || gates == nil { t.Fatal("the plugin's Register published no *classes.LoginGates") } gate := &countingGate{result: result} gates.Add(gate) return env, gate } // TestLoginGateOrder pins where POST /login consults the gates (D-02, D-18 // A10): after the ban check, the password, the activation and the bcrypt // rehash, and before the restore of a deactivated account, its reactivate // mail, the mint and the login event. A gate's answer passes through with // its status and body; JWT refresh never consults a gate. func TestLoginGateOrder(t *testing.T) { if testing.Short() { t.Skip("Postgres") } challenge := &classes.LoginGateResult{Body: map[string]any{ "two_factor_required": true, "challenge_token": "c", "available_methods": []string{"totp"}, "expires_at": "2026-10-26T12:00:00+00:00", }} t.Run("first_factor_failures_never_reach_the_gate", func(t *testing.T) { env, gate := gatedEnv(t, challenge) user := env.seedUser(t, "lgo-wrong", nil) if status, _ := env.userLogin(t, user.Email, "wrong-password"); status != http.StatusUnauthorized { t.Fatalf("wrong password = %d", status) } if status, _ := env.userLogin(t, "lgo-nobody-"+adminStamp()+"@example.test", "correct-horse"); status != http.StatusUnauthorized { t.Fatalf("unknown email = %d", status) } inactive := env.seedUser(t, "lgo-inactive", func(u *models.User) { u.IsActivated = false }) if status, _ := env.userLogin(t, inactive.Email, "correct-horse"); status != http.StatusUnauthorized { t.Fatalf("not activated = %d", status) } banned := env.seedUser(t, "lgo-banned", nil) env.seedThrottle(t, banned, "", true, nil) if status, _ := env.userLogin(t, banned.Email, "correct-horse"); status != http.StatusUnauthorized { t.Fatalf("banned = %d", status) } if gate.count() != 0 { t.Fatalf("the gate ran %d times for refused first factors", gate.count()) } }) t.Run("gate_sees_the_attempt", func(t *testing.T) { env, gate := gatedEnv(t, challenge) user := env.seedUser(t, "lgo-attempt", nil) rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{ "email": user.Email, "password": "correct-horse", "trusted_device_token": "td-attempt", }) if rec.Code != http.StatusOK { t.Fatalf("login = %d %s", rec.Code, rec.Body.String()) } if gate.count() != 1 { t.Fatalf("gate calls = %d, want 1", gate.count()) } seen := gate.seen[0] if seen.Source != classes.LoginSourcePassword || seen.User == nil || seen.User.ID != user.ID || seen.Request == nil { t.Fatalf("attempt = %+v", seen) } if seen.Fields["trusted_device_token"] != "td-attempt" || seen.Fields["email"] != user.Email { t.Fatalf("attempt fields = %#v", seen.Fields) } }) t.Run("rehash_before_a_challenge", func(t *testing.T) { env, gate := gatedEnv(t, challenge) probe := &passwordProbe{} env.app.Events.Listen("test.login-gate-order.rehash", func(_ context.Context, ev *classes.AccessRevokedEvent) error { probe.mu.Lock() probe.seen = append(probe.seen, *ev) probe.mu.Unlock() return nil }) user := env.seedUser(t, "lgo-rehash", nil) if err := env.app.Config.Set("golem15.user.password.bcrypt_cost", 11); err != nil { t.Fatal(err) } rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"}) if rec.Code != http.StatusOK || decodeBody(t, rec.Body.Bytes())["two_factor_required"] != true { t.Fatalf("challenged login = %d %s", rec.Code, rec.Body.String()) } if gate.count() != 1 { t.Fatalf("gate calls = %d", gate.count()) } stored := env.reload(t, user.ID).Password if stored == user.Password || bouncer.NeedsRehash(stored, 11) || !bouncer.CheckPassword(stored, "correct-horse") { t.Fatal("a challenged login did not rehash the password at the higher cost") } if seen := probe.take(); len(seen) != 0 { t.Fatalf("the rehash fired %+v", seen) } }) t.Run("challenged_soft_deleted_stays_deleted", func(t *testing.T) { env, gate := gatedEnv(t, challenge) events := listenLoginEvents(env.app, "test.login-gate-order.soft") user := env.seedUser(t, "lgo-soft", nil) if err := env.gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil { t.Fatal(err) } rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"}) body := decodeBody(t, rec.Body.Bytes()) if rec.Code != http.StatusOK || body["two_factor_required"] != true || body["token"] != nil { t.Fatalf("challenged login = %d %s", rec.Code, rec.Body.String()) } if !gate.seen[0].User.DeletedAt.Valid { t.Fatal("the gate saw a restored account; the restore must wait for the gates") } if !env.reload(t, user.ID).DeletedAt.Valid { t.Fatal("a challenged login restored the deactivated account") } if mails := env.mails(user.Email); len(mails) != 0 { t.Fatalf("a challenged login sent %d mails", len(mails)) } if events.count() != 0 { t.Fatalf("a challenged login fired %d login events", events.count()) } }) t.Run("allowed_soft_deleted_restored_once", func(t *testing.T) { env, gate := gatedEnv(t, nil) events := listenLoginEvents(env.app, "test.login-gate-order.allowed") user := env.seedUser(t, "lgo-allowed", nil) if err := env.gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil { t.Fatal(err) } status, token := env.userLogin(t, user.Email, "correct-horse") if status != http.StatusOK || token == "" { t.Fatalf("allowed login = %d", status) } if gate.count() != 1 { t.Fatalf("gate calls = %d", gate.count()) } if env.reload(t, user.ID).DeletedAt.Valid { t.Fatal("an allowed login did not restore the account") } if mails := env.mails(user.Email); len(mails) != 1 { t.Fatalf("reactivate mails = %d, want 1", len(mails)) } if events.count() != 1 || events.got[0].Source != classes.LoginSourcePassword || events.got[0].User.ID != user.ID { t.Fatalf("login events = %+v", events.got) } }) t.Run("gate_answer_passes_through", func(t *testing.T) { for _, c := range []struct { name string result *classes.LoginGateResult status int body string }{ {"status_zero_is_200", &classes.LoginGateResult{Body: map[string]any{"two_factor_required": true}}, http.StatusOK, `{"two_factor_required":true}`}, {"refusal", &classes.LoginGateResult{Status: http.StatusUnauthorized, Body: map[string]any{"error": "Invalid or expired challenge."}}, http.StatusUnauthorized, `{"error":"Invalid or expired challenge."}`}, {"other_status", &classes.LoginGateResult{Status: http.StatusTooManyRequests, Body: map[string]any{"error": "slow down", "retry": 3}}, http.StatusTooManyRequests, `{"error":"slow down","retry":3}`}, } { t.Run(c.name, func(t *testing.T) { env, _ := gatedEnv(t, c.result) events := listenLoginEvents(env.app, "test.login-gate-order."+c.name) user := env.seedUser(t, "lgo-"+c.name, nil) rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"}) if rec.Code != c.status || rec.Body.String() != c.body { t.Fatalf("answer = %d %s, want %d %s", rec.Code, rec.Body.String(), c.status, c.body) } if events.count() != 0 { t.Fatal("a gate answer fired a login event") } if env.reload(t, user.ID).LastSeen != nil { t.Fatal("a gate answer stamped last_seen") } }) } }) t.Run("refresh_never_consults_a_gate", func(t *testing.T) { env, gate := gatedEnv(t, nil) user := env.seedUser(t, "lgo-refresh", nil) status, token := env.userLogin(t, user.Email, "correct-horse") if status != http.StatusOK { t.Fatalf("login = %d", status) } if gate.count() != 1 { t.Fatalf("gate calls after login = %d", gate.count()) } // From now on the gate would challenge: refresh must not ask it. gate.mu.Lock() gate.result = challenge gate.mu.Unlock() for range 2 { rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/refresh", token, nil) var body struct { Token string `json:"token"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" { t.Fatalf("refresh body %s: %v", rec.Body.String(), err) } token = body.Token } if gate.count() != 1 { t.Fatalf("refresh consulted the gate: %d calls", gate.count()) } }) }