feat: device authorization by QR or short code
PHP's device flow on golem15_user_device_auth_sessions (PHP's columns,
the token stored as its SHA-256, plus jwt_jti): POST /devices/initiate
answers a 64-character token, an XXXX-XXXX short code, the configurable
auth_url (golem15.user.device_auth.auth_url) and the expiry; a signed-in
user authorizes it with POST /devices/authorize (short_code or token,
one guarded pending-to-confirmed UPDATE); the device's first poll of
GET /devices/status/{token} after that receives a session JWT through one
guarded confirmed-to-used UPDATE, so exactly one poll gets it. Used and
revoked sessions never turn into expired; each initiate deletes a bounded
batch of stale sessions.
This commit is contained in:
128
classes/device_auth.go
Normal file
128
classes/device_auth.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
)
|
||||
|
||||
// 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"
|
||||
}
|
||||
Reference in New Issue
Block a user