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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +597 -0
  4. data/Rakefile +24 -0
  5. data/app/controllers/api_guard/application_controller.rb +7 -0
  6. data/app/controllers/api_guard/authentication_controller.rb +29 -0
  7. data/app/controllers/api_guard/passwords_controller.rb +27 -0
  8. data/app/controllers/api_guard/registration_controller.rb +28 -0
  9. data/app/controllers/api_guard/tokens_controller.rb +27 -0
  10. data/app/models/api_guard/application_record.rb +5 -0
  11. data/app/views/layouts/api_guard/application.html.erb +14 -0
  12. data/config/routes.rb +4 -0
  13. data/lib/api_guard.rb +32 -0
  14. data/lib/api_guard/engine.rb +15 -0
  15. data/lib/api_guard/jwt_auth/authentication.rb +83 -0
  16. data/lib/api_guard/jwt_auth/blacklist_token.rb +31 -0
  17. data/lib/api_guard/jwt_auth/json_web_token.rb +66 -0
  18. data/lib/api_guard/jwt_auth/refresh_jwt_token.rb +42 -0
  19. data/lib/api_guard/models/concerns.rb +25 -0
  20. data/lib/api_guard/modules.rb +24 -0
  21. data/lib/api_guard/resource_mapper.rb +41 -0
  22. data/lib/api_guard/response_formatters/renderer.rb +19 -0
  23. data/lib/api_guard/route_mapper.rb +81 -0
  24. data/lib/api_guard/test/controller_helper.rb +11 -0
  25. data/lib/api_guard/version.rb +3 -0
  26. data/lib/generators/api_guard/controllers/USAGE +11 -0
  27. data/lib/generators/api_guard/controllers/controllers_generator.rb +23 -0
  28. data/lib/generators/api_guard/controllers/templates/authentication_controller.rb +27 -0
  29. data/lib/generators/api_guard/controllers/templates/passwords_controller.rb +25 -0
  30. data/lib/generators/api_guard/controllers/templates/registration_controller.rb +26 -0
  31. data/lib/generators/api_guard/controllers/templates/tokens_controller.rb +25 -0
  32. data/lib/generators/api_guard/initializer/USAGE +8 -0
  33. data/lib/generators/api_guard/initializer/initializer_generator.rb +11 -0
  34. data/lib/generators/api_guard/initializer/templates/initializer.rb +13 -0
  35. 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,7 @@
1
+ module ApiGuard
2
+ class ApplicationController < ActionController::Base
3
+ def authenticate_resource
4
+ public_send("authenticate_and_set_#{resource_name}")
5
+ end
6
+ end
7
+ end
@@ -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,5 @@
1
+ module ApiGuard
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ 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
@@ -0,0 +1,4 @@
1
+ ApiGuard::Engine.routes.draw do
2
+ # Can't see anything here?
3
+ # Goto 'lib/api_guard/route_mapper.rb'
4
+ end
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