golem15.user.device_auth.auth_url gains a {short_code} placeholder and
defaults to {frontend_url}/device/scan/{short_code}; POST
/devices/authorize accepts the short code in its token field, so a scan
page that posts what its URL carries keeps working. The status poll hands
the session token over only to a client with the User-Agent the device
initiated with; another client sees the session confirmed and takes
nothing.
574 lines
20 KiB
Go
574 lines
20 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.DeviceAuthURLFor(app, token, *row.ShortCode),
|
|
"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":…}}. Only a poll with the
|
|
// User-Agent the device initiated with receives the token: another client
|
|
// that learned the device token answers {"status":"confirmed"} and takes
|
|
// nothing, so the device still collects its session.
|
|
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()) && sameDeviceClient(r, row) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// sameDeviceClient reports whether r comes with the User-Agent the device
|
|
// initiated the session with (none for none), the binding of the token
|
|
// hand-over to the initiating client.
|
|
func sameDeviceClient(r *http.Request, row models.DeviceAuthSession) bool {
|
|
stored := ""
|
|
if row.DeviceUserAgent != nil {
|
|
stored = *row.DeviceUserAgent
|
|
}
|
|
return r.UserAgent() == stored
|
|
}
|
|
|
|
// 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. A user who is gone, deactivated or
|
|
// banned, or whose tokens_valid_after (a password change, a revocation)
|
|
// is later than the authorization, gets nothing: the session becomes
|
|
// revoked, as a token minted at the authorization would be refused.
|
|
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] || authorizedBeforeCutoff(&user, row)
|
|
}
|
|
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
|
|
}
|
|
|
|
// authorizedBeforeCutoff reports whether row was confirmed before the
|
|
// user's tokens_valid_after, compared as the jwt guard compares a token's
|
|
// whole-second iat.
|
|
func authorizedBeforeCutoff(user *models.User, row models.DeviceAuthSession) bool {
|
|
if user.TokensValidAfter == nil || row.ConfirmedAt == nil {
|
|
return false
|
|
}
|
|
return row.ConfirmedAt.Truncate(time.Second).Before(*user.TokensValidAfter)
|
|
}
|
|
|
|
// 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 page). The token
|
|
// field also takes the short code, which the default auth_url carries in
|
|
// place of the device token. 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 {
|
|
raw := strField(fields, "token")
|
|
if code, isCode := classes.NormalizeDeviceShortCode(raw); isCode {
|
|
// The QR page of the default auth_url carries the short
|
|
// code; a 64-character device token never normalizes.
|
|
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 {
|
|
row, found, err = deviceByToken(r, gdb, raw)
|
|
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}
|
|
}
|