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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
@@ -407,3 +409,115 @@ func nullableString(s string) any {
|
||||
func mintWithJTI(app *backpack.App, secret string, user *models.User, issuer string) (string, string, error) {
|
||||
return bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, sessionTTL(app))
|
||||
}
|
||||
|
||||
// DeviceIndex is GET /devices: the caller's authorized devices (sessions
|
||||
// confirmed or used), most recent activity first, as {"success":true,
|
||||
// "devices":[{id, device_name, device_ip, authorized_at, last_activity,
|
||||
// is_current}]}. device_name is the stored name or one parsed from the user
|
||||
// agent (classes.DeviceDisplayName); is_current is true for the session
|
||||
// whose token is the one the request carries. Without a session it answers
|
||||
// 401 {"error":"Unauthorized"}.
|
||||
func DeviceIndex(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, secret, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, jti, ok := deviceCaller(w, r, app, gdb, secret)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var rows []models.DeviceAuthSession
|
||||
if err := gdb.WithContext(r.Context()).
|
||||
Where("user_id = ? AND status IN ?", user.ID, []string{models.DeviceStatusConfirmed, models.DeviceStatusUsed}).
|
||||
Order("last_activity_at DESC NULLS LAST, id DESC").
|
||||
Find(&rows).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
devices := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ua := ""
|
||||
if row.DeviceUserAgent != nil {
|
||||
ua = *row.DeviceUserAgent
|
||||
}
|
||||
devices = append(devices, map[string]any{
|
||||
"id": row.ID,
|
||||
"device_name": classes.DeviceDisplayName(row.DeviceName, ua),
|
||||
"device_ip": row.DeviceIP,
|
||||
"authorized_at": wireTimeOrNil(row.ConfirmedAt),
|
||||
"last_activity": wireTimeOrNil(row.LastActivityAt),
|
||||
"is_current": jti != "" && row.JWTJTI != nil && *row.JWTJTI == jti,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "devices": wire.Slice(devices)})
|
||||
}
|
||||
}
|
||||
|
||||
// DeviceDestroy is DELETE /devices/{id}: the caller revokes one of their
|
||||
// device sessions. In one transaction the row is marked revoked and the jti
|
||||
// of the session token the device holds (followed through refresh) is
|
||||
// added to the JWT blacklist for the token's whole refresh window, so the
|
||||
// device is signed out at once and cannot refresh. A blacklist failure
|
||||
// answers the opaque 500 and leaves the row as it was. Success answers
|
||||
// PHP's body with success true and the message Device revoked
|
||||
// successfully, also for a row already revoked; a row of another user, or
|
||||
// none, answers 404 with the error Device not found; no session answers
|
||||
// 401 {"error":"Unauthorized"}.
|
||||
func DeviceDestroy(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, secret, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _, ok := deviceCaller(w, r, app, gdb, secret)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
notFound := func() { writeJSON(w, http.StatusNotFound, map[string]any{"error": "Device not found"}) }
|
||||
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
notFound()
|
||||
return
|
||||
}
|
||||
var row models.DeviceAuthSession
|
||||
res := gdb.WithContext(r.Context()).Where("id = ? AND user_id = ?", id, user.ID).Limit(1).Find(&row)
|
||||
if res.Error != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
notFound()
|
||||
return
|
||||
}
|
||||
err = lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Exec(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW()
|
||||
WHERE id = ? AND user_id = ?`, row.ID, user.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if row.JWTJTI == nil || *row.JWTJTI == "" {
|
||||
return nil
|
||||
}
|
||||
bl, ok := app.Lookup[bouncer.BlacklistStore]()
|
||||
if !ok || bl == nil {
|
||||
return errors.New("golem15.user: no JWT blacklist store is published")
|
||||
}
|
||||
now := time.Now()
|
||||
return bl.Add(ctx, *row.JWTJTI, now.Add(refreshTTL(app)+sessionTTL(app)+time.Minute), now)
|
||||
})
|
||||
if err != nil {
|
||||
pluginLog.Error("golem15.user: revoke device session", "user_id", user.ID, "err", err)
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "Device revoked successfully"})
|
||||
}
|
||||
}
|
||||
|
||||
// wireTimeOrNil is t as a +00:00 timestamp, or nil.
|
||||
func wireTimeOrNil(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return wire.Time{Time: *t}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user