From 1bca8cf6c5d5322b42c45a77a467c8f8251e79fa Mon Sep 17 00:00:00 2001 From: Alexey Ramazanov Date: Mon, 11 May 2020 20:27:30 +0300 Subject: [PATCH 1/2] JWT module --- README.md | 15 + .../sorcery/templates/initializer.rb | 41 +- lib/sorcery.rb | 1 + lib/sorcery/controller/submodules/jwt.rb | 139 ++++++ sorcery.gemspec | 1 + spec/controllers/controller_jwt_spec.rb | 472 ++++++++++++++++++ .../app/controllers/sorcery_controller.rb | 17 + spec/rails_app/config/routes.rb | 2 + 8 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 lib/sorcery/controller/submodules/jwt.rb create mode 100644 spec/controllers/controller_jwt_spec.rb diff --git a/README.md b/README.md index c99830ae..f6a6f4c4 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,15 @@ User.load_from_activation_token(token) @user.activate! ``` +### JWT Authentication + +```ruby +require_jwt_authentication # This is a before action +jwt_authenticate(email, password) # => {token: 'token', payload: {..}} +jwt_from_header # Token extracted from header +jwt_decoded_payload # Payload extracted from token +``` + Please see the tutorials in the github wiki for detailed usage information. ## Installation @@ -214,6 +223,12 @@ Inside the initializer, the comments will tell you what each setting does. - Configurable database column names - Authentications table +**JWT** (see [lib/sorcery/controller/submodules/jwt.rb](https://github.com/Sorcery/sorcery/blob/master/lib/sorcery/controller/submodules/jwt.rb)): + +- Token generation (various algorithms are supported) +- Before action for authenticating via header +- Configurable payload + ## Planned Features - Passing a block to encrypt, allowing the developer to define his own mix of salting and encrypting diff --git a/lib/generators/sorcery/templates/initializer.rb b/lib/generators/sorcery/templates/initializer.rb index 9b7829de..20b5ac96 100644 --- a/lib/generators/sorcery/templates/initializer.rb +++ b/lib/generators/sorcery/templates/initializer.rb @@ -3,7 +3,7 @@ # # Available submodules are: :user_activation, :http_basic_auth, :remember_me, # :reset_password, :session_timeout, :brute_force_protection, :activity_logging, -# :magic_login, :external +# :magic_login, :external, :jwt Rails.application.config.sorcery.submodules = [] # Here you can configure each submodule's features. @@ -229,6 +229,45 @@ # config.discord.secret = "xxxxxx" # config.discord.callback_url = "http://localhost:3000/oauth/callback?provider=discord" # config.discord.scope = "email guilds" + + # -- jwt -- + # REQUIRED: + # Algorithm and keys for token management. + # Default: `nil` + # Depending on algorithm keys can be equal strings - 'secret_key', 'secret_key'. + # Or a key objects - OpenSSL::PKey.read(File.read('private.key')), OpenSSL::PKey.read(File.read('public.key')). + # Check out https://github.com/jwt/ruby-jwt for more details. + # + # config.jwt_algorithm = + # config.jwt_encode_key = + # config.jwt_decode_key = + + # How long generated token is valid (in seconds). + # Default: `3600` + # + # config.jwt_lifetime = + + # Additional time (in seconds) to account for clock skew. + # Default: `30` + # + # config.jwt_lifetime_leeway = + + # Header which will be used as token source. + # Default: `'Authorization'` + # + # config.jwt_header = + + # User action to be called and merged with default payload. + # Default: `nil` + # + # config.jwt_additional_user_payload_action = + + # What controller action to call when token is invalid. + # You can also override the 'jwt_not_authenticated' method of course. + # Default: `:jwt_not_authenticated` + # + # config.jwt_not_authenticated_action = + # --- user config --- config.user_config do |user| # -- core -- diff --git a/lib/sorcery.rb b/lib/sorcery.rb index 2a0af8cc..5fbc4a04 100644 --- a/lib/sorcery.rb +++ b/lib/sorcery.rb @@ -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' end end diff --git a/lib/sorcery/controller/submodules/jwt.rb b/lib/sorcery/controller/submodules/jwt.rb new file mode 100644 index 00000000..020c8ce9 --- /dev/null +++ b/lib/sorcery/controller/submodules/jwt.rb @@ -0,0 +1,139 @@ +module Sorcery + module Controller + module Submodules + # This submodule adds support for authentication via JSON Web Tokens. + # https://jwt.io/ + module Jwt + TOKEN_REGEX = /^(Token|Bearer)\s+/.freeze + + def self.included(base) + base.send(:include, InstanceMethods) + + Config.module_eval do + class << self + attr_accessor :jwt_algorithm + attr_accessor :jwt_encode_key + attr_accessor :jwt_decode_key + attr_accessor :jwt_lifetime + attr_accessor :jwt_lifetime_leeway + attr_accessor :jwt_header + attr_accessor :jwt_additional_user_payload_action + attr_accessor :jwt_not_authenticated_action + + def merge_jwt_defaults! + @defaults.merge!(:@jwt_algorithm => nil, + :@jwt_encode_key => nil, + :@jwt_decode_key => nil, + :@jwt_lifetime => 3600, + :@jwt_lifetime_leeway => 30, + :@jwt_header => 'Authorization', + :@jwt_additional_user_payload_action => nil, + :@jwt_not_authenticated_action => :jwt_not_authenticated) + end + end + + merge_jwt_defaults! + end + + Config.login_sources << :login_from_jwt_header + end + + module InstanceMethods + # To be used as before_action. + # Will trigger auto-login attempts via the call to logged_in? + # If all attempts to auto-login fail, the failure callback will be called. + def require_jwt_authentication + return if logged_in? + + send(Config.jwt_not_authenticated_action) + end + + # Takes credentials and returns generated token on successful authentication. + # Runs hooks after login or failed login. + def jwt_authenticate(*credentials) + validate_jwt_configuration + + @current_user = nil + + user_class.authenticate(*credentials) do |user, failure_reason| + if failure_reason + after_failed_login!(credentials) + + yield(user, failure_reason) if block_given? + + break + end + + # Identical to auto_login but doesn't touch session + @current_user = user + + after_login!(user, credentials) + + yield(user, failure_reason) if block_given? + + # Return our own value, not the return_value from authentication_response + break generate_jwt(user) + end + end + + # Generate token and payload hash based on provided user + def generate_jwt(user) + now = Time.current.to_i + + payload = { sub: user.id, + iat: now, + exp: now + Config.jwt_lifetime } + + payload.merge!(user.public_send(Config.jwt_additional_user_payload_action)) if Config.jwt_additional_user_payload_action + + { token: jwt_encode(payload), + payload: payload } + end + + # Checks header for a token and tries to log in user if token is valid. + # Runs as a login source callback. Check current_user method for more details. + def login_from_jwt_header + @current_user = if jwt_decoded_payload['sub'] + user_class.sorcery_adapter.find_by_id(jwt_decoded_payload['sub']) + end + end + + # The default action for denying non-authenticated users. + # You can override this method in your controllers, + # or provide a different method in the configuration. + def jwt_not_authenticated + head :unauthorized + end + + def validate_jwt_configuration + raise ArgumentError, "To use jwt submodule, you must define an algorithm (config.jwt_algorithm = 'algorithm')." if Config.jwt_algorithm.nil? || Config.jwt_algorithm == '' + raise ArgumentError, "To use jwt submodule, you must define an encode key (config.jwt_encode_key = 'your_key')." if Config.jwt_encode_key.nil? || Config.jwt_encode_key == '' + raise ArgumentError, "To use jwt submodule, you must define a decode key (config.jwt_decode_key = 'your_key')." if Config.jwt_decode_key.nil? || Config.jwt_decode_key == '' + end + + # Token (without type) extracted from header + def jwt_from_header + @jwt_from_header ||= request.headers[Config.jwt_header].to_s.sub(TOKEN_REGEX, '') + end + + # Payload decoded from token + def jwt_decoded_payload + @jwt_decoded_payload ||= jwt_decode(jwt_from_header) + end + + # Create JWT from payload + def jwt_encode(payload) + JWT.encode(payload, Config.jwt_encode_key, Config.jwt_algorithm) + end + + # Decode payload from JWT + def jwt_decode(token) + JWT.decode(token, Config.jwt_decode_key, true, { algorithm: Config.jwt_algorithm, exp_leeway: Config.jwt_lifetime_leeway })[0] + rescue JWT::DecodeError + {} + end + end + end + end + end +end diff --git a/sorcery.gemspec b/sorcery.gemspec index 3ec42c14..83c4f206 100644 --- a/sorcery.gemspec +++ b/sorcery.gemspec @@ -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.2.0' s.add_development_dependency 'byebug', '~> 10.0.0' s.add_development_dependency 'rspec-rails', '~> 3.7.0' diff --git a/spec/controllers/controller_jwt_spec.rb b/spec/controllers/controller_jwt_spec.rb new file mode 100644 index 00000000..f9174fd1 --- /dev/null +++ b/spec/controllers/controller_jwt_spec.rb @@ -0,0 +1,472 @@ +require 'spec_helper' + +describe SorceryController, type: :controller do + let(:user) { double('user', id: 42, email: 'bla@bla.com') } + + describe 'with jwt auth features' do + before(:all) do + sorcery_reload!([:jwt]) + end + + before do + sorcery_controller_property_set(:jwt_algorithm, 'HS256') + sorcery_controller_property_set(:jwt_encode_key, 'secret_key') + sorcery_controller_property_set(:jwt_decode_key, 'secret_key') + sorcery_controller_property_set(:jwt_lifetime, 600) + sorcery_controller_property_set(:jwt_lifetime_leeway, 10) + sorcery_controller_property_set(:jwt_header, 'Authorization') + sorcery_controller_property_set(:jwt_additional_user_payload_action, nil) + + # TODO: dirty hack, fix this + allow(subject).to receive(:register_last_activity_time_to_db) + end + + describe '#require_jwt_authentication' do + before do + sorcery_controller_property_set(:jwt_not_authenticated_action, :test_not_authenticated_action) + end + + it 'triggers auto-login attempts via the call to logged_in?' do + expect(subject).to receive(:logged_in?).and_call_original + + get :test_jwt_auth + end + + context 'when login succeeds' do + before do + allow(subject).to receive(:logged_in?).and_return(true) + end + + it 'does not call not_authenticated_action' do + expect(subject).not_to receive(:test_not_authenticated_action) + + get :test_jwt_auth + end + end + + context 'when login fails' do + before do + allow(subject).to receive(:logged_in?).and_return(false) + end + + it 'calls not_authenticated_action' do + expect(subject).to receive(:test_not_authenticated_action).and_call_original + + get :test_jwt_auth + end + end + end + + describe '#jwt_authenticate' do + let(:other_user) { double('user', id: 31, email: 'user@bla.com') } + + it 'validates jwt configuration' do + expect(subject).to receive(:validate_jwt_configuration) + + subject.jwt_authenticate('bla@bla.com', 'secret') + end + + context 'when succeeds' do + before do + allow(User).to receive(:authenticate).with('bla@bla.com', 'secret') do |&block| + block.call(user, nil) + # simulate authentication_response response_value + user + end + allow(subject).to receive(:generate_jwt).and_return({ token: 'token', payload: {} }) + allow(user).to receive(:email=) + end + + it 'returns hash with jwt token' do + get :test_jwt_login, params: { email: 'bla@bla.com', password: 'secret' } + + expect(assigns[:result]).to eq({ token: 'token', payload: {}}) + end + + it 'updates current user' do + # simulate situation when other user is logged in + subject.auto_login(other_user) + expect(subject.current_user).to eq(other_user) + + get :test_jwt_login, params: { email: 'bla@bla.com', password: 'secret' } + + expect(subject.current_user).to eq(user) + end + + it 'calls block' do + expect(user).to receive(:email=).with('some@email.com') + + get :test_jwt_login, params: { email: 'bla@bla.com', password: 'secret' } + end + + it 'runs after_login callbacks' do + expect(subject).to receive(:after_login!).with(user, %w[bla@bla.com secret]) + + get :test_jwt_login, params: { email: 'bla@bla.com', password: 'secret' } + end + end + + context 'when fails' do + before do + allow(User).to receive(:authenticate).with('bla@bla.com', 'secret') do |&block| + block.call(nil, :invalid_login) + # simulate authentication_response response_value + false + end + end + + it 'returns nil' do + get :test_jwt_login, params: { email: 'bla@bla.com', password: 'secret' } + + expect(assigns[:result]).to be_nil + end + + it 'updates current user' do + # simulate situation when other user is logged in + subject.auto_login(other_user) + expect(subject.current_user).to eq(other_user) + + get :test_jwt_login, params: { email: 'bla@bla.com', password: 'secret' } + + expect(subject.current_user).to be_nil + end + + it 'calls block' do + get :test_jwt_login, params: { email: 'bla@bla.com', password: 'secret' } + + expect(assigns[:error]).to eq(:invalid_login) + end + + it 'runs after_failed_login callbacks' do + expect(subject).to receive(:after_failed_login!).with(%w[bla@bla.com secret]) + + get :test_jwt_login, params: { email: 'bla@bla.com', password: 'secret' } + end + end + end + + describe '#generate_jwt' do + # Number of seconds - 1_589_100_200 + let(:current_time) { Time.new(2020, 5, 10, 11, 43, 20) } + let(:expected_payload) do + { sub: 42, + iat: 1_589_100_200, + exp: 1_589_100_800, + email: 'bla@bla.com' } + end + + before do + sorcery_controller_property_set(:jwt_additional_user_payload_action, :jwt_custom_payload) + Timecop.freeze(current_time) + end + after { Timecop.return } + + it 'returns hash with token and payload' do + expect(subject).to receive(:jwt_encode).with(expected_payload).and_return('token') + expect(user).to receive(:jwt_custom_payload).and_return({ email: 'bla@bla.com' }) + + jwt = subject.generate_jwt(user) + + expect(jwt[:token]).to eq('token') + expect(jwt[:payload]).to eq(expected_payload) + end + end + + describe '#login_from_jwt_header' do + let(:other_user) { double('user', id: 31, email: 'user@bla.com') } + + before do + # simulate situation when other user is logged in + subject.auto_login(other_user) + expect(subject.current_user).to eq(other_user) + end + + context 'jwt is present in header and correctly decoded' do + before do + allow(subject).to receive(:jwt_decoded_payload).and_return({ 'sub' => 42 }) + end + + it 'updates current user' do + expect(User.sorcery_adapter).to receive(:find_by_id).with(42).and_return(user) + + subject.login_from_jwt_header + + expect(subject.current_user).to eq(user) + end + end + + context 'jwt is missing or incorrect' do + before do + allow(subject).to receive(:jwt_decoded_payload).and_return({}) + end + + it 'updates current user' do + expect(User.sorcery_adapter).not_to receive(:find_by_id) + + subject.login_from_jwt_header + + expect(subject.current_user).to be_nil + end + end + end + + describe '#jwt_not_authenticated' do + it 'returns unauthorized header' do + expect(subject).to receive(:head).with(:unauthorized).and_return('HTTP/1.1 401 Unauthorized') + + expect(subject.jwt_not_authenticated).to eq('HTTP/1.1 401 Unauthorized') + end + end + + describe '#validate_jwt_configuration' do + context 'configuration is valid' do + it 'does not raise any exceptions' do + expect { subject.validate_jwt_configuration }.not_to raise_error + end + end + + context 'jwt_algorithm is missing' do + before { sorcery_controller_property_set(:jwt_algorithm, '') } + + it 'raises an exception' do + expect { subject.validate_jwt_configuration }.to raise_error(ArgumentError) + end + end + + context 'jwt_encode_key is missing' do + before { sorcery_controller_property_set(:jwt_encode_key, '') } + + it 'raises an exception' do + expect { subject.validate_jwt_configuration }.to raise_error(ArgumentError) + end + end + + context 'jwt_decode_key is missing' do + before { sorcery_controller_property_set(:jwt_decode_key, '') } + + it 'raises an exception' do + expect { subject.validate_jwt_configuration }.to raise_error(ArgumentError) + end + end + end + + describe '#jwt_from_header' do + context 'token is present without type' do + before { request.headers['Authorization'] = 'token' } + + it 'returns token' do + expect(subject.jwt_from_header).to eq('token') + end + end + + context 'token is present with Token type' do + before { request.headers['Authorization'] = 'Token token' } + + it 'returns token' do + expect(subject.jwt_from_header).to eq('token') + end + end + + context 'token is present with Bearer type' do + before { request.headers['Authorization'] = 'Bearer token' } + + it 'returns token' do + expect(subject.jwt_from_header).to eq('token') + end + end + + context 'token is present with invalid type' do + before { request.headers['Authorization'] = 'Some token' } + + it 'returns full header as token' do + expect(subject.jwt_from_header).to eq('Some token') + end + end + + context 'token is missing' do + before { request.headers['Authorization'] = nil } + + it 'returns empty string' do + expect(subject.jwt_from_header).to eq('') + end + end + + context 'header is missing' do + it 'returns empty string' do + expect(subject.jwt_from_header).to eq('') + end + end + end + + describe '#jwt_decoded_payload' do + it 'returns decoded payload from token stored in header' do + expect(subject).to receive(:jwt_from_header).and_return('token') + expect(subject).to receive(:jwt_decode).with('token').and_return({ 'sub' => 1 }) + expect(subject.jwt_decoded_payload).to eq({ 'sub' => 1 }) + end + end + + describe 'jwt encoding and decoding' do + # Number of seconds - 1_589_100_200 + let(:current_time) { Time.new(2020, 5, 10, 11, 43, 20) } + # token valid for 10 minutes + let(:payload) do + { 'sub' => 42, + 'iat' => 1_589_100_200, + 'exp' => 1_589_100_800 } + end + let(:plain_text_key) { 'secret_key' } + # generated using OpenSSL::PKey::RSA.generate(2048) + let(:rsa_private_key) do + <<~PRIVATEKEY + -----BEGIN RSA PRIVATE KEY----- + MIIEowIBAAKCAQEAng2jkht0lZa6kvReXTj5mPirDb41Sm48cBySIeGZhr/WDQLf + SvzC8vLXCatx0OwIWT4uYvLbpsVXjJFgb3eIJ/9hWdf/1BeSw9FMtbXg5kvy7+iz + aMgl6sssTCqc7sLgh5BQ6hclN3ivuZH8mBOaWjPQXXed4zd4DrtKCN0ObrMz7jDT + zYG/PN9Iy6SWeKhvPzIp397Na6b6c91ZHjYr+Ueo0PDcWkeAsJXy8SilNCYNajLh + jWbb9sY94PQ6+j49OpvfUvc1utjcE76Jl9MCqiuhpD49VRPYngyBWdniDWhvnkst + mReBteFmgrsyTnLCDFYwgAAx6XO9zdyS4yS3CwIDAQABAoIBAElfW4gAZubqykJe + X1A3mueAySfgHS0ob7Y8DTrdWEBN3ji8FJzjKj1OrrU2eefbKyUC0NXumDmbc0E2 + W+ZjPzoSPEdRFtqG9wMgrtPMU1OV/nmRNXh3MeMF3tKdFa1hmopUXLvPct+Fj04+ + j1yp/QXS9+/sD8fjgECWgZALzx9kJvDgHfFJjPPD/UKr7g3yc+Qiqw7km/Ix5D+G + ZhagDDQ1o9j02rAH4hi6qmsBbeLlyyt/Ainm+FZcVPR5aftw55ipdJW2M1BXKrXg + HQkWBYoswcIeIxucA02DUjWA/X5b4It2Kru+oGO1iAZIjVFctwkuvbdEs8PD4JgF + lxOfH4ECgYEAzZ8bhATm9s0I2JkUkI58DbcqZIqxpKwlZbxh+4/ccgmLqczUBJnI + fOTeJRDTJm3Q+lCvfzAF3AnxMZe24G1b0OxYrD9ZdC4fbVKQOlQB04SXvbdJa62E + gUbkYmuRvM0gJWu2YSQ8i7mIUQ2Nry5fovO1rSShysSpyZzPCF3ArhMCgYEAxMb6 + zKRCNfs9Ws1x7VQwzu46ISM7NETgPUMjigJZs9eetqn4uytNyhn3ME+S4RYArXFX + YTZ2DPB+ygqD+v01mWTN5GRfAnQzYdrfH1OLDZ9Dr0tmia4Hk043ImybDX6JmEnz + UxIo4I9Qkhfw9yklKJr31I5OWzmXofeDfCw2kikCgYEApWyM4YBkJEg+BqvZTJcl + HI+wrmSamEXabGfLWGybyK7/SqM8K1thXYFvatiHV1JgHxIMrsF+5VCmV+SbvyCc + DpAmoqTwnbSBmh0jZZmyQm5Y+ctcaSGXCb5z/O5XuFI6u4BVoP9bKnogPj0uMLKZ + RGrXTa278HqZslbShQOQATsCgYAKFR/4qFn0JiFoq6owvOWbVL2JwSJhdT4AJZaG + lcQ+4MdzGJZ0EK31swrlYM5n1hbGzE3r3zyBQTld5NgKXjsG1xFtqG7t00Jmuy4/ + jqpLUmPHcZeZal9c/t74VpRDRr6KHQ/oq7+Icg9wzOU95M/QmtAkBf6h0fuhAuur + yyAosQKBgC7QP7/yV+bNVZO0rsiApKwVlSSGu6T7unUUOnJsTyl17tFCkU3cn/1E + 1OrilgLt77O4yeQaJf9KUgFLcNVIU3gyhf4BIX2m7t5Z4rYzqIdt4OcxpvaU5x9J + taaOtqVlt4Sblou0UP178lNPe6ODc6GLteGgHGvJz6fKRN5T3757 + -----END RSA PRIVATE KEY----- + PRIVATEKEY + end + let(:rsa_public_key) do + <<~PUBLICKEY + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAng2jkht0lZa6kvReXTj5 + mPirDb41Sm48cBySIeGZhr/WDQLfSvzC8vLXCatx0OwIWT4uYvLbpsVXjJFgb3eI + J/9hWdf/1BeSw9FMtbXg5kvy7+izaMgl6sssTCqc7sLgh5BQ6hclN3ivuZH8mBOa + WjPQXXed4zd4DrtKCN0ObrMz7jDTzYG/PN9Iy6SWeKhvPzIp397Na6b6c91ZHjYr + +Ueo0PDcWkeAsJXy8SilNCYNajLhjWbb9sY94PQ6+j49OpvfUvc1utjcE76Jl9MC + qiuhpD49VRPYngyBWdniDWhvnkstmReBteFmgrsyTnLCDFYwgAAx6XO9zdyS4yS3 + CwIDAQAB + -----END PUBLIC KEY----- + PUBLICKEY + end + + describe '#jwt_encode' do + context 'password-based algorithm' do + before do + sorcery_controller_property_set(:jwt_encode_key, plain_text_key) + sorcery_controller_property_set(:jwt_algorithm, 'HS256') + end + + it 'returns token' do + expect(subject.jwt_encode(payload)).to eq('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjQyLCJpYXQiOjE1ODkxMDAyMDAsImV4cCI6MTU4OTEwMDgwMH0.nPWyXQzqJqkMTMGuEzWtkeXN2BpVYecr3Lmpd99MJhY') + end + end + + context 'key-based algorithm' do + before do + sorcery_controller_property_set(:jwt_encode_key, OpenSSL::PKey.read(rsa_private_key)) + sorcery_controller_property_set(:jwt_algorithm, 'RS256') + end + + it 'returns token' do + expect(subject.jwt_encode(payload)).to eq('eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOjQyLCJpYXQiOjE1ODkxMDAyMDAsImV4cCI6MTU4OTEwMDgwMH0.E69mTt7gKJHN_QXD5YfFYA4CdQKMYVDotNkzekN62WYurpNynEQGz1W6PABTSLCPQPTFXmUDgzvc1JedQ3MQtpcKSbaowxiQBh0_wHwPca9hzQDZH8tBiujcYemY5URJRZUOO9d3izHpAg35GcODQays0cnK1ianshp9nLzExogKv0e5WMXHlCaH7Hxw2gNaTKO5S_qKeE0J-6WMk1FbSYCgBfSmVnf1lx9LNKkF0l_dejZEBVczzVAK5agbbTiG6yunzewKpBwGcVR4CGG2Xbsh6Ey3SwoOqygrrosxMP3d_DlfUntKKLwn6O2p7jGqnYGGZj8MMe7UKQN8X65dxQ') + end + end + end + + describe '#jwt_decode' do + context 'password-based algorithm' do + let(:token) { 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjQyLCJpYXQiOjE1ODkxMDAyMDAsImV4cCI6MTU4OTEwMDgwMH0.nPWyXQzqJqkMTMGuEzWtkeXN2BpVYecr3Lmpd99MJhY' } + + before do + sorcery_controller_property_set(:jwt_decode_key, plain_text_key) + sorcery_controller_property_set(:jwt_algorithm, 'HS256') + end + + context 'token has not expired' do + before { Timecop.freeze(current_time) } + after { Timecop.return } + + it 'returns payload' do + expect(subject.jwt_decode(token)).to eq(payload) + end + end + + context 'token has expired' do + before { Timecop.freeze(current_time + 11.minutes) } + after { Timecop.return } + + it 'returns empty hash' do + expect(subject.jwt_decode(token)).to eq({}) + end + end + + context 'token is within leeway' do + before do + Timecop.freeze(current_time + 11.minutes) + # token is valid for 10 minutes, we are 11 minutes ahead, so adding 61 second + sorcery_controller_property_set(:jwt_lifetime_leeway, 61) + end + after { Timecop.return } + + it 'returns payload' do + expect(subject.jwt_decode(token)).to eq(payload) + end + end + end + + context 'key-based algorithm' do + let(:token) { 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOjQyLCJpYXQiOjE1ODkxMDAyMDAsImV4cCI6MTU4OTEwMDgwMH0.E69mTt7gKJHN_QXD5YfFYA4CdQKMYVDotNkzekN62WYurpNynEQGz1W6PABTSLCPQPTFXmUDgzvc1JedQ3MQtpcKSbaowxiQBh0_wHwPca9hzQDZH8tBiujcYemY5URJRZUOO9d3izHpAg35GcODQays0cnK1ianshp9nLzExogKv0e5WMXHlCaH7Hxw2gNaTKO5S_qKeE0J-6WMk1FbSYCgBfSmVnf1lx9LNKkF0l_dejZEBVczzVAK5agbbTiG6yunzewKpBwGcVR4CGG2Xbsh6Ey3SwoOqygrrosxMP3d_DlfUntKKLwn6O2p7jGqnYGGZj8MMe7UKQN8X65dxQ' } + + before do + sorcery_controller_property_set(:jwt_decode_key, OpenSSL::PKey.read(rsa_public_key)) + sorcery_controller_property_set(:jwt_algorithm, 'RS256') + end + + context 'token has not expired' do + before { Timecop.freeze(current_time) } + after { Timecop.return } + + it 'returns payload' do + expect(subject.jwt_decode(token)).to eq(payload) + end + end + + context 'token has expired' do + before { Timecop.freeze(current_time + 11.minutes) } + after { Timecop.return } + + it 'returns empty hash' do + expect(subject.jwt_decode(token)).to eq({}) + end + end + + context 'token is within leeway' do + before do + Timecop.freeze(current_time + 11.minutes) + # token is valid for 10 minutes, we are 11 minutes ahead, so adding 61 second + sorcery_controller_property_set(:jwt_lifetime_leeway, 61) + end + after { Timecop.return } + + it 'returns payload' do + expect(subject.jwt_decode(token)).to eq(payload) + end + end + end + end + end + end +end diff --git a/spec/rails_app/app/controllers/sorcery_controller.rb b/spec/rails_app/app/controllers/sorcery_controller.rb index 1f843c37..159db4d7 100644 --- a/spec/rails_app/app/controllers/sorcery_controller.rb +++ b/spec/rails_app/app/controllers/sorcery_controller.rb @@ -4,6 +4,7 @@ class SorceryController < ActionController::Base protect_from_forgery before_action :require_login_from_http_basic, only: [:test_http_basic_auth] + before_action :require_jwt_authentication, only: [:test_jwt_auth] before_action :require_login, only: %i[ test_logout test_logout_with_forget_me @@ -83,6 +84,18 @@ def test_login_with_remember_in_login head :ok end + def test_jwt_login + @result = jwt_authenticate(params[:email], params[:password]) do |user, failure_reason| + if failure_reason + @error = failure_reason + else + user.email = 'some@email.com' + end + end + + head :ok + end + def test_login_from_cookie @user = current_user head :ok @@ -100,6 +113,10 @@ def test_http_basic_auth head :ok end + def test_jwt_auth + head :ok + end + def login_at_test_twitter login_at(:twitter) end diff --git a/spec/rails_app/config/routes.rb b/spec/rails_app/config/routes.rb index 1ae84fb4..cd2a6231 100644 --- a/spec/rails_app/config/routes.rb +++ b/spec/rails_app/config/routes.rb @@ -74,5 +74,7 @@ post :test_login_with_remember get :test_create_from_provider_with_block get :login_at_test_with_state + post :test_jwt_login + get :test_jwt_auth end end From 7509316fb785286029e98fbe3f006f1694d77f96 Mon Sep 17 00:00:00 2001 From: Alexey Ramazanov Date: Mon, 11 May 2020 20:43:29 +0300 Subject: [PATCH 2/2] Fix TZ bug in specs --- spec/controllers/controller_jwt_spec.rb | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/spec/controllers/controller_jwt_spec.rb b/spec/controllers/controller_jwt_spec.rb index f9174fd1..bc908146 100644 --- a/spec/controllers/controller_jwt_spec.rb +++ b/spec/controllers/controller_jwt_spec.rb @@ -146,12 +146,12 @@ end describe '#generate_jwt' do - # Number of seconds - 1_589_100_200 - let(:current_time) { Time.new(2020, 5, 10, 11, 43, 20) } + # Number of seconds - 1_589_111_000 + let(:current_time) { Time.utc(2020, 5, 10, 11, 43, 20) } let(:expected_payload) do { sub: 42, - iat: 1_589_100_200, - exp: 1_589_100_800, + iat: 1_589_111_000, + exp: 1_589_111_600, email: 'bla@bla.com' } end @@ -307,13 +307,13 @@ end describe 'jwt encoding and decoding' do - # Number of seconds - 1_589_100_200 - let(:current_time) { Time.new(2020, 5, 10, 11, 43, 20) } + # Number of seconds - 1_589_111_000 + let(:current_time) { Time.utc(2020, 5, 10, 11, 43, 20) } # token valid for 10 minutes let(:payload) do { 'sub' => 42, - 'iat' => 1_589_100_200, - 'exp' => 1_589_100_800 } + 'iat' => 1_589_111_000, + 'exp' => 1_589_111_600 } end let(:plain_text_key) { 'secret_key' } # generated using OpenSSL::PKey::RSA.generate(2048) @@ -370,7 +370,7 @@ end it 'returns token' do - expect(subject.jwt_encode(payload)).to eq('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjQyLCJpYXQiOjE1ODkxMDAyMDAsImV4cCI6MTU4OTEwMDgwMH0.nPWyXQzqJqkMTMGuEzWtkeXN2BpVYecr3Lmpd99MJhY') + expect(subject.jwt_encode(payload)).to eq('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjQyLCJpYXQiOjE1ODkxMTEwMDAsImV4cCI6MTU4OTExMTYwMH0.QYLB-Dd4iGQmSF-uyWXM93ER0fbSWJddjYFzdwFBEJA') end end @@ -381,14 +381,14 @@ end it 'returns token' do - expect(subject.jwt_encode(payload)).to eq('eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOjQyLCJpYXQiOjE1ODkxMDAyMDAsImV4cCI6MTU4OTEwMDgwMH0.E69mTt7gKJHN_QXD5YfFYA4CdQKMYVDotNkzekN62WYurpNynEQGz1W6PABTSLCPQPTFXmUDgzvc1JedQ3MQtpcKSbaowxiQBh0_wHwPca9hzQDZH8tBiujcYemY5URJRZUOO9d3izHpAg35GcODQays0cnK1ianshp9nLzExogKv0e5WMXHlCaH7Hxw2gNaTKO5S_qKeE0J-6WMk1FbSYCgBfSmVnf1lx9LNKkF0l_dejZEBVczzVAK5agbbTiG6yunzewKpBwGcVR4CGG2Xbsh6Ey3SwoOqygrrosxMP3d_DlfUntKKLwn6O2p7jGqnYGGZj8MMe7UKQN8X65dxQ') + expect(subject.jwt_encode(payload)).to eq('eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOjQyLCJpYXQiOjE1ODkxMTEwMDAsImV4cCI6MTU4OTExMTYwMH0.KHLryqKjLD6ESNzpb4hbYyIjxTz8-FcMTYhJZn81XBr4b7oZioNbeYKhzR3BVyGFfFFMjWNsLa_P30xiEXL4BRha8eUMhBeM_1ilWZM2lKEK2Sk3Iuf__lp-ZnQYALT9Oe1n3fv7yJCLvqTUHOXQuzftxeQ7CgAEVDsefT-7npW1ym-ompFiYl4M9GzC8VX0HCeN_T3i02fEc9fAQlIhMRhhe38OCv3lnxcOeLPcd7eOLwCNSWBVnupZiNsIjIyhhh9ApJqVUI5j2n1azyDueajvvr2c0L444Yf5Q44KMZgJ4S7RkhKuS6tzfH6Qyjn3HtPTL8R8xP9MGnTk8p4f_Q') end end end describe '#jwt_decode' do context 'password-based algorithm' do - let(:token) { 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjQyLCJpYXQiOjE1ODkxMDAyMDAsImV4cCI6MTU4OTEwMDgwMH0.nPWyXQzqJqkMTMGuEzWtkeXN2BpVYecr3Lmpd99MJhY' } + let(:token) { 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjQyLCJpYXQiOjE1ODkxMTEwMDAsImV4cCI6MTU4OTExMTYwMH0.QYLB-Dd4iGQmSF-uyWXM93ER0fbSWJddjYFzdwFBEJA' } before do sorcery_controller_property_set(:jwt_decode_key, plain_text_key) @@ -428,7 +428,7 @@ end context 'key-based algorithm' do - let(:token) { 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOjQyLCJpYXQiOjE1ODkxMDAyMDAsImV4cCI6MTU4OTEwMDgwMH0.E69mTt7gKJHN_QXD5YfFYA4CdQKMYVDotNkzekN62WYurpNynEQGz1W6PABTSLCPQPTFXmUDgzvc1JedQ3MQtpcKSbaowxiQBh0_wHwPca9hzQDZH8tBiujcYemY5URJRZUOO9d3izHpAg35GcODQays0cnK1ianshp9nLzExogKv0e5WMXHlCaH7Hxw2gNaTKO5S_qKeE0J-6WMk1FbSYCgBfSmVnf1lx9LNKkF0l_dejZEBVczzVAK5agbbTiG6yunzewKpBwGcVR4CGG2Xbsh6Ey3SwoOqygrrosxMP3d_DlfUntKKLwn6O2p7jGqnYGGZj8MMe7UKQN8X65dxQ' } + let(:token) { 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOjQyLCJpYXQiOjE1ODkxMTEwMDAsImV4cCI6MTU4OTExMTYwMH0.KHLryqKjLD6ESNzpb4hbYyIjxTz8-FcMTYhJZn81XBr4b7oZioNbeYKhzR3BVyGFfFFMjWNsLa_P30xiEXL4BRha8eUMhBeM_1ilWZM2lKEK2Sk3Iuf__lp-ZnQYALT9Oe1n3fv7yJCLvqTUHOXQuzftxeQ7CgAEVDsefT-7npW1ym-ompFiYl4M9GzC8VX0HCeN_T3i02fEc9fAQlIhMRhhe38OCv3lnxcOeLPcd7eOLwCNSWBVnupZiNsIjIyhhh9ApJqVUI5j2n1azyDueajvvr2c0L444Yf5Q44KMZgJ4S7RkhKuS6tzfH6Qyjn3HtPTL8R8xP9MGnTk8p4f_Q' } before do sorcery_controller_property_set(:jwt_decode_key, OpenSSL::PKey.read(rsa_public_key))