diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index b92a8bd65e1..42501f459e4 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -540,6 +540,7 @@ public function scores($_userId, $type) * * See [Get User](#get-user). * + * `session_verified` attribute is included. * Additionally, `statistics_rulesets` is included, containing statistics for all rulesets. * * @urlParam mode string [Ruleset](#ruleset). User default mode will be used if not specified. Example: osu @@ -548,7 +549,7 @@ public function scores($_userId, $type) */ public function me($mode = null) { - $user = auth()->user(); + $user = \Auth::user(); $currentMode = $mode ?? $user->playmode; if (!Beatmap::isModeValid($currentMode)) { @@ -561,6 +562,7 @@ public function me($mode = null) $user, (new UserTransformer())->setMode($currentMode), [ + 'session_verified', ...$this->showUserIncludes(), ...array_map( fn (string $ruleset) => "statistics_rulesets.{$ruleset}", diff --git a/app/Http/Middleware/AuthApi.php b/app/Http/Middleware/AuthApi.php index 592c1ef5a17..d7f6a50ecf8 100644 --- a/app/Http/Middleware/AuthApi.php +++ b/app/Http/Middleware/AuthApi.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; +use App\Libraries\SessionVerification; use Closure; use Illuminate\Auth\AuthenticationException; use Laravel\Passport\ClientRepository; @@ -95,10 +96,14 @@ private function validTokenFromRequest($psr) } if ($user !== null) { - auth()->setUser($user); + \Auth::setUser($user); $user->withAccessToken($token); - // this should match osu-notification-server OAuthVerifier - $user->markSessionVerified(); + + if ($token->isVerified()) { + $user->markSessionVerified(); + } else { + SessionVerification\Helper::issue($token, $user, true); + } } return $token; diff --git a/app/Http/Middleware/UpdateUserLastvisit.php b/app/Http/Middleware/UpdateUserLastvisit.php index a817773224a..02bf1f984f8 100644 --- a/app/Http/Middleware/UpdateUserLastvisit.php +++ b/app/Http/Middleware/UpdateUserLastvisit.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; +use App\Libraries\SessionVerification; use App\Models\Country; use Carbon\Carbon; use Closure; @@ -30,7 +31,7 @@ public function handle($request, Closure $next) if ($shouldUpdate) { $isInactive = $user->isInactive(); if ($isInactive) { - $isVerified = $user->isSessionVerified(); + $isVerified = SessionVerification\Helper::currentSession()->isVerified(); } if (!$isInactive || $isVerified) { diff --git a/app/Http/Middleware/VerifyUser.php b/app/Http/Middleware/VerifyUser.php index f50855bf4da..88c01549b4e 100644 --- a/app/Http/Middleware/VerifyUser.php +++ b/app/Http/Middleware/VerifyUser.php @@ -20,6 +20,7 @@ class VerifyUser 'notifications_controller@endpoint' => true, 'sessions_controller@destroy' => true, 'sessions_controller@store' => true, + 'users_controller@me' => true, 'wiki_controller@image' => true, 'wiki_controller@show' => true, 'wiki_controller@sitemap' => true, diff --git a/app/Libraries/OAuth/RefreshTokenGrant.php b/app/Libraries/OAuth/RefreshTokenGrant.php new file mode 100644 index 00000000000..690aca36490 --- /dev/null +++ b/app/Libraries/OAuth/RefreshTokenGrant.php @@ -0,0 +1,40 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Libraries\OAuth; + +use App\Models\OAuth\Token; +use League\OAuth2\Server\Grant\RefreshTokenGrant as BaseRefreshTokenGrant; +use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; +use Psr\Http\Message\ServerRequestInterface; + +class RefreshTokenGrant extends BaseRefreshTokenGrant +{ + private ?array $oldRefreshToken = null; + + public function respondToAccessTokenRequest( + ServerRequestInterface $request, + ResponseTypeInterface $responseType, + \DateInterval $accessTokenTTL + ) { + $refreshTokenData = parent::respondToAccessTokenRequest($request, $responseType, $accessTokenTTL); + + // Copy previous verification state + $accessToken = (new \ReflectionProperty($refreshTokenData, 'accessToken'))->getValue($refreshTokenData); + Token::where('id', $accessToken->getIdentifier())->update([ + 'verified' => Token::select('verified')->find($this->oldRefreshToken['access_token_id'])->verified, + ]); + $this->oldRefreshToken = null; + + return $refreshTokenData; + } + + protected function validateOldRefreshToken(ServerRequestInterface $request, $clientId) + { + return $this->oldRefreshToken = parent::validateOldRefreshToken($request, $clientId); + } +} diff --git a/app/Libraries/SessionVerification/Controller.php b/app/Libraries/SessionVerification/Controller.php index 0333f65b192..a115cc41bd2 100644 --- a/app/Libraries/SessionVerification/Controller.php +++ b/app/Libraries/SessionVerification/Controller.php @@ -21,11 +21,11 @@ public static function initiate() $user = Helper::currentUserOrFail(); $email = $user->user_email; - $session = \Session::instance(); - if (State::fromSession($session) === null) { - Helper::logAttempt('input', 'new'); + $session = Helper::currentSession(); + Helper::issue($session, $user, true); - Helper::issue($session, $user); + if (is_api_request()) { + return response(null, $statusCode); } if (\Request::ajax()) { @@ -43,7 +43,7 @@ public static function initiate() public static function reissue() { - $session = \Session::instance(); + $session = Helper::currentSession(); if ($session->isVerified()) { return response(null, 204); } @@ -57,7 +57,7 @@ public static function verify() { $key = strtr(get_string(\Request::input('verification_key')) ?? '', [' ' => '']); $user = Helper::currentUserOrFail(); - $session = \Session::instance(); + $session = Helper::currentSession(); $state = State::fromSession($session); try { diff --git a/app/Libraries/SessionVerification/Helper.php b/app/Libraries/SessionVerification/Helper.php index aa44cf8c374..cb9a8c53e6b 100644 --- a/app/Libraries/SessionVerification/Helper.php +++ b/app/Libraries/SessionVerification/Helper.php @@ -15,6 +15,11 @@ class Helper { + public static function currentSession(): ?SessionVerificationInterface + { + return is_api_request() ? oauth_token() : \Session::instance(); + } + public static function currentUserOrFail(): User { $user = \Auth::user(); @@ -23,8 +28,16 @@ public static function currentUserOrFail(): User return $user; } - public static function issue(SessionVerificationInterface $session, User $user): void + public static function issue(SessionVerificationInterface $session, User $user, bool $initial = false): void { + if ($initial) { + if (State::fromSession($session) === null) { + static::logAttempt('input', 'new'); + } else { + return; + } + } + if (!is_valid_email_format($user->user_email)) { return; } diff --git a/app/Models/OAuth/Token.php b/app/Models/OAuth/Token.php index 402388f50cf..526168427f3 100644 --- a/app/Models/OAuth/Token.php +++ b/app/Models/OAuth/Token.php @@ -7,6 +7,7 @@ use App\Events\UserSessionEvent; use App\Exceptions\InvalidScopeException; +use App\Interfaces\SessionVerificationInterface; use App\Models\Traits\FasterAttributes; use App\Models\User; use Ds\Set; @@ -14,13 +15,25 @@ use Laravel\Passport\RefreshToken; use Laravel\Passport\Token as PassportToken; -class Token extends PassportToken +class Token extends PassportToken implements SessionVerificationInterface { // PassportToken doesn't have factory use HasFactory, FasterAttributes; + protected $casts = [ + 'expires_at' => 'datetime', + 'revoked' => 'boolean', + 'scopes' => 'array', + 'verified' => 'boolean', + ]; + private ?Set $scopeSet; + public static function findForVerification(string $id): ?static + { + return static::find($id); + } + public function refreshToken() { return $this->hasOne(RefreshToken::class, 'access_token_id'); @@ -49,8 +62,10 @@ public function getAttribute($key) 'name', 'user_id' => $this->getRawAttribute($key), - 'revoked' => (bool) $this->getRawAttribute($key), - 'scopes' => json_decode($this->getRawAttribute($key), true), + 'revoked', + 'verified' => $this->getNullableBool($key), + + 'scopes' => json_decode($this->getRawAttribute($key) ?? 'null', true), 'created_at', 'expires_at', @@ -62,6 +77,11 @@ public function getAttribute($key) }; } + public function getKeyForEvent(): string + { + return "oauth:{$this->getKey()}"; + } + /** * Resource owner for the token. * @@ -90,6 +110,16 @@ public function isOwnToken(): bool return $clientUserId !== null && $clientUserId === $this->user_id; } + public function isVerified(): bool + { + return $this->verified; + } + + public function markVerified(): void + { + $this->update(['verified' => true]); + } + public function revokeRecursive() { $result = $this->revoke(); @@ -103,7 +133,7 @@ public function revoke() $saved = parent::revoke(); if ($saved && $this->user_id !== null) { - UserSessionEvent::newLogout($this->user_id, ["oauth:{$this->getKey()}"])->broadcast(); + UserSessionEvent::newLogout($this->user_id, [$this->getKeyForEvent()])->broadcast(); } return $saved; @@ -124,6 +154,11 @@ public function setScopesAttribute(?array $value) $this->attributes['scopes'] = $this->castAttributeAsJson('scopes', $value); } + public function userId(): ?int + { + return $this->user_id; + } + public function validate(): void { static $scopesRequireDelegation = new Set(['chat.write', 'chat.write_manage', 'delegate']); @@ -185,6 +220,9 @@ public function save(array $options = []) { // Forces error if passport tries to issue an invalid client_credentials token. $this->validate(); + if (!$this->exists) { + $this->setVerifiedState(); + } return parent::save($options); } @@ -193,4 +231,9 @@ private function scopeSet(): Set { return $this->scopeSet ??= new Set($this->scopes ?? []); } + + private function setVerifiedState(): void + { + $this->verified ??= $this->user === null || !$this->client->password_client; + } } diff --git a/app/Models/Traits/FasterAttributes.php b/app/Models/Traits/FasterAttributes.php index f54fc880274..e0e4d7e1452 100644 --- a/app/Models/Traits/FasterAttributes.php +++ b/app/Models/Traits/FasterAttributes.php @@ -16,6 +16,13 @@ public function getRawAttribute(string $key) return $this->attributes[$key] ?? null; } + protected function getNullableBool(string $key) + { + $raw = $this->getRawAttribute($key); + + return $raw === null ? null : (bool) $raw; + } + /** * Fast Time Attribute to Json Transformer * diff --git a/app/Providers/PassportServiceProvider.php b/app/Providers/PassportServiceProvider.php new file mode 100644 index 00000000000..038519ff900 --- /dev/null +++ b/app/Providers/PassportServiceProvider.php @@ -0,0 +1,25 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Providers; + +use App\Libraries\OAuth\RefreshTokenGrant; +use Laravel\Passport\Bridge\RefreshTokenRepository; +use Laravel\Passport\Passport; +use Laravel\Passport\PassportServiceProvider as BasePassportServiceProvider; + +class PassportServiceProvider extends BasePassportServiceProvider +{ + protected function makeRefreshTokenGrant() + { + $repository = $this->app->make(RefreshTokenRepository::class); + + return tap(new RefreshTokenGrant($repository), function ($grant) { + $grant->setRefreshTokenTTL(Passport::refreshTokensExpireIn()); + }); + } +} diff --git a/app/Transformers/UserCompactTransformer.php b/app/Transformers/UserCompactTransformer.php index 9532222aebe..a8269628298 100644 --- a/app/Transformers/UserCompactTransformer.php +++ b/app/Transformers/UserCompactTransformer.php @@ -86,6 +86,7 @@ class UserCompactTransformer extends TransformerAbstract 'scores_first_count', 'scores_pinned_count', 'scores_recent_count', + 'session_verified', 'statistics', 'statistics_rulesets', 'support_level', @@ -405,6 +406,11 @@ public function includeScoresRecentCount(User $user) return $this->primitive($user->scores($this->mode, true)->includeFails(false)->count()); } + public function includeSessionVerified(User $user) + { + return $this->primitive($user->token()?->isVerified() ?? false); + } + public function includeStatistics(User $user) { $stats = $user->statistics($this->mode); diff --git a/app/helpers.php b/app/helpers.php index 4929b170b43..cb6ef36bd9b 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -539,7 +539,7 @@ function mysql_escape_like($string) function oauth_token(): ?App\Models\OAuth\Token { - return request()->attributes->get(App\Http\Middleware\AuthApi::REQUEST_OAUTH_TOKEN_KEY); + return Request::instance()->attributes->get(App\Http\Middleware\AuthApi::REQUEST_OAUTH_TOKEN_KEY); } function osu_trans($key = null, $replace = [], $locale = null) diff --git a/config/app.php b/config/app.php index d39297d1714..9217d6732b2 100644 --- a/config/app.php +++ b/config/app.php @@ -186,6 +186,7 @@ App\Providers\EventServiceProvider::class, // Override default migrate:fresh App\Providers\MigrationServiceProvider::class, + App\Providers\PassportServiceProvider::class, App\Providers\RouteServiceProvider::class, // Override the session id naming (for redis key namespacing) App\Providers\SessionServiceProvider::class, diff --git a/database/factories/OAuth/TokenFactory.php b/database/factories/OAuth/TokenFactory.php index a956346bcc1..c5857ab6864 100644 --- a/database/factories/OAuth/TokenFactory.php +++ b/database/factories/OAuth/TokenFactory.php @@ -23,8 +23,9 @@ public function definition(): array 'expires_at' => fn () => now()->addDays(), 'id' => str_random(40), 'revoked' => false, - 'scopes' => ['public'], + 'scopes' => ['identify', 'public'], 'user_id' => User::factory(), + 'verified' => true, ]; } } diff --git a/database/migrations/2023_12_18_104437_add_verified_column_to_oauth_access_tokens.php b/database/migrations/2023_12_18_104437_add_verified_column_to_oauth_access_tokens.php new file mode 100644 index 00000000000..6510da683c6 --- /dev/null +++ b/database/migrations/2023_12_18_104437_add_verified_column_to_oauth_access_tokens.php @@ -0,0 +1,27 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::table('oauth_access_tokens', function (Blueprint $table) { + $table->boolean('verified')->default(true); + }); + } + + public function down(): void + { + Schema::table('oauth_access_tokens', function (Blueprint $table) { + $table->dropColumn('verified'); + }); + } +}; diff --git a/resources/views/docs/_structures/user.md b/resources/views/docs/_structures/user.md index 56e706c5a85..37e52d70201 100644 --- a/resources/views/docs/_structures/user.md +++ b/resources/views/docs/_structures/user.md @@ -69,6 +69,7 @@ replays_watched_counts | | scores_best_count | integer scores_first_count | integer scores_recent_count | integer +session_verified | boolean statistics | | statistics_rulesets | UserStatisticsRulesets support_level | | diff --git a/routes/web.php b/routes/web.php index cdf21216f54..7e113e9434f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -403,6 +403,11 @@ // There's also a different group which skips throttle middleware. Route::group(['as' => 'api.', 'prefix' => 'api', 'middleware' => ['api', ThrottleRequests::getApiThrottle(), 'require-scopes']], function () { Route::group(['prefix' => 'v2'], function () { + Route::group(['middleware' => ['require-scopes:identify']], function () { + Route::post('verify-session', 'AccountController@verify')->name('verify'); + Route::post('verify-session/reissue', 'AccountController@reissueCode')->name('verify.reissue'); + }); + Route::group(['as' => 'beatmaps.', 'prefix' => 'beatmaps'], function () { Route::get('lookup', 'BeatmapsController@lookup')->name('lookup'); diff --git a/tests/Controllers/OAuth/TokensControllerTest.php b/tests/Controllers/OAuth/TokensControllerTest.php index 296fef5edd0..fe641294377 100644 --- a/tests/Controllers/OAuth/TokensControllerTest.php +++ b/tests/Controllers/OAuth/TokensControllerTest.php @@ -5,12 +5,25 @@ namespace Tests\Controllers\OAuth; +use App\Mail\UserVerification as UserVerificationMail; use App\Models\OAuth\Token; +use App\Models\User; +use Database\Factories\OAuth\ClientFactory; use Database\Factories\OAuth\RefreshTokenFactory; +use Database\Factories\UserFactory; +use Defuse\Crypto\Crypto; use Tests\TestCase; class TokensControllerTest extends TestCase { + public static function dataProviderForTestIssueTokenWithRefreshTokenInheritsVerified(): array + { + return [ + [true], + [false], + ]; + } + public function testDestroyCurrent() { $refreshToken = (new RefreshTokenFactory())->create(); @@ -36,4 +49,76 @@ public function testDestroyCurrentClientGrant() $this->assertTrue($token->fresh()->revoked); } + + public function testIssueTokenWithPassword(): void + { + \Mail::fake(); + + $user = User::factory()->create(); + $client = (new ClientFactory())->create([ + 'password_client' => true, + ]); + + $this->expectCountChange(fn () => $user->tokens()->count(), 1); + + $tokenResp = $this->json('POST', route('oauth.passport.token'), [ + 'grant_type' => 'password', + 'client_id' => $client->getKey(), + 'client_secret' => $client->secret, + 'scope' => '*', + 'username' => $user->username, + 'password' => UserFactory::DEFAULT_PASSWORD, + ])->assertSuccessful(); + $tokenJson = json_decode($tokenResp->getContent(), true); + + $meResp = $this->json('GET', route('api.me'), [], [ + 'Authorization' => "Bearer {$tokenJson['access_token']}", + ])->assertSuccessful(); + $meJson = json_decode($meResp->getContent(), true); + + $this->assertFalse($meJson['session_verified']); + // unverified access to api should trigger this but not necessarily return 401 + \Mail::assertQueued(UserVerificationMail::class); + } + + /** + * @dataProvider dataProviderForTestIssueTokenWithRefreshTokenInheritsVerified + */ + public function testIssueTokenWithRefreshTokenInheritsVerified(bool $verified): void + { + \Mail::fake(); + + $refreshToken = (new RefreshTokenFactory())->create(); + $accessToken = $refreshToken->accessToken; + $accessToken->forceFill(['scopes' => ['*'], 'verified' => $verified])->save(); + $client = $accessToken->client; + $user = $accessToken->user; + $refreshTokenString = Crypto::encryptWithPassword(json_encode([ + 'client_id' => (string) $client->getKey(), + 'refresh_token_id' => $refreshToken->getKey(), + 'access_token_id' => $accessToken->getKey(), + 'scopes' => $accessToken->scopes, + 'user_id' => $user->getKey(), + 'expire_time' => $refreshToken->expires_at->getTimestamp(), + ]), \Crypt::getKey()); + + $this->expectCountChange(fn () => $user->tokens()->count(), 1); + + $tokenResp = $this->json('POST', route('oauth.passport.token'), [ + 'grant_type' => 'refresh_token', + 'client_id' => $client->getKey(), + 'client_secret' => $client->secret, + 'refresh_token' => $refreshTokenString, + 'scope' => implode(' ', $accessToken->scopes), + ])->assertSuccessful(); + $tokenJson = json_decode($tokenResp->getContent(), true); + + $meResp = $this->json('GET', route('api.me'), [], [ + 'Authorization' => "Bearer {$tokenJson['access_token']}", + ])->assertSuccessful(); + $meJson = json_decode($meResp->getContent(), true); + + $this->assertSame($meJson['session_verified'], $verified); + \Mail::assertQueued(UserVerificationMail::class, $verified ? 0 : 1); + } } diff --git a/tests/Libraries/SessionVerification/ControllerTest.php b/tests/Libraries/SessionVerification/ControllerTest.php index 58f3ecc3da9..b7a6a8e8504 100644 --- a/tests/Libraries/SessionVerification/ControllerTest.php +++ b/tests/Libraries/SessionVerification/ControllerTest.php @@ -11,6 +11,8 @@ use App\Libraries\SessionVerification; use App\Mail\UserVerification as UserVerificationMail; use App\Models\LoginAttempt; +use App\Models\OAuth\Client; +use App\Models\OAuth\Token; use App\Models\User; use Tests\TestCase; @@ -131,6 +133,32 @@ public function testVerifyLinkMismatch(): void $this->assertFalse(SessionStore::findOrNew($sessionId)->isVerified()); } + public function testVerifyLinkOAuth(): void + { + $token = Token::factory()->create([ + 'client_id' => Client::factory()->create(['password_client' => true]), + 'verified' => false, + ]); + + $this + ->actingWithToken($token) + ->get(route('api.me')) + ->assertSuccessful(); + + $linkKey = SessionVerification\State::fromSession($token)->linkKey; + + \Auth::logout(); + $this + ->withPersistentSession(SessionStore::findOrNew()) + ->get(route('account.verify', ['key' => $linkKey])) + ->assertSuccessful(); + + $record = LoginAttempt::find('127.0.0.1'); + + $this->assertFalse($record->containsUser($token->user, 'verify-mismatch:')); + $this->assertTrue($token->fresh()->isVerified()); + } + public function testVerifyMismatch(): void { $user = User::factory()->create(); @@ -156,4 +184,29 @@ public function testVerifyMismatch(): void $this->assertTrue($record->containsUser($user, 'verify-mismatch:')); $this->assertFalse($session->isVerified()); } + + public function testVerifyOAuth(): void + { + $token = Token::factory()->create([ + 'client_id' => Client::factory()->create(['password_client' => true]), + 'verified' => false, + ]); + + $this + ->actingWithToken($token) + ->get(route('api.me')) + ->assertSuccessful(); + + $key = SessionVerification\State::fromSession($token)->key; + + $this + ->actingWithToken($token) + ->post(route('api.verify', ['verification_key' => $key])) + ->assertSuccessful(); + + $record = LoginAttempt::find('127.0.0.1'); + + $this->assertFalse($record->containsUser($token->user, 'verify-mismatch:')); + $this->assertTrue($token->fresh()->isVerified()); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 7fe0f8462e2..5b0852dbcfb 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -245,17 +245,14 @@ protected function clearMailFake() */ protected function createToken(?User $user, ?array $scopes = null, ?Client $client = null) { - $client ??= Client::factory()->create(); - - $token = $client->tokens()->create([ + return ($client ?? Client::factory()->create())->tokens()->create([ 'expires_at' => now()->addDays(1), 'id' => uniqid(), 'revoked' => false, 'scopes' => $scopes, - 'user_id' => optional($user)->getKey(), + 'user_id' => $user?->getKey(), + 'verified' => true, ]); - - return $token; } protected function expectCountChange(callable $callback, int $change, string $message = '') diff --git a/tests/api_routes.json b/tests/api_routes.json index b2d1ca6eb11..50bc3234933 100644 --- a/tests/api_routes.json +++ b/tests/api_routes.json @@ -1,4 +1,40 @@ [ + { + "uri": "api/v2/verify-session", + "methods": [ + "POST" + ], + "controller": "App\\Http\\Controllers\\AccountController@verify", + "middlewares": [ + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", + "App\\Http\\Middleware\\RequireScopes", + "App\\Http\\Middleware\\RequireScopes:identify", + "Illuminate\\Auth\\Middleware\\Authenticate", + "App\\Http\\Middleware\\VerifyUser", + "App\\Http\\Middleware\\ThrottleRequests:60,10" + ], + "scopes": [ + "identify" + ] + }, + { + "uri": "api/v2/verify-session/reissue", + "methods": [ + "POST" + ], + "controller": "App\\Http\\Controllers\\AccountController@reissueCode", + "middlewares": [ + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", + "App\\Http\\Middleware\\RequireScopes", + "App\\Http\\Middleware\\RequireScopes:identify", + "Illuminate\\Auth\\Middleware\\Authenticate", + "App\\Http\\Middleware\\VerifyUser", + "App\\Http\\Middleware\\ThrottleRequests:60,10" + ], + "scopes": [ + "identify" + ] + }, { "uri": "api/v2/beatmaps/lookup", "methods": [