doorkeeper 5.0.3 → 5.1.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of doorkeeper might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/.travis.yml +7 -3
- data/Dangerfile +5 -2
- data/Gemfile +3 -1
- data/NEWS.md +20 -13
- data/README.md +1 -1
- data/app/controllers/doorkeeper/applications_controller.rb +3 -3
- data/app/controllers/doorkeeper/authorized_applications_controller.rb +1 -1
- data/app/controllers/doorkeeper/tokens_controller.rb +6 -6
- data/app/views/doorkeeper/applications/show.html.erb +1 -1
- data/app/views/layouts/doorkeeper/admin.html.erb +5 -3
- data/bin/console +15 -0
- data/gemfiles/rails_4_2.gemfile +1 -0
- data/gemfiles/rails_5_0.gemfile +1 -0
- data/gemfiles/rails_5_1.gemfile +1 -0
- data/gemfiles/rails_5_2.gemfile +2 -1
- data/gemfiles/rails_master.gemfile +1 -0
- data/lib/doorkeeper.rb +1 -0
- data/lib/doorkeeper/config.rb +73 -6
- data/lib/doorkeeper/helpers/controller.rb +3 -2
- data/lib/doorkeeper/models/access_grant_mixin.rb +8 -1
- data/lib/doorkeeper/models/access_token_mixin.rb +40 -9
- data/lib/doorkeeper/models/application_mixin.rb +52 -1
- data/lib/doorkeeper/models/concerns/hashable.rb +137 -0
- data/lib/doorkeeper/models/concerns/scopes.rb +1 -1
- data/lib/doorkeeper/oauth/authorization/code.rb +1 -1
- data/lib/doorkeeper/oauth/authorization/token.rb +1 -1
- data/lib/doorkeeper/oauth/authorization_code_request.rb +1 -1
- data/lib/doorkeeper/oauth/client.rb +1 -1
- data/lib/doorkeeper/oauth/client_credentials/validation.rb +4 -3
- data/lib/doorkeeper/oauth/code_response.rb +2 -2
- data/lib/doorkeeper/oauth/helpers/scope_checker.rb +23 -8
- data/lib/doorkeeper/oauth/helpers/uri_checker.rb +32 -0
- data/lib/doorkeeper/oauth/password_access_token_request.rb +7 -2
- data/lib/doorkeeper/oauth/pre_authorization.rb +8 -3
- data/lib/doorkeeper/oauth/refresh_token_request.rb +4 -1
- data/lib/doorkeeper/oauth/token_response.rb +2 -2
- data/lib/doorkeeper/orm/active_record/access_grant.rb +22 -2
- data/lib/doorkeeper/orm/active_record/application.rb +12 -53
- data/lib/doorkeeper/version.rb +3 -3
- data/lib/generators/doorkeeper/templates/initializer.rb +41 -1
- data/spec/controllers/application_metal_controller_spec.rb +18 -4
- data/spec/controllers/tokens_controller_spec.rb +7 -11
- data/spec/dummy/app/controllers/application_controller.rb +1 -1
- data/spec/factories.rb +3 -3
- data/spec/lib/config_spec.rb +84 -0
- data/spec/lib/models/hashable_spec.rb +183 -0
- data/spec/lib/oauth/base_request_spec.rb +7 -7
- data/spec/lib/oauth/client_credentials/validation_spec.rb +3 -0
- data/spec/lib/oauth/helpers/scope_checker_spec.rb +52 -17
- data/spec/lib/oauth/helpers/uri_checker_spec.rb +20 -2
- data/spec/lib/oauth/password_access_token_request_spec.rb +32 -11
- data/spec/lib/oauth/pre_authorization_spec.rb +24 -0
- data/spec/lib/oauth/token_response_spec.rb +13 -13
- data/spec/lib/oauth/token_spec.rb +14 -0
- data/spec/models/doorkeeper/access_grant_spec.rb +61 -0
- data/spec/models/doorkeeper/access_token_spec.rb +123 -0
- data/spec/models/doorkeeper/application_spec.rb +227 -295
- data/spec/requests/flows/authorization_code_spec.rb +40 -0
- data/spec/requests/flows/password_spec.rb +4 -2
- data/spec/requests/flows/revoke_token_spec.rb +14 -30
- data/spec/spec_helper.rb +2 -1
- data/spec/support/ruby_2_6_rails_4_2_patch.rb +14 -0
- data/spec/support/shared/hashing_shared_context.rb +29 -0
- metadata +12 -4
@@ -1,6 +1,25 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
require 'ipaddr'
|
2
3
|
|
3
4
|
module Doorkeeper
|
5
|
+
module IPAddrLoopback
|
6
|
+
def loopback?
|
7
|
+
case @family
|
8
|
+
when Socket::AF_INET
|
9
|
+
@addr & 0xff000000 == 0x7f000000
|
10
|
+
when Socket::AF_INET6
|
11
|
+
@addr == 1
|
12
|
+
else
|
13
|
+
raise AddressFamilyError, "unsupported address family"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# For backward compatibility with old rubies
|
19
|
+
if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2.5.0")
|
20
|
+
IPAddr.send(:include, Doorkeeper::IPAddrLoopback)
|
21
|
+
end
|
22
|
+
|
4
23
|
module OAuth
|
5
24
|
module Helpers
|
6
25
|
module URIChecker
|
@@ -23,10 +42,23 @@ module Doorkeeper
|
|
23
42
|
client_url.query = nil
|
24
43
|
end
|
25
44
|
|
45
|
+
# RFC8252, Paragraph 7.3
|
46
|
+
# @see https://tools.ietf.org/html/rfc8252#section-7.3
|
47
|
+
if loopback_uri?(url) && loopback_uri?(client_url)
|
48
|
+
url.port = nil
|
49
|
+
client_url.port = nil
|
50
|
+
end
|
51
|
+
|
26
52
|
url.query = nil
|
27
53
|
url == client_url
|
28
54
|
end
|
29
55
|
|
56
|
+
def self.loopback_uri?(uri)
|
57
|
+
IPAddr.new(uri.host).loopback?
|
58
|
+
rescue IPAddr::Error
|
59
|
+
false
|
60
|
+
end
|
61
|
+
|
30
62
|
def self.valid_for_authorization?(url, client_url)
|
31
63
|
valid?(url) && client_url.split.any? { |other_url| matches?(url, other_url) }
|
32
64
|
end
|
@@ -32,7 +32,12 @@ module Doorkeeper
|
|
32
32
|
client_scopes = client.try(:scopes)
|
33
33
|
return true if scopes.blank?
|
34
34
|
|
35
|
-
ScopeChecker.valid?(
|
35
|
+
ScopeChecker.valid?(
|
36
|
+
scope_str: scopes.to_s,
|
37
|
+
server_scopes: server.scopes,
|
38
|
+
app_scopes: client_scopes,
|
39
|
+
grant_type: grant_type
|
40
|
+
)
|
36
41
|
end
|
37
42
|
|
38
43
|
def validate_resource_owner
|
@@ -40,7 +45,7 @@ module Doorkeeper
|
|
40
45
|
end
|
41
46
|
|
42
47
|
def validate_client
|
43
|
-
!parameters[:client_id] ||
|
48
|
+
!parameters[:client_id] || client.present?
|
44
49
|
end
|
45
50
|
end
|
46
51
|
end
|
@@ -77,12 +77,17 @@ module Doorkeeper
|
|
77
77
|
return true if scope.blank?
|
78
78
|
|
79
79
|
Helpers::ScopeChecker.valid?(
|
80
|
-
scope,
|
81
|
-
server.scopes,
|
82
|
-
client.application.scopes
|
80
|
+
scope_str: scope,
|
81
|
+
server_scopes: server.scopes,
|
82
|
+
app_scopes: client.application.scopes,
|
83
|
+
grant_type: grant_type
|
83
84
|
)
|
84
85
|
end
|
85
86
|
|
87
|
+
def grant_type
|
88
|
+
response_type == 'code' ? AUTHORIZATION_CODE : IMPLICIT
|
89
|
+
end
|
90
|
+
|
86
91
|
def validate_redirect_uri
|
87
92
|
return false if redirect_uri.blank?
|
88
93
|
|
@@ -99,7 +99,10 @@ module Doorkeeper
|
|
99
99
|
|
100
100
|
def validate_scope
|
101
101
|
if @original_scopes.present?
|
102
|
-
ScopeChecker.valid?(
|
102
|
+
ScopeChecker.valid?(
|
103
|
+
scope_str: @original_scopes,
|
104
|
+
server_scopes: refresh_token.scopes
|
105
|
+
)
|
103
106
|
else
|
104
107
|
true
|
105
108
|
end
|
@@ -11,10 +11,10 @@ module Doorkeeper
|
|
11
11
|
|
12
12
|
def body
|
13
13
|
{
|
14
|
-
'access_token' => token.
|
14
|
+
'access_token' => token.plaintext_token,
|
15
15
|
'token_type' => token.token_type,
|
16
16
|
'expires_in' => token.expires_in_seconds,
|
17
|
-
'refresh_token' => token.
|
17
|
+
'refresh_token' => token.plaintext_refresh_token,
|
18
18
|
'scope' => token.scopes_string,
|
19
19
|
'created_at' => token.created_at.to_i
|
20
20
|
}.reject { |_, value| value.blank? }
|
@@ -16,11 +16,30 @@ module Doorkeeper
|
|
16
16
|
|
17
17
|
belongs_to :application, belongs_to_options
|
18
18
|
|
19
|
-
validates :resource_owner_id,
|
19
|
+
validates :resource_owner_id,
|
20
|
+
:application_id,
|
21
|
+
:token,
|
22
|
+
:expires_in,
|
23
|
+
:redirect_uri,
|
24
|
+
presence: true
|
25
|
+
|
20
26
|
validates :token, uniqueness: true
|
21
27
|
|
22
28
|
before_validation :generate_token, on: :create
|
23
29
|
|
30
|
+
# Keep a reference to the generated token during generation
|
31
|
+
# of this access grant. The actual token may be mapped by
|
32
|
+
# the configuration hasher and may not be available in plaintext.
|
33
|
+
#
|
34
|
+
# If hash tokens are enabled, this will return nil on fetched tokens
|
35
|
+
def plaintext_token
|
36
|
+
if perform_secret_hashing?
|
37
|
+
@raw_token
|
38
|
+
else
|
39
|
+
token
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
24
43
|
private
|
25
44
|
|
26
45
|
# Generates token value with UniqueToken class.
|
@@ -28,7 +47,8 @@ module Doorkeeper
|
|
28
47
|
# @return [String] token value
|
29
48
|
#
|
30
49
|
def generate_token
|
31
|
-
|
50
|
+
@raw_token = UniqueToken.generate
|
51
|
+
self.token = hashed_or_plain_token(@raw_token)
|
32
52
|
end
|
33
53
|
end
|
34
54
|
end
|
@@ -45,26 +45,13 @@ module Doorkeeper
|
|
45
45
|
AccessGrant.revoke_all_for(id, resource_owner)
|
46
46
|
end
|
47
47
|
|
48
|
-
#
|
49
|
-
#
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
#
|
54
|
-
# @return [Hash] entity attributes for JSON
|
55
|
-
#
|
56
|
-
def as_json(options = {})
|
57
|
-
# if application belongs to some owner we need to check if it's the same as
|
58
|
-
# the one passed in the options or check if we render the client as an owner
|
59
|
-
if (respond_to?(:owner) && owner && owner == options[:current_resource_owner]) ||
|
60
|
-
options[:as_owner]
|
61
|
-
# Owners can see all the client attributes, fallback to ActiveModel serialization
|
62
|
-
super
|
48
|
+
# We keep a volatile copy of the raw client_secret for initial communication
|
49
|
+
# The stored secret may be mapped and not available in cleartext.
|
50
|
+
def plaintext_secret
|
51
|
+
if perform_secret_hashing?
|
52
|
+
@raw_secret
|
63
53
|
else
|
64
|
-
|
65
|
-
# we render only minimum set of attributes that could be exposed to a public
|
66
|
-
only = extract_serializable_attributes(options)
|
67
|
-
super(options.merge(only: only))
|
54
|
+
secret
|
68
55
|
end
|
69
56
|
end
|
70
57
|
|
@@ -75,12 +62,16 @@ module Doorkeeper
|
|
75
62
|
end
|
76
63
|
|
77
64
|
def generate_secret
|
78
|
-
|
65
|
+
return unless secret.blank?
|
66
|
+
|
67
|
+
@raw_secret = UniqueToken.generate
|
68
|
+
self.secret = hashed_or_plain_token(@raw_secret)
|
79
69
|
end
|
80
70
|
|
81
71
|
def scopes_match_configured
|
82
72
|
if scopes.present? &&
|
83
|
-
!ScopeChecker.valid?(scopes.to_s,
|
73
|
+
!ScopeChecker.valid?(scope_str: scopes.to_s,
|
74
|
+
server_scopes: Doorkeeper.configuration.scopes)
|
84
75
|
errors.add(:scopes, :not_match_configured)
|
85
76
|
end
|
86
77
|
end
|
@@ -88,37 +79,5 @@ module Doorkeeper
|
|
88
79
|
def enforce_scopes?
|
89
80
|
Doorkeeper.configuration.enforce_configured_scopes?
|
90
81
|
end
|
91
|
-
|
92
|
-
# Helper method to extract collection of serializable attribute names
|
93
|
-
# considering serialization options (like `only`, `except` and so on).
|
94
|
-
#
|
95
|
-
# @param options [Hash] serialization options
|
96
|
-
#
|
97
|
-
# @return [Array<String>]
|
98
|
-
# collection of attributes to be serialized using #as_json
|
99
|
-
#
|
100
|
-
def extract_serializable_attributes(options = {})
|
101
|
-
opts = options.try(:dup) || {}
|
102
|
-
only = Array.wrap(opts[:only]).map(&:to_s)
|
103
|
-
|
104
|
-
only = if only.blank?
|
105
|
-
serializable_attributes
|
106
|
-
else
|
107
|
-
only & serializable_attributes
|
108
|
-
end
|
109
|
-
|
110
|
-
only -= Array.wrap(opts[:except]).map(&:to_s) if opts.key?(:except)
|
111
|
-
only.uniq
|
112
|
-
end
|
113
|
-
|
114
|
-
# Collection of attributes that could be serialized for public.
|
115
|
-
# Override this method if you need additional attributes to be serialized.
|
116
|
-
#
|
117
|
-
# @return [Array<String>] collection of serializable attributes
|
118
|
-
def serializable_attributes
|
119
|
-
attributes = %w[id name created_at]
|
120
|
-
attributes << "uid" unless confidential?
|
121
|
-
attributes
|
122
|
-
end
|
123
82
|
end
|
124
83
|
end
|
data/lib/doorkeeper/version.rb
CHANGED
@@ -75,8 +75,39 @@ Doorkeeper.configure do
|
|
75
75
|
# doesn't updates existing token expiration time, it will create a new token instead.
|
76
76
|
# Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383
|
77
77
|
#
|
78
|
+
# You can not enable this option together with +hash_token_secrets+.
|
79
|
+
#
|
78
80
|
# reuse_access_token
|
79
81
|
|
82
|
+
# Hash access and refresh tokens before persisting them.
|
83
|
+
# Note: This will disable the possibility to use +reuse_access_token+
|
84
|
+
# since plain values can no longer be retrieved.
|
85
|
+
#
|
86
|
+
# hash_token_secrets
|
87
|
+
|
88
|
+
# Hash application secrets before persisting them.
|
89
|
+
#
|
90
|
+
# hash_application_secrets
|
91
|
+
|
92
|
+
# When the above option is enabled,
|
93
|
+
# and a hashed token or secret is not found,
|
94
|
+
# look up the plain text token as a fallback.
|
95
|
+
#
|
96
|
+
# This will ensure that old access tokens and secrets
|
97
|
+
# will remain valid even if the hashing above is enabled
|
98
|
+
#
|
99
|
+
# fallback_to_plain_secrets
|
100
|
+
|
101
|
+
#
|
102
|
+
# Since old values will not be re-hashed, lookups to tokens and secrets
|
103
|
+
# will fall back to plain value comparison so any existing tokens will
|
104
|
+
# not be invalidated.
|
105
|
+
#
|
106
|
+
# For example, to use SHA256 digests on plain values, uncomment these lines:
|
107
|
+
# hash_secrets do |plain_value|
|
108
|
+
# Digest::SHA256.hexdigest plain_value
|
109
|
+
# end
|
110
|
+
|
80
111
|
# Issue access tokens with refresh token (disabled by default), you may also
|
81
112
|
# pass a block which accepts `context` to customize when to give a refresh
|
82
113
|
# token or not. Similar to `custom_access_token_expires_in`, `context` has
|
@@ -108,6 +139,15 @@ Doorkeeper.configure do
|
|
108
139
|
# default_scopes :public
|
109
140
|
# optional_scopes :write, :update
|
110
141
|
|
142
|
+
# Define scopes_by_grant_type to restrict only certain scopes for grant_type
|
143
|
+
# By default, all the scopes will be available for all the grant types.
|
144
|
+
#
|
145
|
+
# Keys to this hash should be the name of grant_type and
|
146
|
+
# values should be the array of scopes for that grant type.
|
147
|
+
# Note: scopes should be from configured_scopes(i.e. deafult or optional)
|
148
|
+
#
|
149
|
+
# scopes_by_grant_type password: [:write], client_credentials: [:update]
|
150
|
+
|
111
151
|
# Change the way client credentials are retrieved from the request object.
|
112
152
|
# By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
|
113
153
|
# falls back to the `:client_id` and `:client_secret` params from the `params` object.
|
@@ -195,7 +235,7 @@ Doorkeeper.configure do
|
|
195
235
|
# end
|
196
236
|
|
197
237
|
# Hook into Authorization flow in order to implement Single Sign Out
|
198
|
-
# or add
|
238
|
+
# or add any other functionality.
|
199
239
|
#
|
200
240
|
# before_successful_authorization do |controller|
|
201
241
|
# Rails.logger.info(params.inspect)
|
@@ -7,6 +7,10 @@ describe Doorkeeper::ApplicationMetalController do
|
|
7
7
|
def index
|
8
8
|
render json: {}, status: 200
|
9
9
|
end
|
10
|
+
|
11
|
+
def create
|
12
|
+
render json: {}, status: 200
|
13
|
+
end
|
10
14
|
end
|
11
15
|
|
12
16
|
it "lazy run hooks" do
|
@@ -22,13 +26,18 @@ describe Doorkeeper::ApplicationMetalController do
|
|
22
26
|
context 'enabled' do
|
23
27
|
let(:flag) { true }
|
24
28
|
|
25
|
-
it '200 for the
|
26
|
-
get :index, params: {}
|
29
|
+
it 'returns a 200 for the requests without body' do
|
30
|
+
get :index, params: {}
|
27
31
|
expect(response).to have_http_status 200
|
28
32
|
end
|
29
33
|
|
30
|
-
it 'returns a
|
31
|
-
|
34
|
+
it 'returns a 200 for the requests with body and correct media type' do
|
35
|
+
post :create, params: {}, as: :url_encoded_form
|
36
|
+
expect(response).to have_http_status 200
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'returns a 415 for the requests with body and incorrect media type' do
|
40
|
+
post :create, params: {}, as: :json
|
32
41
|
expect(response).to have_http_status 415
|
33
42
|
end
|
34
43
|
end
|
@@ -45,6 +54,11 @@ describe Doorkeeper::ApplicationMetalController do
|
|
45
54
|
get :index, as: :json
|
46
55
|
expect(response).to have_http_status 200
|
47
56
|
end
|
57
|
+
|
58
|
+
it 'returns a 200 for the requests with body and incorrect media type' do
|
59
|
+
post :create, params: {}, as: :json
|
60
|
+
expect(response).to have_http_status 200
|
61
|
+
end
|
48
62
|
end
|
49
63
|
end
|
50
64
|
end
|
@@ -28,7 +28,7 @@ describe Doorkeeper::TokensController do
|
|
28
28
|
describe 'when there is a failure due to a custom error' do
|
29
29
|
it 'returns the error response with a custom message' do
|
30
30
|
# I18n looks for `doorkeeper.errors.messages.custom_message` in locale files
|
31
|
-
custom_message =
|
31
|
+
custom_message = 'my_message'
|
32
32
|
allow(I18n).to receive(:translate)
|
33
33
|
.with(
|
34
34
|
custom_message,
|
@@ -60,21 +60,17 @@ describe Doorkeeper::TokensController do
|
|
60
60
|
let(:client) { FactoryBot.create(:application) }
|
61
61
|
let(:access_token) { FactoryBot.create(:access_token, application: client) }
|
62
62
|
|
63
|
-
before(:each) do
|
64
|
-
allow(controller).to receive(:token) { access_token }
|
65
|
-
end
|
66
|
-
|
67
63
|
context 'when associated app is public' do
|
68
64
|
let(:client) { FactoryBot.create(:application, confidential: false) }
|
69
65
|
|
70
66
|
it 'returns 200' do
|
71
|
-
post :revoke
|
67
|
+
post :revoke, params: { token: access_token.token }
|
72
68
|
|
73
69
|
expect(response.status).to eq 200
|
74
70
|
end
|
75
71
|
|
76
72
|
it 'revokes the access token' do
|
77
|
-
post :revoke
|
73
|
+
post :revoke, params: { token: access_token.token }
|
78
74
|
|
79
75
|
expect(access_token.reload).to have_attributes(revoked?: true)
|
80
76
|
end
|
@@ -89,13 +85,13 @@ describe Doorkeeper::TokensController do
|
|
89
85
|
end
|
90
86
|
|
91
87
|
it 'returns 200' do
|
92
|
-
post :revoke
|
88
|
+
post :revoke, params: { token: access_token.token }
|
93
89
|
|
94
90
|
expect(response.status).to eq 200
|
95
91
|
end
|
96
92
|
|
97
93
|
it 'revokes the access token' do
|
98
|
-
post :revoke
|
94
|
+
post :revoke, params: { token: access_token.token }
|
99
95
|
|
100
96
|
expect(access_token.reload).to have_attributes(revoked?: true)
|
101
97
|
end
|
@@ -105,13 +101,13 @@ describe Doorkeeper::TokensController do
|
|
105
101
|
let(:oauth_client) { Doorkeeper::OAuth::Client.new(some_other_client) }
|
106
102
|
|
107
103
|
it 'returns 200' do
|
108
|
-
post :revoke
|
104
|
+
post :revoke, params: { token: access_token.token }
|
109
105
|
|
110
106
|
expect(response.status).to eq 200
|
111
107
|
end
|
112
108
|
|
113
109
|
it 'does not revoke the access token' do
|
114
|
-
post :revoke
|
110
|
+
post :revoke, params: { token: access_token.token }
|
115
111
|
|
116
112
|
expect(access_token.reload).to have_attributes(revoked?: false)
|
117
113
|
end
|
data/spec/factories.rb
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
FactoryBot.define do
|
2
|
-
factory :access_grant, class: Doorkeeper::AccessGrant do
|
2
|
+
factory :access_grant, class: "Doorkeeper::AccessGrant" do
|
3
3
|
sequence(:resource_owner_id) { |n| n }
|
4
4
|
application
|
5
5
|
redirect_uri { 'https://app.com/callback' }
|
@@ -7,7 +7,7 @@ FactoryBot.define do
|
|
7
7
|
scopes { 'public write' }
|
8
8
|
end
|
9
9
|
|
10
|
-
factory :access_token, class: Doorkeeper::AccessToken do
|
10
|
+
factory :access_token, class: "Doorkeeper::AccessToken" do
|
11
11
|
sequence(:resource_owner_id) { |n| n }
|
12
12
|
application
|
13
13
|
expires_in { 2.hours }
|
@@ -17,7 +17,7 @@ FactoryBot.define do
|
|
17
17
|
end
|
18
18
|
end
|
19
19
|
|
20
|
-
factory :application, class: Doorkeeper::Application do
|
20
|
+
factory :application, class: "Doorkeeper::Application" do
|
21
21
|
sequence(:name) { |n| "Application #{n}" }
|
22
22
|
redirect_uri { 'https://app.com/callback' }
|
23
23
|
end
|