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
}