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. It // carries the short code, never the device token: the token is the secret // the device polls the session token with, and a QR code, a scan URL in a // browser history or a Referer must not hand it to an onlooker. const defaultDeviceAuthURL = "{frontend_url}/device/scan/{short_code}" // 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 DeviceAuthURLFor without a short code: {short_code} // is filled with the device token, as the template's only key. func DeviceAuthURL(app *backpack.App, token string) string { return DeviceAuthURLFor(app, token, "") } // DeviceAuthURLFor 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/{short_code}) with {frontend_url} replaced by // FrontendURL (golem15.user.frontend_url, else app.url, no trailing slash), // {short_code} by the session's short code and {token} by the device // token. POST /devices/authorize accepts the short code in its token field // too, so a page that posts what its URL carries as token works with // either template. An empty shortCode fills {short_code} with the token. func DeviceAuthURLFor(app *backpack.App, token, shortCode 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 } if shortCode == "" { shortCode = token } return strings.NewReplacer("{frontend_url}", FrontendURL(app), "{short_code}", shortCode, "{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 }