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

@@ -29,6 +29,7 @@ import (
"git.golem15.com/golem15/summercms/modules/postcard"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/wire"
"github.com/golang-jwt/jwt/v5"
"gocloud.dev/blob"
"gorm.io/gorm"
)
@@ -174,7 +175,9 @@ func Fetch(app *backpack.App) http.HandlerFunc {
}
}
// Refresh slides a still-refreshable bearer token.
// Refresh slides a still-refreshable bearer token of a user who may still
// sign in: deactivated and banned users and tokens issued before the user's
// tokens_valid_after are refused with the token-failure 401 body.
func Refresh(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
@@ -198,19 +201,85 @@ func Refresh(app *backpack.App) http.HandlerFunc {
})
return
}
// The refresh never loads the user row: the subject comes from the
// new token. A failed last_seen write is logged and never fails it.
if sub, _, _, _, err := bouncer.VerifyClaims(next, secret); err == nil {
if id, err := strconv.ParseUint(sub, 10, 64); err == nil && id != 0 {
if err := classes.TouchLastSeen(r.Context(), gdb, uint(id)); err != nil {
pluginLog.Warn("golem15.user: stamp last_seen on refresh", "user_id", id, "err", err)
}
}
// bouncer.Refresh checked the token only (signature, audience,
// refresh window, blacklist). The subject check loads the user: a
// missing or deactivated user (who signs in again, which restores the
// account), a token issued before tokens_valid_after, and a banned
// user are refused with the same 401 body. Suspension is not
// checked: it is the failed-password throttle, and refusing refresh
// on it would let anyone who knows an email log its owner out.
userID, refused, err := refreshSubjectRefused(r.Context(), app, gdb, raw)
if err != nil {
writeOpaque500(w)
return
}
if refused {
writeJSON(w, http.StatusUnauthorized, map[string]any{
"error": "Could not refresh token",
"msg": bouncer.ErrSubjectRejected.Error(),
})
return
}
// A failed last_seen write is logged and never fails the refresh.
if err := classes.TouchLastSeen(r.Context(), gdb, userID); err != nil {
pluginLog.Warn("golem15.user: stamp last_seen on refresh", "user_id", userID, "err", err)
}
writeJSON(w, http.StatusOK, map[string]any{"token": next})
}
}
// refreshSubjectRefused reads the subject and iat of a token bouncer.Refresh
// has already verified and reports whether its user may not slide the
// session: not found by a scoped load (missing or deactivated), issued
// before the user's tokens_valid_after, or banned. Only a database failure
// is an error.
func refreshSubjectRefused(ctx context.Context, app *backpack.App, gdb *gorm.DB, raw string) (uint, bool, error) {
claims := jwt.MapClaims{}
if _, _, err := jwt.NewParser().ParseUnverified(raw, claims); err != nil {
return 0, true, nil
}
var id uint64
switch v := claims["sub"].(type) {
case string:
n, err := strconv.ParseUint(strings.TrimSpace(v), 10, 64)
if err != nil {
return 0, true, nil
}
id = n
case float64:
if v <= 0 || v != float64(uint64(v)) {
return 0, true, nil
}
id = uint64(v)
}
if id == 0 {
return 0, true, nil
}
iat, err := claims.GetIssuedAt()
if err != nil || iat == nil {
return 0, true, nil
}
var user models.User
result := gdb.WithContext(ctx).Where("id = ?", id).Limit(1).Find(&user)
if result.Error != nil {
return 0, false, result.Error
}
if result.RowsAffected == 0 {
return 0, true, nil
}
if user.TokensValidAfter != nil && iat.Time.Before(*user.TokensValidAfter) {
return 0, true, nil
}
banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, app), gdb.WithContext(ctx), []uint{user.ID})
if err != nil {
return 0, false, err
}
if banned[user.ID] {
return 0, true, nil
}
return user.ID, false, nil
}
// OAuthProviders lists the social sign-in providers a social-login plugin
// has enabled, as {success: true, providers: [{name, label}]}. The plugin
// publishes a classes.OAuthProviderSource, looked up on every request