fix: deactivate and ban revoke sessions and API tokens, and /refresh refuses deactivated, banned and cut-off subjects

classes.RevokeUserAccess stamps users.tokens_valid_after (whole seconds) and
revokes the users' personal API tokens; DeactivateUsers and BanUsers call
it for every selected user. Restore and unban revive nothing, and a
deactivated user who signs in again gets a fresh working token.

POST /refresh now loads the token's subject after bouncer.Refresh and
refuses a missing, deactivated or banned user and a token issued before
tokens_valid_after with the existing 401 body; a refused refresh stamps no
last_seen. Suspension is deliberately not checked.
This commit is contained in:
Jakub Zych
2026-10-22 03:11:32 +02:00
parent e0b5ffe20d
commit c3712dc0d4
6 changed files with 456 additions and 18 deletions

View File

@@ -90,8 +90,41 @@ func ActivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int,
return changed, nil
}
// RevokeUserAccess ends every session JWT issued before now and every
// personal API token of userIDs: it stamps users.tokens_valid_after with the
// current time truncated to whole seconds (soft-deleted rows included) and
// sets revoked_at on each of their user_api_tokens rows that is not revoked
// yet (earlier revoked_at values stay). The jwt guard, RequestUser and
// /refresh refuse a token whose iat precedes tokens_valid_after; the
// user.api_token guard refuses a revoked row. JWT iat has whole-second
// precision, so the cutoff is truncated: a token minted right after the
// revocation (a reactivating login) is valid, and so is one minted earlier
// within that same second. An empty list writes nothing. It takes the
// handle of the caller's transaction. DeactivateUsers and BanUsers call it;
// an application may call it for its own offboarding, and after writing a
// ban directly to user_throttle.
func RevokeUserAccess(ctx context.Context, db *gorm.DB, userIDs []uint) error {
if db == nil {
return fmt.Errorf("golem15.user: gorm handle is missing")
}
if len(userIDs) == 0 {
return nil
}
cutoff := time.Now().Truncate(time.Second)
if err := fresh(ctx, db).Unscoped().Model(&models.User{}).Where("id IN ?", userIDs).UpdateColumn("tokens_valid_after", cutoff).Error; err != nil {
return err
}
return fresh(ctx, db).Model(&models.APIToken{}).
Where("user_id IN ? AND revoked_at IS NULL", userIDs).
UpdateColumn("revoked_at", cutoff).Error
}
// DeactivateUsers soft-deletes the users that are not deactivated yet, the
// PHP $user->delete(), and returns how many it changed.
// PHP $user->delete(), and returns how many it changed. It then ends the
// sessions and personal API tokens of every selected user, already
// deactivated ones included (RevokeUserAccess), so a deactivated account
// keeps no working credential: its owner signs in again, which restores
// it, and gets a fresh token. Restoring revives no revoked token.
func DeactivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
if db == nil {
return 0, fmt.Errorf("golem15.user: gorm handle is missing")
@@ -109,6 +142,13 @@ func DeactivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (in
user.DeletedAt = gorm.DeletedAt{Time: now, Valid: true}
changed++
}
ids, err := userIDs(db, users)
if err != nil {
return 0, err
}
if err := RevokeUserAccess(ctx, db, ids); err != nil {
return 0, err
}
return changed, nil
}
@@ -136,7 +176,9 @@ func RestoreUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int,
// BanUsers bans the users and returns how many were not banned before. A ban
// is set on every user_throttle row of the user, and a row without an IP
// address is created when the user has none, so a login from any address is
// refused (RejectIfThrottled falls back to that row).
// refused (RejectIfThrottled falls back to that row). It then ends the
// sessions and personal API tokens of every selected user, already banned
// ones included (RevokeUserAccess); unbanning revives none of them.
func BanUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
ids, err := userIDs(db, users)
if err != nil {
@@ -164,6 +206,9 @@ func BanUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, erro
changed++
}
}
if err := RevokeUserAccess(ctx, db, ids); err != nil {
return 0, err
}
return changed, nil
}

View File

