Files
sm-user-plugin/classes/device_auth.go
Jakub Zych 3b22134292 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.
2026-10-26 10:19:25 +01:00

145 lines
4.6 KiB
Go

package classes
import (
"context"
"crypto/rand"
"encoding/base64"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"gorm.io/gorm"
)
// DeviceShortCodeAlphabet is the alphabet of device short codes (PHP's
// recovery-code alphabet: no 0, 1, I, L or O).
const DeviceShortCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
// defaultDeviceAuthURL is golem15.user.device_auth.auth_url when unset.
const defaultDeviceAuthURL = "{frontend_url}/device/scan/{token}"
// defaultDeviceAuthTTL is golem15.user.device_auth.ttl_minutes when unset.
const defaultDeviceAuthTTL = 5 * time.Minute
// NewDeviceToken returns a fresh device token: 48 bytes from crypto/rand as
// unpadded base64url, 64 characters. Store only its HashAPIToken.
func NewDeviceToken() (string, error) {
var b [48]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
// NewDeviceShortCode returns a fresh short code XXXX-XXXX, eight characters
// drawn uniformly from DeviceShortCodeAlphabet with crypto/rand (rejection
// sampling, no modulo bias).
func NewDeviceShortCode() (string, error) {
n := len(DeviceShortCodeAlphabet)
limit := 256 - 256%n // largest multiple of n that fits a byte
out := make([]byte, 0, 9)
var buf [16]byte
for len(out) < 9 {
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
for _, c := range buf {
if int(c) >= limit {
continue
}
if len(out) == 4 {
out = append(out, '-')
}
out = append(out, DeviceShortCodeAlphabet[int(c)%n])
if len(out) == 9 {
break
}
}
}
return string(out), nil
}
// NormalizeDeviceShortCode turns what a user typed into the stored form
// XXXX-XXXX: upper case, spaces and hyphens removed, exactly eight
// characters of DeviceShortCodeAlphabet. It reports false otherwise.
func NormalizeDeviceShortCode(s string) (string, bool) {
s = strings.ToUpper(s)
s = strings.NewReplacer(" ", "", "-", "").Replace(s)
if len(s) != 8 {
return "", false
}
for i := 0; i < len(s); i++ {
if strings.IndexByte(DeviceShortCodeAlphabet, s[i]) < 0 {
return "", false
}
}
return s[:4] + "-" + s[4:], true
}
// DeviceAuthTTL is how long a device authorization session can be
// authorized and handed over: golem15.user.device_auth.ttl_minutes, default
// 5.
func DeviceAuthTTL(app *backpack.App) time.Duration {
if app != nil && app.Config != nil {
if mins := app.Config.Int("golem15.user.device_auth.ttl_minutes"); mins > 0 {
return time.Duration(mins) * time.Minute
}
}
return defaultDeviceAuthTTL
}
// DeviceAuthURL is the auth_url a new device shows as its QR code: the
// template golem15.user.device_auth.auth_url (default
// {frontend_url}/device/scan/{token}) with {frontend_url} replaced by
// FrontendURL (golem15.user.frontend_url, else app.url, no trailing slash)
// and {token} by the device token.
func DeviceAuthURL(app *backpack.App, token string) string {
tpl := ""
if app != nil && app.Config != nil {
tpl = strings.TrimSpace(app.Config.String("golem15.user.device_auth.auth_url"))
}
if tpl == "" {
tpl = defaultDeviceAuthURL
}
return strings.NewReplacer("{frontend_url}", FrontendURL(app), "{token}", token).Replace(tpl)
}
// DeviceDisplayName is the name a device list shows (PHP's device_name
// accessor): the stored name, else a name parsed from the user agent
// (iPhone, iPad, Android Device, Windows PC, Mac, Linux PC), else Unknown
// Device.
func DeviceDisplayName(stored *string, userAgent string) string {
if stored != nil && *stored != "" {
return *stored
}
switch {
case strings.Contains(userAgent, "iPhone"):
return "iPhone"
case strings.Contains(userAgent, "iPad"):
return "iPad"
case strings.Contains(userAgent, "Android"):
return "Android Device"
case strings.Contains(userAgent, "Windows"):
return "Windows PC"
case strings.Contains(userAgent, "Macintosh"):
return "Mac"
case strings.Contains(userAgent, "Linux"):
return "Linux PC"
}
return "Unknown Device"
}
// HandOverDeviceJTI moves a used device session from the jti of a session
// token to the jti of the token that refreshed it, and stamps its
// last_activity_at, so revoking the device still reaches the token the
// device holds now. POST /refresh calls it after a successful refresh;
// empty values do nothing.
func HandOverDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) error {
if db == nil || oldJTI == "" || newJTI == "" || oldJTI == newJTI {
return nil
}
return db.WithContext(ctx).Exec(`UPDATE golem15_user_device_auth_sessions
SET jwt_jti = ?, last_activity_at = NOW(), updated_at = NOW()
WHERE jwt_jti = ? AND status = 'used'`, newJTI, oldJTI).Error
}