Files
sm-user-plugin/login_gate_test.go
Jakub Zych 6844d362c4 test: login gates, password-changed sources and the reset action
- TestLoginGates: registry order, first result or error wins, nil gates,
  concurrent Add and Check, CheckLoginGates without a registry
- TestLoginGateOrder: refused first factors never reach a gate, rehash
  before a challenge, restore and reactivate mail only after the gates,
  gate answers pass through, refresh never asks a gate
- TestPasswordChangedSources: reset, change and admin set fire once inside
  the write's transaction; rehash and registration fire none
- TestResetTwoFactorAction: offered only with a service and a factor,
  danger, permission, transaction and rollback
2026-10-26 14:06:09 +01:00

402 lines
15 KiB
Go

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