Resetting the second factors of a privileged-group member now needs golem15.users.manage_privileged_groups, as changing their password or deleting them does; without it the action answers 403 before Reset.
243 lines
9.3 KiB
Go
243 lines
9.3 KiB
Go
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/compass"
|
|
"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. A member of a
|
|
// privileged group keeps the takeover guard: without
|
|
// golem15.users.manage_privileged_groups Run answers 403 with the guard's
|
|
// message before Reset.
|
|
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("privileged_member_needs_the_privileged_permission", func(t *testing.T) {
|
|
code := "rta-priv-" + adminStamp()
|
|
env := newAdminEnv(t, func(cfg *compass.Config) {
|
|
if err := cfg.Set("golem15.user.privileged_groups", []string{"admin", code}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
svc := &scriptedTwoFactorAdmin{has: true}
|
|
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
limited := env.admin(t, permAccessUsers, permResetTwoFactor)
|
|
manager := env.admin(t, permAccessUsers, permResetTwoFactor, permManagePrivileged)
|
|
member := env.seedUser(t, "rta-privileged", nil)
|
|
env.join(t, member, env.seedGroup(t, "Privileged "+code, code))
|
|
ordinary := env.seedUser(t, "rta-ordinary", nil)
|
|
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, userPath(member.ID, "/actions/reset_two_factor"), limited, map[string]any{})
|
|
answer := adminDecode(t, rec)
|
|
const want = "This user belongs to a privileged group. Resetting their two-factor authentication needs the permission to manage privileged groups. Nothing was changed."
|
|
if answer.Error.Code != "forbidden" || answer.Error.Message != want {
|
|
t.Fatalf("refusal = %s", rec.Body.String())
|
|
}
|
|
if len(svc.calls()) != 0 {
|
|
t.Fatalf("a refused reset reached Reset: %v", svc.calls())
|
|
}
|
|
if name := env.reload(t, member.ID).Name; name == nil || *name != "rta-privileged" {
|
|
t.Fatalf("the refused reset changed the member: name = %v", name)
|
|
}
|
|
// An ordinary user stays resettable with the reset permission alone.
|
|
if !slices.Contains(env.offered(t, limited, ordinary.ID), "reset_two_factor") {
|
|
t.Fatal("not offered for an ordinary user")
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodPost, userPath(ordinary.ID, "/actions/reset_two_factor"), limited, map[string]any{})
|
|
// With the privileged permission the member is offered and reset.
|
|
if !slices.Contains(env.offered(t, manager, member.ID), "reset_two_factor") {
|
|
t.Fatal("not offered to an administrator who manages privileged groups")
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodPost, userPath(member.ID, "/actions/reset_two_factor"), manager, map[string]any{})
|
|
if calls := svc.calls(); len(calls) != 2 || calls[0] != ordinary.ID || calls[1] != member.ID {
|
|
t.Fatalf("Reset calls = %v", calls)
|
|
}
|
|
})
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|