Files
sm-user-plugin/password_changed_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

225 lines
7.9 KiB
Go

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