Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ create_from(provider) # Create the user in the local app database
build_from(provider) # Build user instance using user_info_mappings
```

### JWT authentication

```ruby
jwt_require_auth # This is a before action
jwt_auth(email, password) # => return json web token
jwt_encode # This method creating JWT token by payload
jwt_decode # This method decoding JWT token
jwt_from_header # Take token from header, by key defined in config
jwt_user_data(token = jwt_from_header) # Return user data which decoded from token
jwt_user_id # Return user id from user data if id present.
jwt_not_authenticated # This method called if user not authenticated

```

### Remember Me

```ruby
Expand Down
39 changes: 38 additions & 1 deletion lib/generators/sorcery/templates/initializer.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# The first thing you need to configure is which modules you need in your app.
# The default is nothing which will include only core features (password encryption, login/logout).
# Available submodules are: :user_activation, :http_basic_auth, :remember_me,
# :reset_password, :session_timeout, :brute_force_protection, :activity_logging, :external
# :reset_password, :session_timeout, :brute_force_protection, :activity_logging, :external, :jwt_auth
Rails.application.config.sorcery.submodules = []

# Here you can configure each submodule's features.
Expand Down Expand Up @@ -523,6 +523,43 @@
# Default: `:uid`
#
# user.provider_uid_attribute_name =

# -- jwt_auth --

# Parameters passed for generating payload part of token
# Default: [:id]
#
# config.jwt_user_params =

# Payload to set like expiration time
# See https://github.com/jwt/ruby-jwt what claims can be used
# Default: `{}`
# config.jwt_payload =

# Header name which will parsed
# Default: `Authorization`
#
# config.jwt_headers_key =

# Key on which returned user data
# Default: :user_data
#
# config.jwt_user_data_key =

# Key on which returned token
# Default: :auth_token
#
# config.jwt_auth_token_key =

# A flag that specifies whether to perform a database query to set the current_user
# Default: true
#
# config.jwt_set_user =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this flag? Is there ever a case when current_user should not be an instance of a user model?


# Secret key for token generation
# Default: nil
#
# config.jwt_secret_key = '<%= SecureRandom.hex(64) %>'
end

# This line must come after the 'user config' block.
Expand Down
1 change: 1 addition & 0 deletions lib/sorcery.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ module Submodules
require 'sorcery/controller/submodules/http_basic_auth'
require 'sorcery/controller/submodules/activity_logging'
require 'sorcery/controller/submodules/external'
require 'sorcery/controller/submodules/jwt_auth'
end
end

Expand Down
20 changes: 19 additions & 1 deletion lib/sorcery/controller/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ class << self
attr_accessor :after_failed_login
attr_accessor :before_logout
attr_accessor :after_logout
attr_accessor :jwt_user_params
attr_accessor :jwt_headers_key
attr_accessor :jwt_user_data_key
attr_accessor :jwt_auth_token_key
# If true, will set user by request to db.
# If false will use data from jwt_user_params without executing db requests.
attr_accessor :jwt_set_user
attr_accessor :jwt_secret_key
attr_accessor :jwt_payload
attr_accessor :jwt_algorithm
attr_accessor :after_remember_me

def init!
Expand All @@ -32,7 +42,15 @@ def init!
:@after_logout => [],
:@after_remember_me => [],
:@save_return_to_url => true,
:@cookie_domain => nil
:@cookie_domain => nil,
:@jwt_user_params => [:id],
:@jwt_headers_key => 'Authorization',
:@jwt_user_data_key => :user_data,
:@jwt_payload => {},
:@jwt_algorithm => 'HS256',
:@jwt_auth_token_key => :auth_token,
:@jwt_set_user => true,
:@jwt_secret_key => 'default_secret_key'
}
end

Expand Down
85 changes: 85 additions & 0 deletions lib/sorcery/controller/submodules/jwt_auth.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
module Sorcery
module Controller
module Submodules
module JwtAuth
def self.included(base)
base.send(:include, InstanceMethods)
end

module InstanceMethods
# This method return generated token if user can be authenticated
def jwt_auth(*credentials)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think slightly more appropriate name for the method would be jwt_authenticate. Simply to stay consistent with User.authenticate.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method should also allow users to get login failure reason, similar to what login method does.

user = user_class.authenticate(*credentials)
if user
now = Time.current
default_payload = {
sub: user.id,
exp: (now + 3.days).to_i,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Token duration should be configurable.

iat: now.to_i
}

payload = default_payload.merge Config.jwt_payload

{ Config.jwt_user_data_key => default_payload,
Config.jwt_auth_token_key => jwt_encode(payload) }
end
end

# To be used as a before_action.
def jwt_require_auth
binding.pry
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is here by accident? :)

@current_user = Config.jwt_set_user ? User.find(jwt_user_id) : jwt_user_data
rescue JWT::DecodeError => e
jwt_not_authenticated(message: e.message) && return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is trailing return call needed here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jwt_not_authenticated is currently only called when JWT is not decoded properly. We should also handle cases when JWT is properly decoded, but the user record is missing.

end

# This method creating JWT token by payload
def jwt_encode(payload)
JWT.encode(payload, Config.jwt_secret_key, Config.jwt_algorithm)
end

# This method decoding JWT token
# Return nil if token incorrect
def jwt_decode(token)
HashWithIndifferentAccess.new(
JWT.decode(token, Config.jwt_secret_key)[0]
)
end

