From ba27065d2d703599f08812d9664b620f8a9197dd Mon Sep 17 00:00:00 2001 From: Soumya Ray Date: Thu, 17 Jun 2021 16:21:53 +0800 Subject: [PATCH] Requres signed requests for non-authenticated routes --- Rakefile | 14 ++++++-- app/controllers/accounts.rb | 11 ++++--- app/controllers/auth.rb | 21 +++++++----- app/lib/signed_request.rb | 47 +++++++++++++++++++++++++++ config/secrets-example.yml | 12 +++++-- spec/integration/api_accounts_spec.rb | 14 ++++++-- spec/integration/api_auth_spec.rb | 21 ++++++++---- 7 files changed, 112 insertions(+), 28 deletions(-) create mode 100644 app/lib/signed_request.rb diff --git a/Rakefile b/Rakefile index d925066..fc39edf 100644 --- a/Rakefile +++ b/Rakefile @@ -101,17 +101,27 @@ namespace :db do end namespace :newkey do + task(:load_libs) { require_app 'lib' } + desc 'Create sample cryptographic key for database' - task :db do + task :db => :load_libs do require_app('lib') puts "DB_KEY: #{SecureDB.generate_key}" end desc 'Create sample cryptographic key for tokens and messaging' - task :msg do + task :msg => :load_libs do require_app('lib') puts "MSG_KEY: #{AuthToken.generate_key}" end + + desc 'Create sample sign/verify keypair for signed communication' + task :signing => :load_libs do + keypair = SignedRequest.generate_keypair + + puts "SIGNING_KEY: #{keypair[:signing_key]}" + puts " VERIFY_KEY: #{keypair[:verify_key]}" + end end namespace :run do diff --git a/app/controllers/accounts.rb b/app/controllers/accounts.rb index 1866bd4..22298b3 100644 --- a/app/controllers/accounts.rb +++ b/app/controllers/accounts.rb @@ -28,19 +28,20 @@ class Api < Roda # POST api/v1/accounts routing.post do - new_data = JSON.parse(routing.body.read) - new_account = Account.new(new_data) - raise('Could not save account') unless new_account.save + account_data = SignedRequest.new(Api.config).parse(request.body.read) + new_account = Account.create(account_data) response.status = 201 response['Location'] = "#{@account_route}/#{new_account.username}" { message: 'Account created', data: new_account }.to_json rescue Sequel::MassAssignmentRestriction - Api.logger.warn "MASS-ASSIGNMENT:: #{new_data.keys}" + Api.logger.warn "MASS-ASSIGNMENT:: #{account_data.keys}" routing.halt 400, { message: 'Illegal Request' }.to_json + rescue SignedRequest::VerificationError + routing.halt 403, { message: 'Must sign request' }.to_json rescue StandardError => e Api.logger.error 'Unknown error saving account' - routing.halt 500, { message: e.message }.to_json + routing.halt 500, { message: 'Error creating account' }.to_json end end end diff --git a/app/controllers/auth.rb b/app/controllers/auth.rb index 2e9eafd..7b702a3 100644 --- a/app/controllers/auth.rb +++ b/app/controllers/auth.rb @@ -7,11 +7,17 @@ module Credence # Web controller for Credence API class Api < Roda route('auth') do |routing| # rubocop:disable Metrics/BlockLength + # All requests in this route require signed requests + begin + @request_data = SignedRequest.new(Api.config).parse(request.body.read) + rescue SignedRequest::VerificationError + routing.halt '403', { message: 'Must sign request' }.to_json + end + routing.on 'register' do # POST api/v1/auth/register routing.post do - reg_data = JSON.parse(request.body.read, symbolize_names: true) - VerifyRegistration.new(reg_data).call + VerifyRegistration.new(@request_data).call response.status = 202 { message: 'Verification email sent' }.to_json @@ -29,8 +35,7 @@ class Api < Roda routing.is 'authenticate' do # POST /api/v1/auth/authenticate routing.post do - credentials = JSON.parse(request.body.read, symbolize_names: true) - auth_account = AuthenticateAccount.call(credentials) + auth_account = AuthenticateAccount.call(@request_data) { data: auth_account }.to_json rescue AuthenticateAccount::UnauthorizedError routing.halt '401', { message: 'Invalid credentials' }.to_json @@ -39,12 +44,10 @@ class Api < Roda # POST /api/v1/auth/sso routing.post 'sso' do - auth_request = JSON.parse(request.body.read, symbolize_names: true) - - auth_account = AuthorizeSso.new.call(auth_request[:access_token]) + auth_account = AuthorizeSso.new.call(@request_data[:access_token]) { data: auth_account }.to_json - rescue StandardError => error - puts "FAILED to validate Github account: #{error.inspect}" + rescue StandardError => e + puts "FAILED to validate Github account: #{e.inspect}" puts error.backtrace routing.halt 400 end diff --git a/app/lib/signed_request.rb b/app/lib/signed_request.rb new file mode 100644 index 0000000..58b7c21 --- /dev/null +++ b/app/lib/signed_request.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'rbnacl' +require 'base64' + +# Verifies digitally signed requests +class SignedRequest + class VerificationError < StandardError; end + + def initialize(config) + @verify_key = Base64.strict_decode64(config.VERIFY_KEY) + @config = config # For SIGNING_KEY during tests + end + + def self.generate_keypair + signing_key = RbNaCl::SigningKey.generate + verify_key = signing_key.verify_key + + { signing_key: Base64.strict_encode64(signing_key), + verify_key: Base64.strict_encode64(verify_key) } + end + + def parse(signed_json) + parsed = JSON.parse(signed_json, symbolize_names: true) + parsed[:data] if verify(parsed[:data], parsed[:signature]) + end + + # Signing for internal tests (should be same as client method) + def sign(message) + signing_key = Base64.strict_decode64(@config.SIGNING_KEY) + signature = RbNaCl::SigningKey.new(signing_key) + .sign(message.to_json) + .then { |sig| Base64.strict_encode64(sig) } + + { data: message, signature: signature } + end + + private + + def verify(message, signature64) + signature = Base64.strict_decode64(signature64) + verifier = RbNaCl::VerifyKey.new(@verify_key) + verifier.verify(signature, message.to_json) + rescue StandardError + raise VerificationError + end +end diff --git a/config/secrets-example.yml b/config/secrets-example.yml index a313260..206bc0c 100644 --- a/config/secrets-example.yml +++ b/config/secrets-example.yml @@ -2,9 +2,11 @@ # COPY and this file to secrets.yml and modify as needed development: SECURE_SCHEME: HTTP + DATABASE_URL: sqlite://app/db/store/development.db MSG_KEY: QwsjC6WdxnNYjiWn5qOFp4xBRFcWY+wjrARjV0Vz3cA= DB_KEY: pFrP9v4qQNRvpEeLl4RL0s8C3pmMyOKTYrjHhb2rq4g= - DATABASE_URL: sqlite://app/db/store/development.db + SIGNING_KEY: nNuYJVHnMTPfArqFo3Rb81xvXoPDuqcdoUhjWVfGooE= + VERIFY_KEY: j50XXfk5tXJ9oWomovLVOFlbgKKY/YSDPHAy4s1fA6U= SENDGRID_API_KEY: SENDGRID_API_URL: https://api.sendgrid.com/v3/mail/send SENDGRID_FROM_EMAIL: @@ -12,9 +14,11 @@ development: test: SECURE_SCHEME: HTTP + DATABASE_URL: sqlite://app/db/store/test.db MSG_KEY: QwsjC6WdxnNYjiWn5qOFp4xBRFcWY+wjrARjV0Vz3cA= DB_KEY: pFrP9v4qQNRvpEeLl4RL0s8C3pmMyOKTYrjHhb2rq4g= - DATABASE_URL: sqlite://app/db/store/test.db + SIGNING_KEY: nNuYJVHnMTPfArqFo3Rb81xvXoPDuqcdoUhjWVfGooE= + VERIFY_KEY: j50XXfk5tXJ9oWomovLVOFlbgKKY/YSDPHAy4s1fA6U= SENDGRID_API_KEY: SENDGRID_API_URL: https://api.sendgrid.com/v3/mail/send SENDGRID_FROM_EMAIL: @@ -22,9 +26,11 @@ test: production: SECURE_SCHEME: HTTPS + DATABASE_URL: MSG_KEY: <`rake new_key:msg`> DB_KEY: <`rake new_key:db`> - DATABASE_URL: + SIGNING_KEY: <`rake newkey:signing`> # needed by client app; used in api tests + VERIFY_KEY: <`rake newkey:signing`> SENDGRID_API_KEY: SENDGRID_API_URL: https://api.sendgrid.com/v3/mail/send SENDGRID_FROM_EMAIL: diff --git a/spec/integration/api_accounts_spec.rb b/spec/integration/api_accounts_spec.rb index 1b6cae8..1460214 100644 --- a/spec/integration/api_accounts_spec.rb +++ b/spec/integration/api_accounts_spec.rb @@ -35,7 +35,8 @@ end it 'HAPPY: should be able to create new accounts' do - post 'api/v1/accounts', @account_data.to_json + post 'api/v1/accounts', + SignedRequest.new(app.config).sign(@account_data).to_json _(last_response.status).must_equal 201 _(last_response.headers['Location'].size).must_be :>, 0 @@ -48,13 +49,20 @@ _(account.password?('not_really_the_password')).must_equal false end - it 'BAD: should not create account with illegal attributes' do + it 'BAD MASS_ASSIGNMENT: should not accept illegal attributes' do bad_data = @account_data.clone bad_data['created_at'] = '1900-01-01' - post 'api/v1/accounts', bad_data.to_json + post 'api/v1/accounts', + SignedRequest.new(app.config).sign(bad_data).to_json _(last_response.status).must_equal 400 _(last_response.headers['Location']).must_be_nil end + + it 'BAD SIGNED_REQUEST: should not accept unsigned requests' do + post 'api/v1/accounts', @account_data.to_json + _(last_response.status).must_equal 403 + _(last_response.headers['Location']).must_be_nil + end end end diff --git a/spec/integration/api_auth_spec.rb b/spec/integration/api_auth_spec.rb index 46d32e2..71cdabd 100644 --- a/spec/integration/api_auth_spec.rb +++ b/spec/integration/api_auth_spec.rb @@ -20,7 +20,9 @@ it 'HAPPY: should authenticate valid credentials' do credentials = { username: @account_data['username'], password: @account_data['password'] } - post 'api/v1/auth/authenticate', credentials.to_json, @req_header + post 'api/v1/auth/authenticate', + SignedRequest.new(app.config).sign(credentials).to_json, + @req_header auth_account = JSON.parse(last_response.body)['data'] account = auth_account['attributes']['account']['attributes'] @@ -31,10 +33,13 @@ end it 'BAD: should not authenticate invalid password' do - credentials = { username: @account_data['username'], - password: 'fakepassword' } + bad_credentials = { username: @account_data['username'], + password: 'fakepassword' } + + post 'api/v1/auth/authenticate', + SignedRequest.new(app.config).sign(bad_credentials).to_json, + @req_header - post 'api/v1/auth/authenticate', credentials.to_json, @req_header result = JSON.parse(last_response.body) _(last_response.status).must_equal 401 @@ -59,7 +64,9 @@ it 'HAPPY AUTH SSO: should authenticate+authorize new valid SSO account' do gh_access_token = { access_token: GOOD_GH_ACCESS_TOKEN } - post 'api/v1/auth/sso', gh_access_token.to_json, @req_header + post 'api/v1/auth/sso', + SignedRequest.new(app.config).sign(gh_access_token).to_json, + @req_header auth_account = JSON.parse(last_response.body)['data'] account = auth_account['attributes']['account']['attributes'] @@ -77,7 +84,9 @@ ) gh_access_token = { access_token: GOOD_GH_ACCESS_TOKEN } - post 'api/v1/auth/sso', gh_access_token.to_json, @req_header + post 'api/v1/auth/sso', + SignedRequest.new(app.config).sign(gh_access_token).to_json, + @req_header auth_account = JSON.parse(last_response.body)['data'] account = auth_account['attributes']['account']['attributes']