rack-oauth2-server 2.0.0.beta4 → 2.0.0.beta5
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +1 -1
- data/README.rdoc +25 -1
- data/VERSION +1 -1
- data/lib/rack/oauth2/models/access_grant.rb +8 -4
- data/lib/rack/oauth2/models/access_token.rb +3 -3
- data/lib/rack/oauth2/models/auth_request.rb +3 -3
- data/lib/rack/oauth2/models/client.rb +2 -2
- data/lib/rack/oauth2/server/errors.rb +2 -2
- data/lib/rack/oauth2/server/railtie.rb +1 -2
- data/lib/rack/oauth2/server.rb +25 -22
- data/test/oauth/access_grant_test.rb +9 -0
- data/test/oauth/{server_test.rb → server_methods_test.rb} +44 -0
- data/test/rails2/log/test.log +8023 -0
- data/test/rails3/log/test.log +14122 -0
- metadata +5 -5
data/CHANGELOG
CHANGED
data/README.rdoc
CHANGED
@@ -332,7 +332,7 @@ I'll let you figure that one for yourself.
|
|
332
332
|
|
333
333
|
We haz it, and it's pretty rad:
|
334
334
|
|
335
|
-
http://
|
335
|
+
http://labnotes.org/wp-content/uploads/2010/11/OAuth-Admin-All-Clients.png
|
336
336
|
|
337
337
|
To get the Web admin running, you'll need to do the following. First, you'll
|
338
338
|
need to register a new client application that can access the OAuth Web admin,
|
@@ -479,6 +479,30 @@ server you +curl+. Useful for development, testing, just don't use it with any
|
|
479
479
|
production access tokens.
|
480
480
|
|
481
481
|
|
482
|
+
== Methods You'll Want To Use From Your App
|
483
|
+
|
484
|
+
You can use the Server module to create, fetch and otherwise work with access
|
485
|
+
tokens and grants. Available methods include:
|
486
|
+
|
487
|
+
- access_grant -- Creates and returns a new access grant. You can use that for
|
488
|
+
one-time token, e.g. users who forgot their password and need to login using
|
489
|
+
an email message.
|
490
|
+
- token_for -- Returns access token for particular identity. You can use that to
|
491
|
+
give access tokens to clients other than through the OAuth 2.0 protocol, e.g.
|
492
|
+
if you let users authenticate using Facebook Connect or Twitter OAuth.
|
493
|
+
- get_access_token -- Resolves access token (string) into access token
|
494
|
+
(AccessToken object).
|
495
|
+
- list_access_tokens -- Returns all access tokens for a given identity, which
|
496
|
+
you'll need if you offer a UI for uses to review and revoke access tokens they
|
497
|
+
previously granted.
|
498
|
+
- get_client -- Resolves client identifier into a Client object.
|
499
|
+
- register -- Registers a new client application. Can also be used to change
|
500
|
+
existing registration (if you know the client's ID and secret). Idempotent, so
|
501
|
+
perfect for running during setup and migration.
|
502
|
+
- get_auth_request -- Resolves authorization request handle into an AuthRequest
|
503
|
+
object. Could be useful during the authorization flow.
|
504
|
+
|
505
|
+
|
482
506
|
== Mandatory ASCII Diagram
|
483
507
|
|
484
508
|
This is briefly what the authorization flow looks like, how the workload is
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
2.0.0.
|
1
|
+
2.0.0.beta5
|
@@ -12,11 +12,13 @@ module Rack
|
|
12
12
|
end
|
13
13
|
|
14
14
|
# Create a new access grant.
|
15
|
-
def create(identity, client, scope, redirect_uri = nil)
|
15
|
+
def create(identity, client, scope, redirect_uri = nil, expires = nil)
|
16
16
|
scope = Utils.normalize_scope(scope) & client.scope # Only allowed scope
|
17
|
+
expires_at = Time.now.to_i + (expires || 300)
|
17
18
|
fields = { :_id=>Server.secure_random, :identity=>identity.to_s, :scope=>scope,
|
18
19
|
:client_id=>client.id, :redirect_uri=>client.redirect_uri || redirect_uri,
|
19
|
-
:created_at=>Time.now.
|
20
|
+
:created_at=>Time.now.to_i, :expires_at=>expires_at, :granted_at=>nil,
|
21
|
+
:access_token=>nil, :revoked=>nil }
|
20
22
|
collection.insert fields
|
21
23
|
Server.new_instance self, fields
|
22
24
|
end
|
@@ -41,6 +43,8 @@ module Rack
|
|
41
43
|
attr_reader :created_at
|
42
44
|
# Tells us when (and if) access token was created.
|
43
45
|
attr_accessor :granted_at
|
46
|
+
# Tells us when this grant expires.
|
47
|
+
attr_accessor :expires_at
|
44
48
|
# Access token created from this grant. Set and spent.
|
45
49
|
attr_accessor :access_token
|
46
50
|
# Timestamp if revoked.
|
@@ -57,7 +61,7 @@ module Rack
|
|
57
61
|
client = Client.find(client_id) or raise InvalidGrantError
|
58
62
|
access_token = AccessToken.get_token_for(identity, client, scope)
|
59
63
|
self.access_token = access_token.token
|
60
|
-
self.granted_at = Time.now.
|
64
|
+
self.granted_at = Time.now.to_i
|
61
65
|
self.class.collection.update({ :_id=>code, :access_token=>nil, :revoked=>nil }, { :$set=>{ :granted_at=>granted_at, :access_token=>access_token.token } }, :safe=>true)
|
62
66
|
reload = self.class.collection.find_one({ :_id=>code, :revoked=>nil }, { :fields=>%w{access_token} })
|
63
67
|
raise InvalidGrantError unless reload && reload["access_token"] == access_token.token
|
@@ -65,7 +69,7 @@ module Rack
|
|
65
69
|
end
|
66
70
|
|
67
71
|
def revoke!
|
68
|
-
self.revoked = Time.now.
|
72
|
+
self.revoked = Time.now.to_i
|
69
73
|
self.class.collection.update({ :_id=>code, :revoked=>nil }, { :$set=>{ :revoked=>revoked } })
|
70
74
|
end
|
71
75
|
|
@@ -19,7 +19,7 @@ module Rack
|
|
19
19
|
scope = Utils.normalize_scope(scope) & client.scope # Only allowed scope
|
20
20
|
unless token = collection.find_one({ :identity=>identity.to_s, :scope=>scope, :client_id=>client.id, :revoked=>nil })
|
21
21
|
token = { :_id=>Server.secure_random, :identity=>identity.to_s, :scope=>scope,
|
22
|
-
:client_id=>client.id, :created_at=>Time.now.
|
22
|
+
:client_id=>client.id, :created_at=>Time.now.to_i,
|
23
23
|
:expires_at=>nil, :revoked=>nil }
|
24
24
|
collection.insert token
|
25
25
|
end
|
@@ -48,7 +48,7 @@ module Rack
|
|
48
48
|
def count(filter = {})
|
49
49
|
select = {}
|
50
50
|
if filter[:days]
|
51
|
-
now = Time.now.
|
51
|
+
now = Time.now.to_i
|
52
52
|
range = { :$gt=>now - filter[:days] * 86400, :$lte=>now }
|
53
53
|
select[ filter[:revoked] ? :revoked : :created_at ] = range
|
54
54
|
elsif filter.has_key?(:revoked)
|
@@ -93,7 +93,7 @@ module Rack
|
|
93
93
|
|
94
94
|
# Revokes this access token.
|
95
95
|
def revoke!
|
96
|
-
self.revoked = Time.now.
|
96
|
+
self.revoked = Time.now.to_i
|
97
97
|
AccessToken.collection.update({ :_id=>token }, { :$set=>{ :revoked=>revoked } })
|
98
98
|
end
|
99
99
|
|
@@ -21,7 +21,7 @@ module Rack
|
|
21
21
|
fields = { :client_id=>client.id, :scope=>scope, :redirect_uri=>client.redirect_uri || redirect_uri,
|
22
22
|
:response_type=>response_type, :state=>state,
|
23
23
|
:grant_code=>nil, :authorized_at=>nil,
|
24
|
-
:created_at=>Time.now.
|
24
|
+
:created_at=>Time.now.to_i, :revoked=>nil }
|
25
25
|
fields[:_id] = collection.insert(fields)
|
26
26
|
Server.new_instance self, fields
|
27
27
|
end
|
@@ -60,7 +60,7 @@ module Rack
|
|
60
60
|
raise ArgumentError, "Must supply a identity" unless identity
|
61
61
|
return if revoked
|
62
62
|
client = Client.find(client_id) or return
|
63
|
-
self.authorized_at = Time.now.
|
63
|
+
self.authorized_at = Time.now.to_i
|
64
64
|
if response_type == "code" # Requested authorization code
|
65
65
|
access_grant = AccessGrant.create(identity, client, scope, redirect_uri)
|
66
66
|
self.grant_code = access_grant.code
|
@@ -75,7 +75,7 @@ module Rack
|
|
75
75
|
|
76
76
|
# Deny access.
|
77
77
|
def deny!
|
78
|
-
self.authorized_at = Time.now.
|
78
|
+
self.authorized_at = Time.now.to_i
|
79
79
|
self.class.collection.update({ :_id=>id }, { :$set=>{ :authorized_at=>authorized_at } })
|
80
80
|
end
|
81
81
|
|
@@ -30,7 +30,7 @@ module Rack
|
|
30
30
|
fields = { :display_name=>args[:display_name], :link=>args[:link],
|
31
31
|
:image_url=>args[:image_url], :redirect_uri=>redirect_uri,
|
32
32
|
:nodes=>args[:notes].to_s, :scope=>scope,
|
33
|
-
:created_at=>Time.now.
|
33
|
+
:created_at=>Time.now.to_i, :revoked=>nil }
|
34
34
|
if args[:id] && args[:secret]
|
35
35
|
fields[:_id], fields[:secret] = BSON::ObjectId(args[:id].to_s), args[:secret]
|
36
36
|
collection.insert(fields, :safe=>true)
|
@@ -95,7 +95,7 @@ module Rack
|
|
95
95
|
# Revoke all authorization requests, access grants and access tokens for
|
96
96
|
# this client. Ward off the evil.
|
97
97
|
def revoke!
|
98
|
-
self.revoked = Time.now.
|
98
|
+
self.revoked = Time.now.to_i
|
99
99
|
Client.collection.update({ :_id=>id }, { :$set=>{ :revoked=>revoked } })
|
100
100
|
AuthRequest.collection.update({ :client_id=>id }, { :$set=>{ :revoked=>revoked } })
|
101
101
|
AccessGrant.collection.update({ :client_id=>id }, { :$set=>{ :revoked=>revoked } })
|
@@ -42,8 +42,8 @@ module Rack
|
|
42
42
|
# assertion, expired authorization token, bad end-user password credentials,
|
43
43
|
# or mismatching authorization code and redirection URI).
|
44
44
|
class InvalidGrantError < OAuthError
|
45
|
-
def initialize
|
46
|
-
super :invalid_grant, "This access grant is no longer valid."
|
45
|
+
def initialize(message)
|
46
|
+
super :invalid_grant, message || "This access grant is no longer valid."
|
47
47
|
end
|
48
48
|
end
|
49
49
|
|
@@ -8,11 +8,10 @@ module Rack
|
|
8
8
|
# Rails 3.x integration.
|
9
9
|
class Railtie < ::Rails::Railtie # :nodoc:
|
10
10
|
config.oauth = Server::Options.new
|
11
|
-
config.oauth.logger = ::Rails.logger
|
12
11
|
|
13
12
|
initializer "rack-oauth2-server" do |app|
|
14
|
-
#app.config.extend ::Rack::OAuth2::Rails::Configuration
|
15
13
|
app.middleware.use ::Rack::OAuth2::Server, app.config.oauth
|
14
|
+
config.oauth.logger ||= ::Rails.logger
|
16
15
|
class ::ActionController::Base
|
17
16
|
helper ::Rack::OAuth2::Rails::Helpers
|
18
17
|
include ::Rack::OAuth2::Rails::Helpers
|
data/lib/rack/oauth2/server.rb
CHANGED
@@ -79,10 +79,12 @@ module Rack
|
|
79
79
|
# @param [String] identity User ID, account ID, etc
|
80
80
|
# @param [String] client_id Client identifier
|
81
81
|
# @param [Array, nil] scope Array of string, nil if you want 'em all
|
82
|
+
# @param [Integer, nil] expires How many seconds before access grant
|
83
|
+
# expires (default to 5 minutes)
|
82
84
|
# @return [String] Access grant authorization code
|
83
|
-
def access_grant(identity, client_id, scope = nil)
|
85
|
+
def access_grant(identity, client_id, scope = nil, expires = nil)
|
84
86
|
client = get_client(client_id) or fail "No such client"
|
85
|
-
AccessGrant.create(identity, client, scope || client.scope).code
|
87
|
+
AccessGrant.create(identity, client, scope || client.scope, nil, expires).code
|
86
88
|
end
|
87
89
|
|
88
90
|
# Returns AccessToken from token.
|
@@ -191,16 +193,16 @@ module Rack
|
|
191
193
|
begin
|
192
194
|
access_token = AccessToken.from_token(token)
|
193
195
|
raise InvalidTokenError if access_token.nil? || access_token.revoked
|
194
|
-
raise ExpiredTokenError if access_token.expires_at && access_token.expires_at <= Time.now.
|
196
|
+
raise ExpiredTokenError if access_token.expires_at && access_token.expires_at <= Time.now.to_i
|
195
197
|
request.env["oauth.access_token"] = token
|
196
198
|
request.env["oauth.identity"] = access_token.identity
|
197
|
-
logger.info "Authorized #{access_token.identity}" if logger
|
199
|
+
logger.info "RO2S: Authorized #{access_token.identity}" if logger
|
198
200
|
rescue OAuthError=>error
|
199
201
|
# 5.2. The WWW-Authenticate Response Header Field
|
200
|
-
logger.info "HTTP authorization failed #{error.code}" if logger
|
202
|
+
logger.info "RO2S: HTTP authorization failed #{error.code}" if logger
|
201
203
|
return unauthorized(request, error)
|
202
204
|
rescue =>ex
|
203
|
-
logger.info "HTTP authorization failed #{ex.message}" if logger
|
205
|
+
logger.info "RO2S: HTTP authorization failed #{ex.message}" if logger
|
204
206
|
return unauthorized(request)
|
205
207
|
end
|
206
208
|
|
@@ -246,13 +248,13 @@ module Rack
|
|
246
248
|
if request.GET["authorization"]
|
247
249
|
auth_request = self.class.get_auth_request(request.GET["authorization"]) rescue nil
|
248
250
|
if !auth_request || auth_request.revoked
|
249
|
-
logger.error "Invalid authorization request #{auth_request}" if logger
|
251
|
+
logger.error "RO2S: Invalid authorization request #{auth_request}" if logger
|
250
252
|
return bad_request("Invalid authorization request")
|
251
253
|
end
|
252
254
|
response_type = auth_request.response_type # Needed for error handling
|
253
255
|
client = self.class.get_client(auth_request.client_id)
|
254
256
|
# Pass back to application, watch for 403 (deny!)
|
255
|
-
logger.info "
|
257
|
+
logger.info "RO2S: Client #{client.display_name} requested #{auth_request.response_type} with scope #{auth_request.scope.join(" ")}" if logger
|
256
258
|
request.env["oauth.authorization"] = auth_request.id.to_s
|
257
259
|
response = @app.call(request.env)
|
258
260
|
raise AccessDeniedError if response[0] == 403
|
@@ -264,7 +266,7 @@ module Rack
|
|
264
266
|
begin
|
265
267
|
redirect_uri = Utils.parse_redirect_uri(request.GET["redirect_uri"])
|
266
268
|
rescue InvalidRequestError=>error
|
267
|
-
logger.error "Authorization request with invalid redirect_uri: #{request.GET["redirect_uri"]} #{error.message}" if logger
|
269
|
+
logger.error "RO2S: Authorization request with invalid redirect_uri: #{request.GET["redirect_uri"]} #{error.message}" if logger
|
268
270
|
return bad_request(error.message)
|
269
271
|
end
|
270
272
|
|
@@ -284,7 +286,7 @@ module Rack
|
|
284
286
|
return [303, { "Location"=>uri.to_s }, ["You are being redirected"]]
|
285
287
|
end
|
286
288
|
rescue OAuthError=>error
|
287
|
-
logger.error "Authorization request error
|
289
|
+
logger.error "RO2S: Authorization request error #{error.code}: #{error.message}" if logger
|
288
290
|
params = { :error=>error.code, :error_description=>error.message, :state=>state }
|
289
291
|
if response_type == "token"
|
290
292
|
redirect_uri.fragment = Rack::Utils.build_query(params)
|
@@ -311,20 +313,20 @@ module Rack
|
|
311
313
|
# 3.1. Authorization Response
|
312
314
|
if auth_request.response_type == "code"
|
313
315
|
if auth_request.grant_code
|
314
|
-
logger.info "
|
316
|
+
logger.info "RO2S: Client #{auth_request.client_id} granted access code #{auth_request.grant_code}" if logger
|
315
317
|
params = { :code=>auth_request.grant_code, :scope=>auth_request.scope.join(" "), :state=>auth_request.state }
|
316
318
|
else
|
317
|
-
logger.info "
|
319
|
+
logger.info "RO2S: Client #{auth_request.client_id} denied authorization" if logger
|
318
320
|
params = { :error=>:access_denied, :state=>auth_request.state }
|
319
321
|
end
|
320
322
|
params = Rack::Utils.parse_query(redirect_uri.query).merge(params)
|
321
323
|
redirect_uri.query = Rack::Utils.build_query(params)
|
322
324
|
else # response type if token
|
323
325
|
if auth_request.access_token
|
324
|
-
logger.info "
|
326
|
+
logger.info "RO2S: Client #{auth_request.client_id} granted access token #{auth_request.access_token}" if logger
|
325
327
|
params = { :access_token=>auth_request.access_token, :scope=>auth_request.scope.join(" "), :state=>auth_request.state }
|
326
328
|
else
|
327
|
-
logger.info "
|
329
|
+
logger.info "RO2S: Client #{auth_request.client_id} denied authorization" if logger
|
328
330
|
params = { :error=>:access_denied, :state=>auth_request.state }
|
329
331
|
end
|
330
332
|
redirect_uri.fragment = Rack::Utils.build_query(params)
|
@@ -342,35 +344,36 @@ module Rack
|
|
342
344
|
when "authorization_code"
|
343
345
|
# 4.1.1. Authorization Code
|
344
346
|
grant = AccessGrant.from_code(request.POST["code"])
|
345
|
-
raise InvalidGrantError unless grant && client.id == grant.client_id
|
346
|
-
raise InvalidGrantError unless grant.redirect_uri.nil? || grant.redirect_uri == Utils.parse_redirect_uri(request.POST["redirect_uri"]).to_s
|
347
|
+
raise InvalidGrantError, "Wrong client" unless grant && client.id == grant.client_id
|
348
|
+
raise InvalidGrantError, "Wrong redirect URI" unless grant.redirect_uri.nil? || grant.redirect_uri == Utils.parse_redirect_uri(request.POST["redirect_uri"]).to_s
|
349
|
+
raise InvalidGrantError, "This access grant expired" if grant.expires_at && grant.expires_at <= Time.now.to_i
|
347
350
|
access_token = grant.authorize!
|
348
351
|
when "password"
|
349
352
|
raise UnsupportedGrantType unless options.authenticator
|
350
353
|
# 4.1.2. Resource Owner Password Credentials
|
351
354
|
username, password = request.POST.values_at("username", "password")
|
352
|
-
raise InvalidGrantError unless username && password
|
355
|
+
raise InvalidGrantError, "Missing username/password" unless username && password
|
353
356
|
requested_scope = Utils.normalize_scope(request.POST["scope"])
|
354
357
|
allowed_scope = client.scope
|
355
358
|
raise InvalidScopeError unless (requested_scope - allowed_scope).empty?
|
356
359
|
args = [username, password]
|
357
360
|
args << client.id << requested_scope unless options.authenticator.arity == 2
|
358
361
|
identity = options.authenticator.call(*args)
|
359
|
-
raise InvalidGrantError unless identity
|
362
|
+
raise InvalidGrantError, "Username/password do not match" unless identity
|
360
363
|
access_token = AccessToken.get_token_for(identity, client, requested_scope)
|
361
364
|
else
|
362
365
|
raise UnsupportedGrantType
|
363
366
|
end
|
364
|
-
logger.info "Access token #{access_token.token} granted to client #{client.display_name}, identity #{access_token.identity}" if logger
|
367
|
+
logger.info "RO2S: Access token #{access_token.token} granted to client #{client.display_name}, identity #{access_token.identity}" if logger
|
365
368
|
response = { :access_token=>access_token.token }
|
366
369
|
response[:scope] = access_token.scope.join(" ")
|
367
|
-
return [200, { "Content-Type"=>"application/json", "Cache-Control"=>"no-store" }, response.to_json]
|
370
|
+
return [200, { "Content-Type"=>"application/json", "Cache-Control"=>"no-store" }, [response.to_json]]
|
368
371
|
# 4.3. Error Response
|
369
372
|
rescue OAuthError=>error
|
370
|
-
logger.error "Access token request error
|
373
|
+
logger.error "RO2S: Access token request error #{error.code}: #{error.message}" if logger
|
371
374
|
return unauthorized(request, error) if InvalidClientError === error && request.basic?
|
372
375
|
return [400, { "Content-Type"=>"application/json", "Cache-Control"=>"no-store" },
|
373
|
-
{ :error=>error.code, :error_description=>error.message }.to_json]
|
376
|
+
[{ :error=>error.code, :error_description=>error.message }.to_json]]
|
374
377
|
end
|
375
378
|
end
|
376
379
|
|
@@ -176,6 +176,15 @@ class AccessGrantTest < Test::Unit::TestCase
|
|
176
176
|
should_respond_with_access_token
|
177
177
|
end
|
178
178
|
|
179
|
+
context "access grant expired" do
|
180
|
+
setup do
|
181
|
+
Timecop.travel 300 do
|
182
|
+
request_access_token
|
183
|
+
end
|
184
|
+
end
|
185
|
+
should_return_error :invalid_grant
|
186
|
+
end
|
187
|
+
|
179
188
|
|
180
189
|
# 4.1.2. Resource Owner Password Credentials
|
181
190
|
|
@@ -166,6 +166,50 @@ class ServerTest < Test::Unit::TestCase
|
|
166
166
|
end
|
167
167
|
end
|
168
168
|
|
169
|
+
context "no expiration" do
|
170
|
+
setup do
|
171
|
+
@code = Server.access_grant("Batman", client.id)
|
172
|
+
end
|
173
|
+
|
174
|
+
should "not expire in a minute" do
|
175
|
+
Timecop.travel 60 do
|
176
|
+
basic_authorize client.id, client.secret
|
177
|
+
post "/oauth/access_token", :scope=>"read", :grant_type=>"authorization_code", :code=>@code, :redirect_uri=>client.redirect_uri
|
178
|
+
assert_equal 200, last_response.status
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
should "expire after 5 minutes" do
|
183
|
+
Timecop.travel 300 do
|
184
|
+
basic_authorize client.id, client.secret
|
185
|
+
post "/oauth/access_token", :scope=>"read", :grant_type=>"authorization_code", :code=>@code, :redirect_uri=>client.redirect_uri
|
186
|
+
assert_equal 400, last_response.status
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
context "expiration set" do
|
192
|
+
setup do
|
193
|
+
@code = Server.access_grant("Batman", client.id, nil, 1800)
|
194
|
+
end
|
195
|
+
|
196
|
+
should "not expire prematurely" do
|
197
|
+
Timecop.travel 1750 do
|
198
|
+
basic_authorize client.id, client.secret
|
199
|
+
post "/oauth/access_token", :scope=>"read", :grant_type=>"authorization_code", :code=>@code, :redirect_uri=>client.redirect_uri
|
200
|
+
assert_equal 200, last_response.status
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
should "expire after specified seconds" do
|
205
|
+
Timecop.travel 1800 do
|
206
|
+
basic_authorize client.id, client.secret
|
207
|
+
post "/oauth/access_token", :scope=>"read", :grant_type=>"authorization_code", :code=>@code, :redirect_uri=>client.redirect_uri
|
208
|
+
assert_equal 400, last_response.status
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
169
213
|
end
|
170
214
|
|
171
215
|
|