Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Requres signed requests for non-authenticated routes #10

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions app/controllers/accounts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 12 additions & 9 deletions app/controllers/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions app/lib/signed_request.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 9 additions & 3 deletions config/secrets-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,35 @@
# 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: <provision new API key on SendGrid>
SENDGRID_API_URL: https://api.sendgrid.com/v3/mail/send
SENDGRID_FROM_EMAIL: <provision single sender email address on SendGrid>
GITHUB_ACCOUNT_URL: https://api.github.com/user

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: <provision new API key on SendGrid>
SENDGRID_API_URL: https://api.sendgrid.com/v3/mail/send
SENDGRID_FROM_EMAIL: <provision single sender email address on SendGrid>
GITHUB_ACCOUNT_URL: https://api.github.com/user

production:
SECURE_SCHEME: HTTPS
DATABASE_URL: <do not edit - allow production server to set>
MSG_KEY: <`rake new_key:msg`>
DB_KEY: <`rake new_key:db`>
DATABASE_URL: <do not edit - allow production server to set>
SIGNING_KEY: <`rake newkey:signing`> # needed by client app; used in api tests
VERIFY_KEY: <`rake newkey:signing`>
SENDGRID_API_KEY: <provision new API key on SendGrid>
SENDGRID_API_URL: https://api.sendgrid.com/v3/mail/send
SENDGRID_FROM_EMAIL: <provision single sender email address on SendGrid>
Expand Down
14 changes: 11 additions & 3 deletions spec/integration/api_accounts_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
21 changes: 15 additions & 6 deletions spec/integration/api_auth_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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
Expand All @@ -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']
Expand All @@ -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']
Expand Down