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
This commit is contained in:
@@ -353,4 +353,6 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
|
||||
|
||||
`TestMemberTokenScopesBoot` (no Docker) checks that the scope Boot step fails for a name two plugins declare (naming both), for a redeclared `read`, `write` or `ai`, for a bad name, an empty label and a missing phrase key. With Docker: `TestMemberTokenScopes` walks the member token scope acceptance list item by item (mint and `middleware.Scope`, the 403 message, the 422 for an undeclared scope, the list, discovery in en and pl, the per-application catalog and the legacy fallback); `TestBackendLinkAuthTime` checks the link proof against login, request, refresh and re-login orders, a token without `auth_time` and another user's session token; `TestBackendLinkShowPost` checks that `POST /backend-link/show` answers like the GET route with `Cache-Control: no-store, private`, the fragment URL and `backend_links.page_path`; `TestAccessRevocation` checks `AccessRevokedEvent` from the ban action, the password reset, the password change and an administrator setting a password (ids, `Before`, reason, the transaction, the subject event after commit, the rollback on a failing listener) and that a banned user loses the backend link until unbanned; `TestUserMCPGuardClientIP` checks `last_used_ip` on the MCP personal-token path; `TestAPITokenRevokeEvent` checks the token-specific revoked event of `DELETE /tokens/{id}`; `TestOrganisationLogo` checks the logo field, the upload, `OrganisationLogoFields` with and without a logo, the URL fallback when the thumbnail fails, and that deleting an organisation removes its avatar and logo attachments, the rows and, once the delete commits, the blobs and thumbnails.
|
||||
|
||||
The login-gate seam, the password-change hook and the Reset 2FA slot have their own tests. `TestLoginGates` (in `-short`, clean under `-race`) pins the registry: a nil or empty registry allows, gates run in the order they were added, the first result or error wins and stops the chain, nil gates are ignored, and `controllers.CheckLoginGates` allows when no registry is published. `TestLoginGateOrder` pins where `POST /login` asks the gates: refused first factors (wrong password, unknown email, not activated, banned) never reach them; the gate sees the attempt with the body already read; the bcrypt rehash happens before a challenge and fires no password-change event; a challenged deactivated account stays deactivated with no mail and no login event, while an allowed one is restored with one reactivate mail and one login event; a gate's status and body pass through; `/refresh` never asks a gate. `TestPasswordChangedSources` checks that the reset, the user's own change and an administrator setting a password each fire one `AccessRevokedEvent` with `PasswordChanged()` true inside the write's transaction (a listener's write through `ev.Tx` commits with it, a listener error rolls both back), and that the rehash and a registration fire none. `TestResetTwoFactorAction` covers the `reset_two_factor` record action: not offered without a published `classes.TwoFactorAdmin`, offered with the danger confirmation when one reports a second factor, hidden when it reports none, refused without `golem15.users.reset_two_factor`, `Run` refused outside the action's transaction, `Reset` committed with the action and rolled back by its error. The plan 01 smoke tests (`TestLoginGatesSmoke`, `TestLoginGateSmoke`, `TestPasswordChangedEventSmoke`, `TestResetTwoFactorSlotSmoke`) stay. The Postgres tests need Docker.
|
||||
|
||||
The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`.
|
||||
|
||||
401
login_gate_test.go
Normal file
401
login_gate_test.go
Normal file
@@ -0,0 +1,401 @@
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
224
password_changed_test.go
Normal file
224
password_changed_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// passwordSource is what a D-07 listener observed for one event: the event
|
||||
// itself, whether the stored hash it read through ev.Tx already checks
|
||||
// against the new password, and the marker it wrote through ev.Tx.
|
||||
type passwordSource struct {
|
||||
ev classes.AccessRevokedEvent
|
||||
newHash bool
|
||||
}
|
||||
|
||||
// passwordListener is a D-07 listener: for every password-change event it
|
||||
// reads the stored hash through ev.Tx, writes a marker row through ev.Tx
|
||||
// (jwt_blacklist, jti = marker) and then fails when fail is set.
|
||||
type passwordListener struct {
|
||||
mu sync.Mutex
|
||||
seen []passwordSource
|
||||
fail bool
|
||||
plain string
|
||||
marker string
|
||||
}
|
||||
|
||||
func (l *passwordListener) arm(plain, marker string, fail bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.seen, l.plain, l.marker, l.fail = nil, plain, marker, fail
|
||||
}
|
||||
|
||||
func (l *passwordListener) take() []passwordSource {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
out := l.seen
|
||||
l.seen = nil
|
||||
return out
|
||||
}
|
||||
|
||||
func (l *passwordListener) handle(ctx context.Context, ev *classes.AccessRevokedEvent) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if !ev.PasswordChanged() {
|
||||
return nil
|
||||
}
|
||||
got := passwordSource{ev: *ev}
|
||||
if ev.Tx != nil && len(ev.UserIDs) == 1 {
|
||||
var hash string
|
||||
if err := ev.Tx.WithContext(ctx).Raw(`SELECT password FROM users WHERE id = ?`, ev.UserIDs[0]).Scan(&hash).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
got.newHash = l.plain != "" && bouncer.CheckPassword(hash, l.plain)
|
||||
if l.marker != "" {
|
||||
if err := ev.Tx.WithContext(ctx).Exec(`INSERT INTO jwt_blacklist (jti, expires_at, valid_until) VALUES (?, NOW(), NOW())`, l.marker).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
l.seen = append(l.seen, got)
|
||||
if l.fail {
|
||||
return errors.New("listener refused")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markerExists(t *testing.T, gdb *gorm.DB, marker string) bool {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, marker).Scan(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n == 1
|
||||
}
|
||||
|
||||
// TestPasswordChangedSources pins the D-07 hook from every source. A
|
||||
// password reset, the user's own change and an administrator setting a
|
||||
// password each fire exactly one AccessRevokedEvent with its own reason and
|
||||
// PasswordChanged() true, inside the write's transaction: a marker the
|
||||
// listener writes through ev.Tx commits with the write, and a listener error
|
||||
// rolls back the write and the marker. Reset and change write the hash
|
||||
// before they fire, so the listener reads the new hash through ev.Tx; the
|
||||
// administrator's save fires from FormBeforeUpdate, before cabana writes
|
||||
// the record in the same transaction. The bcrypt rehash on login and a new
|
||||
// registration fire no password-change event.
|
||||
func TestPasswordChangedSources(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
env := newAdminEnv(t)
|
||||
listener := &passwordListener{}
|
||||
env.app.Events.Listen("test.password_changed_sources", listener.handle)
|
||||
admin := env.superAdmin(t)
|
||||
ctx := context.Background()
|
||||
session := func(user *models.User) string {
|
||||
return craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "pcs-"+adminStamp(), time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
|
||||
}
|
||||
reset := func(t *testing.T, user *models.User, password string) int {
|
||||
t.Helper()
|
||||
code, err := classes.IssueResetCode(ctx, env.gdb, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||
return env.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{
|
||||
"code": packed, "password": password, "password_confirmation": password,
|
||||
}).Code
|
||||
}
|
||||
change := func(t *testing.T, user *models.User, password string) int {
|
||||
t.Helper()
|
||||
return env.call(t, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{
|
||||
"current_password": "correct-horse", "password": password, "password_confirmation": password,
|
||||
}).Code
|
||||
}
|
||||
adminSet := func(t *testing.T, user *models.User, password string) int {
|
||||
t.Helper()
|
||||
return env.call(t, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{
|
||||
"name": user.Name, "email": user.Email, "password": password, "password_confirmation": password,
|
||||
}).Code
|
||||
}
|
||||
sources := []struct {
|
||||
name string
|
||||
reason string
|
||||
write func(*testing.T, *models.User, string) int
|
||||
mutate func(*models.User)
|
||||
newHash bool
|
||||
}{
|
||||
{"reset", classes.AccessRevokedPasswordReset, reset, nil, true},
|
||||
{"change", classes.AccessRevokedPasswordChanged, change, func(u *models.User) { u.HasSelfSetPassword = true }, true},
|
||||
{"admin_set", classes.AccessRevokedPasswordSetByAdmin, adminSet, nil, false},
|
||||
}
|
||||
|
||||
for _, src := range sources {
|
||||
t.Run(src.name+"_fires_once_in_the_transaction", func(t *testing.T) {
|
||||
user := env.seedUser(t, "pcs-"+src.name, src.mutate)
|
||||
plain := "pcs-new-" + src.name + "-password"
|
||||
marker := "pcs-marker-" + src.name + "-" + adminStamp()
|
||||
listener.arm(plain, marker, false)
|
||||
if status := src.write(t, user, plain); status != http.StatusOK {
|
||||
t.Fatalf("%s = %d", src.name, status)
|
||||
}
|
||||
seen := listener.take()
|
||||
if len(seen) != 1 {
|
||||
t.Fatalf("%s fired %d password-change events, want 1", src.name, len(seen))
|
||||
}
|
||||
ev := seen[0].ev
|
||||
if ev.Reason != src.reason || !ev.PasswordChanged() || ev.Tx == nil || len(ev.UserIDs) != 1 || ev.UserIDs[0] != user.ID {
|
||||
t.Fatalf("event = %+v", ev)
|
||||
}
|
||||
if ev.Before.IsZero() || time.Since(ev.Before) > time.Minute {
|
||||
t.Fatalf("event cutoff = %s", ev.Before)
|
||||
}
|
||||
if seen[0].newHash != src.newHash {
|
||||
t.Fatalf("listener read the new hash through ev.Tx = %v, want %v", seen[0].newHash, src.newHash)
|
||||
}
|
||||
if !markerExists(t, env.gdb, marker) {
|
||||
t.Fatal("the listener's write through ev.Tx did not commit with the password write")
|
||||
}
|
||||
if !bouncer.CheckPassword(env.reload(t, user.ID).Password, plain) {
|
||||
t.Fatal("the new password was not stored")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(src.name+"_listener_error_rolls_back", func(t *testing.T) {
|
||||
user := env.seedUser(t, "pcs-rb-"+src.name, src.mutate)
|
||||
marker := "pcs-rb-marker-" + src.name + "-" + adminStamp()
|
||||
listener.arm("pcs-never-set", marker, true)
|
||||
if status := src.write(t, user, "pcs-never-set"); status == http.StatusOK {
|
||||
t.Fatalf("%s with a failing listener answered 200", src.name)
|
||||
}
|
||||
if len(listener.take()) != 1 {
|
||||
t.Fatal("the failing listener did not run exactly once")
|
||||
}
|
||||
if stored := env.reload(t, user.ID).Password; stored != user.Password {
|
||||
t.Fatalf("%s: the stored hash changed although the listener failed", src.name)
|
||||
}
|
||||
if markerExists(t, env.gdb, marker) {
|
||||
t.Fatalf("%s: the listener's write survived the rollback", src.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("rehash_fires_none", func(t *testing.T) {
|
||||
user := env.seedUser(t, "pcs-rehash", nil)
|
||||
if err := env.app.Config.Set("golem15.user.password.bcrypt_cost", 11); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = env.app.Config.Set("golem15.user.password.bcrypt_cost", 10) })
|
||||
listener.arm("", "", false)
|
||||
if status, _ := env.userLogin(t, user.Email, "correct-horse"); status != http.StatusOK {
|
||||
t.Fatalf("login = %d", status)
|
||||
}
|
||||
if bouncer.NeedsRehash(env.reload(t, user.ID).Password, 11) {
|
||||
t.Fatal("the login did not rehash")
|
||||
}
|
||||
if seen := listener.take(); len(seen) != 0 {
|
||||
t.Fatalf("the rehash fired %d password-change events", len(seen))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("register_fires_none", func(t *testing.T) {
|
||||
if err := env.app.Config.Set("golem15.user.registration.use_register_throttle", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener.arm("", "", false)
|
||||
email := "pcs-register-" + adminStamp() + "@example.test"
|
||||
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/register", "", map[string]string{
|
||||
"email": email, "password": "correct-horse", "name": "Registrant",
|
||||
})
|
||||
if seen := listener.take(); len(seen) != 0 {
|
||||
t.Fatalf("a registration fired %d password-change events", len(seen))
|
||||
}
|
||||
})
|
||||
}
|
||||
194
reset_two_factor_test.go
Normal file
194
reset_two_factor_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// scriptedTwoFactorAdmin is a classes.TwoFactorAdmin whose answers a test
|
||||
// sets: has is what HasTwoFactor reports; Reset renames the user through
|
||||
// the transaction it is given (to prove which transaction that is) and then
|
||||
// returns resetErr.
|
||||
type scriptedTwoFactorAdmin struct {
|
||||
mu sync.Mutex
|
||||
has bool
|
||||
resetErr error
|
||||
resets []uint
|
||||
}
|
||||
|
||||
func (s *scriptedTwoFactorAdmin) HasTwoFactor(_ context.Context, tx *gorm.DB, _ uint) (bool, error) {
|
||||
if tx == nil {
|
||||
return false, errors.New("no handle")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.has, nil
|
||||
}
|
||||
|
||||
func (s *scriptedTwoFactorAdmin) Reset(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.resets = append(s.resets, user.ID)
|
||||
if err := tx.WithContext(ctx).Exec(`UPDATE users SET name = 'reset-marker' WHERE id = ?`, user.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.resetErr
|
||||
}
|
||||
|
||||
func (s *scriptedTwoFactorAdmin) calls() []uint {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return slices.Clone(s.resets)
|
||||
}
|
||||
|
||||
// resetAction returns the Users controller's reset_two_factor record action
|
||||
// on app.
|
||||
func resetAction(t *testing.T, app *backpack.App) pact.AdminRecordAction {
|
||||
t.Helper()
|
||||
ctl := adminControllersOn(func() *backpack.App { return app })["golem15.user.users"]
|
||||
for _, action := range ctl.(pact.HasAdminRecordActions).AdminRecordActions() {
|
||||
if action.Name == "reset_two_factor" {
|
||||
return action
|
||||
}
|
||||
}
|
||||
t.Fatal("the Users controller has no reset_two_factor record action")
|
||||
return pact.AdminRecordAction{}
|
||||
}
|
||||
|
||||
// TestResetTwoFactorAction pins the D-15 record action on the Users
|
||||
// screen: never offered without a published classes.TwoFactorAdmin;
|
||||
// offered with the danger confirmation when one is published and reports a
|
||||
// second factor; hidden when it reports none; refused without
|
||||
// golem15.users.reset_two_factor; Run needs the action's transaction, runs
|
||||
// Reset in it, and Reset's error rolls the transaction back.
|
||||
func TestResetTwoFactorAction(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
|
||||
t.Run("not_offered_without_a_service", func(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
token := env.admin(t, permAccessUsers, permResetTwoFactor)
|
||||
user := env.seedUser(t, "rta-none", nil)
|
||||
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
|
||||
t.Fatal("offered without a published TwoFactorAdmin")
|
||||
}
|
||||
if applies, err := resetAction(t, env.app).Applies(context.Background(), user); applies || err != nil {
|
||||
t.Fatalf("Applies without a service = %v, %v", applies, err)
|
||||
}
|
||||
if rec := env.call(t, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), token, map[string]any{}); rec.Code == http.StatusOK {
|
||||
t.Fatalf("ran without a service: %s", rec.Body.String())
|
||||
}
|
||||
if env.reload(t, user.ID).Name == nil || *env.reload(t, user.ID).Name != "rta-none" {
|
||||
t.Fatal("the user changed although nothing ran")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("offered_with_danger_and_hidden_without_a_factor", func(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
svc := &scriptedTwoFactorAdmin{has: true}
|
||||
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := env.admin(t, permAccessUsers, permResetTwoFactor)
|
||||
user := env.seedUser(t, "rta-offer", nil)
|
||||
action := env.recordAction(t, token, user.ID, "reset_two_factor")
|
||||
if action == nil {
|
||||
t.Fatalf("not offered: %v", env.offered(t, token, user.ID))
|
||||
}
|
||||
if action["danger"] != true || action["label"] != "Reset 2FA" || action["confirm"] == nil || action["confirm"] == "" {
|
||||
t.Fatalf("offered action = %v", action)
|
||||
}
|
||||
svc.mu.Lock()
|
||||
svc.has = false
|
||||
svc.mu.Unlock()
|
||||
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
|
||||
t.Fatal("offered for a user without a second factor")
|
||||
}
|
||||
// A superuser holds every permission and sees it too.
|
||||
svc.mu.Lock()
|
||||
svc.has = true
|
||||
svc.mu.Unlock()
|
||||
if !slices.Contains(env.offered(t, env.superAdmin(t), user.ID), "reset_two_factor") {
|
||||
t.Fatal("not offered to a superuser")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("permission_required", func(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
svc := &scriptedTwoFactorAdmin{has: true}
|
||||
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain := env.admin(t, permAccessUsers)
|
||||
user := env.seedUser(t, "rta-perm", nil)
|
||||
if slices.Contains(env.offered(t, plain, user.ID), "reset_two_factor") {
|
||||
t.Fatal("offered without golem15.users.reset_two_factor")
|
||||
}
|
||||
env.expect(t, http.StatusForbidden, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), plain, map[string]any{})
|
||||
if len(svc.calls()) != 0 {
|
||||
t.Fatal("a refused administrator reached Reset")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("run_needs_the_transaction", func(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
svc := &scriptedTwoFactorAdmin{has: true}
|
||||
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := env.seedUser(t, "rta-notx", nil)
|
||||
_, err := resetAction(t, env.app).Run(context.Background(), pact.AdminRecordActionInput{Record: user})
|
||||
wantPlainError(t, "reset_two_factor without a transaction", err)
|
||||
if len(svc.calls()) != 0 {
|
||||
t.Fatal("Reset ran without the action's transaction")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("run_commits_with_the_action", func(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
svc := &scriptedTwoFactorAdmin{has: true}
|
||||
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := env.admin(t, permAccessUsers, permResetTwoFactor)
|
||||
user := env.seedUser(t, "rta-ok", nil)
|
||||
env.expect(t, http.StatusOK, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), token, map[string]any{})
|
||||
if calls := svc.calls(); len(calls) != 1 || calls[0] != user.ID {
|
||||
t.Fatalf("Reset calls = %v", calls)
|
||||
}
|
||||
if name := env.reload(t, user.ID).Name; name == nil || *name != "reset-marker" {
|
||||
t.Fatalf("Reset's write did not commit: name = %v", name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("run_error_rolls_back", func(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
svc := &scriptedTwoFactorAdmin{has: true, resetErr: errors.New("credential delete failed")}
|
||||
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := env.admin(t, permAccessUsers, permResetTwoFactor)
|
||||
user := env.seedUser(t, "rta-rollback", nil)
|
||||
rec := env.call(t, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), token, map[string]any{})
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("failing Reset = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(svc.calls()) != 1 {
|
||||
t.Fatalf("Reset calls = %v", svc.calls())
|
||||
}
|
||||
if name := env.reload(t, user.ID).Name; name == nil || *name != "rta-rollback" {
|
||||
t.Fatalf("Reset's write survived its error: name = %v", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user