api_guard_grape 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +690 -0
- data/Rakefile +21 -0
- data/app/controllers/api_guard/application_controller.rb +11 -0
- data/app/controllers/api_guard/authentication_controller.rb +31 -0
- data/app/controllers/api_guard/passwords_controller.rb +29 -0
- data/app/controllers/api_guard/registration_controller.rb +30 -0
- data/app/controllers/api_guard/tokens_controller.rb +32 -0
- data/config/locales/en.yml +22 -0
- data/config/routes.rb +6 -0
- data/lib/api_guard.rb +40 -0
- data/lib/api_guard/app_secret_key.rb +22 -0
- data/lib/api_guard/engine.rb +17 -0
- data/lib/api_guard/jwt_auth/authentication.rb +98 -0
- data/lib/api_guard/jwt_auth/blacklist_token.rb +35 -0
- data/lib/api_guard/jwt_auth/json_web_token.rb +72 -0
- data/lib/api_guard/jwt_auth/refresh_jwt_token.rb +46 -0
- data/lib/api_guard/models/concerns.rb +27 -0
- data/lib/api_guard/modules.rb +26 -0
- data/lib/api_guard/resource_mapper.rb +43 -0
- data/lib/api_guard/response_formatters/renderer.rb +22 -0
- data/lib/api_guard/route_mapper.rb +85 -0
- data/lib/api_guard/test/controller_helper.rb +13 -0
- data/lib/api_guard/version.rb +5 -0
- data/lib/generators/api_guard/controllers/USAGE +11 -0
- data/lib/generators/api_guard/controllers/controllers_generator.rb +25 -0
- data/lib/generators/api_guard/controllers/templates/authentication_controller.rb +27 -0
- data/lib/generators/api_guard/controllers/templates/passwords_controller.rb +25 -0
- data/lib/generators/api_guard/controllers/templates/registration_controller.rb +26 -0
- data/lib/generators/api_guard/controllers/templates/tokens_controller.rb +28 -0
- data/lib/generators/api_guard/initializer/USAGE +8 -0
- data/lib/generators/api_guard/initializer/initializer_generator.rb +13 -0
- data/lib/generators/api_guard/initializer/templates/initializer.rb +19 -0
- metadata +202 -0
data/Rakefile
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'bundler/setup'
|
5
|
+
rescue LoadError
|
6
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
7
|
+
end
|
8
|
+
|
9
|
+
require 'rdoc/task'
|
10
|
+
|
11
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
12
|
+
rdoc.rdoc_dir = 'rdoc'
|
13
|
+
rdoc.title = 'ApiGuard'
|
14
|
+
rdoc.options << '--line-numbers'
|
15
|
+
rdoc.rdoc_files.include('README.md')
|
16
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
17
|
+
end
|
18
|
+
|
19
|
+
load 'rails/tasks/statistics.rake'
|
20
|
+
|
21
|
+
require 'bundler/gem_tasks'
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ApiGuard
|
4
|
+
class ApplicationController < ActionController::Base
|
5
|
+
skip_before_action :verify_authenticity_token, raise: false
|
6
|
+
|
7
|
+
def authenticate_resource
|
8
|
+
public_send("authenticate_and_set_#{resource_name}")
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_dependency 'api_guard/application_controller'
|
4
|
+
|
5
|
+
module ApiGuard
|
6
|
+
class AuthenticationController < ApplicationController
|
7
|
+
before_action :find_resource, only: [:create]
|
8
|
+
before_action :authenticate_resource, only: [:destroy]
|
9
|
+
|
10
|
+
def create
|
11
|
+
if resource.authenticate(params[:password])
|
12
|
+
create_token_and_set_header(resource, resource_name)
|
13
|
+
render_success(message: I18n.t('api_guard.authentication.signed_in'))
|
14
|
+
else
|
15
|
+
render_error(422, message: I18n.t('api_guard.authentication.invalid_login_credentials'))
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def destroy
|
20
|
+
blacklist_token
|
21
|
+
render_success(message: I18n.t('api_guard.authentication.signed_out'))
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def find_resource
|
27
|
+
self.resource = resource_class.find_by(email: params[:email].downcase.strip) if params[:email].present?
|
28
|
+
render_error(422, message: I18n.t('api_guard.authentication.invalid_login_credentials')) unless resource
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_dependency 'api_guard/application_controller'
|
4
|
+
|
5
|
+
module ApiGuard
|
6
|
+
class PasswordsController < ApplicationController
|
7
|
+
before_action :authenticate_resource, only: [:update]
|
8
|
+
|
9
|
+
def update
|
10
|
+
invalidate_old_jwt_tokens(current_resource)
|
11
|
+
|
12
|
+
if current_resource.update(password_params)
|
13
|
+
blacklist_token unless ApiGuard.invalidate_old_tokens_on_password_change
|
14
|
+
destroy_all_refresh_tokens(current_resource)
|
15
|
+
|
16
|
+
create_token_and_set_header(current_resource, resource_name)
|
17
|
+
render_success(message: I18n.t('api_guard.password.changed'))
|
18
|
+
else
|
19
|
+
render_error(422, object: current_resource)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def password_params
|
26
|
+
params.permit(:password, :password_confirmation)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_dependency 'api_guard/application_controller'
|
4
|
+
|
5
|
+
module ApiGuard
|
6
|
+
class RegistrationController < ApplicationController
|
7
|
+
before_action :authenticate_resource, only: [:destroy]
|
8
|
+
|
9
|
+
def create
|
10
|
+
init_resource(sign_up_params)
|
11
|
+
if resource.save
|
12
|
+
create_token_and_set_header(resource, resource_name)
|
13
|
+
render_success(message: I18n.t('api_guard.registration.signed_up'))
|
14
|
+
else
|
15
|
+
render_error(422, object: resource)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def destroy
|
20
|
+
current_resource.destroy
|
21
|
+
render_success(message: I18n.t('api_guard.registration.account_deleted'))
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def sign_up_params
|
27
|
+
params.permit(:email, :password, :password_confirmation)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_dependency 'api_guard/application_controller'
|
4
|
+
|
5
|
+
module ApiGuard
|
6
|
+
class TokensController < ApplicationController
|
7
|
+
before_action :authenticate_resource, only: [:create]
|
8
|
+
before_action :find_refresh_token, only: [:create]
|
9
|
+
|
10
|
+
def create
|
11
|
+
create_token_and_set_header(current_resource, resource_name)
|
12
|
+
|
13
|
+
@refresh_token.destroy
|
14
|
+
blacklist_token if ApiGuard.blacklist_token_after_refreshing
|
15
|
+
|
16
|
+
render_success(message: I18n.t('api_guard.access_token.refreshed'))
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def find_refresh_token
|
22
|
+
refresh_token_from_header = request.headers['Refresh-Token']
|
23
|
+
|
24
|
+
if refresh_token_from_header
|
25
|
+
@refresh_token = find_refresh_token_of(current_resource, refresh_token_from_header)
|
26
|
+
return render_error(401, message: I18n.t('api_guard.refresh_token.invalid')) unless @refresh_token
|
27
|
+
else
|
28
|
+
render_error(401, message: I18n.t('api_guard.refresh_token.missing'))
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
en:
|
2
|
+
api_guard:
|
3
|
+
authentication:
|
4
|
+
signed_in: 'Signed in successfully'
|
5
|
+
signed_out: 'Signed out successfully'
|
6
|
+
invalid_login_credentials: 'Invalid login credentials'
|
7
|
+
password:
|
8
|
+
changed: 'Password changed successfully'
|
9
|
+
registration:
|
10
|
+
signed_up: 'Signed up successfully'
|
11
|
+
account_deleted: 'Account deleted successfully'
|
12
|
+
access_token:
|
13
|
+
refreshed: 'Token refreshed successfully'
|
14
|
+
missing: 'Access token is missing in the request'
|
15
|
+
invalid: 'Invalid access token'
|
16
|
+
expired: 'Access token expired'
|
17
|
+
refresh_token:
|
18
|
+
invalid: 'Invalid refresh token'
|
19
|
+
missing: 'Refresh token is missing in the request'
|
20
|
+
response:
|
21
|
+
success: 'success'
|
22
|
+
error: 'error'
|
data/config/routes.rb
ADDED
data/lib/api_guard.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'api_guard/engine'
|
4
|
+
require 'api_guard/route_mapper'
|
5
|
+
require 'api_guard/modules'
|
6
|
+
|
7
|
+
module ApiGuard
|
8
|
+
autoload :AppSecretKey, 'api_guard/app_secret_key'
|
9
|
+
|
10
|
+
module Test
|
11
|
+
autoload :ControllerHelper, 'api_guard/test/controller_helper'
|
12
|
+
end
|
13
|
+
|
14
|
+
mattr_accessor :token_validity
|
15
|
+
self.token_validity = 1.day
|
16
|
+
|
17
|
+
mattr_accessor :token_signing_secret
|
18
|
+
self.token_signing_secret = nil
|
19
|
+
|
20
|
+
mattr_accessor :invalidate_old_tokens_on_password_change
|
21
|
+
self.invalidate_old_tokens_on_password_change = false
|
22
|
+
|
23
|
+
mattr_accessor :blacklist_token_after_refreshing
|
24
|
+
self.blacklist_token_after_refreshing = false
|
25
|
+
|
26
|
+
mattr_accessor :api_guard_associations
|
27
|
+
self.api_guard_associations = {}
|
28
|
+
|
29
|
+
mattr_reader :mapped_resource do
|
30
|
+
{}
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.setup
|
34
|
+
yield self
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.map_resource(routes_for, class_name)
|
38
|
+
mapped_resource[routes_for.to_sym] = ApiGuard::ResourceMapper.new(routes_for, class_name)
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ApiGuard
|
4
|
+
class AppSecretKey
|
5
|
+
def initialize(application)
|
6
|
+
@application = application
|
7
|
+
end
|
8
|
+
|
9
|
+
def detect
|
10
|
+
secret_key_base(:credentials) || secret_key_base(:secrets) ||
|
11
|
+
secret_key_base(:config) || secret_key_base
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def secret_key_base(source = nil)
|
17
|
+
return @application.secret_key_base unless source
|
18
|
+
|
19
|
+
@application.send(source).secret_key_base.presence if @application.respond_to?(source)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ApiGuard
|
4
|
+
class Engine < ::Rails::Engine
|
5
|
+
isolate_namespace ApiGuard
|
6
|
+
|
7
|
+
config.generators do |g|
|
8
|
+
g.test_framework :rspec
|
9
|
+
g.fixture_replacement :factory_girl, dir: 'spec/factories'
|
10
|
+
end
|
11
|
+
|
12
|
+
# Use 'secret_key_base' from Rails secrets if 'token_signing_secret' is not configured
|
13
|
+
initializer 'ApiGuard.token_signing_secret' do |app|
|
14
|
+
ApiGuard.token_signing_secret ||= ApiGuard::AppSecretKey.new(app).detect
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ApiGuard
|
4
|
+
module JwtAuth
|
5
|
+
# Common module for API authentication
|
6
|
+
module Authentication
|
7
|
+
# Handle authentication of the resource dynamically
|
8
|
+
def method_missing(name, *args)
|
9
|
+
method_name = name.to_s
|
10
|
+
|
11
|
+
if method_name.start_with?('authenticate_and_set_')
|
12
|
+
resource_name = method_name.split('authenticate_and_set_')[1]
|
13
|
+
authenticate_and_set_resource(resource_name)
|
14
|
+
else
|
15
|
+
super
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def respond_to_missing?(method_name, include_private = false)
|
20
|
+
method_name.to_s.start_with?('authenticate_and_set_') || super
|
21
|
+
end
|
22
|
+
|
23
|
+
# Authenticate the JWT token and set resource
|
24
|
+
def authenticate_and_set_resource(resource_name)
|
25
|
+
@resource_name = resource_name
|
26
|
+
|
27
|
+
@token = request.headers['Authorization']&.split('Bearer ')&.last
|
28
|
+
return render_error(401, message: I18n.t('api_guard.access_token.missing')) unless @token
|
29
|
+
|
30
|
+
authenticate_token
|
31
|
+
|
32
|
+
# Render error response only if no resource found and no previous render happened
|
33
|
+
render_error(401, message: I18n.t('api_guard.access_token.invalid')) if !current_resource && !performed?
|
34
|
+
rescue JWT::DecodeError => e
|
35
|
+
if e.message == 'Signature has expired'
|
36
|
+
render_error(401, message: I18n.t('api_guard.access_token.expired'))
|
37
|
+
else
|
38
|
+
render_error(401, message: I18n.t('api_guard.access_token.invalid'))
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Decode the JWT token
|
43
|
+
# and don't verify token expiry for refresh token API request
|
44
|
+
def decode_token
|
45
|
+
# TODO: Set token refresh controller dynamic
|
46
|
+
verify_token = (controller_name != 'tokens' || action_name != 'create')
|
47
|
+
@decoded_token = decode(@token, verify_token)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Returns whether the JWT token is issued after the last password change
|
51
|
+
# Returns true if password hasn't changed by the user
|
52
|
+
def valid_issued_at?(resource)
|
53
|
+
return true unless ApiGuard.invalidate_old_tokens_on_password_change
|
54
|
+
|
55
|
+
!resource.token_issued_at || @decoded_token[:iat] >= resource.token_issued_at.to_i
|
56
|
+
end
|
57
|
+
|
58
|
+
# Defines "current_{{resource_name}}" method and "@current_{{resource_name}}" instance variable
|
59
|
+
# that returns "resource" value
|
60
|
+
def define_current_resource_accessors(resource)
|
61
|
+
define_singleton_method("current_#{@resource_name}") do
|
62
|
+
instance_variable_get("@current_#{@resource_name}") ||
|
63
|
+
instance_variable_set("@current_#{@resource_name}", resource)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Authenticate the resource with the '{{resource_name}}_id' in the decoded JWT token
|
68
|
+
# and also, check for valid issued at time and not blacklisted
|
69
|
+
#
|
70
|
+
# Also, set "current_{{resource_name}}" method and "@current_{{resource_name}}" instance variable
|
71
|
+
# for accessing the authenticated resource
|
72
|
+
def authenticate_token
|
73
|
+
return unless decode_token
|
74
|
+
|
75
|
+
resource = find_resource_from_token(@resource_name.classify.constantize)
|
76
|
+
|
77
|
+
if resource && valid_issued_at?(resource) && !blacklisted?(resource)
|
78
|
+
define_current_resource_accessors(resource)
|
79
|
+
else
|
80
|
+
render_error(401, message: I18n.t('api_guard.access_token.invalid'))
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def find_resource_from_token(resource_class)
|
85
|
+
resource_id = @decoded_token[:"#{@resource_name}_id"]
|
86
|
+
return if resource_id.blank?
|
87
|
+
|
88
|
+
resource_class.find_by(id: resource_id)
|
89
|
+
end
|
90
|
+
|
91
|
+
def current_resource
|
92
|
+
return unless respond_to?("current_#{@resource_name}")
|
93
|
+
|
94
|
+
public_send("current_#{@resource_name}")
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ApiGuard
|
4
|
+
module JwtAuth
|
5
|
+
# Common module for token blacklisting functionality
|
6
|
+
module BlacklistToken
|
7
|
+
def blacklisted_token_association(resource)
|
8
|
+
resource.class.blacklisted_token_association
|
9
|
+
end
|
10
|
+
|
11
|
+
def token_blacklisting_enabled?(resource)
|
12
|
+
blacklisted_token_association(resource).present?
|
13
|
+
end
|
14
|
+
|
15
|
+
def blacklisted_tokens_for(resource)
|
16
|
+
blacklisted_token_association = blacklisted_token_association(resource)
|
17
|
+
resource.send(blacklisted_token_association)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Returns whether the JWT token is blacklisted or not
|
21
|
+
def blacklisted?(resource)
|
22
|
+
return false unless token_blacklisting_enabled?(resource)
|
23
|
+
|
24
|
+
blacklisted_tokens_for(resource).exists?(token: @token)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Blacklist the current JWT token from future access
|
28
|
+
def blacklist_token
|
29
|
+
return unless token_blacklisting_enabled?(current_resource)
|
30
|
+
|
31
|
+
blacklisted_tokens_for(current_resource).create(token: @token, expire_at: Time.at(@decoded_token[:exp]).utc)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jwt'
|
4
|
+
|
5
|
+
module ApiGuard
|
6
|
+
module JwtAuth
|
7
|
+
# Common module for JWT operations
|
8
|
+
module JsonWebToken
|
9
|
+
def current_time
|
10
|
+
@current_time ||= Time.now.utc
|
11
|
+
end
|
12
|
+
|
13
|
+
def token_expire_at
|
14
|
+
@token_expire_at ||= (current_time + ApiGuard.token_validity).to_i
|
15
|
+
end
|
16
|
+
|
17
|
+
def token_issued_at
|
18
|
+
@token_issued_at ||= current_time.to_i
|
19
|
+
end
|
20
|
+
|
21
|
+
# Encode the payload with the secret key and return the JWT token
|
22
|
+
def encode(payload)
|
23
|
+
JWT.encode(payload, ApiGuard.token_signing_secret)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Decode the JWT token and return the payload
|
27
|
+
def decode(token, verify = true)
|
28
|
+
HashWithIndifferentAccess.new(
|
29
|
+
JWT.decode(token, ApiGuard.token_signing_secret, verify, verify_iat: true)[0]
|
30
|
+
)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Create a JWT token with resource detail in payload.
|
34
|
+
# Also, create refresh token if enabled for the resource.
|
35
|
+
#
|
36
|
+
# This creates expired JWT token if the argument 'expired_token' is true which can be used for testing.
|
37
|
+
def jwt_and_refresh_token(resource, resource_name, expired_token = false)
|
38
|
+
payload = {
|
39
|
+
"#{resource_name}_id": resource.id,
|
40
|
+
exp: expired_token ? token_issued_at : token_expire_at,
|
41
|
+
iat: token_issued_at
|
42
|
+
}
|
43
|
+
|
44
|
+
# Add custom data in the JWT token payload
|
45
|
+
payload.merge!(resource.jwt_token_payload) if resource.respond_to?(:jwt_token_payload)
|
46
|
+
|
47
|
+
[encode(payload), new_refresh_token(resource)]
|
48
|
+
end
|
49
|
+
|
50
|
+
# Create tokens and set response headers
|
51
|
+
def create_token_and_set_header(resource, resource_name)
|
52
|
+
access_token, refresh_token = jwt_and_refresh_token(resource, resource_name)
|
53
|
+
set_token_headers(access_token, refresh_token)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Set token details in response headers
|
57
|
+
def set_token_headers(token, refresh_token = nil)
|
58
|
+
response.headers['Access-Token'] = token
|
59
|
+
response.headers['Refresh-Token'] = refresh_token if refresh_token
|
60
|
+
response.headers['Expire-At'] = token_expire_at.to_s
|
61
|
+
end
|
62
|
+
|
63
|
+
# Set token issued at to current timestamp
|
64
|
+
# to restrict access to old access(JWT) tokens
|
65
|
+
def invalidate_old_jwt_tokens(resource)
|
66
|
+
return unless ApiGuard.invalidate_old_tokens_on_password_change
|
67
|
+
|
68
|
+
resource.token_issued_at = Time.at(token_issued_at).utc
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|