api_guard 0.1.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 +597 -0
- data/Rakefile +24 -0
- data/app/controllers/api_guard/application_controller.rb +7 -0
- data/app/controllers/api_guard/authentication_controller.rb +29 -0
- data/app/controllers/api_guard/passwords_controller.rb +27 -0
- data/app/controllers/api_guard/registration_controller.rb +28 -0
- data/app/controllers/api_guard/tokens_controller.rb +27 -0
- data/app/models/api_guard/application_record.rb +5 -0
- data/app/views/layouts/api_guard/application.html.erb +14 -0
- data/config/routes.rb +4 -0
- data/lib/api_guard.rb +32 -0
- data/lib/api_guard/engine.rb +15 -0
- data/lib/api_guard/jwt_auth/authentication.rb +83 -0
- data/lib/api_guard/jwt_auth/blacklist_token.rb +31 -0
- data/lib/api_guard/jwt_auth/json_web_token.rb +66 -0
- data/lib/api_guard/jwt_auth/refresh_jwt_token.rb +42 -0
- data/lib/api_guard/models/concerns.rb +25 -0
- data/lib/api_guard/modules.rb +24 -0
- data/lib/api_guard/resource_mapper.rb +41 -0
- data/lib/api_guard/response_formatters/renderer.rb +19 -0
- data/lib/api_guard/route_mapper.rb +81 -0
- data/lib/api_guard/test/controller_helper.rb +11 -0
- data/lib/api_guard/version.rb +3 -0
- data/lib/generators/api_guard/controllers/USAGE +11 -0
- data/lib/generators/api_guard/controllers/controllers_generator.rb +23 -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 +25 -0
- data/lib/generators/api_guard/initializer/USAGE +8 -0
- data/lib/generators/api_guard/initializer/initializer_generator.rb +11 -0
- data/lib/generators/api_guard/initializer/templates/initializer.rb +13 -0
- metadata +217 -0
data/Rakefile
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'rdoc/task'
|
8
|
+
|
9
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
10
|
+
rdoc.rdoc_dir = 'rdoc'
|
11
|
+
rdoc.title = 'ApiGuard'
|
12
|
+
rdoc.options << '--line-numbers'
|
13
|
+
rdoc.rdoc_files.include('README.md')
|
14
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
15
|
+
end
|
16
|
+
|
17
|
+
|
18
|
+
|
19
|
+
load 'rails/tasks/statistics.rake'
|
20
|
+
|
21
|
+
|
22
|
+
|
23
|
+
require 'bundler/gem_tasks'
|
24
|
+
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require_dependency 'api_guard/application_controller'
|
2
|
+
|
3
|
+
module ApiGuard
|
4
|
+
class AuthenticationController < ApplicationController
|
5
|
+
before_action :find_resource, only: [:create]
|
6
|
+
before_action :authenticate_resource, only: [:destroy]
|
7
|
+
|
8
|
+
def create
|
9
|
+
if resource.authenticate(params[:password])
|
10
|
+
create_token_and_set_header(resource, resource_name)
|
11
|
+
render_success(message: 'Signed in successfully')
|
12
|
+
else
|
13
|
+
render_error(422, message: 'Invalid login credentials')
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def destroy
|
18
|
+
blacklist_token
|
19
|
+
render_success(message: 'Signed out successfully')
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def find_resource
|
25
|
+
self.resource = resource_class.find_by(email: params[:email].downcase.strip) if params[:email].present?
|
26
|
+
render_error(422, message: 'Invalid login credentials') unless resource
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require_dependency 'api_guard/application_controller'
|
2
|
+
|
3
|
+
module ApiGuard
|
4
|
+
class PasswordsController < ApplicationController
|
5
|
+
before_action :authenticate_resource, only: [:update]
|
6
|
+
|
7
|
+
def update
|
8
|
+
invalidate_old_jwt_tokens(current_resource)
|
9
|
+
|
10
|
+
if current_resource.update_attributes(password_params)
|
11
|
+
blacklist_token unless ApiGuard.invalidate_old_tokens_on_password_change
|
12
|
+
destroy_all_refresh_tokens(current_resource)
|
13
|
+
|
14
|
+
create_token_and_set_header(current_resource, resource_name)
|
15
|
+
render_success(message: 'Password changed successfully')
|
16
|
+
else
|
17
|
+
render_error(422, object: current_resource)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def password_params
|
24
|
+
params.permit(:password, :password_confirmation)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require_dependency 'api_guard/application_controller'
|
2
|
+
|
3
|
+
module ApiGuard
|
4
|
+
class RegistrationController < ApplicationController
|
5
|
+
before_action :authenticate_resource, only: [:destroy]
|
6
|
+
|
7
|
+
def create
|
8
|
+
init_resource(sign_up_params)
|
9
|
+
if resource.save
|
10
|
+
create_token_and_set_header(resource, resource_name)
|
11
|
+
render_success(message: 'Signed up successfully')
|
12
|
+
else
|
13
|
+
render_error(422, object: resource)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def destroy
|
18
|
+
current_resource.destroy
|
19
|
+
render_success(message: "Account deleted successfully")
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def sign_up_params
|
25
|
+
params.permit(:email, :password, :password_confirmation)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require_dependency 'api_guard/application_controller'
|
2
|
+
|
3
|
+
module ApiGuard
|
4
|
+
class TokensController < ApplicationController
|
5
|
+
before_action :authenticate_resource, only: [:create]
|
6
|
+
before_action :find_refresh_token, only: [:create]
|
7
|
+
|
8
|
+
def create
|
9
|
+
@refresh_token.destroy
|
10
|
+
create_token_and_set_header(current_resource, resource_name)
|
11
|
+
render_success(message: 'Token refreshed successfully')
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def find_refresh_token
|
17
|
+
refresh_token_from_header = request.headers['Refresh-Token']
|
18
|
+
|
19
|
+
if refresh_token_from_header
|
20
|
+
@refresh_token = find_refresh_token_of(current_resource, refresh_token_from_header)
|
21
|
+
return render_error(401, message: 'Invalid refresh token') unless @refresh_token
|
22
|
+
else
|
23
|
+
render_error(401, message: 'Refresh token is missing in the request')
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>API Guard</title>
|
5
|
+
<%= stylesheet_link_tag "api_guard/application", media: "all" %>
|
6
|
+
<%= javascript_include_tag "api_guard/application" %>
|
7
|
+
<%= csrf_meta_tags %>
|
8
|
+
</head>
|
9
|
+
<body>
|
10
|
+
|
11
|
+
<%= yield %>
|
12
|
+
|
13
|
+
</body>
|
14
|
+
</html>
|
data/config/routes.rb
ADDED
data/lib/api_guard.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
require "api_guard/engine"
|
2
|
+
require "api_guard/route_mapper"
|
3
|
+
require "api_guard/modules"
|
4
|
+
|
5
|
+
module ApiGuard
|
6
|
+
module Test
|
7
|
+
autoload :ControllerHelper, 'api_guard/test/controller_helper'
|
8
|
+
end
|
9
|
+
|
10
|
+
mattr_accessor :token_validity
|
11
|
+
self.token_validity = 1.day
|
12
|
+
|
13
|
+
mattr_accessor :token_signing_secret
|
14
|
+
self.token_signing_secret = nil
|
15
|
+
|
16
|
+
mattr_accessor :invalidate_old_tokens_on_password_change
|
17
|
+
self.invalidate_old_tokens_on_password_change = false
|
18
|
+
|
19
|
+
mattr_accessor :api_guard_associations
|
20
|
+
self.api_guard_associations = {}
|
21
|
+
|
22
|
+
mattr_reader :mapped_resource
|
23
|
+
@@mapped_resource = {}
|
24
|
+
|
25
|
+
def self.setup
|
26
|
+
yield self
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.map_resource(routes_for, class_name)
|
30
|
+
@@mapped_resource[routes_for.to_sym] = ApiGuard::ResourceMapper.new(routes_for, class_name)
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module ApiGuard
|
2
|
+
class Engine < ::Rails::Engine
|
3
|
+
isolate_namespace ApiGuard
|
4
|
+
|
5
|
+
config.generators do |g|
|
6
|
+
g.test_framework :rspec
|
7
|
+
g.fixture_replacement :factory_girl, :dir => 'spec/factories'
|
8
|
+
end
|
9
|
+
|
10
|
+
# Use 'secret_key_base' from Rails secrets if 'token_signing_secret' is not configured
|
11
|
+
initializer 'ApiGuard.token_signing_secret' do |app|
|
12
|
+
ApiGuard.token_signing_secret ||= Rails.application.secrets.secret_key_base
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module ApiGuard
|
2
|
+
module JwtAuth
|
3
|
+
# Common module for API authentication
|
4
|
+
module Authentication
|
5
|
+
# Handle authentication of the resource dynamically
|
6
|
+
def method_missing(name, *args)
|
7
|
+
method_name = name.to_s
|
8
|
+
|
9
|
+
if method_name.start_with?('authenticate_and_set_')
|
10
|
+
resource_name = method_name.split('authenticate_and_set_')[1]
|
11
|
+
authenticate_and_set_resource(resource_name)
|
12
|
+
else
|
13
|
+
super
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# Authenticate the JWT token and set resource
|
18
|
+
def authenticate_and_set_resource(resource_name)
|
19
|
+
@resource_name = resource_name
|
20
|
+
|
21
|
+
authenticate_or_request_with_http_token do |token, _|
|
22
|
+
@token = token
|
23
|
+
authenticate_token
|
24
|
+
true # Return true to handle 'Invalid access token' request separately
|
25
|
+
end
|
26
|
+
|
27
|
+
# Render error response only if no resource found and no previous render happened
|
28
|
+
render_error(401, message: 'Invalid access token') if !current_resource && !performed?
|
29
|
+
rescue JWT::DecodeError => e
|
30
|
+
if e.message == 'Signature has expired'
|
31
|
+
render_error(401, message: 'Access token expired')
|
32
|
+
else
|
33
|
+
render_error(401, message: 'Invalid access token')
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Override to send JSON response instead of plain HTML
|
38
|
+
# if 'Authorization' header is empty or value of the header is invalid
|
39
|
+
def request_http_token_authentication(realm = 'Application', _message = nil)
|
40
|
+
render_error(401, message: 'Access token is missing in the request')
|
41
|
+
end
|
42
|
+
|
43
|
+
# Decode the JWT token
|
44
|
+
# and don't verify token expiry for refresh token API request
|
45
|
+
def decode_token
|
46
|
+
# TODO: Set token refresh controller dynamic
|
47
|
+
verify_token = (controller_name != 'tokens' || action_name != 'create')
|
48
|
+
@decoded_token = decode(@token, verify_token)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Returns whether the JWT token is issued after the last password change
|
52
|
+
# Returns true if password hasn't changed by the user
|
53
|
+
def valid_issued_at?
|
54
|
+
return true unless ApiGuard.invalidate_old_tokens_on_password_change
|
55
|
+
!current_resource.token_issued_at || @decoded_token[:iat] >= current_resource.token_issued_at.to_i
|
56
|
+
end
|
57
|
+
|
58
|
+
# Authenticate the resource with the '{{resource_name}}_id' in the decoded JWT token
|
59
|
+
# and also, check for valid issued at time and not blacklisted
|
60
|
+
#
|
61
|
+
# Also, set "current_{{resource_name}}" method and "@current_{{resource_name}}" instance variable
|
62
|
+
# for accessing the authenticated resource
|
63
|
+
def authenticate_token
|
64
|
+
return unless decode_token && @decoded_token[:"#{@resource_name}_id"].present?
|
65
|
+
|
66
|
+
resource = @resource_name.classify.constantize.find_by(id: @decoded_token[:"#{@resource_name}_id"])
|
67
|
+
|
68
|
+
self.class.send(:define_method, "current_#{@resource_name}") do
|
69
|
+
instance_variable_get("@current_#{@resource_name}") || instance_variable_set("@current_#{@resource_name}", resource)
|
70
|
+
end
|
71
|
+
|
72
|
+
return if current_resource && valid_issued_at? && !blacklisted?
|
73
|
+
|
74
|
+
render_error(401, message: 'Invalid access token')
|
75
|
+
end
|
76
|
+
|
77
|
+
def current_resource
|
78
|
+
return unless respond_to?("current_#{@resource_name}")
|
79
|
+
public_send("current_#{@resource_name}")
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module ApiGuard
|
2
|
+
module JwtAuth
|
3
|
+
# Common module for token blacklisting functionality
|
4
|
+
module BlacklistToken
|
5
|
+
def blacklisted_token_association(resource)
|
6
|
+
resource.class.blacklisted_token_association
|
7
|
+
end
|
8
|
+
|
9
|
+
def token_blacklisting_enabled?(resource)
|
10
|
+
blacklisted_token_association(resource).present?
|
11
|
+
end
|
12
|
+
|
13
|
+
def blacklisted_tokens_for(resource)
|
14
|
+
blacklisted_token_association = blacklisted_token_association(resource)
|
15
|
+
resource.send(blacklisted_token_association)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Returns whether the JWT token is blacklisted or not
|
19
|
+
def blacklisted?
|
20
|
+
return false unless token_blacklisting_enabled?(current_resource)
|
21
|
+
blacklisted_tokens_for(current_resource).exists?(token: @token)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Blacklist the current JWT token from future access
|
25
|
+
def blacklist_token
|
26
|
+
return unless token_blacklisting_enabled?(current_resource)
|
27
|
+
blacklisted_tokens_for(current_resource).create(token: @token, expire_at: Time.at(@decoded_token[:exp]).utc)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'jwt'
|
2
|
+
|
3
|
+
module ApiGuard
|
4
|
+
module JwtAuth
|
5
|
+
# Common module for JWT operations
|
6
|
+
module JsonWebToken
|
7
|
+
def current_time
|
8
|
+
@current_time ||= Time.now.utc
|
9
|
+
end
|
10
|
+
|
11
|
+
def token_expire_at
|
12
|
+
@expire_at ||= (current_time + ApiGuard.token_validity).to_i
|
13
|
+
end
|
14
|
+
|
15
|
+
def token_issued_at
|
16
|
+
@issued_at ||= current_time.to_i
|
17
|
+
end
|
18
|
+
|
19
|
+
# Encode the payload with the secret key and return the JWT token
|
20
|
+
def encode(payload)
|
21
|
+
JWT.encode(payload, ApiGuard.token_signing_secret)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Decode the JWT token and return the payload
|
25
|
+
def decode(token, verify = true)
|
26
|
+
HashWithIndifferentAccess.new(
|
27
|
+
JWT.decode(token, ApiGuard.token_signing_secret, verify, verify_iat: true)[0]
|
28
|
+
)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Create a JWT token with resource detail in payload.
|
32
|
+
# Also, create refresh token if enabled for the resource.
|
33
|
+
#
|
34
|
+
# This creates expired JWT token if the argument 'expired_token' is true which can be used for testing.
|
35
|
+
def jwt_and_refresh_token(resource, resource_name, expired_token = false)
|
36
|
+
access_token = encode(
|
37
|
+
"#{resource_name}_id": resource.id,
|
38
|
+
exp: expired_token ? token_issued_at : token_expire_at,
|
39
|
+
iat: token_issued_at
|
40
|
+
)
|
41
|
+
|
42
|
+
[access_token, new_refresh_token(resource)]
|
43
|
+
end
|
44
|
+
|
45
|
+
# Create tokens and set response headers
|
46
|
+
def create_token_and_set_header(resource, resource_name)
|
47
|
+
access_token, refresh_token = jwt_and_refresh_token(resource, resource_name)
|
48
|
+
set_token_headers(access_token, refresh_token)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Set token details in response headers
|
52
|
+
def set_token_headers(token, refresh_token = nil)
|
53
|
+
response.headers['Access-Token'] = token
|
54
|
+
response.headers['Refresh-Token'] = refresh_token if refresh_token
|
55
|
+
response.headers['Expire-At'] = token_expire_at.to_s
|
56
|
+
end
|
57
|
+
|
58
|
+
# Set token issued at to current timestamp
|
59
|
+
# to restrict access to old access(JWT) tokens
|
60
|
+
def invalidate_old_jwt_tokens(resource)
|
61
|
+
return unless ApiGuard.invalidate_old_tokens_on_password_change
|
62
|
+
resource.token_issued_at = Time.at(token_issued_at).utc
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module ApiGuard
|
2
|
+
module JwtAuth
|
3
|
+
# Common module for refresh token functionality
|
4
|
+
module RefreshJwtToken
|
5
|
+
def refresh_token_association(resource)
|
6
|
+
resource.class.refresh_token_association
|
7
|
+
end
|
8
|
+
|
9
|
+
def refresh_token_enabled?(resource)
|
10
|
+
refresh_token_association(resource).present?
|
11
|
+
end
|
12
|
+
|
13
|
+
def refresh_tokens_for(resource)
|
14
|
+
refresh_token_association = refresh_token_association(resource)
|
15
|
+
resource.send(refresh_token_association)
|
16
|
+
end
|
17
|
+
|
18
|
+
def find_refresh_token_of(resource, refresh_token)
|
19
|
+
refresh_tokens_for(resource).find_by_token(refresh_token)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Generate and return unique refresh token for the resource
|
23
|
+
def uniq_refresh_token(resource)
|
24
|
+
loop do
|
25
|
+
random_token = SecureRandom.urlsafe_base64
|
26
|
+
return random_token unless refresh_tokens_for(resource).exists?(token: random_token)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Create a new refresh_token for the current resource
|
31
|
+
def new_refresh_token(resource)
|
32
|
+
return unless refresh_token_enabled?(resource)
|
33
|
+
refresh_tokens_for(resource).create(token: uniq_refresh_token(resource)).token
|
34
|
+
end
|
35
|
+
|
36
|
+
def destroy_all_refresh_tokens(resource)
|
37
|
+
return unless refresh_token_enabled?(resource)
|
38
|
+
refresh_tokens_for(resource).destroy_all
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|