From 6c1c42268c2b637edeb42f064d723e13eba00755 Mon Sep 17 00:00:00 2001 From: Eduardo Bravo Date: Wed, 5 Aug 2026 23:38:43 -0400 Subject: [PATCH] feat: implement device registration, admin-secured management, and rate limiting for push notifications --- .env.example | 2 + README.md | 31 +++++++ app/controllers/application_controller.rb | 15 ++++ app/controllers/devices_controller.rb | 42 +++++++++ app/models/device.rb | 10 +++ config/initializers/cors.rb | 2 +- config/initializers/rack_attack.rb | 7 +- config/routes.rb | 1 + db/migrate/20260805000000_create_devices.rb | 12 +++ db/schema.rb | 11 ++- test/controllers/devices_controller_test.rb | 98 +++++++++++++++++++++ test/integration/rate_limiting_test.rb | 12 +++ test/models/device_test.rb | 39 ++++++++ 13 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 app/controllers/devices_controller.rb create mode 100644 app/models/device.rb create mode 100644 db/migrate/20260805000000_create_devices.rb create mode 100644 test/controllers/devices_controller_test.rb create mode 100644 test/models/device_test.rb diff --git a/.env.example b/.env.example index 9851651..e08f0b9 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,5 @@ POSTGRES_PORT= USER_DB= PASSWORD_DB= + +ADMIN_TOKEN= diff --git a/README.md b/README.md index 420ba62..a68b74e 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,36 @@ curl -X POST 'http://localhost:3000/v1/sismos/1/reports' \ - `404 Not Found` — the referenced event does not exist - `429 Too Many Requests` — rate limit exceeded (see [Rate Limiting](#rate-limiting)) +**Register a web notification device** + +```http +POST /v1/devices +``` + +```bash +curl -X POST 'http://localhost:3000/v1/devices' \ + -H 'Content-Type: application/json' \ + -d '{"fcm_token":"token-from-firebase-messaging"}' +``` + +The token is unique and is stored with `platform: "web"`. + +**List or remove notification devices** + +```http +GET /v1/devices +DELETE /v1/devices/:id +``` + +These endpoints require the administrative header: + +```http +X-Admin-Token: +``` + +`GET /v1/devices` returns only the FCM token strings so the notification +service can send alerts without accessing the database directly. + --- ## Rate Limiting @@ -200,6 +230,7 @@ Public write endpoints are protected against abuse via [`rack-attack`](https://g |---|---|---| | Global | 60 requests/minute | Per IP, all endpoints except `/assets` | | Reports | 5 requests/minute | Per IP, `POST /v1/sismos/:id/reports` only | +| Devices | 5 requests/minute | Per IP, `POST /v1/devices` only | Exceeding a limit returns `429 Too Many Requests` with a `Retry-After` header and a JSON error body: diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4ac8823..ebfd03d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,17 @@ class ApplicationController < ActionController::API + private + + def authenticate_admin! + configured_token = ENV['ADMIN_TOKEN'].presence + supplied_token = request.headers['X-Admin-Token'].presence + + return render_unauthorized unless configured_token && supplied_token + return render_unauthorized unless supplied_token.bytesize == configured_token.bytesize + + render_unauthorized unless ActiveSupport::SecurityUtils.secure_compare(supplied_token, configured_token) + end + + def render_unauthorized + render json: { error: 'Unauthorized' }, status: :unauthorized + end end diff --git a/app/controllers/devices_controller.rb b/app/controllers/devices_controller.rb new file mode 100644 index 0000000..2c5709a --- /dev/null +++ b/app/controllers/devices_controller.rb @@ -0,0 +1,42 @@ +class DevicesController < ApplicationController + before_action :authenticate_admin!, only: %i[index destroy] + + def index + no_store + render json: { data: Device.order(:id).pluck(:fcm_token) } + end + + def create + token = device_params[:fcm_token] + device = Device.find_or_initialize_by(fcm_token: token) + new_device = device.new_record? + device.assign_attributes(device_params) + + if device.save + render_device_json(device, status: new_device ? :created : :ok) + else + render json: { errors: device.errors.full_messages }, status: :unprocessable_entity + end + rescue ActiveRecord::RecordNotUnique + existing_device = Device.find_by!(fcm_token: token) + render_device_json(existing_device, status: :ok) + end + + def destroy + device = Device.find_by(id: params[:id]) + return render json: { error: 'Device not found' }, status: :not_found unless device + + device.destroy! + head :no_content + end + + private + + def render_device_json(device, status:) + render json: { data: { id: device.id, type: 'device' } }, status: status + end + + def device_params + params.permit(:fcm_token, :platform) + end +end diff --git a/app/models/device.rb b/app/models/device.rb new file mode 100644 index 0000000..8c9646e --- /dev/null +++ b/app/models/device.rb @@ -0,0 +1,10 @@ +class Device < ApplicationRecord + MAX_FCM_TOKEN_LENGTH = 255 + + validates :fcm_token, + presence: true, + uniqueness: true, + length: { maximum: MAX_FCM_TOKEN_LENGTH }, + format: { without: /\s/ } + validates :platform, inclusion: { in: %w[web] } +end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 8122ea3..93be2d9 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -6,6 +6,6 @@ resource '/v1/*', headers: :any, - methods: %i[get post options] + methods: %i[get post delete options] end end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index d5ebd2d..2c1518f 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -26,7 +26,12 @@ class Attack req.ip if req.path_info.match?(%r{\A/v1/sismos/\d+/reports(?:\.[^/]+)?\z}) && req.post? end - # 3. Custom Response for Throttled Requests (HTTP 429) + # 3. Throttle device registration by IP (5 req/min) + throttle('devices/ip', limit: 5, period: 1.minute) do |req| + req.ip if req.path_info.match?(%r{\A/v1/devices(?:\.[^/]+)?\z}) && req.post? + end + + # 4. Custom Response for Throttled Requests (HTTP 429) self.throttled_responder = lambda do |request| match_data = request.env['rack.attack.match_data'] || {} now = match_data[:epoch_time] || Time.now.to_i diff --git a/config/routes.rb b/config/routes.rb index 53e41a4..132e53c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,5 +4,6 @@ get 'stats', on: :collection resources :reports, only: [:create] end + resources :devices, only: %i[index create destroy] end end diff --git a/db/migrate/20260805000000_create_devices.rb b/db/migrate/20260805000000_create_devices.rb new file mode 100644 index 0000000..b3777d5 --- /dev/null +++ b/db/migrate/20260805000000_create_devices.rb @@ -0,0 +1,12 @@ +class CreateDevices < ActiveRecord::Migration[7.2] + def change + create_table :devices do |t| + t.string :fcm_token, null: false, limit: 255 + t.string :platform, null: false, default: 'web' + t.timestamps + end + + add_index :devices, :fcm_token, unique: true + add_check_constraint :devices, "platform = 'web'", name: 'devices_platform_is_web' + end +end diff --git a/db/schema.rb b/db/schema.rb index c1e0ac8..9eba6a1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,19 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_08_03_034001) do +ActiveRecord::Schema[7.2].define(version: 2026_08_05_000000) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "devices", force: :cascade do |t| + t.string "fcm_token", limit: 255, null: false + t.string "platform", default: "web", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["fcm_token"], name: "index_devices_on_fcm_token", unique: true + t.check_constraint "platform::text = 'web'::text", name: "devices_platform_is_web" + end + create_table "reports", force: :cascade do |t| t.bigint "sismo_id", null: false t.boolean "felt", null: false diff --git a/test/controllers/devices_controller_test.rb b/test/controllers/devices_controller_test.rb new file mode 100644 index 0000000..95aaaa8 --- /dev/null +++ b/test/controllers/devices_controller_test.rb @@ -0,0 +1,98 @@ +require 'test_helper' + +class DevicesControllerTest < ActionDispatch::IntegrationTest + setup do + @admin_token = 'test-admin-token' + ENV['ADMIN_TOKEN'] = @admin_token + end + + teardown do + ENV.delete('ADMIN_TOKEN') + end + + test 'creates a device publicly and does not duplicate its token' do + assert_difference('Device.count', 1) do + post devices_url, params: { fcm_token: 'token-1' }, as: :json + end + assert_response :created + + post devices_url, params: { fcm_token: 'token-1' }, as: :json + assert_response :success + assert_equal 1, Device.where(fcm_token: 'token-1').count + end + + test 'rejects invalid devices' do + post devices_url, params: { fcm_token: '' }, as: :json + + assert_response :unprocessable_entity + end + + test 'requires the admin token to list devices' do + get devices_url + assert_response :unauthorized + assert_equal({ 'error' => 'Unauthorized' }, JSON.parse(response.body)) + + get devices_url, headers: { 'X-Admin-Token' => @admin_token } + assert_response :success + assert_includes response.headers['Cache-Control'], 'no-store' + assert_equal({ 'data' => [] }, JSON.parse(response.body)) + end + + test 'requires the admin token to destroy a device' do + device = Device.create!(fcm_token: 'token-1') + + delete device_url(device) + assert_response :unauthorized + assert_equal({ 'error' => 'Unauthorized' }, JSON.parse(response.body)) + + delete device_url(device), headers: { 'X-Admin-Token' => @admin_token } + assert_response :no_content + assert_not Device.exists?(device.id) + end + + test 'rejects unauthorized requests with mismatched token length or invalid token' do + get devices_url, headers: { 'X-Admin-Token' => 'short' } + assert_response :unauthorized + assert_equal({ 'error' => 'Unauthorized' }, JSON.parse(response.body)) + + get devices_url, headers: { 'X-Admin-Token' => 'wrong-admin-token' } + assert_response :unauthorized + assert_equal({ 'error' => 'Unauthorized' }, JSON.parse(response.body)) + end + + test 'fails closed when ADMIN_TOKEN is not configured' do + ENV.delete('ADMIN_TOKEN') + + get devices_url, headers: { 'X-Admin-Token' => 'test-admin-token' } + + assert_response :unauthorized + assert_equal({ 'error' => 'Unauthorized' }, JSON.parse(response.body)) + end + + test 'rescues ActiveRecord::RecordNotUnique on concurrent device creation' do + existing = Device.create!(fcm_token: 'token-concurrent') + + begin + Device.define_method(:save) { raise ActiveRecord::RecordNotUnique } + post devices_url, params: { fcm_token: 'token-concurrent' }, as: :json + ensure + Device.remove_method(:save) + end + + assert_response :ok + assert_equal({ 'data' => { 'id' => existing.id, 'type' => 'device' } }, JSON.parse(response.body)) + end + + test 'supports CORS preflight for DELETE requests' do + device = Device.create!(fcm_token: 'token-cors') + origin = ENV.fetch('ALLOWED_ORIGIN', 'http://localhost:5173') + + process :options, device_url(device), headers: { + 'Origin' => origin, + 'Access-Control-Request-Method' => 'DELETE' + } + + assert_response :success + assert_includes response.headers['Access-Control-Allow-Methods'], 'DELETE' + end +end diff --git a/test/integration/rate_limiting_test.rb b/test/integration/rate_limiting_test.rb index 1c79302..8924a57 100644 --- a/test/integration/rate_limiting_test.rb +++ b/test/integration/rate_limiting_test.rb @@ -62,4 +62,16 @@ class RateLimitingTest < ActionDispatch::IntegrationTest assert_includes json['error'], 'Rate limit exceeded' end end + + test 'returns 429 when POST device rate limit is exceeded' do + travel_to Time.utc(2026, 8, 4, 12, 0, 0) do + 5.times do |index| + post devices_url, params: { fcm_token: "token-#{index}" }, as: :json + assert_response :success + end + + post devices_url, params: { fcm_token: 'token-5' }, as: :json + assert_response :too_many_requests + end + end end diff --git a/test/models/device_test.rb b/test/models/device_test.rb new file mode 100644 index 0000000..fd82636 --- /dev/null +++ b/test/models/device_test.rb @@ -0,0 +1,39 @@ +require 'test_helper' + +class DeviceTest < ActiveSupport::TestCase + test 'requires a unique FCM token' do + device = Device.new(fcm_token: 'token-1') + assert device.save! + + duplicate = Device.new(fcm_token: 'token-1') + assert_not duplicate.valid? + assert_includes duplicate.errors[:fcm_token], 'has already been taken' + end + + test 'defaults to the web platform' do + assert_equal 'web', Device.new(fcm_token: 'token-1').platform + end + + test 'only accepts the web platform' do + device = Device.new(fcm_token: 'token-1', platform: 'ios') + + assert_not device.valid? + assert_includes device.errors[:platform], 'is not included in the list' + end + + test 'rejects invalid FCM tokens containing whitespace, control characters, or exceeding maximum length' do + invalid_tokens = [ + 'token with space', + "token\nwith\nnewline", + "token\rwith\rreturn", + "token\twith\ttab", + 'a' * (Device::MAX_FCM_TOKEN_LENGTH + 1) + ] + + invalid_tokens.each do |invalid_token| + device = Device.new(fcm_token: invalid_token) + assert_not device.valid?, "Expected device with token #{invalid_token.inspect} to be invalid" + assert_not_empty device.errors[:fcm_token] + end + end +end