fix: access-revoked event on ban and password changes, banned users lose their link, token-specific MCP revoke

- AccessRevokedEvent and FireAccessRevoked: fired in the write's transaction
  by the ban action (newly banned users only), the password reset, an
  administrator setting a password and the self-service password change;
  a revoked subject event per user after commit
- LinkedBackendUser answers no administrator for a banned user
- FireSubjectEvent / FireSubjectEventAfterCommit carry TokenID or ClientID;
  DELETE /tokens/{id} ends only that token's MCP sessions
- the MCP personal-token path stamps last_used_ip from towel.ClientIP
- BackendLinksFor replaces the duplicate lookups; requestUsable uses
  IsUsableAt
This commit is contained in:
Jakub Zych
2026-10-23 16:47:56 +02:00
parent 32f92591d6
commit 970719ce75
13 changed files with 242 additions and 63 deletions

View File

@@ -447,13 +447,21 @@ func ResetPassword(app *backpack.App) http.HandlerFunc {
// address (D-27). A reset that fails here records nothing. The
// session cutoff is truncated to whole seconds, as in
// RevokeUserAccess, because JWT iat has whole-second precision, so
// the login that follows the reset is not refused.
// the login that follows the reset is not refused. The same
// transaction fires the access-revoked event with the untruncated
// instant, so OAuth grants created before the reset are revoked
// with the password change or not at all.
now := time.Now()
if err := gdb.WithContext(r.Context()).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{
"password": hashed,
"tokens_valid_after": now.Truncate(time.Second),
"email_verified_at": now,
}).Error; err != nil {
if err := lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{
"password": hashed,
"tokens_valid_after": now.Truncate(time.Second),
"email_verified_at": now,
}).Error; err != nil {
return err
}
return classes.FireAccessRevoked(ctx, app, tx, []uint{user.ID}, now, classes.AccessRevokedPasswordReset)
}); err != nil {
writeOpaque500(w)
return
}
@@ -707,13 +715,22 @@ func ChangePassword(app *backpack.App) http.HandlerFunc {
writeOpaque500(w)
return
}
// The cutoff keeps the current session (issued at iat) and every
// token issued since its sign-in second; the access-revoked event
// revokes OAuth grants created before that second, in the same
// transaction.
cutoff := iat.Add(-time.Second)
if err := gdb.WithContext(r.Context()).Model(&user).Updates(map[string]any{
"password": hashed,
"must_change_password": false,
"has_self_set_password": true,
"tokens_valid_after": cutoff,
}).Error; err != nil {
if err := lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.WithContext(ctx).Model(&user).Updates(map[string]any{
"password": hashed,
"must_change_password": false,
"has_self_set_password": true,
"tokens_valid_after": cutoff,
}).Error; err != nil {
return err
}
return classes.FireAccessRevoked(ctx, app, tx, []uint{user.ID}, cutoff.Add(time.Second), classes.AccessRevokedPasswordChanged)
}); err != nil {
writeOpaque500(w)
return
}