- 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
225 lines
7.9 KiB
Go
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))
|
|
}
|
|
})
|
|
}
|