diff --git a/README.md b/README.md index 701bf364..1834df85 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,20 @@ login_from(provider) # Tries to login from the external provider's callback create_from(provider) # Create the user in the local app database ``` +### 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 diff --git a/lib/generators/sorcery/templates/initializer.rb b/lib/generators/sorcery/templates/initializer.rb index bb7cece2..eb58741c 100644 --- a/lib/generators/sorcery/templates/initializer.rb +++ b/lib/generators/sorcery/templates/initializer.rb @@ -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. @@ -442,6 +442,38 @@ # Default: `:uid` # # user.provider_uid_attribute_name = + + # -- jwt_auth -- + + # Parameters passed for generating payload part of token + # Default: [:id] + # + # config.jwt_user_params = + + # 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 = + + # Secret key for token generation + # Default: nil + # + # config.jwt_secret_key = '<%= SecureRandom.hex(64) %>' end # This line must come after the 'user config' block. diff --git a/lib/sorcery.rb b/lib/sorcery.rb index e0df9312..ecac1e1d 100644 --- a/lib/sorcery.rb +++ b/lib/sorcery.rb @@ -32,6 +32,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 diff --git a/lib/sorcery/controller/config.rb b/lib/sorcery/controller/config.rb index 99e1f3fb..20ec7770 100644 --- a/lib/sorcery/controller/config.rb +++ b/lib/sorcery/controller/config.rb @@ -18,6 +18,14 @@ 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 def init! @defaults = { @@ -30,7 +38,13 @@ def init! :@before_logout => [], :@after_logout => [], :@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_auth_token_key => :auth_token, + :@jwt_set_user => true, + :@jwt_secret_key => '' } end diff --git a/lib/sorcery/controller/submodules/jwt_auth.rb b/lib/sorcery/controller/submodules/jwt_auth.rb new file mode 100644 index 00000000..1efe4539 --- /dev/null +++ b/lib/sorcery/controller/submodules/jwt_auth.rb @@ -0,0 +1,75 @@ +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) + user = user_class.authenticate(*credentials) + if user + user_params = Config.jwt_user_params.each_with_object({}) do |val, acc| + acc[val] = user.public_send(val) + end + { Config.jwt_user_data_key => user_params, + Config.jwt_auth_token_key => jwt_encode(user_params) } + end + end + + # To be used as a before_action. + def jwt_require_auth + jwt_not_authenticated && return unless jwt_user_id + + @current_user = Config.jwt_set_user ? User.find(jwt_user_id) : jwt_user_data + rescue JWT::VerificationError, JWT::DecodeError + jwt_not_authenticated && return + end + + # This method creating JWT token by payload + def jwt_encode(payload) + JWT.encode(payload, Config.jwt_secret_key) + 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] + ) + rescue JWT::DecodeError + nil + 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] + 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.try(:[], :id) + end + + # This method called if user not authenticated + def jwt_not_authenticated + respond_to do |format| + format.html { not_authenticated } + format.json { render json: { status: 401 }, status: 401 } + end + end + end + end + end + end +end \ No newline at end of file diff --git a/sorcery.gemspec b/sorcery.gemspec index 644ce0ce..ed264adc 100644 --- a/sorcery.gemspec +++ b/sorcery.gemspec @@ -23,6 +23,7 @@ Gem::Specification.new do |s| s.add_dependency 'oauth', '~> 0.4', '>= 0.4.4' s.add_dependency 'oauth2', '~> 1.0', '>= 0.8.0' s.add_dependency 'bcrypt', '~> 3.1' + s.add_dependency 'jwt', '~> 1.5' s.add_development_dependency 'yard', '~> 0.6.0' s.add_development_dependency 'timecop' diff --git a/spec/controllers/controller_jwt_auth_spec.rb b/spec/controllers/controller_jwt_auth_spec.rb new file mode 100644 index 00000000..88be451c --- /dev/null +++ b/spec/controllers/controller_jwt_auth_spec.rb @@ -0,0 +1,92 @@ +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' + end + + describe 'with jwt auth features' do + let(:user_email) { 'test@test.test' } + let(:user_password) { 'testpass' } + let(:auth_token) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NDJ9.rrjj-sXvbIjT8y4MLGb88Cv7XvfpJXj-HEgaBimT_-0' } + let(:response_data) do + { + user_data: { id: user.id }, + 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) { 'test@test.test' } + 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) + 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) + end + end + end + end + end +end \ No newline at end of file diff --git a/spec/rails_app/app/controllers/sorcery_controller.rb b/spec/rails_app/app/controllers/sorcery_controller.rb index 1d916fe5..f9ea4c99 100644 --- a/spec/rails_app/app/controllers/sorcery_controller.rb +++ b/spec/rails_app/app/controllers/sorcery_controller.rb @@ -5,6 +5,7 @@ class SorceryController < ActionController::Base 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] def index; end @@ -367,4 +368,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 diff --git a/spec/rails_app/config/routes.rb b/spec/rails_app/config/routes.rb index 712d7f6a..c278163e 100644 --- a/spec/rails_app/config/routes.rb +++ b/spec/rails_app/config/routes.rb @@ -60,5 +60,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