Add support for standard webpush (#33528)

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
S1m 2025-01-14 10:14:00 +01:00 committed by GitHub
parent ee4edbb94f
commit 4a2813158d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 167 additions and 46 deletions

View file

@ -21,6 +21,7 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
endpoint: subscription_params[:endpoint],
key_p256dh: subscription_params[:keys][:p256dh],
key_auth: subscription_params[:keys][:auth],
standard: subscription_params[:standard] || false,
data: data_params,
user_id: current_user.id,
access_token_id: doorkeeper_token.id
@ -55,7 +56,7 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
end
def subscription_params
params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh])
params.require(:subscription).permit(:endpoint, :standard, keys: [:auth, :p256dh])
end
def data_params

View file

@ -66,7 +66,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
end
def subscription_params
@subscription_params ||= params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh])
@subscription_params ||= params.require(:subscription).permit(:standard, :endpoint, keys: [:auth, :p256dh])
end
def web_push_subscription_params
@ -76,6 +76,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
endpoint: subscription_params[:endpoint],
key_auth: subscription_params[:keys][:auth],
key_p256dh: subscription_params[:keys][:p256dh],
standard: subscription_params[:standard] || false,
user_id: active_session.user_id,
}
end

View file

@ -2,7 +2,8 @@
class WebPushRequest
SIGNATURE_ALGORITHM = 'p256ecdsa'
AUTH_HEADER = 'WebPush'
LEGACY_AUTH_HEADER = 'WebPush'
STANDARD_AUTH_HEADER = 'vapid'
PAYLOAD_EXPIRATION = 24.hours
JWT_ALGORITHM = 'ES256'
JWT_TYPE = 'JWT'
@ -10,6 +11,7 @@ class WebPushRequest
attr_reader :web_push_subscription
delegate(
:standard,
:endpoint,
:key_auth,
:key_p256dh,
@ -24,20 +26,36 @@ class WebPushRequest
@audience ||= Addressable::URI.parse(endpoint).normalized_site
end
def authorization_header
[AUTH_HEADER, encoded_json_web_token].join(' ')
def legacy_authorization_header
[LEGACY_AUTH_HEADER, encoded_json_web_token].join(' ')
end
def crypto_key_header
[SIGNATURE_ALGORITHM, vapid_key.public_key_for_push_header].join('=')
end
def encrypt(payload)
def legacy_encrypt(payload)
Webpush::Legacy::Encryption.encrypt(payload, key_p256dh, key_auth)
end
def standard_authorization_header
[STANDARD_AUTH_HEADER, standard_vapid_value].join(' ')
end
def standard_encrypt(payload)
Webpush::Encryption.encrypt(payload, key_p256dh, key_auth)
end
def legacy
!standard
end
private
def standard_vapid_value
"t=#{encoded_json_web_token},k=#{vapid_key.public_key_for_push_header}"
end
def encoded_json_web_token
JWT.encode(
web_token_payload,

View file

@ -5,10 +5,11 @@
# Table name: web_push_subscriptions
#
# id :bigint(8) not null, primary key
# endpoint :string not null
# key_p256dh :string not null
# key_auth :string not null
# data :json
# endpoint :string not null
# key_auth :string not null
# key_p256dh :string not null
# standard :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# access_token_id :bigint(8)

View file

@ -1,7 +1,9 @@
# frozen_string_literal: true
class REST::WebPushSubscriptionSerializer < ActiveModel::Serializer
attributes :id, :endpoint, :alerts, :server_key, :policy
attributes :id, :endpoint, :standard, :alerts, :server_key, :policy
delegate :standard, to: :object
def alerts
(object.data&.dig('alerts') || {}).each_with_object({}) { |(k, v), h| h[k] = ActiveModel::Type::Boolean.new.cast(v) }

View file

@ -3,7 +3,7 @@
class WebPushKeyValidator < ActiveModel::Validator
def validate(subscription)
begin
Webpush::Legacy::Encryption.encrypt('validation_test', subscription.key_p256dh, subscription.key_auth)
Webpush::Encryption.encrypt('validation_test', subscription.key_p256dh, subscription.key_auth)
rescue ArgumentError, OpenSSL::PKey::EC::Point::Error
subscription.errors.add(:base, I18n.t('crypto.errors.invalid_key'))
end

View file

@ -19,7 +19,19 @@ class Web::PushNotificationWorker
# in the meantime, so we have to double-check before proceeding
return unless @notification.activity.present? && @subscription.pushable?(@notification)
payload = web_push_request.encrypt(push_notification_json)
if web_push_request.legacy
perform_legacy_request
else
perform_standard_request
end
rescue ActiveRecord::RecordNotFound
true
end
private
def perform_legacy_request
payload = web_push_request.legacy_encrypt(push_notification_json)
request_pool.with(web_push_request.audience) do |http_client|
request = Request.new(:post, web_push_request.endpoint, body: payload.fetch(:ciphertext), http_client: http_client)
@ -31,28 +43,48 @@ class Web::PushNotificationWorker
'Content-Encoding' => 'aesgcm',
'Encryption' => "salt=#{Webpush.encode64(payload.fetch(:salt)).delete('=')}",
'Crypto-Key' => "dh=#{Webpush.encode64(payload.fetch(:server_public_key)).delete('=')};#{web_push_request.crypto_key_header}",
'Authorization' => web_push_request.authorization_header,
'Authorization' => web_push_request.legacy_authorization_header,
'Unsubscribe-URL' => subscription_url
)
request.perform do |response|
# If the server responds with an error in the 4xx range
# that isn't about rate-limiting or timeouts, we can
# assume that the subscription is invalid or expired
# and must be removed
if (400..499).cover?(response.code) && ![408, 429].include?(response.code)
@subscription.destroy!
elsif !(200...300).cover?(response.code)
raise Mastodon::UnexpectedResponseError, response
end
end
send(request)
end
rescue ActiveRecord::RecordNotFound
true
end
private
def perform_standard_request
payload = web_push_request.standard_encrypt(push_notification_json)
request_pool.with(web_push_request.audience) do |http_client|
request = Request.new(:post, web_push_request.endpoint, body: payload, http_client: http_client)
request.add_headers(
'Content-Type' => 'application/octet-stream',
'Ttl' => TTL.to_s,
'Urgency' => URGENCY,
'Content-Encoding' => 'aes128gcm',
'Authorization' => web_push_request.standard_authorization_header,
'Unsubscribe-URL' => subscription_url,
'Content-Length' => payload.length.to_s
)
send(request)
end
end
def send(request)
request.perform do |response|
# If the server responds with an error in the 4xx range
# that isn't about rate-limiting or timeouts, we can
# assume that the subscription is invalid or expired
# and must be removed
if (400..499).cover?(response.code) && ![408, 429].include?(response.code)
@subscription.destroy!
elsif !(200...300).cover?(response.code)
raise Mastodon::UnexpectedResponseError, response
end
end
end
def web_push_request
@web_push_request || WebPushRequest.new(@subscription)