Skip to content

Commit 88544f1

Browse files
committed
API Access Tokens, encrypted and signed
1 parent 32054c5 commit 88544f1

22 files changed

+812
-24
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ gem 'sass-rails', '~> 4.0'
1010
gem 'sqlite3'
1111
gem 'turbolinks'
1212
gem 'uglifier', '>= 1.3.0'
13+
gem 'symmetric-encryption', '~> 3.8.1'
1314

1415
gem 'quiet_assets', group: :development
1516

Gemfile.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ GEM
5454
ffi (~> 1.0, >= 1.0.11)
5555
cliver (0.3.2)
5656
coderay (1.1.0)
57+
coercible (1.0.0)
58+
descendants_tracker (~> 0.0.1)
5759
coffee-rails (4.1.0)
5860
coffee-script (>= 2.2.0)
5961
railties (>= 4.0.0, < 5.0)
@@ -67,6 +69,8 @@ GEM
6769
safe_yaml (~> 1.0.0)
6870
database_cleaner (1.4.1)
6971
debug_inspector (0.0.2)
72+
descendants_tracker (0.0.4)
73+
thread_safe (~> 0.3, >= 0.3.1)
7074
devise (3.5.1)
7175
bcrypt (~> 3.0)
7276
orm_adapter (~> 0.1)
@@ -226,6 +230,8 @@ GEM
226230
activesupport (>= 3.0)
227231
sprockets (>= 2.8, < 4.0)
228232
sqlite3 (1.3.10)
233+
symmetric-encryption (3.8.1)
234+
coercible (~> 1.0)
229235
thor (0.19.1)
230236
thread_safe (0.3.5)
231237
tilt (1.4.1)
@@ -282,6 +288,7 @@ DEPENDENCIES
282288
spring
283289
spring-commands-rspec
284290
sqlite3
291+
symmetric-encryption (~> 3.8.1)
285292
turbolinks
286293
uglifier (>= 1.3.0)
287294
vcr

README.md

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Hopefully this will be of help to those of you learning RSpec and Rails. If ther
2222

