slots-jwt 0.0.4 → 0.1.0

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +121 -6
  3. data/app/controllers/slots/jwt/sessions_controller.rb +38 -0
  4. data/app/models/slots/jwt/application_record.rb +9 -0
  5. data/app/models/slots/jwt/session.rb +44 -0
  6. data/config/initializers/inflections.rb +3 -0
  7. data/config/routes.rb +2 -2
  8. data/lib/generators/slots/install/USAGE +1 -1
  9. data/lib/generators/slots/install/install_generator.rb +1 -1
  10. data/lib/generators/slots/install/templates/create_slots_sessions.rb +2 -2
  11. data/lib/generators/slots/install/templates/slots.rb +1 -1
  12. data/lib/generators/slots/model/model_generator.rb +1 -1
  13. data/lib/slots.rb +1 -44
  14. data/lib/slots/jwt.rb +49 -0
  15. data/lib/slots/jwt/authentication_helper.rb +147 -0
  16. data/lib/slots/jwt/configuration.rb +84 -0
  17. data/lib/slots/jwt/database_authentication.rb +21 -0
  18. data/lib/slots/jwt/engine.rb +9 -0
  19. data/lib/slots/jwt/extra_classes.rb +14 -0
  20. data/lib/slots/jwt/generic_methods.rb +53 -0
  21. data/lib/slots/jwt/generic_validations.rb +53 -0
  22. data/lib/slots/jwt/permission_filter.rb +37 -0
  23. data/lib/slots/jwt/slokens.rb +115 -0
  24. data/lib/slots/jwt/tests.rb +37 -0
  25. data/lib/slots/jwt/tokens.rb +104 -0
  26. data/lib/slots/jwt/type_helper.rb +30 -0
  27. data/lib/slots/{version.rb → jwt/version.rb} +3 -1
  28. data/lib/tasks/slots_tasks.rake +5 -5
  29. metadata +23 -19
  30. data/app/controllers/slots/sessions_controller.rb +0 -36
  31. data/app/mailers/slots/application_mailer.rb +0 -8
  32. data/app/models/slots/application_record.rb +0 -7
  33. data/app/models/slots/session.rb +0 -42
  34. data/lib/slots/authentication_helper.rb +0 -144
  35. data/lib/slots/configuration.rb +0 -82
  36. data/lib/slots/database_authentication.rb +0 -19
  37. data/lib/slots/engine.rb +0 -7
  38. data/lib/slots/extra_classes.rb +0 -12
  39. data/lib/slots/generic_methods.rb +0 -51
  40. data/lib/slots/generic_validations.rb +0 -51
  41. data/lib/slots/slokens.rb +0 -113
  42. data/lib/slots/tests.rb +0 -35
  43. data/lib/slots/tokens.rb +0 -102
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slots
4
+ module JWT
5
+ module AuthenticationHelper
6
+ ALL = Object.new
7
+
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ include ActionController::HttpAuthentication::Token::ControllerMethods
12
+ end
13
+
14
+ def jw_token
15
+ return @_jw_token if instance_variable_defined?(:@_jw_token)
16
+ token = authenticate_with_http_token { |t, _| t }
17
+ @_jw_token = token ? Slots::JWT::Slokens.decode(token) : nil
18
+ end
19
+
20
+ def jw_token!
21
+ jw_token&.valid!
22
+ end
23
+
24
+ def update_expired_session_tokens
25
+ return false unless Slots::JWT.configuration.session_lifetime
26
+ return false unless jw_token&.expired? && jw_token.session.present?
27
+ new_session_token
28
+ end
29
+
30
+ def new_session_token
31
+ _current_user = Slots::JWT.configuration.authentication_model.from_sloken(@_jw_token)
32
+ return false unless _current_user&.update_session
33
+ @_current_user = _current_user
34
+ true
35
+ end
36
+
37
+ def current_user
38
+ return @_current_user if instance_variable_defined?(:@_current_user)
39
+ @_current_user = jw_token ? Slots::JWT.configuration.authentication_model.from_sloken(jw_token!) : nil
40
+ end
41
+ def load_user
42
+ current_user&.valid_in_database? && current_user.allowed_new_token?
43
+ end
44
+
45
+ def set_token_header!
46
+ # check if current user for logout
47
+ response.set_header('authorization', "Bearer token=#{current_user.token}") if current_user&.new_token?
48
+ end
49
+
50
+ def require_valid_user
51
+ # Load user will make sure it is in the database and valid in the database
52
+ raise Slots::JWT::InvalidToken, "User doesnt exist" if @_require_load_user && !load_user
53
+ access_denied! unless current_user && (@_ignore_callbacks || token_allowed?)
54
+ end
55
+ def require_load_user
56
+ # Use varaible so that if this action is prepended it will still only be called when checking for valid user,
57
+ # i.e. so its not called before update_expired_session_tokens if set
58
+ @_require_load_user = true
59
+ end
60
+ def ignore_callbacks
61
+ @_ignore_callbacks = true
62
+ end
63
+
64
+ def access_denied!
65
+ raise Slots::JWT::AccessDenied
66
+ end
67
+
68
+ def token_allowed?
69
+ !(self.class._reject_token?(self))
70
+ end
71
+
72
+ def new_token!(session)
73
+ current_user.create_token(session)
74
+ set_token_header!
75
+ end
76
+
77
+ def update_token!
78
+ current_user.update_token
79
+ end
80
+
81
+ module ClassMethods
82
+ def update_expired_session_tokens!(**options)
83
+ prepend_before_action :update_expired_session_tokens, **options
84
+ after_action :set_token_header!, **options
85
+ end
86
+
87
+ def require_login!(load_user: false, **options)
88
+ before_action :require_load_user, **options if load_user
89
+ before_action :require_valid_user, **options
90
+ end
91
+
92
+ def require_user_load!(**options)
93
+ prepend_before_action :require_load_user, **options
94
+ end
95
+
96
+ def skip_callback!(**options)
97
+ prepend_before_action :ignore_callbacks, **options
98
+ end
99
+
100
+ def ignore_login!(**options)
101
+ skip_before_action :require_valid_user, **options
102
+ skip_before_action :require_load_user, **options, raise: false
103
+ skip_before_action :update_expired_session_tokens, **options, raise: false
104
+ skip_after_action :set_token_header!, **options, raise: false
105
+ end
106
+
107
+ def catch_invalid_login(response: {errors: {authentication: ['login or password is invalid']}}, status: :unauthorized)
108
+ rescue_from Slots::JWT::AuthenticationFailed do |exception|
109
+ render json: response, status: status
110
+ end
111
+ end
112
+
113
+ def catch_invalid_token(response: {errors: {authentication: ['invalid or missing token']}}, status: :unauthorized)
114
+ rescue_from Slots::JWT::InvalidToken do |exception|
115
+ render json: response, status: status
116
+ end
117
+ end
118
+
119
+ def catch_access_denied(response: {errors: {authorization: ["can't access"]}}, status: :forbidden)
120
+ rescue_from Slots::JWT::AccessDenied do |exception|
121
+ render json: response, status: status
122
+ end
123
+ end
124
+
125
+ def reject_token(only: ALL, except: ALL, &block)
126
+ raise 'Cant pass both only and except' unless only == ALL || except == ALL
127
+ only = Array(only) if only != ALL
128
+ except = Array(except) if except != ALL
129
+
130
+ (@_reject_token ||= []).push([only, except, block])
131
+ end
132
+ def _reject_token?(con)
133
+ (@_reject_token ||= []).any? { |o, e, b| _check_to_reject?(con, o, e, b) } || _superclass_reject_token?(con)
134
+ end
135
+ def _check_to_reject?(con, only, except, block)
136
+ return false unless only == ALL || only.any? { |o| o.to_sym == con.action_name.to_sym }
137
+ return false if except != ALL && except.any? { |e| e.to_sym == con.action_name.to_sym }
138
+ con.instance_eval(&block)
139
+ end
140
+
141
+ def _superclass_reject_token?(con)
142
+ self.superclass.respond_to?('_reject_token?') && self.superclass._reject_token?(con)
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Slots
6
+ module JWT
7
+ class Configuration
8
+ attr_accessor :login_regex_validations, :token_lifetime, :session_lifetime, :previous_jwt_lifetime
9
+ attr_reader :logins
10
+ attr_writer :authentication_model
11
+
12
+ # raise_no_error is used for rake to load
13
+ def initialize
14
+ @logins = {email: //}
15
+ @login_regex_validations = true
16
+ @authentication_model = 'User'
17
+ @secret_keys = [{created_at: 0, secret: ENV['SLOT_SECRET']}]
18
+ @token_lifetime = 1.hour
19
+ @session_lifetime = 2.weeks # Set to nil if you dont want sessions
20
+ @previous_jwt_lifetime = 5.seconds # Set to nil if you dont want sessions
21
+ @manage_callbacks = Proc.new { }
22
+ end
23
+
24
+ def logins=(value)
25
+ if value.is_a? Symbol
26
+ @logins = {value => //}
27
+ elsif value.is_a?(Hash)
28
+ # Should do most inclusive regex last
29
+ raise 'must be hash of symbols => regex' unless value.length > 0 && value.all? { |k, v| k.is_a?(Symbol) && v.is_a?(Regexp) }
30
+ @logins = value
31
+ else
32
+ raise 'must be a symbol or hash'
33
+ end
34
+ end
35
+
36
+ def authentication_model
37
+ @authentication_model.to_s.constantize rescue nil
38
+ end
39
+
40
+ def secret=(v)
41
+ @secret_keys = [{created_at: 0, secret: v}]
42
+ end
43
+
44
+ def secret_yaml=(file_path_string)
45
+ secret_keys = YAML.load_file(Slots::JWT.secret_yaml_file)
46
+ @secret_keys = []
47
+ secret_keys.each do |secret_key|
48
+ raise ArgumentError, 'Need CREATED_AT' unless (created_at = secret_key['CREATED_AT']&.to_i)
49
+ raise ArgumentError, 'Need SECRET' unless (secret = secret_key['SECRET'])
50
+ previous_created_at = @secret_keys[-1]&.dig(:created_at) || Time.now.to_i
51
+
52
+ raise ArgumentError, 'CREATED_AT must be newest to latest' unless previous_created_at > created_at
53
+ @secret_keys.push(
54
+ created_at: created_at,
55
+ secret: secret
56
+ )
57
+ end
58
+ end
59
+
60
+ def secret(at = Time.now.to_i)
61
+ @secret_keys.each do |secret_hash|
62
+ return secret_hash[:secret] if at > secret_hash[:created_at]
63
+ end
64
+ raise InvalidSecret, 'Invalid Secret'
65
+ end
66
+ end
67
+
68
+ class << self
69
+ attr_writer :configuration
70
+
71
+ def configuration
72
+ @configuration ||= Configuration.new
73
+ end
74
+
75
+ def configure
76
+ yield configuration
77
+ end
78
+
79
+ def secret_yaml_file
80
+ Rails.root.join('config', 'slots_secrets.yml')
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slots
4
+ module JWT
5
+ module DatabaseAuthentication
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ has_secure_password
10
+ end
11
+
12
+ # TODO allow super
13
+ def as_json(*)
14
+ super.except('password_digest')
15
+ end
16
+
17
+ module ClassMethods
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slots
4
+ module JWT
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Slots::JWT
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slots
4
+ module JWT
5
+ class AuthenticationFailed < StandardError
6
+ end
7
+ class InvalidToken < StandardError
8
+ end
9
+ class AccessDenied < StandardError
10
+ end
11
+ class InvalidSecret < StandardError
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slots
4
+ module JWT
5
+ module GenericMethods
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ end
10
+
11
+ def allowed_new_token?
12
+ !(self.class._reject_new_token?(self))
13
+ end
14
+
15
+ def run_token_created_callback
16
+ self.class._token_created_callback(self)
17
+ end
18
+
19
+ def authenticate?(password)
20
+ password.present? && persisted? && respond_to?(:authenticate) && authenticate(password) && allowed_new_token?
21
+ end
22
+
23
+ def authenticate!(password)
24
+ raise Slots::JWT::AuthenticationFailed unless self.authenticate?(password)
25
+ true
26
+ end
27
+
28
+ module ClassMethods
29
+ def find_for_authentication(login)
30
+ Slots::JWT.configuration.logins.each do |k, v|
31
+ next unless login&.match(v)
32
+ return find_by(arel_table[k].lower.eq(login.downcase)) || new
33
+ end
34
+ new
35
+ end
36
+
37
+ def reject_new_token(&block)
38
+ (@_reject_new_token ||= []).push(block)
39
+ end
40
+ def _reject_new_token?(user)
41
+ (@_reject_new_token ||= []).any? { |b| user.instance_eval(&b) }
42
+ end
43
+
44
+ def token_created_callback(&block)
45
+ (@_token_created_callback ||= []).push(block)
46
+ end
47
+ def _token_created_callback(user)
48
+ (@_token_created_callback ||= []).each { |b| user.instance_eval(&b) }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slots
4
+ module JWT
5
+ module GenericValidations
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ validate :unique_and_present, :logins_meets_criteria
10
+ end
11
+
12
+ def logins_meets_criteria
13
+ return if self.errors.any?
14
+ return unless Slots::JWT.configuration.login_regex_validations
15
+ logins = Slots::JWT.configuration.logins
16
+ login_c = logins.keys
17
+ logins.each do |col, reg|
18
+ login_c.delete(col) # Login columns left
19
+ column_match(reg, col)
20
+ column_dont_match(reg, col, login_c)
21
+ end
22
+ end
23
+
24
+ def unique_and_present
25
+ # Use this rather than validates because logins in configure might not be set yet on include
26
+ Slots::JWT.configuration.logins.each do |column, _|
27
+ value = self.send(column)
28
+ next self.errors.add(column, "can't be blank") unless value.present?
29
+
30
+ pk_value = self.send(self.class.primary_key)
31
+ lower_case = self.class.arel_table[column].lower.eq(value.downcase)
32
+ next unless self.class.where.not(self.class.primary_key => pk_value).where(lower_case).exists?
33
+ self.errors.add(column, "has already been taken")
34
+ end
35
+ end
36
+
37
+ def column_match(regex, column)
38
+ # TODO change error message to use locals? or something configurable
39
+ self.errors.add(column, "didn't match login criteria") unless self.send(column).match(regex)
40
+ end
41
+
42
+ def column_dont_match(regex, column_not_to_match, columns)
43
+ columns.each do |c|
44
+ # Since we check if any errors should be present
45
+ self.errors.add(c, "matched #{column_not_to_match} login criteria") if self.send(c).match(regex)
46
+ end
47
+ end
48
+
49
+ module ClassMethods
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slots
4
+ module JWT
5
+ class PermissionFilter
6
+ def initialize(current_user)
7
+ @current_user = current_user
8
+ end
9
+
10
+ def call(schema_member, ctx)
11
+ @schema_member = schema_member
12
+ return true if _dont_check?
13
+ allowed?
14
+ end
15
+ protected
16
+ attr_reader :schema_member, :current_user
17
+
18
+ private
19
+ def _dont_check?
20
+ !schema_member.metadata[:has_required_permission]
21
+ end
22
+
23
+ def required_permission
24
+ schema_member.metadata[:required_permission]
25
+ end
26
+
27
+ def valid_loaded_user
28
+ return @valid_loaded_user if instance_variable_defined?(:@valid_loaded_user)
29
+ @valid_loaded_user = current_user&.valid_in_database? && current_user.allowed_new_token?
30
+ end
31
+
32
+ def is_admin
33
+ valid_loaded_user && current_user.admin
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+ module Slots
5
+ module JWT
6
+ class Slokens
7
+ attr_reader :token, :exp, :iat, :extra_payload, :authentication_model_values, :session
8
+ def initialize(decode: false, encode: false, token: nil, authentication_record: nil, extra_payload: nil, session: nil)
9
+ if decode
10
+ @token = token
11
+ decode()
12
+ elsif encode
13
+ @authentication_model_values = authentication_record.as_json
14
+ @extra_payload = extra_payload.as_json
15
+ @session = session
16
+ update_iat
17
+ update_exp
18
+ encode()
19
+ @valid = true
20
+ else
21
+ raise 'must encode or decode'
22
+ end
23
+ end
24
+ def self.decode(token)
25
+ self.new(decode: true, token: token)
26
+ end
27
+ def self.encode(authentication_record, session = '', extra_payload)
28
+ self.new(encode: true, authentication_record: authentication_record, session: session, extra_payload: extra_payload)
29
+ end
30
+
31
+ def expired?
32
+ @expired
33
+ end
34
+
35
+ def valid?
36
+ @valid
37
+ end
38
+
39
+ def valid!
40
+ raise InvalidToken, "Invalid Token" unless valid?
41
+ self
42
+ end
43
+
44
+ def update_token_data(authentication_record, extra_payload)
45
+ @authentication_model_values = authentication_record.as_json
46
+ @extra_payload = extra_payload.as_json
47
+ update_iat
48
+ encode
49
+ end
50
+
51
+ def update_token(authentication_record, extra_payload)
52
+ update_exp
53
+ update_token_data(authentication_record, extra_payload)
54
+ end
55
+
56
+ def payload
57
+ {
58
+ authentication_model_key => @authentication_model_values,
59
+ 'exp' => @exp,
60
+ 'iat' => @iat,
61
+ 'session' => @session,
62
+ 'extra_payload' => @extra_payload,
63
+ }
64
+ end
65
+
66
+ private
67
+ def authentication_model_key
68
+ Slots::JWT.configuration.authentication_model.name.underscore
69
+ end
70
+
71
+ def default_expected_keys
72
+ ['exp', 'iat', 'session', authentication_model_key]
73
+ end
74
+ def secret
75
+ Slots::JWT.configuration.secret(@iat)
76
+ end
77
+ def update_iat
78
+ @iat = Time.now.to_i
79
+ end
80
+ def update_exp
81
+ @exp = Slots::JWT.configuration.token_lifetime.from_now.to_i
82
+ end
83
+ def encode
84
+ @token = ::JWT.encode self.payload, secret, 'HS256'
85
+ @expired = false
86
+ @valid = true
87
+ end
88
+
89
+ def decode
90
+ begin
91
+ set_payload
92
+ ::JWT.decode @token, secret, true, verify_iat: true, algorithm: 'HS256'
93
+ rescue ::JWT::ExpiredSignature
94
+ @expired = true
95
+ rescue ::JWT::InvalidIatError, ::JWT::VerificationError, ::JWT::DecodeError, NoMethodError, JSON::ParserError, Slots::JWT::InvalidSecret
96
+ @valid = false
97
+ else
98
+ @valid = payload.slice(*default_expected_keys).compact.length == default_expected_keys.length
99
+ end
100
+ end
101
+
102
+ def set_payload
103
+ encoded64 = @token.split('.')[1] || ''
104
+ string_payload = Base64.decode64(encoded64)
105
+ local_payload = JSON.parse(string_payload)
106
+ raise JSON::ParserError unless local_payload.is_a?(Hash)
107
+ @exp = local_payload['exp']&.to_i
108
+ @iat = local_payload['iat']&.to_i
109
+ @session = local_payload['session']
110
+ @authentication_model_values = local_payload[authentication_model_key]
111
+ @extra_payload = local_payload['extra_payload']
112
+ end
113
+ end
114
+ end
115
+ end