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 }