# Take token from header, by key defined in config
# With memoization
def jwt_from_header
@jwt_header_token ||= request.headers[Config.jwt_headers_key]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that more sensible default here would be to expect header value in one of the following formats:Bearer <jwt-token> or Token <jwt-token>. Same as what ActionController's authentication does – https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token.html

Further more, does it makes sense to allow users to define how should token be extracted from request?

end

# Return user data which decoded from token
# With memoization
def jwt_user_data(token = jwt_from_header)
@jwt_user_data ||= jwt_decode(token)
end

# Return user id from user data if id present.
# Else return nil
def jwt_user_id
jwt_user_data[:sub]
end

# This method called if user not authenticated
def jwt_not_authenticated(message:)
respond_to do |format|
format.html { not_authenticated }
format.json {
render json: {
"error": {
"message": message,
}
},
status: :unauthorized
}
end
end
end
end
end
end
end
1 change: 1 addition & 0 deletions sorcery.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Gem::Specification.new do |s|
s.add_dependency 'bcrypt', '~> 3.1'
s.add_dependency 'oauth', '~> 0.4', '>= 0.4.4'
s.add_dependency 'oauth2', '~> 1.0', '>= 0.8.0'
s.add_dependency 'jwt', '~> 2.1.0'

s.add_development_dependency 'byebug', '~> 10.0.0'
s.add_development_dependency 'rspec-rails', '~> 3.7.0'
Expand Down
121 changes: 121 additions & 0 deletions spec/controllers/controller_jwt_auth_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
require 'spec_helper'

describe SorceryController, type: :controller do
let!(:user) { double('user', id: 42) }
before(:each) do
request.env['HTTP_ACCEPT'] = "application/json" if ::Rails.version < '5.0.0'
Timecop.freeze(Time.new(2019, 01, 14, 19, 00, 00))
end

describe 'with jwt auth features' do
let(:user_email) { '[email protected]' }
let(:user_password) { 'testpass' }
let(:auth_token) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjQyLCJleHAiOjE1NDc3MTkyMDAsImlhdCI6MTU0NzQ2MDAwMH0.QM5mTkYiDwI-10cEOq4b_bfrwe99BRuef6pnIB-jqIk' }
let(:response_data) do
{
user_data: {
sub: user.id,
exp: 1547719200,
iat: 1547460000
},
auth_token: auth_token
}
end

before(:all) do
sorcery_reload!([:jwt_auth])
end

describe '#jwt_auth' do
context 'when success' do
before do
allow(User).to receive(:authenticate).with(user_email, user_password).and_return(user)

post :test_jwt_auth, params: { email: user_email, password: user_password }
end

it 'assigns user to @token variable' do
expect(assigns[:token]).to eq response_data
end
end

context 'when fails' do
before do
allow(User).to receive(:authenticate).with(user_email, user_password).and_return(nil)

post :test_jwt_auth, params: { email: user_email, password: user_password }
end

it 'assigns user to @token variable' do
expect(assigns[:token]).to eq nil
end
end
end

describe '#jwt_require_auth' do
context 'when success' do
before do
allow(User).to receive(:find).with(user.id).and_return(user)
allow(user).to receive(:set_last_activity_at)
end

it 'does return 200' do
request.headers.merge! Authorization: auth_token

get :some_action_jwt, format: :json

expect(response.status).to eq(200)
end
end

context 'when fails' do
let(:user_email) { '[email protected]' }
let(:user_password) { 'testpass' }

context 'without auth header' do
it 'does return 401' do
get :some_action_jwt, format: :json

expect(response.status).to eq(401)
expect(JSON.parse(response.body)["error"]["message"]).not_to be nil
end
end

context 'with incorrect auth header' do
let(:incorrect_header) { '123.123.123' }

it 'does return 401' do
request.headers.merge! Authorization: incorrect_header

get :some_action_jwt, format: :json

expect(response.status).to eq(401)
expect(JSON.parse(response.body)["error"]["message"]).not_to be nil
end
end

context "token is expired" do
before do
Timecop.freeze(Time.new(2099, 01, 14, 19, 00, 00))
request.headers.merge! Authorization: auth_token
end

it "does return 401" do
get :some_action_jwt, format: :json

expect(response.status).to eq(401)
expect(JSON.parse(response.body)["error"]["message"]).not_to be nil
end

after do
Timecop.return
end
end
end
end
end

after do
Timecop.return
end
end
11 changes: 11 additions & 0 deletions spec/rails_app/app/controllers/sorcery_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ class SorceryController < ActionController::Base
protect_from_forgery

before_action :require_login_from_http_basic, only: [:test_http_basic_auth]
before_action :require_login, only: [:test_logout, :test_logout_with_force_forget_me, :test_should_be_logged_in, :some_action]
before_action :jwt_require_auth, only: [:some_action_jwt]
before_action :require_login, only: %i[
test_logout
test_logout_with_force_forget_me
Expand Down Expand Up @@ -418,4 +420,13 @@ def test_create_from_provider_with_block
redirect_to 'blu', alert: 'Failed!'
end
end

def test_jwt_auth
@token = jwt_auth(params[:email], params[:password])
head :ok
end

def some_action_jwt
head :ok
end
end
2 changes: 2 additions & 0 deletions spec/rails_app/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,7 @@
post :test_login_with_remember
get :test_create_from_provider_with_block
get :login_at_test_with_state
post :test_jwt_auth
get :some_action_jwt
end
end