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}