the browser-facing portion of osu!
at master 185 lines 5.9 kB view raw
1<?php 2 3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4// See the LICENCE file in the repository root for full licence text. 5 6namespace App\Models\OAuth; 7 8use App\Exceptions\InvariantException; 9use App\Models\User; 10use App\Traits\Validatable; 11use Carbon\Carbon; 12use DB; 13use Laravel\Passport\Client as PassportClient; 14use Laravel\Passport\RefreshToken; 15 16/** 17 * @property Carbon|null $created_at 18 * @property int $id 19 * @property string $name 20 * @property bool $password_client 21 * @property bool $personal_access_client 22 * @property string $provider 23 * @property string $redirect 24 * @property-read Collection<RefreshToken> refreshTokens 25 * @property bool $revoked 26 * @property string $secret 27 * @property-read Collection<Token> tokens 28 * @property Carbon|null $updated_at 29 * @property-read User|null $user 30 * @property int|null $user_id 31 */ 32class Client extends PassportClient 33{ 34 use Validatable; 35 36 public ?array $scopes = null; 37 38 public static function forUser(User $user) 39 { 40 // Get clients matching non-revoked tokens. Expired tokens should be included. 41 $tokensQuery = Token::where('user_id', $user->getKey())->where('revoked', false); 42 43 $clients = static::whereIn('id', (clone $tokensQuery)->select('client_id')) 44 ->thirdParty() 45 ->where('revoked', false) 46 ->with('user') 47 ->get(); 48 49 // Aggregate permissions granted to client via tokens. 50 $tokenScopes = $tokensQuery->whereIn('client_id', $clients->pluck('id'))->select('client_id', 'scopes')->get(); 51 $clientScopes = $tokenScopes->mapToGroups(function ($item) { 52 return [$item->client_id => $item->scopes]; 53 }); 54 55 foreach ($clients as $client) { 56 $client->scopes = array_values(array_sort(array_unique(array_flatten($clientScopes[$client->id])))); 57 } 58 59 return $clients; 60 } 61 62 public static function newFactory() 63 { 64 // force default factory class name instead of passport 65 return null; 66 } 67 68 public function refreshTokens() 69 { 70 return $this->hasManyThrough( 71 RefreshToken::class, 72 Token::class, 73 'client_id', 74 'access_token_id' 75 ); 76 } 77 78 public function setRedirectAttribute(string $value) 79 { 80 $this->attributes['redirect'] = implode(',', array_unique(preg_split('/[\s,]+/', $value, 0, PREG_SPLIT_NO_EMPTY))); 81 } 82 83 public function isValid() 84 { 85 $this->validationErrors()->reset(); 86 87 if (!$this->exists && $this->user !== null) { 88 $max = $GLOBALS['cfg']['osu']['oauth']['max_user_clients']; 89 if ($this->user->oauthClients()->thirdParty()->where('revoked', false)->count() >= $max) { 90 $this->validationErrors()->add('user.oauthClients.count', '.too_many'); 91 } 92 } 93 94 if (mb_strlen(trim($this->name)) === 0) { 95 $this->validationErrors()->add('name', 'required'); 96 } 97 98 $redirects = explode(',', $this->redirect ?? ''); 99 foreach ($redirects as $redirect) { 100 // TODO: this url validation is not very good. 101 if (present($redirect) && !filter_var($redirect, FILTER_VALIDATE_URL)) { 102 $this->validationErrors()->add('redirect', '.url'); 103 break; 104 } 105 } 106 107 return $this->validationErrors()->isEmpty(); 108 } 109 110 public function resetSecret() 111 { 112 return $this->getConnection()->transaction(function () { 113 $now = now('UTC'); 114 115 $this->revokeTokens($now); 116 117 return $this->update(['secret' => str_random(40), 'updated_at' => $now], ['skipValidations' => true]); 118 }); 119 } 120 121 public function revokeForUser(User $user) 122 { 123 if ($this->firstParty()) { 124 // not sure if necessary? 125 throw new InvariantException('First party tokens cannot be revoked through this method.'); 126 } 127 128 $user->getConnection()->transaction(function () use ($user) { 129 $clientTokens = Token::where('user_id', $user->getKey())->where('client_id', $this->id); 130 131 (clone $clientTokens)->update([ 132 'revoked' => true, 133 'updated_at' => now('UTC'), 134 ]); 135 136 $user->getConnection() 137 // force mysql optimizer to optimize properly with a fake multi-table update 138 // https://dev.mysql.com/doc/refman/8.0/en/subquery-optimization.html 139 ->table(DB::raw('oauth_refresh_tokens, (SELECT 1) dummy')) 140 ->whereIn('access_token_id', (clone $clientTokens)->select('id')) 141 ->update(['revoked' => true]); 142 }); 143 } 144 145 public function revoke() 146 { 147 $this->getConnection()->transaction(function () { 148 $now = now('UTC'); 149 150 $this->revokeTokens($now); 151 $this->update(['revoked' => true, 'updated_at' => $now], ['skipValidations' => true]); 152 }); 153 } 154 155 public function save(array $options = []) 156 { 157 if (!($options['skipValidations'] ?? false) && !$this->isValid()) { 158 return false; 159 } 160 161 return parent::save($options); 162 } 163 164 public function scopeThirdParty($query) 165 { 166 return $query->where('personal_access_client', false)->where('password_client', false); 167 } 168 169 public function user() 170 { 171 return $this->belongsTo(User::class, 'user_id'); 172 } 173 174 public function validationErrorsTranslationPrefix(): string 175 { 176 return 'oauth.client'; 177 } 178 179 private function revokeTokens($timestamp) 180 { 181 $this->tokens()->update(['revoked' => true, 'updated_at' => $timestamp]); 182 $this->refreshTokens()->update([(new RefreshToken())->qualifyColumn('revoked') => true]); 183 $this->authCodes()->update(['revoked' => true]); 184 } 185}