Merge branch 'codex/tur-84-sync-token' into 'master'

TUR-84: Protect sync token

See merge request turniere/turniere-backend!76
This commit is contained in:
Daniel Schädler 2026-04-30 10:14:12 +00:00
commit 40e0f12969
10 changed files with 183 additions and 12 deletions

View File

@ -319,9 +319,7 @@ class TournamentsController < ApplicationController
token = request.authorization.to_s.delete_prefix('Bearer ').presence || request.headers['X-Tournament-Sync-Token'].to_s token = request.authorization.to_s.delete_prefix('Bearer ').presence || request.headers['X-Tournament-Sync-Token'].to_s
return render json: { error: 'Missing sync token' }, status: :unauthorized if token.blank? return render json: { error: 'Missing sync token' }, status: :unauthorized if token.blank?
matches = token.bytesize == @tournament.sync_auth_token.to_s.bytesize && return if @tournament.sync_token_matches?(token)
ActiveSupport::SecurityUtils.secure_compare(token, @tournament.sync_auth_token.to_s)
return if matches
render json: { error: 'Invalid sync token' }, status: :unauthorized render json: { error: 'Invalid sync token' }, status: :unauthorized
end end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'securerandom' require 'securerandom'
require 'openssl'
class Tournament < ApplicationRecord class Tournament < ApplicationRecord
TIMER_MODES = %w[countdown countup].freeze TIMER_MODES = %w[countdown countup].freeze
@ -26,6 +27,7 @@ class Tournament < ApplicationRecord
after_initialize :generate_code after_initialize :generate_code
after_create_commit :ensure_default_beamer! after_create_commit :ensure_default_beamer!
before_validation :normalize_timer_reason before_validation :normalize_timer_reason
before_validation :protect_follower_sync_token
before_validation :clear_follower_sync_token_when_disabling_read_only_mode before_validation :clear_follower_sync_token_when_disabling_read_only_mode
after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state? after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state?
@ -50,7 +52,27 @@ class Tournament < ApplicationRecord
end end
def sync_accepts_push? def sync_accepts_push?
read_only_mode? && sync_auth_token.present? read_only_mode? && sync_auth_configured?
end
def sync_auth_configured?
sync_auth_token.present? || sync_auth_token_digest.present?
end
def sync_token_matches?(token)
return false if token.blank?
if sync_auth_token_digest.present?
expected_digest = self.class.sync_auth_token_digest(token)
return ActiveSupport::SecurityUtils.secure_compare(expected_digest, sync_auth_token_digest)
end
token.bytesize == sync_auth_token.to_s.bytesize &&
ActiveSupport::SecurityUtils.secure_compare(token, sync_auth_token.to_s)
end
def self.sync_auth_token_digest(token)
OpenSSL::HMAC.hexdigest('SHA256', Rails.application.secret_key_base, token.to_s)
end end
private private
@ -80,15 +102,15 @@ class Tournament < ApplicationRecord
end end
def sync_configuration_blank? def sync_configuration_blank?
sync_target_url.blank? && sync_auth_token.blank? sync_target_url.blank? && !sync_auth_configured?
end end
def sync_configuration_complete? def sync_configuration_complete?
sync_target_url.present? && sync_auth_token.present? sync_target_url.present? && sync_auth_configured?
end end
def follower_sync_token_only? def follower_sync_token_only?
read_only_mode? && sync_auth_token.present? && sync_target_url.blank? read_only_mode? && sync_auth_configured? && sync_target_url.blank?
end end
def ensure_default_beamer! def ensure_default_beamer!
@ -100,11 +122,24 @@ class Tournament < ApplicationRecord
def clear_follower_sync_token_when_disabling_read_only_mode def clear_follower_sync_token_when_disabling_read_only_mode
return if read_only_mode? return if read_only_mode?
self.sync_auth_token_digest = nil
return if sync_target_url.present? return if sync_target_url.present?
self.sync_auth_token = nil self.sync_auth_token = nil
end end
def protect_follower_sync_token
return unless read_only_mode?
if sync_auth_token.present?
self.sync_auth_token_digest = self.class.sync_auth_token_digest(sync_auth_token)
self.sync_auth_token = nil
elsif will_save_change_to_sync_auth_token? && sync_auth_token.blank?
self.sync_auth_token_digest = nil
end
end
def normalize_timer_reason def normalize_timer_reason
self.timer_reason = timer_reason.presence self.timer_reason = timer_reason.presence
self.timer_reason_text = timer_reason_text&.strip&.presence self.timer_reason_text = timer_reason_text&.strip&.presence

View File

