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,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bcrypt'
4
+
5
+ require 'dry/monads/result'
6
+ require 'dry/matcher/result_matcher'
7
+
8
+ # Include and interact with `CryptIdent` to add authentication to a
9
+ # Hanami controller action.
10
+ #
11
+ # Note the emphasis on *controller action*; this module interacts with session
12
+ # data, which is quite theoretically possible in an Interactor but practically
13
+ # *quite* the PITA. YHBW.
14
+ #
15
+ # @author Jeff Dickey
16
+ # @version 0.2.0
17
+ module CryptIdent
18
+ # Attempt to Authenticate a User, passing in an Entity for that User (which
19
+ # **must** contain a `password_hash` attribute), and a Clear-Text Password.
20
+ # It also passes in the Current User.
21
+ #
22
+ # If the Current User is not a Registered User, then Authentication of the
23
+ # specified User Entity against the specified Password is accomplished by
24
+ # comparing the User Entity's `password_hash` attribute to the passed-in
25
+ # Clear-Text Password.
26
+ #
27
+ # The method *requires* a block, to which a `result` indicating success or
28
+ # failure is yielded. That block **must** in turn call **both**
29
+ # `result.success` and `result.failure` to handle success and failure results,
30
+ # respectively. On success, the block yielded to by `result.success` is called
31
+ # and passed a `user:` parameter, which is the Authenticated User (and is the
32
+ # same Entity as the `user` parameter passed in to `#sign_in`).
33
+ #
34
+ # On failure, the `result.failure` call will yield a `code:` parameter to its
35
+ # block, which indicates the cause of failure as follows:
36
+ #
37
+ # If the specified password *did not* match the passed-in `user` Entity, then
38
+ # the `code:` for failure will be `:invalid_password`.
39
+ #
40
+ # If the specified `user` was not a Registered User, then the `code:` for
41
+ # failure will be `:user_is_guest`.
42
+ #
43
+ # If the specified `current_user` is *neither* the Guest User *nor* the `user`
44
+ # passed in as a parameter to `#sign_in`, then the `code:` for failure will be
45
+ # `:illegal_current_user`.
46
+ #
47
+ # On *success,* the Controller-level client code **must** set:
48
+ #
49
+ # * `session[:expires_at]` to the expiration time for the session. This is
50
+ # ordinarily computed by adding the current time as returned by `Time.now`
51
+ # to the `:session_expiry` value in the current configuration.
52
+ # * `session[:current_user]` to tne returned *Entity* for the successfully
53
+ # Authenticated User. This is to eliminate possible repeated reads of the
54
+ # Repository.
55
+ #
56
+ # On *failure,* the Controller-level client code **should** set:
57
+ #
58
+ # * `session[:expires_at]` to some sufficiently-past time to *always* trigger
59
+ # `#session_expired?`; `Hanami::Utils::Kernel.Time(0)` does this quite well
60
+ # (returning midnight GMT on 1 January 1970, converted to local time).
61
+ # * `session[:current_user]` to either `nil` or the Guest User.
62
+ #
63
+ # @since 0.1.0
64
+ # @authenticated Must not be Authenticated as a different User.
65
+ # @param [User] user_in Entity representing a User to be Authenticated.
66
+ # @param [String] password Claimed Clear-Text Password for the specified User.
67
+ # @param [User, nil] current_user Entity representing the currently
68
+ # Authenticated User Entity; either `nil` or the Guest User if
69
+ # none.
70
+ # @return (void) Use the `result` yield parameter to determine results.
71
+ # @yieldparam result [Dry::Matcher::Evaluator] Indicates whether the attempt
72
+ # to Authenticate a User succeeded or failed. Block **must**
73
+ # call **both** `result.success` and `result.failure` methods,
74
+ # where the block passed to `result.success` accepts a parameter
75
+ # for `user:` (which is the newly-created User Entity). The
76
+ # block passed to `result.failure` accepts a parameter for
77
+ # `code:`, which is a Symbol reporting the reason for the
78
+ # failure (as described above).
79
+ # @yieldreturn [void]
80
+ # @example As in a Controller Action Class (which you'd refactor somewhat):
81
+ # def call(params)
82
+ # user = UserRepository.new.find_by_email(params[:email])
83
+ # guest_user = CryptIdent.config.guest_user
84
+ # return update_session_data(guest_user, 0) unless user
85
+ #
86
+ # current_user = session[:current_user]
87
+ # config = CryptIdent.config
88
+ # sign_in(user, params[:password], current_user: current_user) do |result|
89
+ # result.success do |user:|
90
+ # @user = user
91
+ # update_session_data(user, Time.now)
92
+ # flash[config.success_key] = "User #{user.name} signed in."
93
+ # redirect_to routes.root_path
94
+ # end
95
+ #
96
+ # result.failure do |code:|
97
+ # update_session_data(guest_user, config, 0)
98
+ # flash[config.error_key] = error_message_for(code)
99
+ # end
100
+ # end
101
+ #
102
+ # private
103
+ #
104
+ # def error_message_for(code)
105
+ # # ...
106
+ # end
107
+ #
108
+ # def update_session_data(user, time)
109
+ # session[:current_user] = user
110
+ # expiry = Time.now + CryptIdent.config.session_expiry
111
+ # session[:expires_at] == Hanami::Utils::Kernel.Time(expiry)
112
+ # end
113
+ # @session_data
114
+ # `:current_user` **must not** be a Registered User
115
+ # @ubiq_lang
116
+ # - Authenticated User
117
+ # - Authentication
118
+ # - Clear-Text Password
119
+ # - Entity
120
+ # - Guest User
121
+ # - Registered User
122
+ #
123
+ def sign_in(user_in, password, current_user: nil)
124
+ params = { user: user_in, password: password, current_user: current_user }
125
+ SignIn.new.call(params) { |result| yield result }
126
+ end
127
+
128
+ # Reworked sign-in logic for `CryptIdent`, per Issue #9.
129
+ #
130
+ # This class *is not* part of the published API.
131
+ # @private
132
+ class SignIn
133
+ include Dry::Monads::Result::Mixin
134
+ include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
135
+
136
+ # As a reminder, calling `Failure` *does not* interrupt control flow *or*
137
+ # prevent a future `Success` call from overriding the result. This is one
138
+ # case where raising *and catching* an exception is Useful
139
+ def call(user:, password:, current_user: nil)
140
+ set_ivars(user, password, current_user)
141
+ validate_call_params
142
+ Success(user: user)
143
+ rescue LogicError => error
144
+ Failure(code: error.message.to_sym)
145
+ end
146
+
147
+ private
148
+
149
+ attr_reader :current_user, :password, :user
150
+
151
+ LogicError = Class.new(RuntimeError)
152
+ private_constant :LogicError
153
+
154
+ def illegal_current_user?
155
+ !current_user.guest? && !same_user?
156
+ end
157
+
158
+ def password_comparator
159
+ BCrypt::Password.new(user.password_hash)
160
+ end
161
+
162
+ def same_user?
163
+ current_user.name == user.name
164
+ end
165
+
166
+ # Reek complains about a :reek:ControlParameter for `current`. Never mind.
167
+ def set_ivars(user, password, current)
168
+ @user = user
169
+ @password = password
170
+ guest_user = CryptIdent.config.guest_user
171
+ current ||= guest_user
172
+ @current_user = guest_user.class.new(current)
173
+ end
174
+
175
+ def validate_call_params
176
+ raise LogicError, 'user_is_guest' if user.guest?
177
+ raise LogicError, 'illegal_current_user' if illegal_current_user?
178
+
179
+ verify_matching_password
180
+ end
181
+
182
+ def verify_matching_password
183
+ match = password_comparator == password
184
+ raise LogicError, 'invalid_password' unless match
185
+ end
186
+ end
187
+ # Leave the class visible durinig Gem development and testing; hide in an app
188
+ private_constant :SignIn if Hanami.respond_to?(:env?)
189
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/monads/result'
4
+ require 'dry/matcher/result_matcher'
5
+
6
+ # Include and interact with `CryptIdent` to add authentication to a
7
+ # Hanami controller action.
8
+ #
9
+ # Note the emphasis on *controller action*; this module interacts with session
10
+ # data, which is quite theoretically possible in an Interactor but practically
11
+ # *quite* the PITA. YHBW.
12
+ #
13
+ # @author Jeff Dickey
14
+ # @version 0.2.0
15
+ module CryptIdent
16
+ # Sign out a previously Authenticated User.
17
+ #
18
+ # The method *requires* a block, to which a `result` indicating success or
19
+ # failure is yielded. (Presently, any call to `#sign_out` results in success.)
20
+ # That block **must** in turn call **both** `result.success` and
21
+ # `result.failure` (even though no failure is implemented) to handle success
22
+ # and failure results, respectively. On success, the block yielded to by
23
+ # `result.success` is called without parameters.
24
+ #
25
+ # @since 0.1.0
26
+ # @authenticated Should be Authenticated.
27
+ # @param [User, `nil`] current_user Entity representing the currently
28
+ # Authenticated User Entity. This **should** be a Registered
29
+ # User.
30
+ # @return (void)
31
+ # @yieldparam result [Dry::Matcher::Evaluator] Normally, used to report
32
+ # whether a method succeeded or failed. The block **must**
33
+ # call **both** `result.success` and `result.failure` methods.
34
+ # In practice, parameters to both may presently be safely
35
+ # ignored.
36
+ # @yieldreturn [void]
37
+ #
38
+ # @example Controller Action Class method example resetting values
39
+ # def call(_params)
40
+ # sign_out(session[:current_user]) do |result|
41
+ # result.success do
42
+ # session[:current_user] = CryptIdent.config.guest_user
43
+ # session[:expires_at] = Hanami::Utils::Kernel.Time(0)
44
+ # end
45
+ #
46
+ # result.failure { next }
47
+ # end
48
+ # end
49
+ #
50
+ # @example Controller Action Class method example deleting values
51
+ # def call(_params)
52
+ # sign_out(session[:current_user]) do |result|
53
+ # result.success do
54
+ # session[:current_user] = nil
55
+ # session[:expires_at] = nil
56
+ # end
57
+ #
58
+ # result.failure { next }
59
+ # end
60
+ # end
61
+ #
62
+ # @session_data
63
+ # See method description above.
64
+ #
65
+ # @ubiq_lang
66
+ # - Authenticated User
67
+ # - Authentication
68
+ # - Controller Action Class
69
+ # - Entity
70
+ # - Guest User
71
+ # - Interactor
72
+ # - Repository
73
+ #
74
+ def sign_out(current_user:)
75
+ SignOut.new.call(current_user: current_user) { |result| yield result }
76
+ end
77
+
78
+ # Sign-out logic for `CryptIdent`, per Issue #9.
79
+ #
80
+ # This class *is not* part of the published API.
81
+ # @private
82
+ class SignOut
83
+ include Dry::Monads::Result::Mixin
84
+ include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
85
+
86
+ # This method exists, despite YAGNI, to provide for future expansion of
87
+ # features like analytics. More importantly, it provides an API congruent
88
+ # with that of the (reworked) `#sign_up` and `#sign_in` methods.
89
+ def call(current_user:)
90
+ _ = current_user # presently ignored
91
+ Success()
92
+ end
93
+ end
94
+ # Leave the class visible durinig Gem development and testing; hide in an app
95
+ private_constant :SignOut if Hanami.respond_to?(:env?)
96
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ require 'bcrypt'
6
+
7
+ require 'dry/monads/result'
8
+ require 'dry/matcher/result_matcher'
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
+ # Persist a new User to a Repository based on passed-in attributes, where the
21
+ # resulting Entity (on success) contains a `:password_hash` attribute
22
+ # containing the encrypted value of a **random** Clear-Text Password; any
23
+ # `password` value within `attribs` is ignored.
24
+ #
25
+ # The method *requires* a block, to which a `result` indicating success or
26
+ # failure is yielded. That block **must** in turn call **both**
27
+ # `result.success` and `result.failure` to handle success and failure results,
28
+ # respectively. On success, the block yielded to by `result.success` is called
29
+ # and passed a `user:` parameter, which is the newly-created User Entity.
30
+ #
31
+ # If the call fails, the `result.success` block is yielded to, and passed a
32
+ # `code:` parameter, which will contain one of the following symbols:
33
+ #
34
+ # * `:current_user_exists` indicates that the method was called with a
35
+ # Registered User as the `current_user` parameter.
36
+ # * `:user_already_created` indicates that the specified `name` attribute
37
+ # matches a record that already exists in the underlying Repository.
38
+ # * `:user_creation_failed` indicates that the Repository was unable to create
39
+ # the new User for some other reason, such as an internal error.
40
+ #
41
+ # **NOTE** that the incoming `params` are expected to have been whitelisted at
42
+ # the Controller Action Class level.
43
+ #
44
+ # @since 0.1.0
45
+ # @authenticated Must not be Authenticated.
46
+ # @param [Hash] attribs Hash-like object of attributes for new User Entity and
47
+ # record. **Must** include `name` and any other attributes
48
+ # required by the underlying database schema. Any `password`
49
+ # attribute will be ignored.
50
+ # @param [User, nil] current_user Entity representing the current
51
+ # Authenticated User, or the Guest User. A value of `nil` is
52
+ # treated as though the Guest User had been specified.
53
+ # @return (void) Use the `result` yield parameter to determine results.
54
+ # @yieldparam result [Dry::Matcher::Evaluator] Indicates whether the attempt
55
+ # to create a new User succeeded or failed. Block **must**
56
+ # call **both** `result.success` and `result.failure` methods,
57
+ # where the block passed to `result.success` accepts a parameter
58
+ # for `user:` (which is the newly-created User Entity). The
59
+ # block passed to `result.failure` accepts a parameter for
60
+ # `code:`, which is a Symbol reporting the reason for the
61
+ # failure (as described above).
62
+ # @example in a Controller Action Class
63
+ # def call(_params)
64
+ # sign_up(params, current_user: session[:current_user]) do |result|
65
+ # result.success do |user:|
66
+ # @user = user
67
+ # message = "#{user.name} successfully created. You may sign in now."
68
+ # flash[CryptIdent.config.success_key] = message
69
+ # redirect_to routes.root_path
70
+ # end
71
+ #
72
+ # result.failure do |code:|
73
+ # # `#error_message_for` is a method on the same class, not shown
74
+ # failure_key = CryptIdent.config.failure_key
75
+ # flash[failure_key] = error_message_for(code, params)
76
+ # end
77
+ # end
78
+ # end
79
+ # @session_data
80
+ # `:current_user` **must not** be a Registered User.
81
+ # @ubiq_lang
82
+ # - Authentication
83
+ # - Clear-Text Password
84
+ # - Entity
85
+ # - Guest User
86
+ # - Registered User
87
+ def sign_up(attribs, current_user:)
88
+ SignUp.new.call(attribs, current_user: current_user) do |result|
89
+ yield result
90
+ end
91
+ end
92
+
93
+ # Reworked sign-up logic for `CryptIdent`, per Issue #9
94
+ #
95
+ # This class *is not* part of the published API.
96
+ # @private
97
+ class SignUp
98
+ include Dry::Monads::Result::Mixin
99
+ include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
100
+
101
+ def call(attribs, current_user:)
102
+ return failure_for(:current_user_exists) if current_user?(current_user)
103
+
104
+ create_result(all_attribs(attribs))
105
+ end
106
+
107
+ private
108
+
109
+ def all_attribs(attribs)
110
+ new_attribs.merge(attribs)
111
+ end
112
+
113
+ # XXX: This has a Flog score of 9.8. Truly simplifying PRs welcome.
114
+ def create_result(attribs_in)
115
+ user = CryptIdent.config.repository.create(attribs_in)
116
+ success_for(user)
117
+ rescue Hanami::Model::UniqueConstraintViolationError
118
+ failure_for(:user_already_created)
119
+ rescue Hanami::Model::Error
120
+ failure_for(:user_creation_failed)
121
+ end
122
+
123
+ def current_user?(user)
124
+ guest_user = CryptIdent.config.guest_user
125
+ user ||= guest_user
126
+ !guest_user.class.new(user).guest?
127
+ end
128
+
129
+ def failure_for(code)
130
+ Failure(code: code)
131
+ end
132
+
133
+ def hashed_password(password_in)
134
+ password = password_in.to_s.strip
135
+ password = SecureRandom.alphanumeric(64) if password.empty?
136
+ ::BCrypt::Password.create(password)
137
+ end
138
+
139
+ def new_attribs
140
+ prea = Time.now + CryptIdent.config.reset_expiry
141
+ {
142
+ password_hash: hashed_password(nil),
143
+ password_reset_expires_at: prea,
144
+ token: new_token
145
+ }
146
+ end
147
+
148
+ def new_token
149
+ token_length = CryptIdent.config.token_bytes
150
+ clear_text_token = SecureRandom.alphanumeric(token_length)
151
+ Base64.strict_encode64(clear_text_token)
152
+ end
153
+
154
+ def success_for(user)
155
+ Success(user: user)
156
+ end
157
+ end
158
+ # Leave the class visible durinig Gem development and testing; hide in an app
159
+ private_constant :SignUp if Hanami.respond_to?(:env?)
160
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './config'
4
+
5
+ # Include and interact with `CryptIdent` to add authentication to a
6
+ # Hanami controller action.
7
+ #
8
+ # Note the emphasis on *controller action*; this module interacts with session
9
+ # data, which is quite theoretically possible in an Interactor but practically
10
+ # *quite* the PITA. YHBW.
11
+ #
12
+ # @author Jeff Dickey
13
+ # @version 0.2.0
14
+ module CryptIdent
15
+ # Generate a Hash containing an updated Session Expiration timestamp, which
16
+ # can then be used for session management.
17
+ #
18
+ # This is one of two methods in `CryptIdent` (the other being
19
+ # [`#session_expired?`](#session-expired)) which *does not* follow the
20
+ # `result`/success/failure [monad workflow](#interfaces). This is because
21
+ # there is no success/failure division in the workflow. Calling the method
22
+ # only makes sense if there is a Registered User as the Current User, but *all
23
+ # this method does* is build a Hash with `:current_user` and `:expires_at`
24
+ # entries. The returned `:current_user` is the passed-in `:current_user` if a
25
+ # Registered User, or the Guest User if not. The returned `:updated_at` value,
26
+ # for a Registered User, is the configured Session Expiry added to the current
27
+ # time, and for the Guest User, a time far enough in the future that any call
28
+ # to `#session_expired?` will be highly unlikely to ever return `true`.
29
+ #
30
+ # The client code is responsible for applying these values to its own actual
31
+ # session data, as described by the sample session-management code shown in
32
+ # the README.
33
+ #
34
+ # @param [Hash] session_data The Rack session data of interest to the method.
35
+ # If the `:current_user` entry is defined, it **must** be either
36
+ # a User Entity or `nil`, signifying the Guest User. If the
37
+ # `:expires_at` entry is defined, its value in the returned Hash
38
+ # *will* be different.
39
+ # @since 0.1.0
40
+ # @authenticated Must be Authenticated.
41
+ # @return [Hash] A `Hash` with entries to be used to update session data.
42
+ # `expires_at` will have a value of the current time plus the
43
+ # configuration-specified `session_expiry` offset *if* the
44
+ # supplied `:current_user` value is a Registered User;
45
+ # otherwise it will have a value far enough in advance of the
46
+ # current time (e.g., by 100 years) that the
47
+ # `#session_expired?` method is highly unlikely to ever return
48
+ # `true`. The `:current_user` value will be the passed-in
49
+ # `session_data[:current_user]` value if that represents a
50
+ # Registered User, or the Guest User otherwise.
51
+ #
52
+ # @example As used in module included by Controller Action Class (see README)
53
+ # def validate_session
54
+ # if !session_expired?(session)
55
+ # updates = update_session_expiry(session)
56
+ # session[:expires_at] = updates[:expires_at]
57
+ # return
58
+ # end
59
+ #
60
+ # # ... sign out and redirect appropriately ...
61
+ # end
62
+ # @session_data
63
+ # `:current_user` **must** be a User Entity. `nil` is accepted to indicate
64
+ # the Guest User
65
+ # `:expires_at` set to the session-expiration time on exit, which will be
66
+ # arbitrarily far in the future for the Guest User.
67
+ # @ubiq_lang
68
+ # - Authentication
69
+ # - Guest User
70
+ # - Registered User
71
+ # - Session Expiration
72
+ # - User
73
+ #
74
+ def update_session_expiry(session_data = {})
75
+ UpdateSessionExpiry.new.call(session_data)
76
+ end
77
+
78
+ # Produce an updated Session Expiration timestamp, to support session
79
+ # management and prevent prematurely Signing Out a User.
80
+ #
81
+ # This class *is not* part of the published API.
82
+ # @private
83
+ class UpdateSessionExpiry
84
+ def initialize
85
+ config = CryptIdent.config
86
+ @guest_user = config.guest_user
87
+ @session_expiry = config.session_expiry
88
+ end
89
+
90
+ def call(session_data = {})
91
+ if guest_user?(session_data)
92
+ session_data.merge(guest_data)
93
+ else
94
+ session_data.merge(updated_expiry)
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ attr_reader :guest_user, :session_expiry
101
+
102
+ GUEST_YEARS = 100
103
+ SECONDS_PER_YEAR = 31_536_000
104
+ private_constant :GUEST_YEARS, :SECONDS_PER_YEAR
105
+
106
+ def guest_data
107
+ { expires_at: Time.now + GUEST_YEARS * SECONDS_PER_YEAR }
108
+ end
109
+
110
+ def guest_user?(session_data)
111
+ user = session_data[:current_user] || guest_user
112
+ # If the `session_data` in fact came from Rack session data, then any
113
+ # objects (such as a `User` Entity) have been converted to JSON-compatible
114
+ # types. Hanami Entities can be implicitly converted to and from Hashes of
115
+ # their attributes, so this part's easy...
116
+ User.new(user).guest?
117
+ end
118
+
119
+ def updated_expiry
120
+ { expires_at: Time.now + session_expiry }
121
+ end
122
+ end
123
+ # Leave the class visible durinig Gem development and testing; hide in an app
124
+ private_constant :UpdateSessionExpiry if Hanami.respond_to?(:env?)
125
+ end