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,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