@ -5,6 +5,7 @@ class TournamentSerializer < SimpleTournamentSerializer
:instant_finalists_amount, :intermediate_round_participants_amount :instant_finalists_amount, :intermediate_round_participants_amount
attribute :read_only_mode, if: :sync_metadata_visible? attribute :read_only_mode, if: :sync_metadata_visible?
attribute :sync_target_url, if: :sync_metadata_visible? attribute :sync_target_url, if: :sync_metadata_visible?
attribute :sync_auth_configured, if: :sync_metadata_visible?
attribute :sync_last_push_error, if: :sync_metadata_visible? attribute :sync_last_push_error, if: :sync_metadata_visible?
# NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against # NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against
@ -41,4 +42,8 @@ class TournamentSerializer < SimpleTournamentSerializer
def sync_metadata_visible? def sync_metadata_visible?
scope.present? && scope == object.owner scope.present? && scope == object.owner
end end
def sync_auth_configured
object.sync_auth_configured?
end
end end

View File

@ -24,6 +24,7 @@ class TournamentSyncSchema
read_only_mode read_only_mode
sync_target_url sync_target_url
sync_auth_token sync_auth_token
sync_auth_token_digest
sync_source_tournament_id sync_source_tournament_id
sync_last_pushed_at sync_last_pushed_at
sync_last_push_error sync_last_push_error

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
require 'openssl'
class AddSyncAuthTokenDigestToTournaments < ActiveRecord::Migration[7.0]
class MigrationTournament < ActiveRecord::Base
self.table_name = 'tournaments'
end
def up
unless column_exists?(:tournaments, :sync_auth_token_digest)
add_column :tournaments, :sync_auth_token_digest, :string
end
MigrationTournament.reset_column_information
MigrationTournament
.where(read_only_mode: true)
.where.not(sync_auth_token: [nil, ''])
.find_each do |tournament|
tournament.update!(
sync_auth_token: nil,
sync_auth_token_digest: sync_auth_token_digest(tournament.sync_auth_token)
)
end
end
def down
remove_column :tournaments, :sync_auth_token_digest if column_exists?(:tournaments, :sync_auth_token_digest)
end
private
def sync_auth_token_digest(token)
OpenSSL::HMAC.hexdigest('SHA256', Rails.application.secret_key_base, token.to_s)
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do ActiveRecord::Schema[8.1].define(version: 2026_04_30_120000) do
create_table "beamers", force: :cascade do |t| create_table "beamers", force: :cascade do |t|
t.json "config", default: {}, null: false t.json "config", default: {}, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@ -158,15 +158,16 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do
t.boolean "public", default: true t.boolean "public", default: true
t.boolean "read_only_mode", default: false, null: false t.boolean "read_only_mode", default: false, null: false
t.string "sync_auth_token" t.string "sync_auth_token"
t.string "sync_auth_token_digest"
t.datetime "sync_last_imported_snapshot_at" t.datetime "sync_last_imported_snapshot_at"
t.string "sync_last_push_error" t.string "sync_last_push_error"
t.datetime "sync_last_pushed_at" t.datetime "sync_last_pushed_at"
t.integer "sync_source_tournament_id" t.integer "sync_source_tournament_id"
t.string "sync_target_url" t.string "sync_target_url"
t.string "timer_mode" t.string "timer_mode"
t.datetime "timestamp"
t.string "timer_reason" t.string "timer_reason"
t.text "timer_reason_text" t.text "timer_reason_text"
t.datetime "timestamp"
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
t.integer "user_id", null: false t.integer "user_id", null: false
t.index ["code"], name: "index_tournaments_on_code", unique: true t.index ["code"], name: "index_tournaments_on_code", unique: true

View File

@ -24,6 +24,7 @@ Create empty tournament with:
- `sync_auth_token: <shared secret>` - `sync_auth_token: <shared secret>`
Do not send `teams` payload for follower. Backend allows empty read only follower tournament creation. Do not send `teams` payload for follower. Backend allows empty read only follower tournament creation.
The token is write-only. Backend responses never return it; owner responses only expose `sync_auth_configured`.
### 2. Create normal leader tournament on local backend ### 2. Create normal leader tournament on local backend
@ -178,9 +179,9 @@ Owner-facing frontend can expose:
Recommended owner UX: Recommended owner UX:
1. create remote follower first 1. generate a shared token outside the backend
2. copy remote `sync_auth_token` 2. create remote follower with that `sync_auth_token`
3. paste remote `sync_state` URL into leader 3. paste same token and remote `sync_state` URL into leader
4. save leader sync config 4. save leader sync config
5. show last push time / error state 5. show last push time / error state
6. allow explicit follower takeover by disabling `read_only_mode` 6. allow explicit follower takeover by disabling `read_only_mode`

