From 93718f7408d43d4fc9f0572cf9f81837da87c718 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 19 Oct 2026 12:39:03 -0700 Subject: [PATCH] Remove push subscriptions on a password change, and post them again A push subscription belongs to the user, not a session, so a device signed out by a password change kept getting notifications. Changing the password now removes every subscription along with the sessions, in the same transaction. A browser still signed in keeps its own subscription in its service worker, so the notifications controller posts it once per session: the bell names the session by a digest of its id, never its token, and the browser remembers the last one it posted for. That post never unsubscribes the browser when the server refuses it. A request that authenticated just before the change could put a subscription back after it. The create action now checks the session still exists and saves in one immediate transaction, after validating the endpoint outside it. From #371. (cherry picked from commit 6cab9240978ec6d305d5f043bacda1da800d0d12) --- .../users/push_subscriptions_controller.rb | 27 ++++--- .../users/push_subscriptions_helper.rb | 7 ++ .../controllers/notifications_controller.js | 21 +++++- app/models/user.rb | 6 +- app/views/rooms/involvements/_bell.html.erb | 3 +- .../users/profiles_controller_test.rb | 14 +++- .../push_subscriptions_controller_test.rb | 48 +++++++++++++ test/models/user_test.rb | 20 +++++- test/system/push_subscription_resync_test.rb | 71 +++++++++++++++++++ 9 files changed, 201 insertions(+), 16 deletions(-) create mode 100644 app/helpers/users/push_subscriptions_helper.rb create mode 100644 test/system/push_subscription_resync_test.rb diff --git a/app/controllers/users/push_subscriptions_controller.rb b/app/controllers/users/push_subscriptions_controller.rb index 8e263b23..e4e46524 100644 --- a/app/controllers/users/push_subscriptions_controller.rb +++ b/app/controllers/users/push_subscriptions_controller.rb @@ -5,17 +5,14 @@ class Users::PushSubscriptionsController < ApplicationController end def create - if subscription = @push_subscriptions.find_by(push_subscription_params) - # Existing endpoints must pass current validations - if subscription.valid? - subscription.touch - head :ok - else - head :unprocessable_entity - end + subscription = @push_subscriptions.find_or_initialize_by(push_subscription_params) + subscription.user_agent = request.user_agent if subscription.new_record? + + # Existing endpoints must pass current validations too + if subscription.valid? + head save_while_signed_in(subscription) ? :ok : :unauthorized else - subscription = @push_subscriptions.create push_subscription_params.merge(user_agent: request.user_agent) - head subscription.persisted? ? :ok : :unprocessable_entity + head :unprocessable_entity end end @@ -29,6 +26,16 @@ class Users::PushSubscriptionsController < ApplicationController @push_subscriptions = Current.user.push_subscriptions end + # A password change ends every session and subscription. A request that authenticated just before it mustn't + # put one back, so the session is checked under the same write lock as the save. Validation, which looks up + # the endpoint's address, runs before taking the lock. + def save_while_signed_in(subscription) + Push::Subscription.transaction do + Session.exists?(Current.session.id) && + (subscription.new_record? ? subscription.save!(validate: false) : subscription.touch) + end + end + def push_subscription_params params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key) end diff --git a/app/helpers/users/push_subscriptions_helper.rb b/app/helpers/users/push_subscriptions_helper.rb new file mode 100644 index 00000000..57c681f9 --- /dev/null +++ b/app/helpers/users/push_subscriptions_helper.rb @@ -0,0 +1,7 @@ +module Users::PushSubscriptionsHelper + # Names the session without revealing its token, so the browser can tell when it's in a new one. The creation + # time keeps an id reused by a new database from looking like the same session. + def push_subscription_session_marker + Digest::SHA256.hexdigest("push-subscription-session-#{Current.session.id}-#{Current.session.created_at.to_f}") if Current.session + end +end diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 3ce53a5c..b3698a31 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -4,8 +4,10 @@ import { pageIsTurboPreview } from "helpers/turbo_helpers" import { onNextEventLoopTick } from "helpers/timing_helpers" import { getCookie, setCookie } from "lib/cookie" +const PUSH_SUBSCRIPTION_SYNCED_KEY = "push-subscription-synced-session" + export default class extends Controller { - static values = { subscriptionsUrl: String } + static values = { subscriptionsUrl: String, session: String } static targets = [ "notAllowedNotice", "bell", "details" ] static classes = [ "attention" ] @@ -25,6 +27,8 @@ export default class extends Controller { this.#showBellAlert() } } + + this.#resyncPushSubscriptionOncePerSession() } } @@ -109,6 +113,21 @@ export default class extends Controller { if (!response.ok) subscription.unsubscribe() } + // A password change removes the account's subscriptions, the one this browser holds included. Post it again + // once per session, so a browser that's still signed in keeps getting notifications. A failure here leaves the + // browser's own subscription alone: the next session tries again. + async #resyncPushSubscriptionOncePerSession() { + if (this.#allowed && this.sessionValue && localStorage.getItem(PUSH_SUBSCRIPTION_SYNCED_KEY) !== this.sessionValue) { + const registration = await this.#serviceWorkerRegistration + const subscription = await registration?.pushManager?.getSubscription() + + if (Notification.permission === "granted" && subscription) { + await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" }) + localStorage.setItem(PUSH_SUBSCRIPTION_SYNCED_KEY, this.sessionValue) + } + } + } + async #requestPermissionAndSubscribe(registration) { const permission = await Notification.requestPermission() if (permission === "granted") this.#subscribe(registration) diff --git a/app/models/user.rb b/app/models/user.rb index 716088e8..07f4bd03 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -60,10 +60,12 @@ class User < ApplicationRecord end # A new password is how someone shuts out whoever else got into their account, so it ends every session, copies - # of the changing browser's cookie included. Connections close once that commits, registered on the transaction - # because a later save in it would replace the saved change an after_commit condition reads. + # of the changing browser's cookie included, and every push subscription; browsers still signed in post theirs + # again. Connections close once that commits, registered on the transaction because a later save in it would + # replace the saved change an after_commit condition reads. def sign_out_all_sessions sessions.delete_all + push_subscriptions.delete_all self.class.current_transaction.after_commit { disconnect_signed_out_sessions } end diff --git a/app/views/rooms/involvements/_bell.html.erb b/app/views/rooms/involvements/_bell.html.erb index d9fa32c5..266bdfb0 100644 --- a/app/views/rooms/involvements/_bell.html.erb +++ b/app/views/rooms/involvements/_bell.html.erb @@ -1,6 +1,7 @@ + data-controller="notifications" data-notifications-subscriptions-url-value="<%= user_push_subscriptions_path %>" + data-notifications-session-value="<%= push_subscription_session_marker %>" data-notifications-attention-class="btn--pulsing"> <%= turbo_frame_tag dom_id(room, :involvement), data: { controller: "turbo-frame", action: "notifications:ready@window->turbo-frame#load", turbo_frame_url_param: room_involvement_path(room) } do %>