Skip to content

Commit 6610165

Browse files
authored
Merge pull request #6 from andreobrown/add-jwt-token-authentication
Add jwt token authentication
2 parents f49572e + ea20f52 commit 6610165

File tree

16 files changed

+357
-5
lines changed

16 files changed

+357
-5
lines changed

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,5 @@ end
6363
gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby]
6464

6565
gem "devise", "~> 4.7", ">= 4.7.1"
66+
67+
gem "devise-jwt", "~> 0.6.0"

Gemfile.lock

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,19 @@ GEM
8080
railties (>= 4.1.0)
8181
responders
8282
warden (~> 1.2.3)
83+
devise-jwt (0.6.0)
84+
devise (~> 4.0)
85+
warden-jwt_auth (~> 0.4)
86+
dry-auto_inject (0.7.0)
87+
dry-container (>= 0.3.4)
88+
dry-configurable (0.9.0)
89+
concurrent-ruby (~> 1.0)
90+
dry-core (~> 0.4, >= 0.4.7)
91+
dry-container (0.7.2)
92+
concurrent-ruby (~> 1.0)
93+
dry-configurable (~> 0.1, >= 0.1.3)
94+
dry-core (0.4.9)
95+
concurrent-ruby (~> 1.0)
8396
erubi (1.9.0)
8497
execjs (2.7.0)
8598
ffi (1.12.2)
@@ -90,6 +103,7 @@ GEM
90103
io-like (0.3.1)
91104
jbuilder (2.10.0)
92105
activesupport (>= 5.0.0)
106+
jwt (2.2.1)
93107
listen (3.1.5)
94108
rb-fsevent (~> 0.9, >= 0.9.4)
95109
rb-inotify (~> 0.9, >= 0.9.7)
@@ -188,6 +202,11 @@ GEM
188202
execjs (>= 0.3.0, < 3)
189203
warden (1.2.8)
190204
rack (>= 2.0.6)
205+
warden-jwt_auth (0.4.2)
206+
dry-auto_inject (~> 0.6)
207+
dry-configurable (~> 0.9, < 0.11)
208+
jwt (~> 2.1)
209+
warden (~> 1.2)
191210
web-console (3.7.0)
192211
actionview (>= 5.0)
193212
activemodel (>= 5.0)
@@ -210,6 +229,7 @@ DEPENDENCIES
210229
chromedriver-helper
211230
coffee-rails (~> 4.2)
212231
devise (~> 4.7, >= 4.7.1)
232+
devise-jwt (~> 0.6.0)
213233
jbuilder (~> 2.5)
214234
listen (>= 3.0.5, < 3.2)
215235
pg (>= 0.18, < 2.0)

README.md

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,183 @@ Devise is used for authentication and was setup as follows:
8080