2323
- [Support Configuration](#support-configuration)
2424
- [Run Specs in a Random Order](#run-specs-in-a-random-order)
25+
- [Testing Production Errors](#testing-production-errors)
2526
- [Testing Rake Tasks with RSpec](#testing-rake-tasks-with-rspec)
2627
- [Pry-rescue debugging](#pry-rescue-debugging)
2728
- [Time Travel Examples](#time-travel-examples)
@@ -41,6 +42,7 @@ Hopefully this will be of help to those of you learning RSpec and Rails. If ther
4142
- [Matchers](#matchers)
4243
- [Generators](#generators)
4344
- [Feature Specs & Docs](#feature-specs--docs)
45+
- [API Request Specs, Docs, & Helpers](#api-request-specs-docs--helpers)
4446
- [Mailer Specs & Docs](#mailer-specs--docs)
4547
- [Controller Specs & Docs](#controller-specs--docs)
4648
- [View Specs & Docs](#view-specs--docs)
@@ -73,12 +75,18 @@ The specs run in a random order each time the test suite is run. This helps prev
7375
Random order test runs are configured using the `config.order = :random` and `Kernel.srand config.seed` options in [spec/spec_helper.rb](spec/spec_helper.rb).
7476

7577

78+
# Testing Production Errors
79+
80+
When errors are raised, the Rails test environment may not behave as in production. The test environment may mask the actual error response you want to test. To help with this, you can disable test environment exception handling temporarily, [spec/support/error_responses.rb](spec/support/error_responses.rb) provides `respond_without_detailed_exceptions`. See it in use in [spec/api/v1/token_spec.rb](spec/api/v1/token_spec.rb) to provide production-like error responses in the test environment.
81+
82+
7683
# Testing Rake Tasks with RSpec
7784

7885
RSpec testing Rake task configuration and example:
7986
- [spec/support/tasks.rb](spec/support/tasks.rb)
8087
- [spec/tasks/subscription_tasks_spec.rb](spec/tasks/subscription_tasks_spec.rb)
8188

89+
8290
# Pry-rescue debugging
8391
pry-rescue can be used to debug failing specs, by opening pry's debugger whenever a test failure is encountered. For setup and usage see [pry-rescue's README](https://github.com/ConradIrwin/pry-rescue).
8492

@@ -208,6 +216,7 @@ Custom matchers configuration how-to and examples:
208216
- Chainable matcher: [spec/matchers/be_confirm_subscription_page.rb](spec/matchers/be_confirm_subscription_page.rb)
209217
- [spec/matchers/have_error_messages.rb](spec/matchers/have_error_messages.rb)
210218
- [spec/features/subscribe_to_newsletter_spec.rb](spec/features/subscribe_to_newsletter_spec.rb)
219+
- Lightweight matcher with `satisfy`: [spec/api/v1/token_spec.rb](spec/api/v1/token_spec.rb)
211220

212221

213222
# RSpec-Expectations Docs
@@ -220,41 +229,47 @@ Custom matchers configuration how-to and examples:
220229
- [spec/controllers/subscriptions_controller_spec.rb](spec/controllers/subscriptions_controller_spec.rb)
221230
- [spec/mailers/subscription_mailer_spec.rb](spec/mailers/subscription_mailer_spec.rb)
222231
- [spec/models/subscription_spec.rb](spec/models/subscription_spec.rb)
223-
- [RSpec Mocks API](https://relishapp.com/rspec/rspec-mocks/v/3-1/docs)
232+
- [RSpec Mocks API](https://relishapp.com/rspec/rspec-mocks/docs)
224233

225234
# RSpec-Rails
226-
See [RSpec Rails](https://relishapp.com/rspec/rspec-rails/v/3-1/docs) for installation instructions.
235+
See [RSpec Rails](https://relishapp.com/rspec/rspec-rails/docs) for installation instructions.
227236

228237
## Matchers
229-
- https://relishapp.com/rspec/rspec-rails/v/3-1/docs/matchers
238+
- https://relishapp.com/rspec/rspec-rails/docs/matchers
230239

231240
## Generators
232-
- https://relishapp.com/rspec/rspec-rails/v/3-1/docs/generators
241+
- https://relishapp.com/rspec/rspec-rails/docs/generators
233242

234243
## Feature Specs & Docs
235244
- [spec/features/subscribe_to_newsletter_spec.rb](spec/features/subscribe_to_newsletter_spec.rb)
236-
- [Feature specs API](https://relishapp.com/rspec/rspec-rails/v/3-1/docs/feature-specs/feature-spec)
245+
- [Feature specs API](https://relishapp.com/rspec/rspec-rails/docs/feature-specs/feature-spec)
246+
247+
## API Request Specs, Docs, & Helpers
248+
- [spec/api/v1/token_spec.rb](spec/api/v1/token_spec.rb)
249+
- [spec/support/json_helper.rb](spec/support/json_helper.rb)
250+
- [spec/support/error_responses.rb](spec/support/error_responses.rb)
251+
- [Request specs API](https://relishapp.com/rspec/rspec-rails/docs/request-specs/request-spec)
237252

238253
## Mailer Specs & Docs
239254
- [spec/mailers/subscription_mailer_spec.rb](spec/mailers/subscription_mailer_spec.rb)
240-
- [Mailer specs API](https://relishapp.com/rspec/rspec-rails/v/3-1/docs/mailer-specs/url-helpers-in-mailer-examples)
255+
- [Mailer specs API](https://relishapp.com/rspec/rspec-rails/docs/mailer-specs/url-helpers-in-mailer-examples)
241256

242257
## Controller Specs & Docs
243258
- [spec/controllers/subscriptions_controller_spec.rb](spec/controllers/subscriptions_controller_spec.rb)
244-
- [Controller specs API](https://relishapp.com/rspec/rspec-rails/v/3-1/docs/controller-specs)
259+
- [Controller specs API](https://relishapp.com/rspec/rspec-rails/docs/controller-specs)
245260
- [Controller specs cheatsheet](https://gist.github.com/eliotsykes/5b71277b0813fbc0df56)
246261

247262
## View Specs & Docs
248263
- [The Big List of View Specs](https://eliotsykes.com/view-specs)
249-
- [View specs API](https://relishapp.com/rspec/rspec-rails/v/3-3/docs/view-specs)
264+
- [View specs API](https://relishapp.com/rspec/rspec-rails/docs/view-specs)
250265

251266
## Helper Specs & Docs
252267
- [spec/helpers/application_helper_spec.rb](spec/helpers/application_helper_spec.rb)
253-
- [Helper specs API](https://relishapp.com/rspec/rspec-rails/v/3-1/docs/helper-specs/helper-spec)
268+
- [Helper specs API](https://relishapp.com/rspec/rspec-rails/docs/helper-specs/helper-spec)
254269

255270
## Routing Specs & Docs
256271
- [spec/routing/subscriptions_routing_spec.rb](spec/routing/subscriptions_routing_spec.rb)
257-
- [Routing specs API](https://relishapp.com/rspec/rspec-rails/v/3-1/docs/routing-specs)
272+
- [Routing specs API](https://relishapp.com/rspec/rspec-rails/docs/routing-specs)
258273

259274

260275
# Enable Spring for RSpec

app/controllers/api/api_controller.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class Api::ApiController < ActionController::Base
2+
protect_from_forgery with: :null_session
3+
4+
def self.disable_turbolinks_cookies
5+
skip_before_action :set_request_method_cookie
6+
end
7+
8+
disable_turbolinks_cookies
9+
end
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
class Api::V1::AccessTokensController < Api::ApiController
2+
3+
def create
4+
respond_to_json do
5+
render json: { access_token: token_guardian.issue_token }, status: :created
6+
end
7+
end
8+
9+
private
10+
11+
def respond_to_json
12+
assert_request_content_type Mime::JSON
13+
respond_to { |format| format.json { yield } }
14+
end
15+
16+
def assert_request_content_type(content_type)
17+
raise UnsupportedMediaType unless request.content_type == content_type
18+
end
19+
20+
def token_guardian
21+
@token_guardian ||= TokenGuardian.build(controller: self)
22+
end
23+
24+
end

app/models/access_token.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class AccessToken < ActiveRecord::Base
2+
include HardenedToken
3+
hardened_token
4+
5+
belongs_to :user
6+
7+
end

app/models/concerns/hardened_token.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
module HardenedToken
2+
extend ActiveSupport::Concern
3+
4+
LOCATOR_LENGTH = { bytes: 48.freeze, chars: 64.freeze }.freeze
5+
SECRET_LENGTH = { bytes: 192.freeze, chars: 256.freeze }.freeze
6+
7+
def unencrypted
8+
"#{locator}:#{secret}"
9+
end
10+
11+
class_methods do
12+
13+
def hardened_token
14+
attr_encrypted :secret, random_iv: true
15+
16+
before_validation do
17+
self.locator = self.class.generate_locator if locator.blank?
18+
self.secret = self.class.generate_secret if secret.blank?
19+
end
20+
21+
validates_length_of :locator, is: LOCATOR_LENGTH[:chars]
22+
validates_uniqueness_of :locator, case_sensitive: true
23+
24+
validates_length_of :secret, is: SECRET_LENGTH[:chars]
25+
validates :encrypted_secret, symmetric_encryption: true
26+
end
27+
28+
def generate_locator
29+
SecureRandom.urlsafe_base64 LOCATOR_LENGTH[:bytes]
30+
end
31+
32+
def generate_secret
33+
SecureRandom.urlsafe_base64 SECRET_LENGTH[:bytes]
34+
end
35+
end
36+
37+
end

app/models/token_guardian.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
class TokenGuardian
2+
3+
attr_reader :encryptor, :params
4+
5+
def self.build(controller:)
6+
# Borrow MessageEncryptor, thank you CookieJar.
7+
encryptor = controller.request.cookie_jar.encrypted.instance_variable_get :@encryptor
8+
TokenGuardian.new(encryptor: encryptor, params: controller.params)
9+
end
10+
11+
def initialize(encryptor:, params:)
12+
@encryptor = encryptor
13+
@params = params
14+
end
15+
16+
def issue_token
17+
user = authenticate_user
18+
access_token = user.issue_access_token
19+
encryptor.encrypt_and_sign(access_token.unencrypted)
20+
end
21+
22+
private
23+
24+
def authenticate_user
25+
authenticate_user_with_devise
26+
end
27+
28+
def authenticate_user_with_devise
29+
user = User.find_for_database_authentication(email: user_params[:email])
30+
31+
if user && user.valid_for_authentication? { user.valid_password?(user_params[:password]) }
32+
user.update_attribute(:failed_attempts, 0) unless user.failed_attempts.zero?
33+
return user
34+
end
35+
36+
raise UnauthorizedAccess
37+
end
38+
39+
def user_params
40+
@user_params ||= if @user_params.nil?
41+
user_parameters = params.require(:user)
42+
user_parameters.require(:email)
43+
user_parameters.require(:password)
44+
user_parameters.permit(:email, :password)
45+
end
46+
end
47+
end

app/models/user.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,10 @@ class User < ActiveRecord::Base
44
devise :database_authenticatable, :registerable,
55
:recoverable, :rememberable, :trackable, :validatable,
66
:confirmable, :lockable
7+
8+
has_many :access_tokens, dependent: :destroy
9+
10+
def issue_access_token
11+
access_tokens.create!
12+
end
713
end

config/initializers/devise.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,11 @@
169169
# :time = Re-enables login after a certain amount of time (see :unlock_in below)
170170
# :both = Enables both strategies
171171
# :none = No unlock strategy. You should handle unlocking by yourself.
172-
# config.unlock_strategy = :both
172+
config.unlock_strategy = :none
173173

174174
# Number of authentication tries before locking an account if lock_strategy
175175
# is failed attempts.
176-
config.maximum_attempts = 3
176+
config.maximum_attempts = 10
177177

178178
# Time interval to unlock the account if :time is enabled as unlock_strategy.
179179
# config.unlock_in = 1.hour

0 commit comments

Comments
 (0)