Skip to content

Commit

Permalink
Merge pull request #10800 from nanaya/oauth-verification-2
Browse files Browse the repository at this point in the history
Add verification for password oauth token
  • Loading branch information
peppy authored Jan 29, 2024
2 parents 98ffa4d + 9a04ce8 commit a6fea4e
Show file tree
Hide file tree
Showing 23 changed files with 492 additions and 46 deletions.
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
1 change: 1 addition & 0 deletions app/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class Kernel extends HttpKernel
Middleware\SetLocaleApi::class,
Middleware\CheckUserBanStatus::class,
Middleware\UpdateUserLastvisit::class,
Middleware\VerifyUserAlways::class,
],
'web' => [
Middleware\StripCookies::class,
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
45 changes: 45 additions & 0 deletions app/Libraries/OAuth/EncodeToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?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 Defuse\Crypto\Crypto;
use Firebase\JWT\JWT;
use Laravel\Passport\Passport;
use Laravel\Passport\RefreshToken;

class EncodeToken
{
public static function encodeAccessToken(Token $token): string
{
$privateKey = $GLOBALS['cfg']['passport']['private_key']
?? file_get_contents(Passport::keyPath('oauth-private.key'));

return JWT::encode([
'aud' => $token->client_id,
'exp' => $token->expires_at->timestamp,
'iat' => $token->created_at->timestamp, // issued at
'jti' => $token->getKey(),
'nbf' => $token->created_at->timestamp, // valid after
'sub' => $token->user_id,
'scopes' => $token->scopes,
], $privateKey, 'RS256');
}

public static function encodeRefreshToken(RefreshToken $refreshToken, Token $accessToken): string
{
return Crypto::encryptWithPassword(json_encode([
'client_id' => (string) $accessToken->client_id,
'refresh_token_id' => $refreshToken->getKey(),
'access_token_id' => $accessToken->getKey(),
'scopes' => $accessToken->scopes,
'user_id' => $accessToken->user_id,
'expire_time' => $refreshToken->expires_at->timestamp,
]), \Crypt::getKey());
}
}
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);
}
}
18 changes: 11 additions & 7 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,9 +43,9 @@ public static function initiate()

public static function reissue()
{
$session = \Session::instance();
$session = Helper::currentSession();
if ($session->isVerified()) {
return response(null, 204);
return response(null, 422);
}

Helper::issue($session, Helper::currentUserOrFail());
Expand All @@ -55,9 +55,13 @@ public static function reissue()

public static function verify()
{
$session = Helper::currentSession();
if ($session->isVerified()) {
return response(null, 204);
}

$key = strtr(get_string(\Request::input('verification_key')) ?? '', [' ' => '']);
$user = Helper::currentUserOrFail();
$session = \Session::instance();
$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
53 changes: 49 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,11 @@ private function scopeSet(): Set
{
return $this->scopeSet ??= new Set($this->scopes ?? []);
}

private function setVerifiedState(): void
{
// client credential doesn't have user attached and auth code is
// already verified during grant process
$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
Loading

0 comments on commit a6fea4e

Please sign in to comment.