crypt_ident 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
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