View File

@ -150,9 +150,11 @@ RSpec.describe TournamentsController, type: :controller do
json = deserialize_response(response) json = deserialize_response(response)
expect(json[:read_only_mode]).to eq(true) expect(json[:read_only_mode]).to eq(true)
expect(json[:sync_target_url]).to eq('https://remote.example.com/tournaments/1/sync_state') expect(json[:sync_target_url]).to eq('https://remote.example.com/tournaments/1/sync_state')
expect(json[:sync_auth_configured]).to eq(true)
expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601) expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601)
expect(json[:sync_last_push_error]).to eq('push failed') expect(json[:sync_last_push_error]).to eq('push failed')
expect(json).not_to have_key(:sync_auth_token) expect(json).not_to have_key(:sync_auth_token)
expect(json).not_to have_key(:sync_auth_token_digest)
expect(json).to eq(TournamentSerializer.new(@tournament, scope: @tournament.owner).as_json) expect(json).to eq(TournamentSerializer.new(@tournament, scope: @tournament.owner).as_json)
end end
@ -389,8 +391,12 @@ RSpec.describe TournamentsController, type: :controller do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
tournament = Tournament.find(deserialize_response(response)[:id]) tournament = Tournament.find(deserialize_response(response)[:id])
expect(tournament.read_only_mode?).to eq(true) expect(tournament.read_only_mode?).to eq(true)
expect(tournament.sync_auth_token).to be_nil
expect(tournament.sync_auth_token_digest).to be_present
expect(tournament.teams).to be_empty expect(tournament.teams).to be_empty
expect(tournament.stages).to be_empty expect(tournament.stages).to be_empty
expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)
end end
end end
@ -602,6 +608,9 @@ RSpec.describe TournamentsController, type: :controller do
expect(@tournament.read_only_mode?).to eq(false) expect(@tournament.read_only_mode?).to eq(false)
expect(@tournament.sync_target_url).to include('/sync_state') expect(@tournament.sync_target_url).to include('/sync_state')
expect(@tournament.sync_auth_token).to eq('new-token') expect(@tournament.sync_auth_token).to eq('new-token')
expect(@tournament.sync_auth_token_digest).to be_nil
expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)
end end
it 'allows follower takeover when only read_only_mode is disabled' do it 'allows follower takeover when only read_only_mode is disabled' do
@ -618,6 +627,7 @@ RSpec.describe TournamentsController, type: :controller do
expect(@tournament.read_only_mode?).to eq(false) expect(@tournament.read_only_mode?).to eq(false)
expect(@tournament.sync_target_url).to be_blank expect(@tournament.sync_target_url).to be_blank
expect(@tournament.sync_auth_token).to be_blank expect(@tournament.sync_auth_token).to be_blank
expect(@tournament.sync_auth_token_digest).to be_blank
end end
it 'blocks normal updates while tournament is read only' do it 'blocks normal updates while tournament is read only' do
@ -818,6 +828,8 @@ RSpec.describe TournamentsController, type: :controller do
expect(@tournament.sync_source_tournament_id).to eq(123) expect(@tournament.sync_source_tournament_id).to eq(123)
expect(@tournament.name).to eq('Synced Tournament') expect(@tournament.name).to eq('Synced Tournament')
expect(@tournament.teams.pluck(:name)).to eq(['Alpha']) expect(@tournament.teams.pluck(:name)).to eq(['Alpha'])
expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)
end end
it 'rejects invalid tokens' do it 'rejects invalid tokens' do
@ -857,6 +869,9 @@ RSpec.describe TournamentsController, type: :controller do
expect(json[:id]).to eq(@tournament.id) expect(json[:id]).to eq(@tournament.id)
expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601) expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601)
expect(json[:sync_last_push_error]).to be_nil expect(json[:sync_last_push_error]).to be_nil
expect(json[:sync_auth_configured]).to eq(true)
expect(json).not_to have_key(:sync_auth_token)
expect(json).not_to have_key(:sync_auth_token_digest)
expect(TournamentSyncPusher).to have_received(:push!).with(@tournament) expect(TournamentSyncPusher).to have_received(:push!).with(@tournament)
end end

View File

