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