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.
317 lines
11 KiB
Go
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
|
|
}
|