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 6cab924097)
This commit is contained in:
Jeremy Daer committed 2026-10-19 12:39:14 -07:00
1 parent 417f087964
commit 93718f7408
9 files changed
+201 -16

No files matched your search

@@ -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
@@ -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
@@ -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)
+4 -2
View File
@@ -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
+2 -1
View File
@@ -1,6 +1,7 @@
<span>
<span class="button_to_change_notifying"
data-controller="notifications" data-notifications-subscriptions-url-value="<%= user_push_subscriptions_path %>" data-notifications-attention-class="btn--pulsing">
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 %>
<button class="btn" data-action="click->notifications#attemptToSubscribe" data-notifications-target="bell">