8181
Only return Customer's orders when listing and searching.
8282
Associate Orders to Customers on creation (using `current_customer`).
83-
83+
84+
### Add Token Authentication
85+
86+
This section [follows this guide](https://medium.com/@brentkearney/json-web-token-jwt-and-html-logins-with-devise-and-ruby-on-rails-5-9d5e8195193d).
87+
88+
1. Add [`devise-jwt`](https://github.com/waiting-for-dev/devise-jwt) gem
89+
90+
2. Configure Devise and Warden for JWT
91+
92+
A few things that I don't understand here:
93+
94+
* What exactly do these changes do and why are they needed?
95+
96+
* skip_session_storage - what's the purpose of setting this?
97+
98+
* config.navigational_formats - why does this need to be set?
99+
100+
3. Configure routes for API login and logout
101+
102+
Questions:
103+
104+
* Why is the following code needed inside the api route:
105+
106+
```
107+
devise_scope :customer do
108+
get "login", to: "customers/sessions#new"
109+
delete "logout", to: "customers/sessions#destroy"
110+
end
111+
```
112+
113+
4. Update the Customer table to add field for jti
114+
115+
We are using the [JTIMatcher recovation strategy](https://github.com/waiting-for-dev/devise-jwt#revocation-strategies)
116+
117+
I had to uncomment `class_name: "ApiCustomer",` in `routes.rb` to get this to run, since the model hasn't been setup yet.
118+
119+
Update the Customer table using the following migration:
120+
121+
`rails generate migration AddJTIToCustomers`
122+
123+
```
124+
#db/migrate/20200517053419_add_jti_to_customers.rb
125+
class AddJtiToCustomers < ActiveRecord::Migration[5.2]
126+
def change
127+
add_column :customers, :jti, :string
128+
# populate jti so we can make it not nullable
129+
Customer.all.each do |customer|
130+
customer.update_column(:jti, SecureRandom.uuid)
131+
end
132+
change_column_null :customers, :jti, false
133+
add_index :customers, :jti, unique: true
134+
end
135+
end
136+
```
137+
138+
`rails db:migrate`
139+
140+
5. Update the Customer model to ensure that the jti column is filled out at time of Customer creation
141+
142+
```
143+
before_create :add_jti
144+
145+
def add_jti
146+
self.jti ||= SecureRandom.uuid
147+
end
148+
```
149+
150+
6. Add an ApiCustomer model, as a sub-class of Customer
151+
152+
```
153+
class ApiCustomer < Customer
154+
include Devise::JWT::RevocationStrategies::JTIMatcher
155+
devise :jwt_authenticatable, jwt_revocation_strategy: self
156+
validates :jti, presence: true
157+
158+
def generate_jwt
159+
JWT.encode({ id: id,
160+
exp: 1.day.from_now.to_i },
161+
Rails.env.devise.jwt.secret_key)
162+
end
163+
end
164+
```
165+
166+
Question: why couldn't this have been in the regular Customer model?
167+
168+
7. Configure json requests to use `api_customer` scope for authentication.
169+
170+
```
171+
# Disable CSRF protection for json calls
172+
protect_from_forgery with: :exception, unless: :json_request?
173+
protect_from_forgery with: :null_session, if: :json_request?
174+
skip_before_action :verify_authenticity_token, if: :json_request?
175+
rescue_from ActionController::InvalidAuthenticityToken,
176+
with: :invalid_auth_token
177+
# Set the current customer so that Devise and other gems that use `current_customer` can work.
178+
before_action :set_current_customer, if: :json_request?
179+
180+
private
181+
def json_request?
182+
request.format.json?
183+
end
184+
# Use api_customer Devise scope for JSON access
185+
def authenticate_customer!(*args)
186+
super and return unless args.blank?
187+
json_request? ? authenticate_api_customer! : super
188+
end
189+
190+
def invalid_auth_token
191+
respond_to do |format|
192+
format.html { redirect_to sign_in_path,
193+
error: 'Login invalid or expired' }
194+
format.json { head 401 }
195+
end
196+
end
197+
198+
# So we can use Pundit policies for api_customers
199+
def set_current_customer
200+
@current_customer ||= warden.authenticate(scope: :api_customer)
201+
end
202+
```
203+
204+
Is this all so that we can have a different set of behaviours for API users (vs. browser users)?
205+
206+
8. Override API SessionsController
207+
208+
This controller responds with json by default, signs in the user and returns the jwt token. I'm guessing that this sign in process is what allows the token to be used transparently and what allows `current_customer` to be set so other controllers just work?
209+
210+
```
211+
class Api::SessionsController < Devise::SessionsController
212+
# I'm guessing this isn't required since we don't track signed in/signed out status for the API user?
213+
skip_before_action :verify_signed_out_user
214+
# This sets the default response format to json instead of html
215+
respond_to :json
216+
# POST /api/login
217+
def create
218+
unless request.format == :json
219+
sign_out # why is this needed?
220+
render status: 406,
221+
json: { message: "JSON requests only." } and return
222+
end
223+
# auth_options should have `scope: :api_customer`
224+
resource = warden.authenticate!(auth_options)
225+
if resource.blank?
226+
render status: 401,
227+
json: { response: "Access denied." } and return
228+
end
229+
sign_in(resource_name, resource)
230+
respond_with resource, location: after_sign_in_path_for(resource) do |format|
231+
format.json {
232+
render json: { success: true,
233+
jwt: current_token,
234+
response: "Authentication successful" }
235+
}
236+
end
237+
end
238+
239+
private
240+
241+
def current_token
242+
request.env["warden-jwt_auth.token"]
243+
end
244+
end
245+
```
246+
9. Add “new” view in json format
247+
248+
If this file isn't added, the follow error is generated when attempting to login:
249+
250+
```
251+
undefined method `api_customers_url' for #<Api::SessionsController:0x00007fb9ded22298> Did you mean? api_customer_session_url
252+
253+
actionpack (5.2.4.2) lib/action_dispatch/routing/polymorphic_routes.rb:232:in `polymorphic_method'
254+
```
255+
256+
10. Add jwt_key_base to credentials file
257+
258+
Generate the key with `rake secret`
259+
260+
Run `rails credentials:edit`
261+
262+
Add the generated key as `jwt_key_base`.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
class Api::SessionsController < Devise::SessionsController
2+
# I'm guessing this isn't required since we don't track signed in/signed out status for the API user?
3+
skip_before_action :verify_signed_out_user
4+
# This sets the default response format to json instead of html
5+
respond_to :json
6+
# POST /api/login
7+
def create
8+
unless request.format == :json
9+
sign_out # why is this needed?
10+
render status: 406,
11+
json: { message: "JSON requests only." } and return
12+
end
13+
# auth_options should have `scope: :api_customer`
14+
resource = warden.authenticate!(auth_options)
15+
if resource.blank?
16+
render status: 401,
17+
json: { response: "Access denied." } and return
18+
end
19+
sign_in(resource_name, resource)
20+
respond_with resource, location: after_sign_in_path_for(resource) do |format|
21+
format.json {
22+
render json: { success: true,
23+
jwt: current_token,
24+
response: "Authentication successful" }
25+
}
26+
end
27+
end
28+
29+
private
30+
31+
def current_token
32+
request.env["warden-jwt_auth.token"]
33+
end
34+
end
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
11
class ApplicationController < ActionController::Base
2+
protect_from_forgery with: :exception, unless: :json_request?
3+
protect_from_forgery with: :null_session, if: :json_request?
4+
skip_before_action :verify_authenticity_token, if: :json_request?
5+
rescue_from ActionController::InvalidAuthenticityToken,
6+
with: :invalid_auth_token
7+
before_action :set_current_customer, if: :json_request?
8+
29
def after_sign_in_path_for(resource)
310
stored_location_for(resource) || orders_path
411
end
12+
13+
private
14+
15+
def json_request?
16+
request.format.json?
17+
end
18+
19+
# Use api_customer Devise scope for JSON access
20+
def authenticate_customer!(*args)
21+
super and return unless args.blank?
22+
json_request? ? authenticate_api_customer! : super
23+
end
24+
25+
def invalid_auth_token
26+
respond_to do |format|
27+
format.html {
28+
redirect_to sign_in_path,
29+
error: "Login invalid or expired"
30+
}
31+
format.json { head 401 }
32+
end
33+
end
34+
35+
# So we can use Pundit policies for api_customers
36+
def set_current_customer
37+
@current_customer ||= warden.authenticate(scope: :api_customer)
38+
end
539
end

app/models/api_customer.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class ApiCustomer < Customer
2+
include Devise::JWT::RevocationStrategies::JTIMatcher
3+
devise :jwt_authenticatable, jwt_revocation_strategy: self
4+
validates :jti, presence: true
5+
6+
def generate_jwt
7+
JWT.encode({ id: id,
8+
exp: 1.day.from_now.to_i },
9+
Rails.env.devise.jwt.secret_key)
10+
end
11+
end

app/models/customer.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,9 @@ class Customer < ApplicationRecord
55
:recoverable, :rememberable, :validatable
66

77
has_many :orders
8+
before_create :add_jti
9+
10+
def add_jti
11+
self.jti ||= SecureRandom.uuid
12+
end
813
end

app/views/devise/sessions/new.json

Whitespace-only changes.

config/credentials.yml.enc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
q7NI51Rvs8SkUC2eIuGLombFLD/RMeq7fEHLQ6X7oHwRTlCuGrExrnNBaINzcJYXW+SdOgdqBDPrFKuOZGMya3mZW7/k8th2wYmUQgqyDafMN2yGYl1Ss58LxKjwE4Vi5ZfEshUNuTST7F9mgFENBnGBF4Q1dD3nQiMbv+h1x0d64XeHSrIiCCvp8EO94WJrBM4x7Jv6U5mHROLeDWVOOQlb+cV1ykM0NVIBm/VEV/prQ/GKWY/9iDvfPwISS3SKb57STSOQ48hrHsG4gwILh3ALdOTd4IISv0b/FnifHwKQt0EPi4PRXVLXCtMCsxZpxLLNT0ZciiwLmCyvk9j5g2SIwf3irHHz/Ry9rOzedpnTecuFIuuVZ5Qne/FaoNWD/5kcTuqapyDiNpWLDtHtph4fZR79gZR72DW7--Mpmw1UCr7shP2BZc--Vs7kJ8FS34x2HLyWIOZJwQ==
1+
T/rPArLPifpyj9Jumylrxl8xEXYhL0ag510hH3UDn2DIad+0CI7b8/IIVNhfvrizjwgjGvJpd4O5Oa/NPA4HVx5J0tsl407DT6tR/DU21NwvblHlgg0qtK67pxunpyYN0NTS5upAWa7vEudgljE5uN1a6il0HThyCoFByoE1JEgV8sfgTOydfBntXLRnZIka3nVITwVeM3BPCjcO0qKPnAYg40yJikuImUWJQVcNHcD3KAZVsAE/ozREb3wLhv4ixgz/5Fn0F6oAJjAHut1IZTXXQy35zkBa62rRCZ/cKZWFXooXbP8I4IzQcOvi3ThTylPfiD/DFmI35f0qsLsUI9cM9AyxG4HFfPTB3xg9tAF8KlrX6uNJxpo/IheLD3C3PnE1o84E71ru/y5dtNZ6OcRsyRlPHZMqETqtTy0QoE4dMVkpGnvTQRUbAGVYJJoARXsTBHRgJZtHNpZIhoBViE5DZLVa2wQVLHzw8gMjVgYN2Wb9Qpi275eoDXJ8dgG5i0gujLPr/v0e91LMqjf0lfFey5NrP1JbO1jxljSA714byTkk7KjpVMFWD3+yUOwj9sn50ddx3kO3pbgTIoMz4Vm4NEna--LuHbZh359xnHArEt--MOPUGr81ytYCmtppA8x9gg==

config/initializers/devise.rb

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
# Configure the e-mail address which will be shown in Devise::Mailer,
1919
# note that it will be overwritten if you use your own mailer class
2020
# with default "from" parameter.
21-
config.mailer_sender = 'please-change-me-at-config-initializers-devise@example.com'
21+
config.mailer_sender = "please-change-me-at-config-initializers-devise@example.com"
2222

2323
# Configure the class responsible to send e-mails.
2424
# config.mailer = 'Devise::Mailer'
@@ -30,7 +30,7 @@
3030
# Load and configure the ORM. Supports :active_record (default) and
3131
# :mongoid (bson_ext recommended) by default. Other ORMs may be
3232
# available as additional gems.
33-
require 'devise/orm/active_record'
33+
require "devise/orm/active_record"
3434

3535
# ==> Configuration for any authentication mechanism
3636
# Configure which keys are used when authenticating a user. The default is
@@ -296,4 +296,20 @@
296296
# When set to false, does not sign a user in automatically after their password is
297297
# changed. Defaults to true, so a user is signed in automatically after changing a password.
298298
# config.sign_in_after_change_password = true
299+
config.jwt do |jwt|
300+
jwt.secret = Rails.application.credentials.jwt_key_base
301+
jwt.dispatch_requests = [
302+
["POST", %r{^/api/login$}],
303+
["POST", %r{^/api/login.json$}],
304+
]
305+
jwt.revocation_requests = [
306+
["DELETE", %r{^/api/logout$}],
307+
["DELETE", %r{^/api/logout.json$}],
308+
]
309+
jwt.expiration_time = 1.day.to_i
310+
jwt.request_formats = { api_customer: [:json] }
311+
end
312+
313+
config.skip_session_storage = [:http_auth]
314+
config.navigational_formats = ["*/*", :html, :json]
299315
end

0 commit comments

Comments
 (0)