@@ -437,3 +437,126 @@ func TestAdminActions(t *testing.T) {
}
})
}
// TestRevokeUserAccess: the cutoff is stamped in whole seconds on active and
// deactivated users, every live API token of the users is revoked, earlier
// revocations keep their time, other users are untouched, and DeactivateUsers
// and BanUsers revoke for every selected user with unchanged counts.
func TestRevokeUserAccess(t *testing.T) {
ctx := t.Context()
db := throttleDB(t)
token := func(t *testing.T, user *models.User, revoked *time.Time) models.APIToken {
t.Helper()
row := models.APIToken{UserID: user.ID, TokenHash: "hash-" + strconv.FormatUint(uint64(user.ID), 10) + "-" + strconv.FormatInt(time.Now().UnixNano(), 10), RevokedAt: revoked}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
return row
}
reloadToken := func(t *testing.T, id uint) models.APIToken {
t.Helper()
var row models.APIToken
if err := db.Take(&row, id).Error; err != nil {
t.Fatal(err)
}
return row
}
t.Run("a missing handle is an error and an empty list writes nothing", func(t *testing.T) {
if err := RevokeUserAccess(ctx, nil, []uint{1}); err == nil {
t.Fatal("nil handle accepted")
}
if err := RevokeUserAccess(ctx, db, nil); err != nil {
t.Fatal(err)
}
})
t.Run("stamps the cutoff and revokes live tokens only", func(t *testing.T) {
active := actionUser(t, db, "revoke-active", nil)
gone := actionUser(t, db, "revoke-gone", func(u *models.User) {
u.DeletedAt = gorm.DeletedAt{Time: time.Now(), Valid: true}
})
other := actionUser(t, db, "revoke-other", nil)
earlier := time.Now().Add(-time.Hour).Truncate(time.Microsecond)
live := token(t, active, nil)
old := token(t, active, &earlier)
goneTok := token(t, gone, nil)
otherTok := token(t, other, nil)
before := time.Now().Truncate(time.Second)
if err := RevokeUserAccess(ctx, db, []uint{active.ID, gone.ID}); err != nil {
t.Fatal(err)
}
after := time.Now()
for _, id := range []uint{active.ID, gone.ID} {
row := actionReload(t, db, id)
if row.TokensValidAfter == nil || row.TokensValidAfter.Before(before) || row.TokensValidAfter.After(after) ||
!row.TokensValidAfter.Equal(row.TokensValidAfter.Truncate(time.Second)) {
t.Fatalf("user %d tokens_valid_after = %v", id, row.TokensValidAfter)
}
}
if row := actionReload(t, db, other.ID); row.TokensValidAfter != nil {
t.Fatalf("other user stamped: %v", row.TokensValidAfter)
}
for _, id := range []uint{live.ID, goneTok.ID} {
if row := reloadToken(t, id); row.RevokedAt == nil {
t.Fatalf("token %d not revoked", id)
}
}
if row := reloadToken(t, old.ID); row.RevokedAt == nil || !row.RevokedAt.Equal(earlier) {
t.Fatalf("earlier revocation changed: %v want %v", row.RevokedAt, earlier)
}
if row := reloadToken(t, otherTok.ID); row.RevokedAt != nil {
t.Fatalf("other user's token revoked")
}
})
t.Run("deactivate and ban revoke for every selected user", func(t *testing.T) {
fresh := actionUser(t, db, "revoke-deactivate", nil)
already := actionUser(t, db, "revoke-already", func(u *models.User) {
u.DeletedAt = gorm.DeletedAt{Time: time.Now(), Valid: true}
})
a, b := token(t, fresh, nil), token(t, already, nil)
changed, err := DeactivateUsers(ctx, db, []*models.User{fresh, already})
if err != nil || changed != 1 {
t.Fatalf("DeactivateUsers = %d, %v", changed, err)
}
for _, id := range []uint{a.ID, b.ID} {
if row := reloadToken(t, id); row.RevokedAt == nil {
t.Fatalf("token %d survived deactivate", id)
}
}
for _, id := range []uint{fresh.ID, already.ID} {
if row := actionReload(t, db, id); row.TokensValidAfter == nil {
t.Fatalf("user %d not stamped by deactivate", id)
}
}
ban := actionUser(t, db, "revoke-ban", nil)
bannedBefore := actionUser(t, db, "revoke-banned-before", nil)
if err := db.Create(&models.Throttle{UserID: bannedBefore.ID, IsBanned: true}).Error; err != nil {
t.Fatal(err)
}
c, d := token(t, ban, nil), token(t, bannedBefore, nil)
changed, err = BanUsers(ContextWithThrottle(ctx, nil), db, []*models.User{ban, bannedBefore})
if err != nil || changed != 1 {
t.Fatalf("BanUsers = %d, %v", changed, err)
}
for _, id := range []uint{c.ID, d.ID} {
if row := reloadToken(t, id); row.RevokedAt == nil {
t.Fatalf("token %d survived ban", id)
}
}
// Unban and restore revive nothing.
if _, err := UnbanUsers(ctx, db, []*models.User{ban}); err != nil {
t.Fatal(err)
}
if _, err := RestoreUsers(ctx, db, []*models.User{fresh}); err != nil {
t.Fatal(err)
}
for _, id := range []uint{a.ID, c.ID} {
if row := reloadToken(t, id); row.RevokedAt == nil {
t.Fatalf("token %d revived", id)
}
}
})
}

View File

@@ -59,8 +59,10 @@ type DeletingEvent struct {
// DeactivatingEvent fires after a user is soft-deleted (deactivated, the
// PHP $user->delete()) by the admin deactivate action, inside the same
// transaction, so listeners can revoke what a deactivated account must not
// keep (OAuth grants, for example) while leaving what a restore needs. A
// listener error rolls the deactivation back.
// keep (OAuth grants, for example) while leaving what a restore needs. The
// user's session JWTs and personal API tokens are already revoked when it
// fires (DeactivateUsers calls RevokeUserAccess). A listener error rolls the
// deactivation back.
type DeactivatingEvent struct {
User *models.User
Tx *gorm.DB