package controllers import ( "context" "errors" "net/http" "strconv" "strings" "time" "unicode/utf8" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/golem15/summercms/modules/wire" "github.com/jackc/pgx/v5/pgconn" "gorm.io/gorm" ) // deviceTable is the device authorization session table. const deviceTable = "golem15_user_device_auth_sessions" // deviceInsertAttempts bounds the retries of an initiate whose fresh short // code (or token) collided with a stored one. const deviceInsertAttempts = 5 // deviceCleanupBatch bounds the stale sessions one initiate deletes. const deviceCleanupBatch = 500 // writeDeviceUnauthorized is the PHP device controller's 401 body, which // differs from the session routes' {"error":true,"message":"Unauthorized"}. func writeDeviceUnauthorized(w http.ResponseWriter) { writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "Unauthorized"}) } // deviceCaller resolves the signed-in user of a device route that needs one // (authorize, list, revoke). It reads and checks the session token as the // plugin's jwt guard does (bearer header, then the session cookies; the // blacklist and tokens_valid_after apply) and also returns the token's jti. // Anything else answers PHP's 401 {"error":"Unauthorized"}. func deviceCaller(w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, secret string) (*models.User, string, bool) { principal, err := sessionGuard(app, secret).Authenticate(r) if err != nil || principal == nil || principal.ID == 0 { writeDeviceUnauthorized(w) return nil, "", false } var user models.User if err := gdb.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error; err != nil { writeDeviceUnauthorized(w) return nil, "", false } jti := "" if raw := classes.SessionToken(r); raw != "" { if _, _, _, id, err := bouncer.VerifyClaims(raw, secret); err == nil { jti = id } } return &user, jti, true } // deviceClientIP is the client address device rows record: surf.ClientIP // with http.trusted_proxies, as /login and /register use. func deviceClientIP(app *backpack.App, r *http.Request) string { return surf.ClientIP(r, surf.TrustedProxies(appConfig(app))) } // DeviceInitiate is POST /devices/initiate, called without a session by the // device that wants to sign in. It stores a pending session (the SHA-256 of // a 64-character token, a short code XXXX-XXXX, expires_at now plus // golem15.user.device_auth.ttl_minutes, the device's address, user agent and // optional device_name of at most 255 characters) and answers // {"success":true,"data":{"token","short_code","auth_url","expires_at"}}. // Each call first deletes at most 500 pending or expired sessions that // expired more than an hour ago. func DeviceInitiate(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gdb, _, ok := sessionDeps(w, app) if !ok { return } fields, err := readFields(r) if err != nil { writeOpaque500(w) return } name := strField(fields, "device_name") if utf8.RuneCountInString(name) > 255 { writeValidation(w, map[string][]string{"device_name": {"The device name may not be greater than 255 characters."}}) return } ctx := r.Context() // Stale sessions go in bounded batches, so the unauthenticated route // cannot grow the table without limit (a failure is only logged). if err := gdb.WithContext(ctx).Exec(`DELETE FROM `+deviceTable+` WHERE id IN ( SELECT id FROM `+deviceTable+` WHERE status IN ('pending', 'expired') AND expires_at < NOW() - INTERVAL '1 hour' LIMIT ?)`, deviceCleanupBatch).Error; err != nil { pluginLog.Warn("golem15.user: device session cleanup", "err", err) } ip := deviceClientIP(app, r) ua := r.UserAgent() expires := time.Now().Add(classes.DeviceAuthTTL(app)).Truncate(time.Second) var ( token string row models.DeviceAuthSession ) for attempt := 0; ; attempt++ { token, err = classes.NewDeviceToken() if err != nil { writeOpaque500(w) return } code, err := classes.NewDeviceShortCode() if err != nil { writeOpaque500(w) return } row = models.DeviceAuthSession{ TokenHash: classes.HashAPIToken(token), ShortCode: &code, Status: models.DeviceStatusPending, ExpiresAt: &expires, } if ip != "" { row.DeviceIP = &ip } if ua != "" { row.DeviceUserAgent = &ua } if name != "" { row.DeviceName = &name } err = gdb.WithContext(ctx).Create(&row).Error if err == nil { break } var pgErr *pgconn.PgError if !errors.As(err, &pgErr) || pgErr.Code != "23505" || attempt+1 >= deviceInsertAttempts { pluginLog.Error("golem15.user: store device session", "err", err) writeOpaque500(w) return } } writeJSON(w, http.StatusOK, map[string]any{ "success": true, "data": map[string]any{ "token": token, "short_code": *row.ShortCode, "auth_url": classes.DeviceAuthURLFor(app, token, *row.ShortCode), "expires_at": wire.Time{Time: expires}, }, }) } } // DeviceStatus is GET /devices/status/{token}, polled by the waiting // device. It answers 404 {"error":"Session not found"} for an unknown token // and PHP's flat {"success":true,"status":"expired","message":…} for a // pending or confirmed session past expires_at (which it marks expired). // The first poll after a signed-in user authorized the session receives a // session token: {"success":true,"data":{"status":"confirmed","user_id", // "message":"Device authorized - complete login","token","user"}}. One // guarded confirmed-to-used UPDATE decides which poll that is, so of // concurrent polls exactly one gets the token; the others, and every later // poll, answer {"success":true,"data":{"status":"used"}}. Other statuses // answer {"success":true,"data":{"status":…}}. Only a poll with the // User-Agent the device initiated with receives the token: another client // that learned the device token answers {"status":"confirmed"} and takes // nothing, so the device still collects its session. func DeviceStatus(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gdb, secret, ok := sessionDeps(w, app) if !ok { return } row, found, err := deviceByToken(r, gdb, r.PathValue("token")) if err != nil { writeOpaque500(w) return } if !found { writeJSON(w, http.StatusNotFound, map[string]any{"error": "Session not found"}) return } if row.Status == models.DeviceStatusConfirmed && row.UserID != nil && !row.ExpiredAt(time.Now()) && sameDeviceClient(r, row) { if handOverDevice(w, r, app, gdb, secret, row) { return } // Another poll won the hand-over or the session just expired: // answer what the row says now, never a token. row, found, err = deviceByID(r, gdb, row.ID) if err != nil || !found { writeOpaque500(w) return } } writeDeviceStatus(w, r, gdb, row) } } // sameDeviceClient reports whether r comes with the User-Agent the device // initiated the session with (none for none), the binding of the token // hand-over to the initiating client. func sameDeviceClient(r *http.Request, row models.DeviceAuthSession) bool { stored := "" if row.DeviceUserAgent != nil { stored = *row.DeviceUserAgent } return r.UserAgent() == stored } // deviceByToken loads the session whose token hash matches token. func deviceByToken(r *http.Request, gdb *gorm.DB, token string) (models.DeviceAuthSession, bool, error) { var row models.DeviceAuthSession if token == "" { return row, false, nil } res := gdb.WithContext(r.Context()).Where("token = ?", classes.HashAPIToken(token)).Limit(1).Find(&row) return row, res.RowsAffected == 1, res.Error } func deviceByID(r *http.Request, gdb *gorm.DB, id uint) (models.DeviceAuthSession, bool, error) { var row models.DeviceAuthSession res := gdb.WithContext(r.Context()).Where("id = ?", id).Limit(1).Find(&row) return row, res.RowsAffected == 1, res.Error } // writeDeviceStatus answers a status poll that hands over no token. A // pending or confirmed session past expires_at is marked expired (used and // revoked sessions never are); an expired session answers PHP's flat // expired body. func writeDeviceStatus(w http.ResponseWriter, r *http.Request, gdb *gorm.DB, row models.DeviceAuthSession) { expire := row.ExpiredAt(time.Now()) if expire || row.Status == models.DeviceStatusExpired { if expire { if err := gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+` SET status = 'expired', updated_at = NOW() WHERE id = ? AND status IN ('pending', 'confirmed')`, row.ID).Error; err != nil { writeOpaque500(w) return } } writeJSON(w, http.StatusOK, map[string]any{ "success": true, "status": models.DeviceStatusExpired, "message": "Authorization session has expired", }) return } writeJSON(w, http.StatusOK, map[string]any{"success": true, "data": map[string]any{"status": row.Status}}) } // handOverDevice gives the confirmed session's user a session token for the // device (D-12a). It reports false, having written nothing, when another // poll already took the session or it expired meanwhile; the token it // minted is then dropped unsent. A user who is gone, deactivated or // banned, or whose tokens_valid_after (a password change, a revocation) // is later than the authorization, gets nothing: the session becomes // revoked, as a token minted at the authorization would be refused. func handOverDevice(w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, secret string, row models.DeviceAuthSession) bool { ctx := r.Context() var user models.User res := gdb.WithContext(ctx).Where("id = ?", *row.UserID).Limit(1).Find(&user) if res.Error != nil { writeOpaque500(w) return true } refused := res.RowsAffected == 0 if !refused { banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, app), gdb.WithContext(ctx), []uint{user.ID}) if err != nil { writeOpaque500(w) return true } refused = banned[user.ID] || authorizedBeforeCutoff(&user, row) } if refused { // A deactivated, deleted or banned user signs no device in. if err := gdb.WithContext(ctx).Exec(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW() WHERE id = ? AND status = 'confirmed'`, row.ID).Error; err != nil { writeOpaque500(w) return true } return false } token, jti, err := mintWithJTI(app, secret, &user, requestURL(r)) if err != nil { writeOpaque500(w) return true } payload, err := apiArray(ctx, app, &user) if err != nil { writeOpaque500(w) return true } res = gdb.WithContext(ctx).Exec(`UPDATE `+deviceTable+` SET status = 'used', used_at = NOW(), last_activity_at = NOW(), jwt_jti = ?, updated_at = NOW() WHERE id = ? AND status = 'confirmed' AND expires_at > NOW()`, jti, row.ID) if res.Error != nil { writeOpaque500(w) return true } if res.RowsAffected != 1 { return false } if err := FinishLogin(ctx, app, gdb, &user, deviceClientIP(app, r)); err != nil { pluginLog.Warn("golem15.user: finish device login", "user_id", user.ID, "err", err) } if err := FireLoginEvent(ctx, app, &user, classes.LoginSourceDevice); err != nil { pluginLog.Warn("golem15.user: login event listener", "user_id", user.ID, "err", err) } writeJSON(w, http.StatusOK, map[string]any{ "success": true, "data": map[string]any{ "status": models.DeviceStatusConfirmed, "user_id": user.ID, "message": "Device authorized - complete login", "token": token, "user": payload, }, }) return true } // authorizedBeforeCutoff reports whether row was confirmed before the // user's tokens_valid_after, compared as the jwt guard compares a token's // whole-second iat. func authorizedBeforeCutoff(user *models.User, row models.DeviceAuthSession) bool { if user.TokensValidAfter == nil || row.ConfirmedAt == nil { return false } return row.ConfirmedAt.Truncate(time.Second).Before(*user.TokensValidAfter) } // DeviceAuthorize is POST /devices/authorize: a signed-in user (a valid, // not blacklisted session token; no second-factor check, D-12c) binds a // pending device session to themselves by its short_code (hyphens and // spaces optional, any case) or by its token (the QR code page). The token // field also takes the short code, which the default auth_url carries in // place of the device token. A short_code of // other than 8 characters after removing hyphens answers 422 {"error": // "Please enter a valid 8-character code"}; an unknown, expired, used, // revoked or foreign session answers 400 {"error":"Invalid or expired // code"}; success, and repeating it for a session the caller already // confirmed, answers {"success":true,"message":"Device authorized // successfully"}. Without a session it answers 401 {"error":"Unauthorized"}. // The fields are read from the body only, never the query string. func DeviceAuthorize(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gdb, secret, ok := sessionDeps(w, app) if !ok { return } user, _, ok := deviceCaller(w, r, app, gdb, secret) if !ok { return } fields, err := readBodyFields(r) if err != nil { writeOpaque500(w) return } invalid := func() { writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Invalid or expired code"}) } var ( row models.DeviceAuthSession found bool ) if _, has := fields["short_code"]; has { raw := strings.ReplaceAll(strField(fields, "short_code"), "-", "") if len(raw) != 8 { writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "Please enter a valid 8-character code"}) return } code, valid := classes.NormalizeDeviceShortCode(raw) if !valid { invalid() return } res := gdb.WithContext(r.Context()).Where("short_code = ?", code).Limit(1).Find(&row) if res.Error != nil { writeOpaque500(w) return } found = res.RowsAffected == 1 } else if _, has := fields["token"]; has { raw := strField(fields, "token") if code, isCode := classes.NormalizeDeviceShortCode(raw); isCode { // The QR page of the default auth_url carries the short // code; a 64-character device token never normalizes. res := gdb.WithContext(r.Context()).Where("short_code = ?", code).Limit(1).Find(&row) if res.Error != nil { writeOpaque500(w) return } found = res.RowsAffected == 1 } else { row, found, err = deviceByToken(r, gdb, raw) if err != nil { writeOpaque500(w) return } } } if !found { invalid() return } res := gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+` SET status = 'confirmed', user_id = ?, confirmed_at = NOW(), auth_ip = ?, updated_at = NOW() WHERE id = ? AND status = 'pending' AND (user_id IS NULL OR user_id = ?) AND expires_at > NOW()`, user.ID, nullableString(deviceClientIP(app, r)), row.ID, user.ID) if res.Error != nil { writeOpaque500(w) return } if res.RowsAffected != 1 { now, ok, err := deviceByID(r, gdb, row.ID) if err != nil || !ok { writeOpaque500(w) return } again := now.Status == models.DeviceStatusConfirmed && now.UserID != nil && *now.UserID == user.ID && !now.ExpiredAt(time.Now()) if !again { if now.ExpiredAt(time.Now()) { _ = gdb.WithContext(r.Context()).Exec(`UPDATE `+deviceTable+` SET status = 'expired', updated_at = NOW() WHERE id = ? AND status IN ('pending', 'confirmed')`, now.ID).Error } invalid() return } } writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "Device authorized successfully"}) } } // nullableString is nil for "", else s. func nullableString(s string) any { if s == "" { return nil } return s } // mintWithJTI mints the session JWT as mintFor does and also returns its // jti, which device sessions store. func mintWithJTI(app *backpack.App, secret string, user *models.User, issuer string) (string, string, error) { return bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, sessionTTL(app)) } // DeviceIndex is GET /devices: the caller's authorized devices (sessions // confirmed or used), most recent activity first, as {"success":true, // "devices":[{id, device_name, device_ip, authorized_at, last_activity, // is_current}]}. device_name is the stored name or one parsed from the user // agent (classes.DeviceDisplayName); is_current is true for the session // whose token is the one the request carries. Without a session it answers // 401 {"error":"Unauthorized"}. func DeviceIndex(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gdb, secret, ok := sessionDeps(w, app) if !ok { return } user, jti, ok := deviceCaller(w, r, app, gdb, secret) if !ok { return } var rows []models.DeviceAuthSession if err := gdb.WithContext(r.Context()). Where("user_id = ? AND status IN ?", user.ID, []string{models.DeviceStatusConfirmed, models.DeviceStatusUsed}). Order("last_activity_at DESC NULLS LAST, id DESC"). Find(&rows).Error; err != nil { writeOpaque500(w) return } devices := make([]map[string]any, 0, len(rows)) for _, row := range rows { ua := "" if row.DeviceUserAgent != nil { ua = *row.DeviceUserAgent } devices = append(devices, map[string]any{ "id": row.ID, "device_name": classes.DeviceDisplayName(row.DeviceName, ua), "device_ip": row.DeviceIP, "authorized_at": wireTimeOrNil(row.ConfirmedAt), "last_activity": wireTimeOrNil(row.LastActivityAt), "is_current": jti != "" && row.JWTJTI != nil && *row.JWTJTI == jti, }) } writeJSON(w, http.StatusOK, map[string]any{"success": true, "devices": wire.Slice(devices)}) } } // DeviceDestroy is DELETE /devices/{id}: the caller revokes one of their // device sessions. In one transaction the row is marked revoked and the jti // of the session token the device holds (followed through refresh) is // added to the JWT blacklist for the token's whole refresh window, so the // device is signed out at once and cannot refresh. The jti is the one the // revoking UPDATE returns, read under the row lock a concurrent refresh's // hand-over also takes (classes.FollowDeviceJTI), so a refresh that moved // the session first is blacklisted and one that comes after is refused. A // blacklist failure answers the opaque 500 and leaves the row as it was. // Success answers PHP's body with success true and the message Device // revoked successfully, also for a row already revoked; a row of another // user, or none, answers 404 with the error Device not found; no session // answers 401 {"error":"Unauthorized"}. func DeviceDestroy(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gdb, secret, ok := sessionDeps(w, app) if !ok { return } user, _, ok := deviceCaller(w, r, app, gdb, secret) if !ok { return } notFound := func() { writeJSON(w, http.StatusNotFound, map[string]any{"error": "Device not found"}) } id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) if err != nil || id == 0 { notFound() return } found := false err = lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error { // The jti current at the revoke, under the row lock: never one // read before a concurrent refresh moved the session on. var revoked []struct { JWTJTI *string `gorm:"column:jwt_jti"` } if err := tx.WithContext(ctx).Raw(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW() WHERE id = ? AND user_id = ? RETURNING jwt_jti`, id, user.ID).Scan(&revoked).Error; err != nil { return err } if len(revoked) == 0 { return nil } found = true jti := revoked[0].JWTJTI if jti == nil || *jti == "" { return nil } bl, ok := app.Lookup[bouncer.BlacklistStore]() if !ok || bl == nil { return errors.New("golem15.user: no JWT blacklist store is published") } now := time.Now() return bl.Add(ctx, *jti, now.Add(refreshTTL(app)+sessionTTL(app)+time.Minute), now) }) if err != nil { pluginLog.Error("golem15.user: revoke device session", "user_id", user.ID, "err", err) writeOpaque500(w) return } if !found { notFound() return } writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "Device revoked successfully"}) } } // wireTimeOrNil is t as a +00:00 timestamp, or nil. func wireTimeOrNil(t *time.Time) any { if t == nil { return nil } return wire.Time{Time: *t} }