The D-07 password-change hook is the existing AccessRevokedEvent, fired inside the write's transaction by a reset, the user's own change and an administrator's password; (*AccessRevokedEvent).PasswordChanged reports those three reasons, and the login rehash fires nothing. The Users screen declares the record action reset_two_factor (danger) behind the new permission golem15.users.reset_two_factor, which no role has by default. It is offered only when a two-factor plugin publishes a classes.TwoFactorAdmin and the user has a second factor; Run calls the service's Reset in the action's transaction.
154 lines
5.8 KiB
Go
154 lines
5.8 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"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"
|
|
)
|
|
|
|
// passwordProbe records the AccessRevokedEvents of an app (the D-07
|
|
// password-change hook) and can make the listener fail.
|
|
type passwordProbe struct {
|
|
mu sync.Mutex
|
|
seen []classes.AccessRevokedEvent
|
|
fail atomic.Bool
|
|
}
|
|
|
|
func (p *passwordProbe) take() []classes.AccessRevokedEvent {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
out := p.seen
|
|
p.seen = nil
|
|
return out
|
|
}
|
|
|
|
// onePasswordChange asserts exactly one event for id with reason, fired in
|
|
// a transaction and reporting PasswordChanged.
|
|
func onePasswordChange(t *testing.T, probe *passwordProbe, id uint, reason string) {
|
|
t.Helper()
|
|
seen := probe.take()
|
|
if len(seen) != 1 {
|
|
t.Fatalf("%s fired %d events, want 1: %+v", reason, len(seen), seen)
|
|
}
|
|
ev := seen[0]
|
|
if ev.Reason != reason || len(ev.UserIDs) != 1 || ev.UserIDs[0] != id || ev.Tx == nil || !ev.PasswordChanged() {
|
|
t.Fatalf("%s event = %+v (PasswordChanged %v)", reason, ev, ev.PasswordChanged())
|
|
}
|
|
}
|
|
|
|
// TestPasswordChangedEventSmoke pins the D-07 hook a two-factor plugin
|
|
// listens to: a password reset, the user's own change and an administrator
|
|
// setting a password each fire one AccessRevokedEvent that reports
|
|
// PasswordChanged, inside the write's transaction; the bcrypt rehash on
|
|
// login fires none; a failing listener rolls the change back. The event
|
|
// itself predates this test (TestAccessRevocation pins its other fields).
|
|
func TestPasswordChangedEventSmoke(t *testing.T) {
|
|
for reason, want := range map[string]bool{
|
|
classes.AccessRevokedPasswordReset: true,
|
|
classes.AccessRevokedPasswordChanged: true,
|
|
classes.AccessRevokedPasswordSetByAdmin: true,
|
|
classes.AccessRevokedBan: false,
|
|
"": false,
|
|
} {
|
|
if got := (&classes.AccessRevokedEvent{Reason: reason}).PasswordChanged(); got != want {
|
|
t.Fatalf("PasswordChanged(%q) = %v, want %v", reason, got, want)
|
|
}
|
|
}
|
|
var nilEvent *classes.AccessRevokedEvent
|
|
if nilEvent.PasswordChanged() {
|
|
t.Fatal("a nil event reports a password change")
|
|
}
|
|
if testing.Short() {
|
|
t.Skip("Postgres")
|
|
}
|
|
|
|
env := newAdminEnv(t)
|
|
probe := &passwordProbe{}
|
|
env.app.Events.Listen("test.password_changed", func(_ context.Context, ev *classes.AccessRevokedEvent) error {
|
|
if probe.fail.Load() {
|
|
return errors.New("listener refused")
|
|
}
|
|
probe.mu.Lock()
|
|
probe.seen = append(probe.seen, *ev)
|
|
probe.mu.Unlock()
|
|
return nil
|
|
})
|
|
admin := env.superAdmin(t)
|
|
ctx := context.Background()
|
|
session := func(user *models.User) string {
|
|
return craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "pc-"+strconv.FormatInt(time.Now().UnixNano(), 10), time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
|
|
}
|
|
|
|
t.Run("reset", func(t *testing.T) {
|
|
user := env.seedUser(t, "pc-reset", nil)
|
|
code, err := classes.IssueResetCode(ctx, env.gdb, user)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
probe.take()
|
|
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
|
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{"code": packed, "password": "pc-new-password", "password_confirmation": "pc-new-password"})
|
|
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordReset)
|
|
})
|
|
|
|
t.Run("change", func(t *testing.T) {
|
|
user := env.seedUser(t, "pc-change", func(u *models.User) { u.HasSelfSetPassword = true })
|
|
probe.take()
|
|
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{"current_password": "correct-horse", "password": "pc-changed-password", "password_confirmation": "pc-changed-password"})
|
|
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordChanged)
|
|
})
|
|
|
|
t.Run("admin_set", func(t *testing.T) {
|
|
user := env.seedUser(t, "pc-admin", nil)
|
|
probe.take()
|
|
const set = "pc-admin-password"
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{"name": user.Name, "email": user.Email, "password": set, "password_confirmation": set})
|
|
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordSetByAdmin)
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{"name": user.Name, "email": user.Email})
|
|
if seen := probe.take(); len(seen) != 0 {
|
|
t.Fatalf("an update without a password fired %+v", seen)
|
|
}
|
|
})
|
|
|
|
t.Run("rehash_fires_none", func(t *testing.T) {
|
|
user := env.seedUser(t, "pc-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) })
|
|
probe.take()
|
|
if status, _ := env.userLogin(t, user.Email, "correct-horse"); status != http.StatusOK {
|
|
t.Fatalf("login = %d", status)
|
|
}
|
|
stored := env.reload(t, user.ID).Password
|
|
if stored == user.Password || bouncer.NeedsRehash(stored, 11) {
|
|
t.Fatal("the 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("listener_error_rolls_back", func(t *testing.T) {
|
|
user := env.seedUser(t, "pc-rollback", func(u *models.User) { u.HasSelfSetPassword = true })
|
|
probe.fail.Store(true)
|
|
rec := env.call(t, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{"current_password": "correct-horse", "password": "pc-never-set", "password_confirmation": "pc-never-set"})
|
|
probe.fail.Store(false)
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("change with a failing listener = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if stored := env.reload(t, user.ID).Password; stored != user.Password {
|
|
t.Fatal("the stored hash changed although the listener failed")
|
|
}
|
|
})
|
|
}
|