feat: device list and revoke with the jti followed through refresh
GET /devices lists the caller's confirmed and used device sessions (PHP's
fields, device_name from the stored name or the user agent, is_current
for the session the request's token belongs to). DELETE /devices/{id}
marks the session revoked and blacklists the device token's jti for its
whole refresh window in one transaction. POST /refresh hands a used
session's jwt_jti over to the refreshed token (classes.HandOverDeviceJTI),
so a device that refreshed before the revoke is signed out too.
This commit is contained in:
@@ -304,10 +304,36 @@ func Refresh(app *backpack.App) http.HandlerFunc {
|
||||
if err := classes.TouchLastSeen(r.Context(), gdb, userID); err != nil {
|
||||
pluginLog.Warn("golem15.user: stamp last_seen on refresh", "user_id", userID, "err", err)
|
||||
}
|
||||
// A device session follows its token through refresh (D-12b), so a
|
||||
// revoke blacklists the token the device holds now. Reading the old
|
||||
// jti unverified is safe: bouncer.Refresh has just verified it. With
|
||||
// golem15.user.jwt.blacklist_grace above 0 two concurrent refreshes of
|
||||
// one token can fork a device's lineage and only one fork is
|
||||
// followed; a password change still ends both (tokens_valid_after).
|
||||
// A failure is logged without any token or jti and never fails the
|
||||
// refresh.
|
||||
if oldJTI := unverifiedJTI(raw); oldJTI != "" {
|
||||
if _, _, _, newJTI, err := bouncer.VerifyClaims(next, secret); err == nil {
|
||||
if err := classes.HandOverDeviceJTI(r.Context(), gdb, oldJTI, newJTI); err != nil {
|
||||
pluginLog.Warn("golem15.user: hand a device session over on refresh", "user_id", userID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
writeSession(w, app, rotate, next, map[string]any{})
|
||||
}
|
||||
}
|
||||
|
||||
// unverifiedJTI returns the jti claim of a token without verifying it, ""
|
||||
// when there is none. Only call it on a token that was verified already.
|
||||
func unverifiedJTI(raw string) string {
|
||||
claims := jwt.MapClaims{}
|
||||
if _, _, err := jwt.NewParser().ParseUnverified(raw, claims); err != nil {
|
||||
return ""
|
||||
}
|
||||
jti, _ := claims["jti"].(string)
|
||||
return jti
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user