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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user