Files
sm-user-plugin/classes/admin_actions.go
Jakub Zych c3712dc0d4 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.
2026-10-22 03:11:32 +02:00

317 lines
11 KiB
Go

package classes
import (
"context"
"fmt"
"strconv"
"time"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gocloud.dev/blob"
"gorm.io/gorm"
)
// The functions in this file are the admin screen's user actions. Each takes
// the handle of the transaction the admin write runs in and never opens one
// of its own. None of them writes users_groups, except the cleanup of a user
// who is being deleted for good.
// ThrottleStates reads the ban and suspension state of userIDs from
// user_throttle in one query. A user is banned when any of their rows is
// banned, and suspended when a row is suspended and its suspended_at lies
// inside the suspension window (golem15.user.throttle.suspension_minutes,
// read from ctx as ContextWithThrottle stores it). It is a pure read: an
// expired suspension is not lifted here.
func ThrottleStates(ctx context.Context, db *gorm.DB, userIDs []uint) (banned, suspended map[uint]bool, err error) {
banned, suspended = map[uint]bool{}, map[uint]bool{}
if db == nil {
return nil, nil, fmt.Errorf("golem15.user: gorm handle is missing")
}
if len(userIDs) == 0 {
return banned, suspended, nil
}
minutes := throttleFrom(ctx).minutes
if minutes <= 0 {
minutes = 15
}
window := time.Duration(minutes) * time.Minute
var rows []models.Throttle
err = fresh(ctx, db).
Where("user_id IN ? AND (is_banned OR is_suspended)", userIDs).
Find(&rows).Error
if err != nil {
return nil, nil, err
}
now := time.Now()
for _, row := range rows {
if row.IsBanned {
banned[row.UserID] = true
}
if row.IsSuspended && row.SuspendedAt != nil && now.Before(row.SuspendedAt.Add(window)) {
suspended[row.UserID] = true
}
}
return banned, suspended, nil
}
// ActivateUsers activates the users that are not active yet and returns how
// many it changed. Like VerifyActivationCode it sets is_activated, stamps
// activated_at, clears the activation code and restores a deactivated user.
// Users that are already active are skipped.
func ActivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
if db == nil {
return 0, fmt.Errorf("golem15.user: gorm handle is missing")
}
changed := 0
for _, user := range users {
if user == nil || user.ID == 0 || user.IsActivated {
continue
}
now := time.Now()
err := fresh(ctx, db).Unscoped().Model(&models.User{}).Where("id = ?", user.ID).UpdateColumns(map[string]any{
"is_activated": true,
"activated_at": now,
"activation_code": nil,
"activation_code_issued_at": nil,
"deleted_at": nil,
}).Error
if err != nil {
return 0, err
}
user.IsActivated = true
user.ActivatedAt = &now
user.ActivationCode = nil
user.ActivationCodeIssuedAt = nil
user.DeletedAt = gorm.DeletedAt{}
changed++
}
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. 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")
}
changed := 0
for _, user := range users {
if user == nil || user.ID == 0 || user.DeletedAt.Valid {
continue
}
now := time.Now()
err := fresh(ctx, db).Unscoped().Model(&models.User{}).Where("id = ?", user.ID).UpdateColumn("deleted_at", now).Error
if err != nil {
return 0, err
}
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
}
// RestoreUsers brings deactivated users back, the PHP $user->restore(), and
// returns how many it changed.
func RestoreUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
if db == nil {
return 0, fmt.Errorf("golem15.user: gorm handle is missing")
}
changed := 0
for _, user := range users {
if user == nil || user.ID == 0 || !user.DeletedAt.Valid {
continue
}
err := fresh(ctx, db).Unscoped().Model(&models.User{}).Where("id = ?", user.ID).UpdateColumn("deleted_at", nil).Error
if err != nil {
return 0, err
}
user.DeletedAt = gorm.DeletedAt{}
changed++
}
return changed, nil
}
// 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). 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 {
return 0, err
}
banned, _, err := ThrottleStates(ctx, db, ids)
if err != nil {
return 0, err
}
changed := 0
for _, id := range ids {
if err := fresh(ctx, db).Model(&models.Throttle{}).Where("user_id = ?", id).UpdateColumn("is_banned", true).Error; err != nil {
return 0, err
}
var anyAddress int64
if err := fresh(ctx, db).Model(&models.Throttle{}).Where("user_id = ? AND ip_address IS NULL", id).Count(&anyAddress).Error; err != nil {
return 0, err
}
if anyAddress == 0 {
if err := fresh(ctx, db).Create(&models.Throttle{UserID: id, IsBanned: true}).Error; err != nil {
return 0, err
}
}
if !banned[id] {
changed++
}
}
if err := RevokeUserAccess(ctx, db, ids); err != nil {
return 0, err
}
return changed, nil
}
// UnbanUsers clears the ban on every user_throttle row of the users and
// returns how many were banned before.
func UnbanUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
ids, err := userIDs(db, users)
if err != nil {
return 0, err
}
banned, _, err := ThrottleStates(ctx, db, ids)
if err != nil {
return 0, err
}
changed := 0
for _, id := range ids {
if !banned[id] {
continue
}
if err := fresh(ctx, db).Model(&models.Throttle{}).Where("user_id = ?", id).UpdateColumn("is_banned", false).Error; err != nil {
return 0, err
}
changed++
}
return changed, nil
}
// UnsuspendUser lifts a suspension: it clears is_suspended, suspended_at and
// the attempt counter on every user_throttle row of the user.
func UnsuspendUser(ctx context.Context, db *gorm.DB, userID uint) error {
if db == nil {
return fmt.Errorf("golem15.user: gorm handle is missing")
}
return fresh(ctx, db).Model(&models.Throttle{}).Where("user_id = ?", userID).UpdateColumns(map[string]any{
"is_suspended": false,
"suspended_at": nil,
"attempts": 0,
}).Error
}
// ForceDeleteCleanup removes what hangs off a user who is being deleted for
// good: the user_throttle rows (their foreign key has no ON DELETE), the
// users_groups rows and the attachments. It runs inside the delete's
// transaction and does not delete the user row; the caller does that with
// the same handle. The attachments' blobs are removed only after the
// transaction commits, so a rollback leaves the files in place. bucket may be
// nil: the rows are still deleted.
func ForceDeleteCleanup(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, user *models.User) error {
if db == nil {
return fmt.Errorf("golem15.user: gorm handle is missing")
}
if user == nil || user.ID == 0 {
return fmt.Errorf("golem15.user: cannot clean up a user without an id")
}
if err := fresh(ctx, db).Exec(`DELETE FROM user_throttle WHERE user_id = ?`, user.ID).Error; err != nil {
return err
}
if err := fresh(ctx, db).Exec(`DELETE FROM users_groups WHERE user_id = ?`, user.ID).Error; err != nil {
return err
}
var keys []string
err := attach.DeleteForOwner(fresh(ctx, db), user, strconv.FormatUint(uint64(user.ID), 10), func(blobKeys []string) error {
keys = append(keys, blobKeys...)
return nil
})
if err != nil {
return err
}
if bucket == nil || len(keys) == 0 {
return nil
}
userID := user.ID
lagoon.AfterCommit(ctx, db, func(ctx context.Context, _ *gorm.DB) {
// The rows are gone by now, so a blob that cannot be removed is
// orphaned, not lost data: log it and carry on.
if err := attach.DeleteKeys(ctx, bucket, keys); err != nil {
pluginLog.Warn("golem15.user: remove attachment blobs of a deleted user", "user_id", userID, "err", err)
}
})
return nil
}
// fresh returns db with an empty statement on ctx, so a query never continues
// from conditions the caller's handle already carries. A NewDB session is
// lazy: GORM builds the empty statement on the next chain method, and a
// WithContext chained directly onto it clones the caller's statement with its
// conditions instead. Clauses() makes the empty statement exist first, so the
// handle stays clean whatever is chained next.
func fresh(ctx context.Context, db *gorm.DB) *gorm.DB {
return db.Session(&gorm.Session{NewDB: true, Context: ctx}).Clauses().Session(&gorm.Session{NewDB: true})
}
// userIDs returns the ids of users, skipping nil and unsaved entries.
func userIDs(db *gorm.DB, users []*models.User) ([]uint, error) {
if db == nil {
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
}
ids := make([]uint, 0, len(users))
for _, user := range users {
if user != nil && user.ID != 0 {
ids = append(ids, user.ID)
}
}
return ids, nil
}