Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add verification for password oauth token #10800

Merged
merged 14 commits into from
Jan 29, 2024
4 changes: 3 additions & 1 deletion app/Http/Controllers/UsersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)) {
Expand All @@ -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}",
Expand Down
11 changes: 8 additions & 3 deletions app/Http/Middleware/AuthApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

namespace App\Http\Middleware;

use App\Libraries\SessionVerification;
use Closure;
use Illuminate\Auth\AuthenticationException;
use Laravel\Passport\ClientRepository;
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion app/Http/Middleware/UpdateUserLastvisit.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

namespace App\Http\Middleware;

use App\Libraries\SessionVerification;
use App\Models\Country;
use Carbon\Carbon;
use Closure;
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions app/Http/Middleware/VerifyUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions app/Libraries/OAuth/RefreshTokenGrant.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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);
}
}
12 changes: 6 additions & 6 deletions app/Libraries/SessionVerification/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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);
}
Expand All @@ -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 {
Expand Down
15 changes: 14 additions & 1 deletion app/Libraries/SessionVerification/Helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}
Expand Down
51 changes: 47 additions & 4 deletions app/Models/OAuth/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,33 @@

use App\Events\UserSessionEvent;
use App\Exceptions\InvalidScopeException;
use App\Interfaces\SessionVerificationInterface;
use App\Models\Traits\FasterAttributes;
use App\Models\User;
use Ds\Set;
use Illuminate\Database\Eloquent\Factories\HasFactory;
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');
Expand Down Expand Up @@ -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',
Expand All @@ -62,6 +77,11 @@ public function getAttribute($key)
};
}

public function getKeyForEvent(): string
{
return "oauth:{$this->getKey()}";
}

/**
* Resource owner for the token.
*
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand All @@ -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']);
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
}
}
7 changes: 7 additions & 0 deletions app/Models/Traits/FasterAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
29 changes: 29 additions & 0 deletions app/Providers/PassportServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/**
* Overrides RefreshTokenGrant to copy verified attribute of the token
*/
protected function makeRefreshTokenGrant()
{
$repository = $this->app->make(RefreshTokenRepository::class);

$grant = new RefreshTokenGrant($repository);
$grant->setRefreshTokenTTL(Passport::refreshTokensExpireIn());

return $grant;
}
}
6 changes: 6 additions & 0 deletions app/Transformers/UserCompactTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class UserCompactTransformer extends TransformerAbstract
'scores_first_count',
'scores_pinned_count',
'scores_recent_count',
'session_verified',
'statistics',
'statistics_rulesets',
'support_level',
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion app/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions config/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion database/factories/OAuth/TokenFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
}
}
Loading