- 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
402 lines
15 KiB
Go
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())
|
|
}
|
|
})
|
|
}
|