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