The device hand-over now refuses a session authorized before the user's tokens_valid_after, and every AccessRevokedEvent revokes the user's pending and confirmed device sessions inside the write's transaction (classes.RevokeDeviceSessions).
187 lines
6.6 KiB
Go
187 lines
6.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. It is FollowDeviceJTI without the revoked report;
|
|
// empty values do nothing.
|
|
func HandOverDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) error {
|
|
_, err := FollowDeviceJTI(ctx, db, oldJTI, newJTI)
|
|
return err
|
|
}
|
|
|
|
// RevokeDeviceSessions marks the pending and confirmed device sessions of
|
|
// userIDs revoked, so none of them hands a session token over any more.
|
|
// Used sessions keep their status: their tokens end through
|
|
// tokens_valid_after or a revoke. The plugin calls it on db, the write's
|
|
// transaction, for every AccessRevokedEvent (a ban or a password change).
|
|
func RevokeDeviceSessions(ctx context.Context, db *gorm.DB, userIDs ...uint) error {
|
|
if db == nil || len(userIDs) == 0 {
|
|
return nil
|
|
}
|
|
return db.WithContext(ctx).Exec(`UPDATE golem15_user_device_auth_sessions
|
|
SET status = 'revoked', updated_at = NOW()
|
|
WHERE user_id IN ? AND status IN ('pending', 'confirmed')`, userIDs).Error
|
|
}
|
|
|
|
// FollowDeviceJTI moves a used device session from oldJTI, the jti of the
|
|
// session token a refresh presented, to newJTI, the jti of the token it
|
|
// minted, and stamps last_activity_at. It reports revoked when no used
|
|
// session moved but a session with oldJTI exists in another status: the
|
|
// device was revoked after the refresh passed the blacklist check, so the
|
|
// caller must refuse the refresh and drop the new token. The move is one
|
|
// guarded UPDATE that takes the session's row lock, as the revoke's
|
|
// UPDATE … RETURNING jwt_jti does (controllers.DeviceDestroy): a revoke
|
|
// that runs after the move blacklists newJTI, and a move that runs after
|
|
// the revoke matches nothing and reports revoked. POST /refresh calls it
|
|
// and fails closed on an error. Empty or equal values do nothing.
|
|
func FollowDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) (revoked bool, err error) {
|
|
if db == nil || oldJTI == "" || newJTI == "" || oldJTI == newJTI {
|
|
return false, nil
|
|
}
|
|
res := 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)
|
|
if res.Error != nil {
|
|
return false, res.Error
|
|
}
|
|
if res.RowsAffected > 0 {
|
|
return false, nil
|
|
}
|
|
var stale int64
|
|
if err := db.WithContext(ctx).Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions
|
|
WHERE jwt_jti = ? AND status <> 'used'`, oldJTI).Scan(&stale).Error; err != nil {
|
|
return false, err
|
|
}
|
|
return stale > 0, nil
|
|
}
|