Files
sm-user-plugin/controllers/devices.go
Jakub Zych cc3636cea9 fix: a device revoke cannot lose to a concurrent refresh
The revoke now reads the device's jti with UPDATE ... RETURNING under the
row lock, and the refresh's hand-over (classes.FollowDeviceJTI) reports a
session revoked meanwhile, so the refresh answers the blacklisted-token
401 without a token. A hand-over error fails the refresh closed instead
of leaving the session on a stale jti.
2026-10-26 16:00:43 +01:00

533 lines
18 KiB
Go

package controllers
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"git.golem15.com/golem15/sm-user-plugin/classes"
"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"
"gorm.io/gorm"
)
// deviceTable is the device authorization session table.
const deviceTable = "golem15_user_device_auth_sessions"
// deviceInsertAttempts bounds the retries of an initiate whose fresh short
// code (or token) collided with a stored one.
const deviceInsertAttempts = 5
// deviceCleanupBatch bounds the stale sessions one initiate deletes.
const deviceCleanupBatch = 500
// writeDeviceUnauthorized is the PHP device controller's 401 body, which
// differs from the session routes' {"error":true,"message":"Unauthorized"}.
func writeDeviceUnauthorized(w http.ResponseWriter) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "Unauthorized"})
}
// deviceCaller resolves the signed-in user of a device route that needs one
// (authorize, list, revoke). It reads and checks the session token as the
// plugin's jwt guard does (bearer header, then the session cookies; the
// blacklist and tokens_valid_after apply) and also returns the token's jti.
// Anything else answers PHP's 401 {"error":"Unauthorized"}.
func deviceCaller(w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, secret string) (*models.User, string, bool) {
principal, err := sessionGuard(app, secret).Authenticate(r)
if err != nil || principal == nil || principal.ID == 0 {
writeDeviceUnauthorized(w)
return nil, "", false
}
var user models.User
if err := gdb.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error; err != nil {
writeDeviceUnauthorized(w)
return nil, "", false
}
jti := ""
if raw := classes.SessionToken(r); raw != "" {
if _, _, _, id, err := bouncer.VerifyClaims(raw, secret); err == nil {
jti = id
}
}
return &user, jti, true
}
// deviceClientIP is the client address device rows record: surf.ClientIP
// with http.trusted_proxies, as /login and /register use.
func deviceClientIP(app *backpack.App, r *http.Request) string {
return surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))
}
// DeviceInitiate is POST /devices/initiate, called without a session by the
// device that wants to sign in. It stores a pending session (the SHA-256 of
// a 64-character token, a short code XXXX-XXXX, expires_at now plus
// golem15.user.device_auth.ttl_minutes, the device's address, user agent and
// optional device_name of at most 255 characters) and answers
// {"success":true,"data":{"token","short_code","auth_url","expires_at"}}.
// Each call first deletes at most 500 pending or expired sessions that
// expired more than an hour ago.
func DeviceInitiate(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, _, ok := sessionDeps(w, app)
if !ok {
return
}
fields, err := readFields(r)
if err != nil {
writeOpaque500(w)
return
}
name := strField(fields, "device_name")
if utf8.RuneCountInString(name) > 255 {
writeValidation(w, map[string][]string{"device_name": {"The device name may not be greater than 255 characters."}})
return
}
ctx := r.Context()
// Stale sessions go in bounded batches, so the unauthenticated route
// cannot grow the table without limit (a failure is only logged).
if err := gdb.WithContext(ctx).Exec(`DELETE FROM `+deviceTable+` WHERE id IN (
SELECT id FROM `+deviceTable+`
WHERE status IN ('pending', 'expired') AND expires_at < NOW() - INTERVAL '1 hour'
LIMIT ?)`, deviceCleanupBatch).Error; err != nil {
pluginLog.Warn("golem15.user: device session cleanup", "err", err)
}
ip := deviceClientIP(app, r)
ua := r.UserAgent()
expires := time.Now().Add(classes.DeviceAuthTTL(app)).Truncate(time.Second)
var (
token string
row models.DeviceAuthSession
)
for attempt := 0; ; attempt++ {
token, err = classes.NewDeviceToken()
if err != nil {
writeOpaque500(w)
return
}
code, err := classes.NewDeviceShortCode()
if err != nil {
writeOpaque500(w)
return
}
row = models.DeviceAuthSession{
TokenHash: classes.HashAPIToken(token),
ShortCode: &code,
Status: models.DeviceStatusPending,
ExpiresAt: &expires,
}
if ip != "" {
row.DeviceIP = &ip
}
if ua != "" {
row.DeviceUserAgent = &ua
}
if name != "" {
row.DeviceName = &name
}
err = gdb.WithContext(ctx).Create(&row).Error
if err == nil {
break
}
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "23505" || attempt+1 >= deviceInsertAttempts {
pluginLog.Error("golem15.user: store device session", "err", err)
writeOpaque500(w)
return
}
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"data": map[string]any{
"token": token,
"short_code": *row.ShortCode,
"auth_url": classes.DeviceAuthURL(app, token),
"expires_at": wire.Time{Time: expires},
},
})
}
}
// DeviceStatus is GET /devices/status/{token}, polled by the waiting
// device. It answers 404 {"error":"Session not found"} for an unknown token
// and PHP's flat {"success":true,"status":"expired","message":…} for a
// pending or confirmed session past expires_at (which it marks expired).
// The first poll after a signed-in user authorized the session receives a
// session token: {"success":true,"data":{"status":"confirmed","user_id",
// "message":"Device authorized - complete login","token","user"}}. One
// guarded confirmed-to-used UPDATE decides which poll that is, so of
// concurrent polls exactly one gets the token; the others, and every later
// poll, answer {"success":true,"data":{"status":"used"}}. Other statuses
// answer {"success":true,"data":{"status":…}}.
func DeviceStatus(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
if !ok {
return
}
row, found, err := deviceByToken(r, gdb, r.PathValue("token"))
if err != nil {
writeOpaque500(w)
return
}
if !found {
writeJSON(w, http.StatusNotFound, map[string]any{"error": "Session not found"})
return
}
if row.Status == models.DeviceStatusConfirmed && row.UserID != nil && !row.ExpiredAt(time.Now()) {
if handOverDevice(w, r, app, gdb, secret, row) {
return
}
// Another poll won the hand-over or the session just expired:
// answer what the row says now, never a token.
row, found, err = deviceByID(r, gdb, row.ID)
if err != nil || !found {
writeOpaque500(w)
return
}
}
writeDeviceStatus(w, r, gdb, row)
}
}
// deviceByToken loads the session whose token hash matches token.
func deviceByToken(r *http.Request, gdb *gorm.DB, token string) (models.DeviceAuthSession, bool, error) {
var row models.DeviceAuthSession
if token == "" {
return row, false, nil
}
res := gdb.WithContext(r.Context()).Where("token = ?", classes.HashAPIToken(token)).Limit(1).Find(&row)
return row, res.RowsAffected == 1, res.Error
}
func deviceByID(r *http.Request, gdb *gorm.DB, id uint) (models.DeviceAuthSession, bool, error) {
var row models.DeviceAuthSession
res := gdb.WithContext(r.Context()).Where("id = ?", id).Limit(1).Find(&row)
return row, res.RowsAffected == 1, res.Error
}
// writeDeviceStatus answers a status poll that hands over no token. A
// pending or confirmed session past expires_at is marked expired (used and
// revoked sessions never are); an expired session answers PHP's flat
// expired body.
func writeDeviceStatus(w http.ResponseWriter, r *http.Request, gdb *gorm.DB, row models.DeviceAuthSession) {
expire := row.ExpiredAt(time.Now())
if expire || row.Status == models.DeviceStatusExpired {
if expire {
if err := gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+` SET status = 'expired', updated_at = NOW()
WHERE id = ? AND status IN ('pending', 'confirmed')`, row.ID).Error; err != nil {
writeOpaque500(w)
return
}
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"status": models.DeviceStatusExpired,
"message": "Authorization session has expired",
})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "data": map[string]any{"status": row.Status}})
}
// handOverDevice gives the confirmed session's user a session token for the
// device (D-12a). It reports false, having written nothing, when another
// poll already took the session or it expired meanwhile; the token it
// minted is then dropped unsent.
func handOverDevice(w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, secret string, row models.DeviceAuthSession) bool {
ctx := r.Context()
var user models.User
res := gdb.WithContext(ctx).Where("id = ?", *row.UserID).Limit(1).Find(&user)
if res.Error != nil {
writeOpaque500(w)
return true
}
refused := res.RowsAffected == 0
if !refused {
banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, app), gdb.WithContext(ctx), []uint{user.ID})
if err != nil {
writeOpaque500(w)
return true
}
refused = banned[user.ID]
}
if refused {
// A deactivated, deleted or banned user signs no device in.
if err := gdb.WithContext(ctx).Exec(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW()
WHERE id = ? AND status = 'confirmed'`, row.ID).Error; err != nil {
writeOpaque500(w)
return true
}
return false
}
token, jti, err := mintWithJTI(app, secret, &user, requestURL(r))
if err != nil {
writeOpaque500(w)
return true
}
payload, err := apiArray(ctx, app, &user)
if err != nil {
writeOpaque500(w)
return true
}
res = gdb.WithContext(ctx).Exec(`UPDATE `+deviceTable+`
SET status = 'used', used_at = NOW(), last_activity_at = NOW(), jwt_jti = ?, updated_at = NOW()
WHERE id = ? AND status = 'confirmed' AND expires_at > NOW()`, jti, row.ID)
if res.Error != nil {
writeOpaque500(w)
return true
}
if res.RowsAffected != 1 {
return false
}
if err := FinishLogin(ctx, app, gdb, &user, deviceClientIP(app, r)); err != nil {
pluginLog.Warn("golem15.user: finish device login", "user_id", user.ID, "err", err)
}
if err := FireLoginEvent(ctx, app, &user, classes.LoginSourceDevice); err != nil {
pluginLog.Warn("golem15.user: login event listener", "user_id", user.ID, "err", err)
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"data": map[string]any{
"status": models.DeviceStatusConfirmed,
"user_id": user.ID,
"message": "Device authorized - complete login",
"token": token,
"user": payload,
},
})
return true
}
// DeviceAuthorize is POST /devices/authorize: a signed-in user (a valid,
// not blacklisted session token; no second-factor check, D-12c) binds a
// pending device session to themselves by its short_code (hyphens and
// spaces optional, any case) or by its token (the QR code). A short_code of
// other than 8 characters after removing hyphens answers 422 {"error":
// "Please enter a valid 8-character code"}; an unknown, expired, used,
// revoked or foreign session answers 400 {"error":"Invalid or expired
// code"}; success, and repeating it for a session the caller already
// confirmed, answers {"success":true,"message":"Device authorized
// successfully"}. Without a session it answers 401 {"error":"Unauthorized"}.
// The fields are read from the body only, never the query string.
func DeviceAuthorize(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
}
fields, err := readBodyFields(r)
if err != nil {
writeOpaque500(w)
return
}
invalid := func() {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Invalid or expired code"})
}
var (
row models.DeviceAuthSession
found bool
)
if _, has := fields["short_code"]; has {
raw := strings.ReplaceAll(strField(fields, "short_code"), "-", "")
if len(raw) != 8 {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "Please enter a valid 8-character code"})
return
}
code, valid := classes.NormalizeDeviceShortCode(raw)
if !valid {
invalid()
return
}
res := gdb.WithContext(r.Context()).Where("short_code = ?", code).Limit(1).Find(&row)
if res.Error != nil {
writeOpaque500(w)
return
}
found = res.RowsAffected == 1
} else if _, has := fields["token"]; has {
row, found, err = deviceByToken(r, gdb, strField(fields, "token"))
if err != nil {
writeOpaque500(w)
return
}
}
if !found {
invalid()
return
}
res := gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+`
SET status = 'confirmed', user_id = ?, confirmed_at = NOW(), auth_ip = ?, updated_at = NOW()
WHERE id = ? AND status = 'pending' AND (user_id IS NULL OR user_id = ?) AND expires_at > NOW()`,
user.ID, nullableString(deviceClientIP(app, r)), row.ID, user.ID)
if res.Error != nil {
writeOpaque500(w)
return
}
if res.RowsAffected != 1 {
now, ok, err := deviceByID(r, gdb, row.ID)
if err != nil || !ok {
writeOpaque500(w)
return
}
again := now.Status == models.DeviceStatusConfirmed && now.UserID != nil && *now.UserID == user.ID && !now.ExpiredAt(time.Now())
if !again {
if now.ExpiredAt(time.Now()) {
_ = gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+` SET status = 'expired', updated_at = NOW()
WHERE id = ? AND status IN ('pending', 'confirmed')`, now.ID).Error
}
invalid()
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "Device authorized successfully"})
}
}
// nullableString is nil for "", else s.
func nullableString(s string) any {
if s == "" {
return nil
}
return s
}
// mintWithJTI mints the session JWT as mintFor does and also returns its
// jti, which device sessions store.
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. The jti is the one the
// revoking UPDATE returns, read under the row lock a concurrent refresh's
// hand-over also takes (classes.FollowDeviceJTI), so a refresh that moved
// the session first is blacklisted and one that comes after is refused. 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
}
found := false
err = lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error {
// The jti current at the revoke, under the row lock: never one
// read before a concurrent refresh moved the session on.
var revoked []struct {
JWTJTI *string `gorm:"column:jwt_jti"`
}
if err := tx.WithContext(ctx).Raw(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW()
WHERE id = ? AND user_id = ?
RETURNING jwt_jti`, id, user.ID).Scan(&revoked).Error; err != nil {
return err
}
if len(revoked) == 0 {
return nil
}
found = true
jti := revoked[0].JWTJTI
if jti == nil || *jti == "" {
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, *jti, 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
}
if !found {
notFound()
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}
}