the browser-facing portion of osu!
at master 248 lines 7.4 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\Events\UserSessionEvent; 9use App\Exceptions\InvalidScopeException; 10use App\Interfaces\SessionVerificationInterface; 11use App\Models\Traits\FasterAttributes; 12use App\Models\User; 13use Ds\Set; 14use Illuminate\Database\Eloquent\Factories\HasFactory; 15use Laravel\Passport\RefreshToken; 16use Laravel\Passport\Token as PassportToken; 17 18class Token extends PassportToken implements SessionVerificationInterface 19{ 20 // PassportToken doesn't have factory 21 use HasFactory, FasterAttributes; 22 23 protected $casts = [ 24 'expires_at' => 'datetime', 25 'revoked' => 'boolean', 26 'scopes' => 'array', 27 'verified' => 'boolean', 28 ]; 29 30 private ?Set $scopeSet; 31 32 public static function findForVerification(string $id): ?static 33 { 34 return static::find($id); 35 } 36 37 public function refreshToken() 38 { 39 return $this->hasOne(RefreshToken::class, 'access_token_id'); 40 } 41 42 public function user() 43 { 44 return $this->belongsTo(User::class, 'user_id'); 45 } 46 47 /** 48 * Whether the resource owner is delegated to the client's owner. 49 * 50 * @return bool 51 */ 52 public function delegatesOwner(): bool 53 { 54 return $this->scopeSet()->contains('delegate'); 55 } 56 57 public function getAttribute($key) 58 { 59 return match ($key) { 60 'client_id', 61 'id', 62 'name', 63 'user_id' => $this->getRawAttribute($key), 64 65 'revoked', 66 'verified' => $this->getNullableBool($key), 67 68 'scopes' => json_decode($this->getRawAttribute($key) ?? 'null', true), 69 70 'created_at', 71 'expires_at', 72 'updated_at' => $this->getTimeFast($key), 73 74 'client', 75 'refreshToken', 76 'user' => $this->getRelationValue($key), 77 }; 78 } 79 80 public function getKeyForEvent(): string 81 { 82 return "oauth:{$this->getKey()}"; 83 } 84 85 /** 86 * Resource owner for the token. 87 * 88 * For client_credentials grants, this is the client that requested the token; 89 * otherwise, it is the user that authorized the token. 90 */ 91 public function getResourceOwner(): ?User 92 { 93 if ($this->isClientCredentials() && $this->delegatesOwner()) { 94 return $this->client->user; 95 } 96 97 return $this->user; 98 } 99 100 public function isClientCredentials() 101 { 102 // explicitly no user_id. 103 return $this->user_id === null; 104 } 105 106 public function isOwnToken(): bool 107 { 108 $clientUserId = $this->client->user_id; 109 110 return $clientUserId !== null && $clientUserId === $this->user_id; 111 } 112 113 public function isVerified(): bool 114 { 115 return $this->verified; 116 } 117 118 public function markVerified(): void 119 { 120 $this->update(['verified' => true]); 121 } 122 123 public function revokeRecursive() 124 { 125 $result = $this->revoke(); 126 $this->refreshToken?->revoke(); 127 128 return $result; 129 } 130 131 public function revoke() 132 { 133 $saved = parent::revoke(); 134 135 if ($saved && $this->user_id !== null) { 136 UserSessionEvent::newLogout($this->user_id, [$this->getKeyForEvent()])->broadcast(); 137 } 138 139 return $saved; 140 } 141 142 public function scopeValidAt($query, $time) 143 { 144 return $query->where('revoked', false)->where('expires_at', '>', $time); 145 } 146 147 public function setScopesAttribute(?array $value) 148 { 149 if ($value !== null) { 150 sort($value); 151 } 152 153 $this->scopeSet = null; 154 $this->attributes['scopes'] = $this->castAttributeAsJson('scopes', $value); 155 } 156 157 public function userId(): ?int 158 { 159 return $this->user_id; 160 } 161 162 public function validate(): void 163 { 164 static $scopesRequireDelegation = new Set(['chat.write', 'chat.write_manage', 'delegate']); 165 166 $scopes = $this->scopeSet(); 167 if ($scopes->isEmpty()) { 168 throw new InvalidScopeException('Tokens without scopes are not valid.'); 169 } 170 171 $client = $this->client; 172 if ($client === null) { 173 throw new InvalidScopeException('The client is not authorized.', 'unauthorized_client'); 174 } 175 176 // no silly scopes. 177 if ($scopes->contains('*')) { 178 if ($scopes->count() > 1) { 179 throw new InvalidScopeException('* is not valid with other scopes'); 180 } 181 } elseif ($client->user === null) { 182 // Only "*" scope is allowed for clients with no user 183 throw new InvalidScopeException('The client is missing owner.'); 184 } 185 186 if ($this->isClientCredentials()) { 187 if ($scopes->contains('*')) { 188 throw new InvalidScopeException('* is not allowed with Client Credentials'); 189 } 190 191 if ($this->delegatesOwner() && !$client->user->isBot()) { 192 throw new InvalidScopeException('Delegation with Client Credentials is only available to chat bots.'); 193 } 194 195 if (!$scopes->intersect($scopesRequireDelegation)->isEmpty()) { 196 if (!$this->delegatesOwner()) { 197 throw new InvalidScopeException('delegate scope is required.'); 198 } 199 200 // delegation is only allowed if scopes given allow delegation. 201 if (!$scopes->diff($scopesRequireDelegation)->isEmpty()) { 202 throw new InvalidScopeException('delegation is not supported for this combination of scopes.'); 203 } 204 } 205 } else { 206 // delegation is only available for client_credentials. 207 if ($this->delegatesOwner()) { 208 throw new InvalidScopeException('delegate scope is only valid for client_credentials tokens.'); 209 } 210 211 // only clients owned by bots are allowed to act on behalf of another user. 212 // the user's own client can send messages as themselves for authorization code flows. 213 static $ownClientScopes = new Set([ 214 'chat.read', 215 'chat.write', 216 'chat.write_manage', 217 ]); 218 if (!$scopes->intersect($ownClientScopes)->isEmpty() && !($this->isOwnToken() || $client->user->isBot())) { 219 throw new InvalidScopeException('This scope is only available for chat bots or your own clients.'); 220 } 221 } 222 } 223 224 public function save(array $options = []) 225 { 226 // Forces error if passport tries to issue an invalid client_credentials token. 227 $this->validate(); 228 if (!$this->exists) { 229 $this->setVerifiedState(); 230 } 231 232 return parent::save($options); 233 } 234 235 private function scopeSet(): Set 236 { 237 return $this->scopeSet ??= new Set($this->scopes ?? []); 238 } 239 240 private function setVerifiedState(): void 241 { 242 // client credential doesn't have user attached and auth code is 243 // already verified during grant process 244 $this->verified ??= $GLOBALS['cfg']['osu']['user']['bypass_verification'] 245 || $this->user === null 246 || !$this->client->password_client; 247 } 248}