crypt_ident 0.2.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 (85) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +8 -0
  3. data/.rubocop.yml +29 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +7 -0
  6. data/.yardopts +16 -0
  7. data/CODE_OF_CONDUCT.md +74 -0
  8. data/Gemfile +6 -0
  9. data/Gemfile.lock +263 -0
  10. data/Guardfile +26 -0
  11. data/HISTORY.md +22 -0
  12. data/LICENSE.txt +21 -0
  13. data/README.md +548 -0
  14. data/Rakefile +93 -0
  15. data/bin/_guard-core +29 -0
  16. data/bin/bundle +105 -0
  17. data/bin/byebug +29 -0
  18. data/bin/code_climate_reek +29 -0
  19. data/bin/coderay +29 -0
  20. data/bin/commonmarker +29 -0
  21. data/bin/console +14 -0
  22. data/bin/erubis +29 -0
  23. data/bin/flay +29 -0
  24. data/bin/flog +29 -0
  25. data/bin/github-markup +29 -0
  26. data/bin/guard +29 -0
  27. data/bin/inch +29 -0
  28. data/bin/kwalify +29 -0
  29. data/bin/listen +29 -0
  30. data/bin/pry +29 -0
  31. data/bin/rackup +29 -0
  32. data/bin/rake +29 -0
  33. data/bin/redcarpet +29 -0
  34. data/bin/reek +29 -0
  35. data/bin/rubocop +29 -0
  36. data/bin/ruby-parse +29 -0
  37. data/bin/ruby-rewrite +29 -0
  38. data/bin/ruby_parse +29 -0
  39. data/bin/ruby_parse_extract_error +29 -0
  40. data/bin/sequel +29 -0
  41. data/bin/setup +34 -0
  42. data/bin/sparkr +29 -0
  43. data/bin/term_cdiff +29 -0
  44. data/bin/term_colortab +29 -0
  45. data/bin/term_decolor +29 -0
  46. data/bin/term_display +29 -0
  47. data/bin/term_mandel +29 -0
  48. data/bin/term_snow +29 -0
  49. data/bin/thor +29 -0
  50. data/bin/yard +29 -0
  51. data/bin/yardoc +29 -0
  52. data/bin/yri +29 -0
  53. data/config.reek +19 -0
  54. data/crypt_ident.gemspec +80 -0
  55. data/docs/CryptIdent.html +2276 -0
  56. data/docs/_index.html +116 -0
  57. data/docs/class_list.html +51 -0
  58. data/docs/css/common.css +1 -0
  59. data/docs/css/full_list.css +58 -0
  60. data/docs/css/style.css +496 -0
  61. data/docs/file.CODE_OF_CONDUCT.html +145 -0
  62. data/docs/file.HISTORY.html +91 -0
  63. data/docs/file.LICENSE.html +70 -0
  64. data/docs/file.README.html +692 -0
  65. data/docs/file_list.html +71 -0
  66. data/docs/frames.html +17 -0
  67. data/docs/index.html +692 -0
  68. data/docs/js/app.js +292 -0
  69. data/docs/js/full_list.js +216 -0
  70. data/docs/js/jquery.js +4 -0
  71. data/docs/method_list.html +115 -0
  72. data/docs/top-level-namespace.html +110 -0
  73. data/lib/crypt_ident.rb +13 -0
  74. data/lib/crypt_ident/change_password.rb +184 -0
  75. data/lib/crypt_ident/config.rb +47 -0
  76. data/lib/crypt_ident/generate_reset_token.rb +212 -0
  77. data/lib/crypt_ident/reset_password.rb +207 -0
  78. data/lib/crypt_ident/session_expired.rb +91 -0
  79. data/lib/crypt_ident/sign_in.rb +189 -0
  80. data/lib/crypt_ident/sign_out.rb +96 -0
  81. data/lib/crypt_ident/sign_up.rb +160 -0
  82. data/lib/crypt_ident/update_session_expiry.rb +125 -0
  83. data/lib/crypt_ident/version.rb +6 -0
  84. data/scripts/build-gem-list.rb +91 -0
  85. metadata +547 -0
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ require 'dry/monads/result'
6
+ require 'dry/matcher/result_matcher'
7
+
8
+ require_relative './config'
9
+
10
+ # Include and interact with `CryptIdent` to add authentication to a
11
+ # Hanami controller action.
12
+ #
13
+ # Note the emphasis on *controller action*; this module interacts with session
14
+ # data, which is quite theoretically possible in an Interactor but practically
15
+ # *quite* the PITA. YHBW.
16
+ #
17
+ # @author Jeff Dickey
18
+ # @version 0.2.0
19
+ module CryptIdent
20
+ # Generate a Password Reset Token
21
+ #
22
+ # Password Reset Tokens are useful for verifying that the person requesting a
23
+ # Password Reset for an existing User is sufficiently likely to be the person
24
+ # who Registered that User or, if not, that no compromise or other harm is
25
+ # done.
26
+ #
27
+ # Typically, this is done by sending a link through email or other such medium
28
+ # to the address previously associated with the User purportedly requesting
29
+ # the Password Reset. `CryptIdent` *does not* automate generation or sending
30
+ # of the email message. What it *does* provide is a method to generate a new
31
+ # Password Reset Token to be embedded into an HTML anchor link within an email
32
+ # that you construct, and then another method (`#reset_password`) to actually
33
+ # change the password given a valid, correct token.
34
+ #
35
+ # It also implements an expiry system, such that if the confirmation of the
36
+ # Password Reset request is not completed within a configurable time, that the
37
+ # token is no longer valid (and so cannot be later reused by unauthorised
38
+ # persons).
39
+ #
40
+ # This method *requires* a block, to which a `result` indicating success or
41
+ # failure is yielded. That block **must** in turn call **both**
42
+ # `result.success` and `result.failure` to handle success and failure results,
43
+ # respectively. On success, the block yielded to by `result.success` is called
44
+ # and passed a `user:` parameter, which is identical to the `user` parameter
45
+ # passed in to `#generate_reset_token` *except* that the `:token` and
46
+ # `:password_reset_expires_at` attributes have been updated to reflect the
47
+ # token request. An updated record matching that `:user` Entity will also have
48
+ # been saved to the Repository.
49
+ #
50
+ # On failure, the `result.failure` call will yield three parameters: `:code`,
51
+ # `:current_user`, and `:name`, and will be set as follows:
52
+ #
53
+ # If the `:code` value is `:user_logged_in`, that indicates that the
54
+ # `current_user` parameter to this method represented a Registered User. In
55
+ # this event, the `:current_user` value passed in to the `result.failure` call
56
+ # will be the same User Entity passed into the method, and the `:name` value
57
+ # will be `:unassigned`.
58
+ #
59
+ # If the `:code` value is `:user_not_found`, the named User was not found in
60
+ # the Repository. The `:current_user` parameter will be the Guest User Entity,
61
+ # and the `:name` parameter to the `result.failure` block will be the
62
+ # `user_name` value passed into the method.
63
+ # @yieldparam result [Dry::Matcher::Evaluator] Indicates whether the attempt
64
+ # to generate a new Reset Token succeeded or failed. The lock
65
+ # **must** call **both** `result.success` and `result.failure`
66
+ # methods, where the block passed to `result.success` accepts a
67
+ # parameter for `user:`, which is a User Entity with the
68
+ # specified `name` value as well as non-`nil` values for its
69
+ # `:token` and `:password_reset_expires_at` attributes. The
70
+ # block passed to `result.failure` accepts parameters for
71
+ # `code:`, `current_user:`, and `name` as described above.
72
+ # @yieldreturn (void) Use the `result.success` and `result.failure`
73
+ # method-call blocks to retrieve data from the method.
74
+ #
75
+ # @since 0.1.0
76
+ # @authenticated Must not be Authenticated.
77
+ # @param [String] user_name The name of the User for whom a Password Reset
78
+ # Token is to be generated.
79
+ # @param [User, Hash] current_user Entity representing the currently
80
+ # Authenticated User Entity. This **must** be a Registered
81
+ # User, either as an Entity or as a Hash of attributes.
82
+ # @return (void)
83
+ # @example Demonstrating a (refactorable) Controller Action Class #call method
84
+ #
85
+ # def call(params)
86
+ # config = CryptIdent.config
87
+ # # Remember that reading an Entity stored in session data will in fact
88
+ # # return a *Hash of its attribute values*. This is acceptable.
89
+ # other_params = { current_user: session[:current_user] }
90
+ # generate_reset_token(params[:name], other_params) do |result|
91
+ # result.success do |user:|
92
+ # @user = user
93
+ # flash[config.success_key] = 'Request for #{user.name} sent'
94
+ # end
95
+ # result.failure do |code:, current_user:, name:| do
96
+ # respond_to_error(code, current_user, name)
97
+ # end
98
+ # end
99
+ # end
100
+ #
101
+ # private
102
+ #
103
+ # def respond_to_error(code, current_user, name)
104
+ # # ...
105
+ # end
106
+ # @session_data
107
+ # `:current_user` **must not** be a Registered User.
108
+ # @ubiq_lang
109
+ # - Authentication
110
+ # - Guest User
111
+ # - Password Reset Token
112
+ # - Registered User
113
+ def generate_reset_token(user_name, current_user: nil)
114
+ other_params = { current_user: current_user }
115
+ GenerateResetToken.new.call(user_name, other_params) do |result|
116
+ yield result
117
+ end
118
+ end
119
+
120
+ # Generate Reset Token for non-Authenticated User
121
+ #
122
+ # This class *is not* part of the published API.
123
+ # @private
124
+ class GenerateResetToken
125
+ include Dry::Monads::Result::Mixin
126
+ include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
127
+
128
+ LogicError = Class.new(RuntimeError)
129
+
130
+ def initialize
131
+ @current_user = nil
132
+ @user_name = :unassigned
133
+ end
134
+
135
+ def call(user_name, current_user: nil)
136
+ init_ivars(user_name, current_user)
137
+ Success(user: updated_user)
138
+ rescue LogicError => error
139
+ # rubocop:disable Security/MarshalLoad
140
+ error_data = Marshal.load(error.message)
141
+ # rubocop:enable Security/MarshalLoad
142
+ Failure(error_data)
143
+ end
144
+
145
+ private
146
+
147
+ attr_reader :current_user, :user_name
148
+
149
+ def current_user_or_guest
150
+ guest_user = CryptIdent.config.repository.guest_user
151
+ current_user = @current_user || guest_user
152
+ # This will convert a Hash of attributes to an Entity instance. It leaves
153
+ # an actual Entity value unmolested.
154
+ @current_user = guest_user.class.new(current_user)
155
+ end
156
+
157
+ def init_ivars(user_name, current_user)
158
+ @current_user = current_user
159
+ @user_name = user_name
160
+ end
161
+
162
+ def new_token
163
+ token_length = CryptIdent.config.token_bytes
164
+ clear_text_token = SecureRandom.alphanumeric(token_length)
165
+ Base64.strict_encode64(clear_text_token)
166
+ end
167
+
168
+ def update_repo(user)
169
+ CryptIdent.config.repository.update(user.id, updated_attribs)
170
+ end
171
+
172
+ def updated_attribs
173
+ prea = Time.now + CryptIdent.config.reset_expiry
174
+ { token: new_token, password_reset_expires_at: prea }
175
+ end
176
+
177
+ def updated_user
178
+ validate_current_user
179
+ update_repo(user_by_name)
180
+ end
181
+
182
+ def find_user_by_name
183
+ # will be `nil` if no match found
184
+ CryptIdent.config.repository.find_by_name(user_name)
185
+ end
186
+
187
+ def user_by_name
188
+ found_user = find_user_by_name
189
+ raise LogicError, user_not_found_error unless found_user
190
+
191
+ found_user
192
+ end
193
+
194
+ def user_logged_in_error
195
+ Marshal.dump(code: :user_logged_in, current_user: current_user,
196
+ name: :unassigned)
197
+ end
198
+
199
+ def user_not_found_error
200
+ Marshal.dump(code: :user_not_found, current_user: current_user,
201
+ name: user_name)
202
+ end
203
+
204
+ def validate_current_user
205
+ return current_user if current_user_or_guest.guest?
206
+
207
+ raise LogicError, user_logged_in_error
208
+ end
209
+ end
210
+ # Leave the class visible durinig Gem development and testing; hide in an app
211
+ private_constant :GenerateResetToken if Hanami.respond_to?(:env?)
212
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bcrypt'
4
+
5
+ require 'dry/monads/result'
6
+ require 'dry/matcher/result_matcher'
7
+ require 'hanami/utils/kernel'
8
+
9
+ require_relative './config'
10
+
11
+ # Include and interact with `CryptIdent` to add authentication to a
12
+ # Hanami controller action.
13
+ #
14
+ # Note the emphasis on *controller action*; this module interacts with session
15
+ # data, which is quite theoretically possible in an Interactor but practically
16
+ # *quite* the PITA. YHBW.
17
+ #
18
+ # @author Jeff Dickey
19
+ # @version 0.2.0
20
+ module CryptIdent
21
+ # Reset the password for the User associated with a Password Reset Token.
22
+ #
23
+ # After a Password Reset Token has been
24
+ # [generated](#generate_reset_token-instance_method) and sent to a User, that
25
+ # User would then exercise the Client system and perform a Password Reset.
26
+ #
27
+ # Calling `#reset_password` is different than calling `#change_password` in
28
+ # one vital respect: with `#change_password`, the User involved **must** be
29
+ # the Current User (as presumed by passing the appropriate User Entity in as
30
+ # the `current_user:` parameter), whereas `#reset_password` **must not** be
31
+ # called with *any* User other than the Guest User as the `current_user:`
32
+ # parameter (and, again presumably, the Current User for the session). How can
33
+ # we assure ourselves that the request is legitimate for a specific User? By
34
+ # use of the Token generated by a previous call to `#generate_reset_token`,
35
+ # which is used _in place of_ a User Name for this request.
36
+ #
37
+ # Given a valid set of parameters, and given that the updated User is
38
+ # successfully persisted, the method calls the **required** block with a
39
+ # `result` whose `result.success` matcher is yielded a `user:` parameter with
40
+ # the updated User as its value.
41
+ #
42
+ # NOTE: Each of the error returns documented below calls the **required**
43
+ # block with a `result` whose `result.failure` matcher is yielded a `code:`
44
+ # parameter as described, and a `token:` parameter that has the same value
45
+ # as the passed-in `token` parameter.
46
+ #
47
+ # If the passed-in `token` parameter matches the `token` field of a record in
48
+ # the Repository *and* that Token is determined to have Expired, then the
49
+ # `code:` parameter mentioned earlier will have the value `:expired_token`.
50
+ #
51
+ # If the passed-in `token` parameter *does not* match the `token` field of any
52
+ # record in the Repository, then the `code:` parameter will have the value
53
+ # `:token_not_found`.
54
+ #
55
+ # If the passed-in `current_user:` parameter is a Registered User, then the
56
+ # `code:` parameter will have the value `:invalid_current_user`.
57
+ #
58
+ # In no event are session values, including the Current User, changed. After a
59
+ # successful Password Reset, the User must Authenticate as usual.
60
+ #
61
+ # @yieldparam result [Dry::Matcher::Evaluator] Indicates whether the attempt
62
+ # to generate a new Reset Token succeeded or failed. The lock
63
+ # **must** call **both** `result.success` and `result.failure`
64
+ # methods, where the block passed to `result.success` accepts a
65
+ # parameter for `user:`, which is a User Entity with the
66
+ # specified `name` value as well as non-`nil` values for its
67
+ # `:token` and `:password_reset_expires_at` attributes. The
68
+ # block passed to `result.failure` accepts parameters for
69
+ # `code:`, `current_user:`, and `name` as described above.
70
+ # @yieldreturn (void) Use the `result.success` and `result.failure`
71
+ # method-call blocks to retrieve data from the method.
72
+ #
73
+ # @since 0.1.0
74
+ # @authenticated Must not be Authenticated.
75
+ # @param [String] token The Password Reset Token previously communicated to
76
+ # the User.
77
+ # @param [String] new_password New Clear-Text Password to encrypt and add to
78
+ # return value
79
+ # @return (void)
80
+ # @example
81
+ # def call(params)
82
+ # reset_password(params[:token], params[:new_password],
83
+ # current_user: session[:current_user]) do |result
84
+ # result.success do |user:|
85
+ # @user = user
86
+ # message = "Password for #{user.name} successfully reset."
87
+ # flash[CryptIdent.config.success_key] = message
88
+ # redirect_to routes.root_path
89
+ # end
90
+ # result.failure do |code:, token:|
91
+ # failure_key = CryptIdent.config.failure_key
92
+ # flash[failure_key] = failure_message_for(code, token)
93
+ # end
94
+ # end
95
+ # end
96
+ #
97
+ # private
98
+ #
99
+ # def failure_message_for(code, token)
100
+ # # ...
101
+ # end
102
+ # @session_data
103
+ # `:current_user` **must not** be a Registered User.
104
+ # @ubiq_lang
105
+ # - Authentication
106
+ # - Clear-Text Password
107
+ # - Encrypted Password
108
+ # - Password Reset Token
109
+ # - Registered User
110
+ #
111
+ def reset_password(token, new_password, current_user: nil)
112
+ other_params = { current_user: current_user }
113
+ ResetPassword.new.call(token, new_password, other_params) do |result|
114
+ yield result
115
+ end
116
+ end
117
+
118
+ # Reset Password using previously-sent Reset Token for non-Authenticated User
119
+ #
120
+ # This class *is not* part of the published API.
121
+ # @private
122
+ class ResetPassword
123
+ include Dry::Monads::Result::Mixin
124
+ include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
125
+
126
+ LogicError = Class.new(RuntimeError)
127
+
128
+ def initialize
129
+ @current_user = :unassigned
130
+ end
131
+
132
+ def call(token, new_password, current_user: nil)
133
+ init_ivars(current_user)
134
+ verify_no_current_user(token)
135
+ user = verify_token(token)
136
+ Success(user: update(user, new_password))
137
+ rescue LogicError => error
138
+ report_failure(error)
139
+ end
140
+
141
+ private
142
+
143
+ attr_reader :current_user
144
+
145
+ def encrypted(password)
146
+ BCrypt::Password.create(password)
147
+ end
148
+
149
+ def expired_token?(entity)
150
+ prea = entity.password_reset_expires_at
151
+ # Calling this on a non-reset Entity is treated as expiring at the epoch
152
+ Time.now > Hanami::Utils::Kernel.Time(prea.to_i)
153
+ end
154
+
155
+ # Reek sees a :reek:ControlParameter. Yep.
156
+ def init_ivars(current_user)
157
+ guest_user = CryptIdent.config.guest_user
158
+ current_user ||= guest_user
159
+ @current_user = guest_user.class.new(current_user)
160
+ end
161
+
162
+ def matching_record_for(token)
163
+ Array(CryptIdent.config.repository.find_by_token(token)).first
164
+ end
165
+
166
+ def new_attribs(password)
167
+ { password_hash: encrypted(password), password_reset_expires_at: nil,
168
+ token: nil }
169
+ end
170
+
171
+ def raise_logic_error(code, token)
172
+ payload = { code: code, token: token }
173
+ raise LogicError, Marshal.dump(payload)
174
+ end
175
+
176
+ def report_failure(error)
177
+ # rubocop:disable Security/MarshalLoad
178
+ error_data = Marshal.load(error.message)
179
+ # rubocop:enable Security/MarshalLoad
180
+ Failure(error_data)
181
+ end
182
+
183
+ def update(user, password)
184
+ CryptIdent.config.repository.update(user.id, new_attribs(password))
185
+ end
186
+
187
+ def validate_match_and_token(match, token)
188
+ raise_logic_error(:token_not_found, token) unless match
189
+ raise_logic_error(:expired_token, token) if expired_token?(match)
190
+ match
191
+ end
192
+
193
+ def verify_no_current_user(token)
194
+ return if current_user.guest?
195
+
196
+ payload = { code: :invalid_current_user, token: token }
197
+ raise LogicError, Marshal.dump(payload)
198
+ end
199
+
200
+ def verify_token(token)
201
+ match = matching_record_for(token)
202
+ validate_match_and_token(match, token)
203
+ end
204
+ end
205
+ # Leave the class visible durinig Gem development and testing; hide in an app
206
+ private_constant :ResetPassword if Hanami.respond_to?(:env?)
207
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Include and interact with `CryptIdent` to add authentication to a
4
+ # Hanami controller action.
5
+ #
6
+ # Note the emphasis on *controller action*; this module interacts with session
7
+ # data, which is quite theoretically possible in an Interactor but practically
8
+ # *quite* the PITA. YHBW.
9
+ #
10
+ # @author Jeff Dickey
11
+ # @version 0.2.0
12
+ module CryptIdent
13
+ # Determine whether the Session has Expired due to User inactivity.
14
+ #
15
+ # This is one of two methods in `CryptIdent` (the other being
16
+ # [`#update_session_expiry?`](#update_session_expiry)) which *does not* follow
17
+ # the `result`/success/failure [monad workflow](#interfaces). This is because
18
+ # there is no success/failure division in the workflow. Calling the method
19
+ # determines if the Current User session has Expired. If the passed-in
20
+ # `:current_user` is a Registered User, then this will return `true` if the
21
+ # current time is *later than* the passed-in `:expires_at` value; for the
22
+ # Guest User, it should always return `false`. (Guest User sessions never
23
+ # expire; after all, what would you change the session state to?).
24
+ #
25
+ # The client code is responsible for applying these values to its own actual
26
+ # session data, as described by the sample session-management code shown in
27
+ # the README.
28
+ #
29
+ # @param [Hash] session_data The Rack session data of interest to the method.
30
+ # If the `:current_user` entry is defined, it **must** be either
31
+ # a User Entity or `nil`, signifying the Guest User. If the
32
+ # `:expires_at` entry is defined, its value in the returned Hash
33
+ # *will* be different.
34
+ #
35
+ # @since 0.1.0
36
+ # @authenticated Must be Authenticated.
37
+ # @return [Boolean]
38
+ # @example As used in module included by Controller Action Class (see README)
39
+ # def validate_session
40
+ # updates = update_session_expiry(session)
41
+ # if !session_expired?(session)
42
+ # session[:expires_at] = updates[:expires_at]
43
+ # return
44
+ # end
45
+ #
46
+ # # ... sign out and redirect appropriately ...
47
+ # end
48
+ # @session_data
49
+ # `:current_user` **must** be an Entity for a Registered User on entry
50
+ # `:expires_at` read during determination of expiry status
51
+ # @ubiq_lang
52
+ # - Authentication
53
+ # - Current User
54
+ # - Guest User
55
+ # - Registered User
56
+ # - Session Expiration
57
+ #
58
+ def session_expired?(session_data = {})
59
+ SessionExpired.new.call(session_data)
60
+ end
61
+
62
+ # Determine whether the Session has Expired due to User inactivity.
63
+ #
64
+ # This class *is not* part of the published API.
65
+ # @private
66
+ class SessionExpired
67
+ def call(session_data)
68
+ # Guest sessions never expire.
69
+ return false if guest_user_from?(session_data)
70
+
71
+ expiry_from(session_data) <= Time.now
72
+ end
73
+
74
+ private
75
+
76
+ def guest_user_from?(session_data)
77
+ user = session_data[:current_user] || UserRepository.guest_user
78
+ # If the `session_data` in fact came from Rack session data, then any
79
+ # objects (such as a `User` Entity) have been converted to JSON-compatible
80
+ # types. Hanami Entities can be implicitly converted to and from Hashes of
81
+ # their attributes, so this part's easy...
82
+ User.new(user).guest?
83
+ end
84
+
85
+ def expiry_from(session_data)
86
+ Hanami::Utils::Kernel.Time(session_data[:expires_at].to_i)
87
+ end
88
+ end
89
+ # Leave the class visible durinig Gem development and testing; hide in an app
90
+ private_constant :SessionExpired if Hanami.respond_to?(:env?)
91
+ end