Files
sm-user-plugin/login_gate_test.go
Jakub Zych b179e6b2e5 fix: activate-by-code asks the login gates before it signs in
After the mailed code verifies, ActivateByCode consults the login gates
with source password, as Login does. A gate answer (for example a
second-factor challenge) replaces the token response and sets no session
cookie; a gate error answers the opaque 500. The account is activated
either way. Without a gate, or when every gate allows, the answer is
unchanged. With require_activation off an account can enable a second
factor before it activates, so the mailed code no longer stands in for it.
2026-11-05 17:54:21 +01:00

538 lines
20 KiB
Go

package user
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"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())
}
})
}
// TestActivateByCodeGates pins where POST /activate-by-code consults the
// login gates: after the mailed code verifies, with source password (the
// code is the first factor here), and before the mint. A gate answer
// replaces the token response with its status and body and sets no session
// cookie, a gate error answers the opaque 500, and the account is activated
// whatever the gate answers. An invalid code never reaches a gate.
func TestActivateByCodeGates(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",
}}
inactive := func(t *testing.T, env *adminEnv, label string) (*models.User, string) {
t.Helper()
user := env.seedUser(t, label, func(u *models.User) { u.IsActivated = false })
code, err := classes.IssueActivationCode(t.Context(), env.gdb, user)
if err != nil {
t.Fatal(err)
}
return user, strconv.FormatUint(uint64(user.ID), 10) + "!" + code
}
activate := func(t *testing.T, env *adminEnv, packed string) *httptest.ResponseRecorder {
t.Helper()
return env.call(t, http.MethodPost, "/_user/api/v1/activate-by-code", "", map[string]string{"code": packed})
}
noSession := func(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if cookies := rec.Header().Values("Set-Cookie"); len(cookies) != 0 {
t.Fatalf("a held-back activation set cookies %v", cookies)
}
if strings.Contains(rec.Body.String(), `"token"`) {
t.Fatalf("a held-back activation answered a token: %s", rec.Body.String())
}
}
t.Run("challenge_replaces_the_token", func(t *testing.T) {
env, gate := gatedEnv(t, challenge)
user, packed := inactive(t, env, "abc-challenge")
rec := activate(t, env, packed)
want := `{"available_methods":["totp"],"challenge_token":"c","expires_at":"2026-10-26T12:00:00+00:00","two_factor_required":true}`
if rec.Code != http.StatusOK || rec.Body.String() != want {
t.Fatalf("challenged activation = %d %s, want 200 %s", rec.Code, rec.Body.String(), want)
}
noSession(t, rec)
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["code"] != packed {
t.Fatalf("attempt fields = %#v", seen.Fields)
}
if !env.reload(t, user.ID).IsActivated {
t.Fatal("a challenged activation left the account not activated")
}
})
t.Run("refusal_passes_through", func(t *testing.T) {
env, _ := gatedEnv(t, &classes.LoginGateResult{Status: http.StatusUnauthorized, Body: map[string]any{"error": "Invalid or expired challenge."}})
user, packed := inactive(t, env, "abc-refusal")
rec := activate(t, env, packed)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Invalid or expired challenge."}` {
t.Fatalf("refused activation = %d %s", rec.Code, rec.Body.String())
}
noSession(t, rec)
if !env.reload(t, user.ID).IsActivated {
t.Fatal("a refused activation left the account not activated")
}
})
t.Run("allowing_gate_mints_as_before", func(t *testing.T) {
env, gate := gatedEnv(t, nil)
user, packed := inactive(t, env, "abc-allow")
rec := activate(t, env, packed)
body := decodeBody(t, rec.Body.Bytes())
userBody, _ := body["user"].(map[string]any)
token, _ := body["token"].(string)
if rec.Code != http.StatusOK || token == "" || body["message"] != "Account activated" || userBody["is_activated"] != true {
t.Fatalf("allowed activation = %d %s", rec.Code, rec.Body.String())
}
if gate.count() != 1 {
t.Fatalf("gate calls = %d, want 1", gate.count())
}
if !env.reload(t, user.ID).IsActivated {
t.Fatal("an allowed activation left the account not activated")
}
})
t.Run("gate_error_fails_closed", func(t *testing.T) {
env := newAdminEnv(t)
gates, ok := env.app.Lookup[*classes.LoginGates]()
if !ok || gates == nil {
t.Fatal("the plugin's Register published no *classes.LoginGates")
}
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
return nil, errors.New("challenge store down")
}))
user, packed := inactive(t, env, "abc-error")
rec := activate(t, env, packed)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("erroring gate = %d %s", rec.Code, rec.Body.String())
}
noSession(t, rec)
if !env.reload(t, user.ID).IsActivated {
t.Fatal("an erroring gate left the account not activated")
}
// The code was spent: the account is activated, so a second try
// answers the Winter error page as before.
assertWinterErrorPage(t, activate(t, env, packed))
})
t.Run("bad_code_never_reaches_the_gate", func(t *testing.T) {
env, gate := gatedEnv(t, challenge)
user, _ := inactive(t, env, "abc-bad")
rec := activate(t, env, strconv.FormatUint(uint64(user.ID), 10)+"!nope")
if rec.Code != http.StatusUnprocessableEntity || rec.Body.String() != `{"error":"This activation link is invalid or has expired"}` {
t.Fatalf("bad code = %d %s", rec.Code, rec.Body.String())
}
if gate.count() != 0 {
t.Fatalf("the gate ran %d times for a bad code", gate.count())
}
if env.reload(t, user.ID).IsActivated {
t.Fatal("a bad code activated the account")
}
})
}