rodauth 2.5.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG +42 -0
- data/MIT-LICENSE +1 -1
- data/README.rdoc +18 -6
- data/doc/argon2.rdoc +49 -0
- data/doc/base.rdoc +3 -2
- data/doc/guides/migrate_password_hash_algorithm.rdoc +15 -0
- data/doc/json.rdoc +47 -0
- data/doc/jwt.rdoc +1 -28
- data/doc/jwt_refresh.rdoc +8 -0
- data/doc/login_password_requirements_base.rdoc +1 -1
- data/doc/recovery_codes.rdoc +2 -1
- data/doc/release_notes/2.10.0.txt +47 -0
- data/doc/release_notes/2.6.0.txt +37 -0
- data/doc/release_notes/2.7.0.txt +33 -0
- data/doc/release_notes/2.8.0.txt +20 -0
- data/doc/release_notes/2.9.0.txt +21 -0
- data/doc/remember.rdoc +1 -1
- data/javascript/webauthn_auth.js +9 -9
- data/javascript/webauthn_setup.js +9 -6
- data/lib/rodauth.rb +14 -6
- data/lib/rodauth/features/argon2.rb +69 -0
- data/lib/rodauth/features/base.rb +12 -3
- data/lib/rodauth/features/confirm_password.rb +2 -2
- data/lib/rodauth/features/disallow_password_reuse.rb +20 -7
- data/lib/rodauth/features/json.rb +189 -0
- data/lib/rodauth/features/jwt.rb +22 -170
- data/lib/rodauth/features/jwt_refresh.rb +63 -13
- data/lib/rodauth/features/login_password_requirements_base.rb +4 -0
- data/lib/rodauth/features/otp.rb +0 -2
- data/lib/rodauth/features/recovery_codes.rb +22 -1
- data/lib/rodauth/features/remember.rb +6 -1
- data/lib/rodauth/features/update_password_hash.rb +1 -1
- data/lib/rodauth/features/verify_account.rb +6 -7
- data/lib/rodauth/features/webauthn_verify_account.rb +1 -1
- data/lib/rodauth/migrations.rb +31 -5
- data/lib/rodauth/version.rb +1 -1
- metadata +55 -24
data/lib/rodauth/features/jwt.rb
CHANGED
@@ -4,41 +4,29 @@ require 'jwt'
|
|
4
4
|
|
5
5
|
module Rodauth
|
6
6
|
Feature.define(:jwt, :Jwt) do
|
7
|
+
depends :json
|
8
|
+
|
7
9
|
translatable_method :invalid_jwt_format_error_message, "invalid JWT format or claim in Authorization header"
|
8
|
-
translatable_method :json_non_post_error_message, 'non-POST method used in JSON API'
|
9
|
-
translatable_method :json_not_accepted_error_message, 'Unsupported Accept header. Must accept "application/json" or compatible content type'
|
10
|
-
auth_value_method :json_accept_regexp, /(?:(?:\*|\bapplication)\/\*|\bapplication\/(?:vnd\.api\+)?json\b)/i
|
11
|
-
auth_value_method :json_request_content_type_regexp, /\bapplication\/(?:vnd\.api\+)?json\b/i
|
12
|
-
auth_value_method :json_response_content_type, 'application/json'
|
13
|
-
auth_value_method :json_response_error_status, 400
|
14
|
-
auth_value_method :json_response_custom_error_status?, true
|
15
|
-
auth_value_method :json_response_error_key, "error"
|
16
|
-
auth_value_method :json_response_field_error_key, "field-error"
|
17
|
-
auth_value_method :json_response_success_key, "success"
|
18
10
|
auth_value_method :jwt_algorithm, "HS256"
|
19
11
|
auth_value_method :jwt_authorization_ignore, /\A(?:Basic|Digest) /
|
20
12
|
auth_value_method :jwt_authorization_remove, /\ABearer:?\s+/
|
21
|
-
auth_value_method :jwt_check_accept?, true
|
22
13
|
auth_value_method :jwt_decode_opts, {}.freeze
|
23
14
|
auth_value_method :jwt_session_key, nil
|
24
15
|
auth_value_method :jwt_symbolize_deeply?, false
|
25
|
-
translatable_method :non_json_request_error_message, 'Only JSON format requests are allowed'
|
26
16
|
|
27
17
|
auth_value_methods(
|
28
|
-
:only_json?,
|
29
18
|
:jwt_secret,
|
30
19
|
:use_jwt?
|
31
20
|
)
|
32
21
|
|
33
22
|
auth_methods(
|
34
|
-
:json_request?,
|
35
23
|
:jwt_session_hash,
|
36
24
|
:jwt_token,
|
37
25
|
:session_jwt,
|
38
26
|
:set_jwt_token
|
39
27
|
)
|
40
28
|
|
41
|
-
|
29
|
+
def_deprecated_alias :json_check_accept?, :jwt_check_accept?
|
42
30
|
|
43
31
|
def session
|
44
32
|
return @session if defined?(@session)
|
@@ -47,11 +35,8 @@ module Rodauth
|
|
47
35
|
s = {}
|
48
36
|
if jwt_token
|
49
37
|
unless session_data = jwt_payload
|
50
|
-
json_response[json_response_error_key]
|
51
|
-
|
52
|
-
response['Content-Type'] ||= json_response_content_type
|
53
|
-
response.write(_json_response_body(json_response))
|
54
|
-
request.halt
|
38
|
+
json_response[json_response_error_key] ||= invalid_jwt_format_error_message
|
39
|
+
_return_json_response
|
55
40
|
end
|
56
41
|
|
57
42
|
if jwt_session_key
|
@@ -73,37 +58,10 @@ module Rodauth
|
|
73
58
|
|
74
59
|
def clear_session
|
75
60
|
super
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
return super unless use_jwt?
|
81
|
-
json_response[json_response_field_error_key] = [field, message]
|
82
|
-
end
|
83
|
-
|
84
|
-
def set_error_flash(message)
|
85
|
-
return super unless use_jwt?
|
86
|
-
json_response[json_response_error_key] = message
|
87
|
-
end
|
88
|
-
|
89
|
-
def set_redirect_error_flash(message)
|
90
|
-
return super unless use_jwt?
|
91
|
-
json_response[json_response_error_key] = message
|
92
|
-
end
|
93
|
-
|
94
|
-
def set_notice_flash(message)
|
95
|
-
return super unless use_jwt?
|
96
|
-
json_response[json_response_success_key] = message if include_success_messages?
|
97
|
-
end
|
98
|
-
|
99
|
-
def set_notice_now_flash(message)
|
100
|
-
return super unless use_jwt?
|
101
|
-
json_response[json_response_success_key] = message if include_success_messages?
|
102
|
-
end
|
103
|
-
|
104
|
-
def json_request?
|
105
|
-
return @json_request if defined?(@json_request)
|
106
|
-
@json_request = request.content_type =~ json_request_content_type_regexp
|
61
|
+
if use_jwt?
|
62
|
+
session.clear
|
63
|
+
set_jwt
|
64
|
+
end
|
107
65
|
end
|
108
66
|
|
109
67
|
def jwt_secret
|
@@ -131,16 +89,15 @@ module Rodauth
|
|
131
89
|
end
|
132
90
|
|
133
91
|
def use_jwt?
|
134
|
-
|
92
|
+
use_json?
|
135
93
|
end
|
136
94
|
|
137
|
-
def
|
138
|
-
|
95
|
+
def use_json?
|
96
|
+
jwt_token || super
|
139
97
|
end
|
140
98
|
|
141
|
-
def
|
142
|
-
|
143
|
-
return_json_response
|
99
|
+
def valid_jwt?
|
100
|
+
!!(jwt_token && jwt_payload)
|
144
101
|
end
|
145
102
|
|
146
103
|
private
|
@@ -150,99 +107,19 @@ module Rodauth
|
|
150
107
|
super
|
151
108
|
end
|
152
109
|
|
153
|
-
def
|
154
|
-
|
155
|
-
if jwt_check_accept? && (accept = request.env['HTTP_ACCEPT']) && accept !~ json_accept_regexp
|
156
|
-
response.status = 406
|
157
|
-
json_response[json_response_error_key] = json_not_accepted_error_message
|
158
|
-
response['Content-Type'] ||= json_response_content_type
|
159
|
-
response.write(_json_response_body(json_response))
|
160
|
-
request.halt
|
161
|
-
end
|
162
|
-
|
163
|
-
unless request.post?
|
164
|
-
response.status = 405
|
165
|
-
response.headers['Allow'] = 'POST'
|
166
|
-
json_response[json_response_error_key] = json_non_post_error_message
|
167
|
-
return_json_response
|
168
|
-
end
|
169
|
-
elsif only_json?
|
170
|
-
response.status = json_response_error_status
|
171
|
-
response.write non_json_request_error_message
|
172
|
-
request.halt
|
173
|
-
end
|
174
|
-
|
175
|
-
super
|
176
|
-
end
|
177
|
-
|
178
|
-
def before_view_recovery_codes
|
179
|
-
super if defined?(super)
|
180
|
-
if use_jwt?
|
181
|
-
json_response[:codes] = recovery_codes
|
182
|
-
json_response[json_response_success_key] ||= "" if include_success_messages?
|
183
|
-
end
|
184
|
-
end
|
185
|
-
|
186
|
-
def before_webauthn_setup_route
|
187
|
-
super if defined?(super)
|
188
|
-
if use_jwt? && !param_or_nil(webauthn_setup_param)
|
189
|
-
cred = new_webauthn_credential
|
190
|
-
json_response[webauthn_setup_param] = cred.as_json
|
191
|
-
json_response[webauthn_setup_challenge_param] = cred.challenge
|
192
|
-
json_response[webauthn_setup_challenge_hmac_param] = compute_hmac(cred.challenge)
|
193
|
-
end
|
194
|
-
end
|
195
|
-
|
196
|
-
def before_webauthn_auth_route
|
197
|
-
super if defined?(super)
|
198
|
-
if use_jwt? && !param_or_nil(webauthn_auth_param)
|
199
|
-
cred = webauth_credential_options_for_get
|
200
|
-
json_response[webauthn_auth_param] = cred.as_json
|
201
|
-
json_response[webauthn_auth_challenge_param] = cred.challenge
|
202
|
-
json_response[webauthn_auth_challenge_hmac_param] = compute_hmac(cred.challenge)
|
203
|
-
end
|
204
|
-
end
|
205
|
-
|
206
|
-
def before_webauthn_login_route
|
207
|
-
super if defined?(super)
|
208
|
-
if use_jwt? && !param_or_nil(webauthn_auth_param) && account_from_login(param(login_param))
|
209
|
-
cred = webauth_credential_options_for_get
|
210
|
-
json_response[webauthn_auth_param] = cred.as_json
|
211
|
-
json_response[webauthn_auth_challenge_param] = cred.challenge
|
212
|
-
json_response[webauthn_auth_challenge_hmac_param] = compute_hmac(cred.challenge)
|
213
|
-
end
|
214
|
-
end
|
215
|
-
|
216
|
-
def before_webauthn_remove_route
|
217
|
-
super if defined?(super)
|
218
|
-
if use_jwt? && !param_or_nil(webauthn_remove_param)
|
219
|
-
json_response[webauthn_remove_param] = account_webauthn_usage
|
220
|
-
end
|
221
|
-
end
|
222
|
-
|
223
|
-
def before_otp_setup_route
|
224
|
-
super if defined?(super)
|
225
|
-
if use_jwt? && otp_keys_use_hmac? && !param_or_nil(otp_setup_raw_param)
|
226
|
-
_otp_tmp_key(otp_new_secret)
|
227
|
-
json_response[otp_setup_param] = otp_user_key
|
228
|
-
json_response[otp_setup_raw_param] = otp_key
|
229
|
-
end
|
110
|
+
def _jwt_decode_opts
|
111
|
+
jwt_decode_opts
|
230
112
|
end
|
231
113
|
|
232
114
|
def jwt_payload
|
233
115
|
return @jwt_payload if defined?(@jwt_payload)
|
234
|
-
@jwt_payload = JWT.decode(jwt_token, jwt_secret, true,
|
235
|
-
rescue JWT::DecodeError
|
236
|
-
|
116
|
+
@jwt_payload = JWT.decode(jwt_token, jwt_secret, true, _jwt_decode_opts.merge(:algorithm=>jwt_algorithm))[0]
|
117
|
+
rescue JWT::DecodeError => e
|
118
|
+
rescue_jwt_payload(e)
|
237
119
|
end
|
238
120
|
|
239
|
-
def
|
240
|
-
|
241
|
-
return_json_response
|
242
|
-
end
|
243
|
-
|
244
|
-
def include_success_messages?
|
245
|
-
!json_response_success_key.nil?
|
121
|
+
def rescue_jwt_payload(_)
|
122
|
+
@jwt_payload = false
|
246
123
|
end
|
247
124
|
|
248
125
|
def set_session_value(key, value)
|
@@ -257,38 +134,13 @@ module Rodauth
|
|
257
134
|
value
|
258
135
|
end
|
259
136
|
|
260
|
-
def json_response
|
261
|
-
@json_response ||= {}
|
262
|
-
end
|
263
|
-
|
264
|
-
def _json_response_body(hash)
|
265
|
-
request.send(:convert_to_json, hash)
|
266
|
-
end
|
267
|
-
|
268
137
|
def return_json_response
|
269
|
-
response.status ||= json_response_error_status if json_response[json_response_error_key]
|
270
138
|
set_jwt
|
271
|
-
|
272
|
-
response.write(_json_response_body(json_response))
|
273
|
-
request.halt
|
139
|
+
super
|
274
140
|
end
|
275
141
|
|
276
142
|
def set_jwt
|
277
143
|
set_jwt_token(session_jwt)
|
278
144
|
end
|
279
|
-
|
280
|
-
def set_redirect_error_status(status)
|
281
|
-
if use_jwt? && json_response_custom_error_status?
|
282
|
-
response.status = status
|
283
|
-
end
|
284
|
-
end
|
285
|
-
|
286
|
-
def set_response_error_status(status)
|
287
|
-
if use_jwt? && !json_response_custom_error_status?
|
288
|
-
status = json_response_error_status
|
289
|
-
end
|
290
|
-
|
291
|
-
super
|
292
|
-
end
|
293
145
|
end
|
294
146
|
end
|
@@ -7,6 +7,9 @@ module Rodauth
|
|
7
7
|
after 'refresh_token'
|
8
8
|
before 'refresh_token'
|
9
9
|
|
10
|
+
auth_value_method :allow_refresh_with_expired_jwt_access_token?, false
|
11
|
+
session_key :jwt_refresh_token_data_session_key, :jwt_refresh_token_data
|
12
|
+
session_key :jwt_refresh_token_hmac_session_key, :jwt_refresh_token_hash
|
10
13
|
auth_value_method :jwt_access_token_key, 'access_token'
|
11
14
|
auth_value_method :jwt_access_token_not_before_period, 5
|
12
15
|
auth_value_method :jwt_access_token_period, 1800
|
@@ -21,12 +24,15 @@ module Rodauth
|
|
21
24
|
auth_value_method :jwt_refresh_token_table, :account_jwt_refresh_keys
|
22
25
|
translatable_method :jwt_refresh_without_access_token_message, 'no JWT access token provided during refresh'
|
23
26
|
auth_value_method :jwt_refresh_without_access_token_status, 401
|
27
|
+
translatable_method :expired_jwt_access_token_message, "expired JWT access token"
|
28
|
+
auth_value_method :expired_jwt_access_token_status, 400
|
24
29
|
|
25
30
|
auth_private_methods(
|
26
31
|
:account_from_refresh_token
|
27
32
|
)
|
28
33
|
|
29
34
|
route do |r|
|
35
|
+
@jwt_refresh_route = true
|
30
36
|
before_jwt_refresh_route
|
31
37
|
|
32
38
|
r.post do
|
@@ -38,17 +44,15 @@ module Rodauth
|
|
38
44
|
before_refresh_token
|
39
45
|
formatted_token = generate_refresh_token
|
40
46
|
remove_jwt_refresh_token_key(refresh_token)
|
47
|
+
set_jwt_refresh_token_hmac_session_key(formatted_token)
|
41
48
|
json_response[jwt_refresh_token_key] = formatted_token
|
42
49
|
json_response[jwt_access_token_key] = session_jwt
|
43
50
|
after_refresh_token
|
44
51
|
end
|
45
52
|
else
|
46
53
|
json_response[json_response_error_key] = jwt_refresh_invalid_token_message
|
47
|
-
response.status ||= json_response_error_status
|
48
54
|
end
|
49
|
-
|
50
|
-
response.write(_json_response_body(json_response))
|
51
|
-
request.halt
|
55
|
+
_return_json_response
|
52
56
|
end
|
53
57
|
end
|
54
58
|
|
@@ -58,7 +62,9 @@ module Rodauth
|
|
58
62
|
# JWT login puts the access token in the header.
|
59
63
|
# We put the refresh token in the body.
|
60
64
|
# Note, do not put the access_token in the body here, as the access token content is not yet finalised.
|
61
|
-
json_response[
|
65
|
+
token = json_response[jwt_refresh_token_key] = generate_refresh_token
|
66
|
+
|
67
|
+
set_jwt_refresh_token_hmac_session_key(token)
|
62
68
|
end
|
63
69
|
|
64
70
|
def set_jwt_token(token)
|
@@ -85,12 +91,33 @@ module Rodauth
|
|
85
91
|
|
86
92
|
private
|
87
93
|
|
94
|
+
def rescue_jwt_payload(e)
|
95
|
+
if e.instance_of?(JWT::ExpiredSignature)
|
96
|
+
begin
|
97
|
+
# Some versions of jwt will raise JWT::ExpiredSignature even when the
|
98
|
+
# JWT is invalid for other reasons. Make sure the expiration is the
|
99
|
+
# only reason the JWT isn't valid before treating this as an expired token.
|
100
|
+
JWT.decode(jwt_token, jwt_secret, true, Hash[jwt_decode_opts].merge!(:verify_expiration=>false, :algorithm=>jwt_algorithm))[0]
|
101
|
+
rescue => e
|
102
|
+
else
|
103
|
+
json_response[json_response_error_key] = expired_jwt_access_token_message
|
104
|
+
response.status ||= expired_jwt_access_token_status
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
super
|
109
|
+
end
|
110
|
+
|
88
111
|
def _account_from_refresh_token(token)
|
89
112
|
id, token_id, key = _account_refresh_token_split(token)
|
90
113
|
|
91
|
-
|
92
|
-
|
93
|
-
|
114
|
+
unless key &&
|
115
|
+
(id == session_value.to_s) &&
|
116
|
+
(actual = get_active_refresh_token(id, token_id)) &&
|
117
|
+
timing_safe_eql?(key, convert_token_key(actual)) &&
|
118
|
+
jwt_refresh_token_match?(key)
|
119
|
+
return
|
120
|
+
end
|
94
121
|
|
95
122
|
ds = account_ds(id)
|
96
123
|
ds = ds.where(account_status_column=>account_open_status_value) unless skip_status_checks?
|
@@ -107,6 +134,23 @@ module Rodauth
|
|
107
134
|
[id, token_id, key]
|
108
135
|
end
|
109
136
|
|
137
|
+
def _jwt_decode_opts
|
138
|
+
if allow_refresh_with_expired_jwt_access_token? && @jwt_refresh_route
|
139
|
+
Hash[super].merge!(:verify_expiration=>false)
|
140
|
+
else
|
141
|
+
super
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def jwt_refresh_token_match?(key)
|
146
|
+
# We don't need to match tokens if we are requiring a valid current access token
|
147
|
+
return true unless allow_refresh_with_expired_jwt_access_token?
|
148
|
+
|
149
|
+
# If allowing with expired jwt access token, check the expired session contains
|
150
|
+
# hmac matching submitted and active refresh token.
|
151
|
+
timing_safe_eql?(compute_hmac(session[jwt_refresh_token_data_session_key].to_s + key), session[jwt_refresh_token_hmac_session_key].to_s)
|
152
|
+
end
|
153
|
+
|
110
154
|
def get_active_refresh_token(account_id, token_id)
|
111
155
|
jwt_refresh_token_account_ds(account_id).
|
112
156
|
where(Sequel::CURRENT_TIMESTAMP > jwt_refresh_token_deadline_column).
|
@@ -130,8 +174,7 @@ module Rodauth
|
|
130
174
|
end
|
131
175
|
|
132
176
|
def remove_jwt_refresh_token_key(token)
|
133
|
-
account_id,
|
134
|
-
token_id, _ = split_token(token)
|
177
|
+
account_id, token_id, _ = _account_refresh_token_split(token)
|
135
178
|
jwt_refresh_token_account_token_ds(account_id, token_id).delete
|
136
179
|
end
|
137
180
|
|
@@ -146,6 +189,15 @@ module Rodauth
|
|
146
189
|
hash
|
147
190
|
end
|
148
191
|
|
192
|
+
def set_jwt_refresh_token_hmac_session_key(token)
|
193
|
+
if allow_refresh_with_expired_jwt_access_token?
|
194
|
+
key = _account_refresh_token_split(token).last
|
195
|
+
data = random_key
|
196
|
+
set_session_value(jwt_refresh_token_data_session_key, data)
|
197
|
+
set_session_value(jwt_refresh_token_hmac_session_key, compute_hmac(data + key))
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
149
201
|
def before_logout
|
150
202
|
if token = param_or_nil(jwt_refresh_token_key_param)
|
151
203
|
if token == 'all'
|
@@ -154,9 +206,7 @@ module Rodauth
|
|
154
206
|
id, token_id, key = _account_refresh_token_split(token)
|
155
207
|
|
156
208
|
if id && token_id && key && (actual = get_active_refresh_token(session_value, token_id)) && timing_safe_eql?(key, convert_token_key(actual))
|
157
|
-
|
158
|
-
where(jwt_refresh_token_id_column=>token_id).
|
159
|
-
delete
|
209
|
+
jwt_refresh_token_account_token_ds(id, token_id).delete
|
160
210
|
end
|
161
211
|
end
|
162
212
|
end
|
data/lib/rodauth/features/otp.rb
CHANGED
@@ -34,6 +34,7 @@ module Rodauth
|
|
34
34
|
auth_value_method :add_recovery_codes_param, 'add'
|
35
35
|
translatable_method :add_recovery_codes_heading, '<h2>Add Additional Recovery Codes</h2>'
|
36
36
|
auth_value_method :auto_add_recovery_codes?, false
|
37
|
+
auth_value_method :auto_remove_recovery_codes?, false
|
37
38
|
translatable_method :invalid_recovery_code_message, "Invalid recovery code"
|
38
39
|
auth_value_method :recovery_codes_limit, 16
|
39
40
|
auth_value_method :recovery_codes_column, :code
|
@@ -56,7 +57,6 @@ module Rodauth
|
|
56
57
|
:can_add_recovery_codes?,
|
57
58
|
:new_recovery_code,
|
58
59
|
:recovery_code_match?,
|
59
|
-
:recovery_codes
|
60
60
|
)
|
61
61
|
|
62
62
|
route(:recovery_auth) do |r|
|
@@ -213,6 +213,21 @@ module Rodauth
|
|
213
213
|
super
|
214
214
|
end
|
215
215
|
|
216
|
+
def after_otp_disable
|
217
|
+
super if defined?(super)
|
218
|
+
auto_remove_recovery_codes
|
219
|
+
end
|
220
|
+
|
221
|
+
def after_sms_disable
|
222
|
+
super if defined?(super)
|
223
|
+
auto_remove_recovery_codes
|
224
|
+
end
|
225
|
+
|
226
|
+
def after_webauthn_remove
|
227
|
+
super if defined?(super)
|
228
|
+
auto_remove_recovery_codes
|
229
|
+
end
|
230
|
+
|
216
231
|
def new_recovery_code
|
217
232
|
random_key
|
218
233
|
end
|
@@ -227,6 +242,12 @@ module Rodauth
|
|
227
242
|
end
|
228
243
|
end
|
229
244
|
|
245
|
+
def auto_remove_recovery_codes
|
246
|
+
if auto_remove_recovery_codes? && (%w'totp webauthn sms_code' & possible_authentication_methods).empty?
|
247
|
+
recovery_codes_remove
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
230
251
|
def _recovery_codes
|
231
252
|
recovery_codes_ds.select_map(recovery_codes_column)
|
232
253
|
end
|