@ -44,6 +44,8 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
expect(test_sync[:status]).to eq(200) expect(test_sync[:status]).to eq(200)
expect(test_sync.dig(:json, :sync_last_pushed_at)).not_to be_nil expect(test_sync.dig(:json, :sync_last_pushed_at)).not_to be_nil
expect(test_sync.dig(:json, :sync_last_push_error)).to be_nil expect(test_sync.dig(:json, :sync_last_push_error)).to be_nil
expect(test_sync.dig(:json, :sync_auth_configured)).to eq(true)
expect_no_sync_token_exposed!(test_sync, sync_token)
after_leader = fetch_tournament(leader.fetch(:id)) after_leader = fetch_tournament(leader.fetch(:id))
after_follower = fetch_tournament(follower.fetch(:id)) after_follower = fetch_tournament(follower.fetch(:id))
@ -54,6 +56,41 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
.to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) }) .to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) })
end end
it 'never exposes sync auth token after create, update, show, or sync test' do
follower_create = client.post('/tournaments', body: {
name: "Protected Follower #{SecureRandom.hex(3)}",
description: 'Follower token should stay write only',
public: true,
read_only_mode: true,
sync_auth_token: sync_token
})
expect(follower_create[:status]).to eq(201)
expect_no_sync_token_exposed!(follower_create, sync_token)
follower_id = follower_create.dig(:json, :id)
follower_owner_read = client.get("/tournaments/#{follower_id}")
expect(follower_owner_read[:status]).to eq(200)
expect(follower_owner_read.dig(:json, :sync_auth_configured)).to eq(true)
expect_no_sync_token_exposed!(follower_owner_read, sync_token)
follower_public_read = anonymous_client.get("/tournaments/#{follower_id}")
expect(follower_public_read[:status]).to eq(200)
expect_no_sync_token_exposed!(follower_public_read, sync_token)
leader = create_group_stage_tournament(name_prefix: 'Protected Leader')
configure_sync = client.patch("/tournaments/#{leader.fetch(:id)}", body: {
sync_target_url: "#{base_url}/tournaments/#{follower_id}/sync_state",
sync_auth_token: sync_token
})
expect(configure_sync[:status]).to eq(200)
expect(configure_sync.dig(:json, :sync_auth_configured)).to eq(true)
expect_no_sync_token_exposed!(configure_sync, sync_token)
test_sync = client.post("/tournaments/#{leader.fetch(:id)}/test_sync")
expect(test_sync[:status]).to eq(200)
expect_no_sync_token_exposed!(test_sync, sync_token)
end
it 'syncs team action list changes made through item id route and business key route' do it 'syncs team action list changes made through item id route and business key route' do
follower = create_follower_tournament(name_prefix: 'Sync Action Follower') follower = create_follower_tournament(name_prefix: 'Sync Action Follower')
leader = create_group_stage_tournament( leader = create_group_stage_tournament(
@ -170,6 +207,14 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
response.fetch(:json) response.fetch(:json)
end end
def expect_no_sync_token_exposed!(response, token)
serialized_json = response.fetch(:json).inspect
expect(serialized_json).not_to include('sync_auth_token')
expect(serialized_json).not_to include('sync_auth_token_digest')
expect(serialized_json).not_to include(token)
end
def finish_group_stage_and_create_playoffs!(tournament_id) def finish_group_stage_and_create_playoffs!(tournament_id)
tournament = fetch_tournament(tournament_id) tournament = fetch_tournament(tournament_id)
group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }

View File

@ -43,6 +43,40 @@ RSpec.describe Tournament, type: :model do
end end
end end
describe 'sync auth token protection' do
it 'stores follower receiver tokens only as a digest' do
tournament = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
expect(tournament.sync_auth_token).to be_nil
expect(tournament.sync_auth_token_digest).to be_present
expect(tournament.sync_token_matches?('shared-secret')).to eq(true)
expect(tournament.sync_token_matches?('wrong-secret')).to eq(false)
expect(tournament.sync_accepts_push?).to eq(true)
end
it 'keeps leader push token available for outbound sync' do
tournament = create(
:tournament,
sync_target_url: 'https://remote.example.com/tournaments/1/sync_state',
sync_auth_token: 'shared-secret'
)
expect(tournament.sync_auth_token).to eq('shared-secret')
expect(tournament.sync_auth_token_digest).to be_nil
expect(tournament.sync_push_enabled?).to eq(true)
end
it 'clears follower token digest on takeover' do
tournament = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
tournament.update!(read_only_mode: false)
expect(tournament.sync_auth_token).to be_nil
expect(tournament.sync_auth_token_digest).to be_nil
expect(tournament.sync_auth_configured?).to eq(false)
end
end
describe '#matches' do describe '#matches' do
context 'group stage tournament' do context 'group stage tournament' do
before do before do