···11+<?php
22+33+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44+// See the LICENCE file in the repository root for full licence text.
55+66+declare(strict_types=1);
77+88+namespace App\Interfaces;
99+1010+interface SessionVerificationInterface
1111+{
1212+ public static function findForVerification(string $id): ?static;
1313+1414+ public function getKey();
1515+ public function isVerified(): bool;
1616+ public function markVerified(): void;
1717+}
+24-1
app/Libraries/Session/Store.php
···88namespace App\Libraries\Session;
991010use App\Events\UserSessionEvent;
1111+use App\Interfaces\SessionVerificationInterface;
1112use Illuminate\Redis\Connections\PhpRedisConnection;
1313+use Illuminate\Session\Store as BaseStore;
1214use Illuminate\Support\Arr;
1315use Jenssegers\Agent\Agent;
14161515-class Store extends \Illuminate\Session\Store
1717+class Store extends BaseStore implements SessionVerificationInterface
1618{
1719 private const PREFIX = 'sessions:';
1820···3840 $redis->srem(self::listKey($userId), ...$ids, ...$idsForEvent);
3941 UserSessionEvent::newLogout($userId, $idsForEvent)->broadcast();
4042 }
4343+ }
4444+4545+ public static function findForVerification(string $id): static
4646+ {
4747+ return static::findOrNew($id);
4148 }
42494350 public static function findOrNew(?string $id = null): static
···157164 static::batchDelete($this->userId(), [$this->getId()]);
158165 }
159166167167+ public function getKey(): string
168168+ {
169169+ return $this->getId();
170170+ }
171171+160172 public function getKeyForEvent(): string
161173 {
162174 return self::keyForEvent($this->getId());
···174186 {
175187 // Overridden to allow prefixed id
176188 return is_string($id);
189189+ }
190190+191191+ public function isVerified(): bool
192192+ {
193193+ return $this->attributes['verified'] ?? false;
194194+ }
195195+196196+ public function markVerified(): void
197197+ {
198198+ $this->attributes['verified'] = true;
199199+ $this->save();
177200 }
178201179202 /**
+107
app/Libraries/SessionVerification/Controller.php
···11+<?php
22+33+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44+// See the LICENCE file in the repository root for full licence text.
55+66+declare(strict_types=1);
77+88+namespace App\Libraries\SessionVerification;
99+1010+use App\Exceptions\UserVerificationException;
1111+use App\Models\LoginAttempt;
1212+1313+class Controller
1414+{
1515+ public static function initiate()
1616+ {
1717+ static $statusCode = 401;
1818+1919+ app('route-section')->setError("{$statusCode}-verification");
2020+2121+ $user = \Auth::user();
2222+ $email = $user->user_email;
2323+2424+ $session = \Session::instance();
2525+ if (State::fromSession($session) === null) {
2626+ Helper::logAttempt('input', 'new');
2727+2828+ Helper::issue($session, $user);
2929+ }
3030+3131+ if (\Request::ajax()) {
3232+ return response([
3333+ 'authentication' => 'verify',
3434+ 'box' => view(
3535+ 'users._verify_box',
3636+ compact('email')
3737+ )->render(),
3838+ ], $statusCode);
3939+ }
4040+4141+ return ext_view('users.verify', compact('email'), null, $statusCode);
4242+ }
4343+4444+ public static function reissue()
4545+ {
4646+ $session = \Session::instance();
4747+ if ($session->isVerified()) {
4848+ return response(null, 204);
4949+ }
5050+5151+ Helper::issue($session, \Auth::user());
5252+5353+ return response(['message' => osu_trans('user_verification.errors.reissued')], 200);
5454+ }
5555+5656+ public static function verify()
5757+ {
5858+ $key = strtr(get_string(\Request::input('verification_key')) ?? '', [' ' => '']);
5959+ $user = \Auth::user();
6060+ $session = \Session::instance();
6161+ $state = State::fromSession($session);
6262+6363+ try {
6464+ if ($state === null) {
6565+ throw new UserVerificationException('expired', true);
6666+ }
6767+ $state->verify($key);
6868+ } catch (UserVerificationException $e) {
6969+ Helper::logAttempt('input', 'fail', $e->reasonKey());
7070+7171+ if ($e->reasonKey() === 'incorrect_key') {
7272+ LoginAttempt::logAttempt(\Request::getClientIp(), $user, 'verify-mismatch', $key);
7373+ }
7474+7575+ if ($e->shouldReissue()) {
7676+ Helper::issue($session, $user);
7777+ }
7878+7979+ return error_popup($e->getMessage());
8080+ }
8181+8282+ Helper::logAttempt('input', 'success');
8383+ Helper::markVerified($session);
8484+8585+ return response(null, 204);
8686+ }
8787+8888+ public static function verifyLink()
8989+ {
9090+ $state = State::fromVerifyLink(get_string(\Request::input('key')) ?? '');
9191+9292+ if ($state === null) {
9393+ Helper::logAttempt('link', 'fail', 'incorrect_key');
9494+9595+ return ext_view('accounts.verification_invalid', null, null, 404);
9696+ }
9797+9898+ $session = $state->findSession();
9999+ // Otherwise pretend everything is okay if session is missing
100100+ if ($session !== null) {
101101+ Helper::logAttempt('link', 'success');
102102+ Helper::markVerified($session);
103103+ }
104104+105105+ return ext_view('accounts.verification_completed');
106106+ }
107107+}
+55
app/Libraries/SessionVerification/Helper.php
···11+<?php
22+33+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44+// See the LICENCE file in the repository root for full licence text.
55+66+declare(strict_types=1);
77+88+namespace App\Libraries\SessionVerification;
99+1010+use App\Events\UserSessionEvent;
1111+use App\Interfaces\SessionVerificationInterface;
1212+use App\Mail\UserVerification as UserVerificationMail;
1313+use App\Models\LoginAttempt;
1414+use App\Models\User;
1515+1616+class Helper
1717+{
1818+ public static function issue(SessionVerificationInterface $session, User $user): void
1919+ {
2020+ if (!is_valid_email_format($user->user_email)) {
2121+ return;
2222+ }
2323+2424+ $state = State::create($session);
2525+ $keys = [
2626+ 'link' => $state->linkKey,
2727+ 'main' => $state->key,
2828+ ];
2929+3030+ $request = \Request::instance();
3131+ LoginAttempt::logAttempt($request->getClientIp(), $user, 'verify');
3232+3333+ $requestCountry = app('countries')->byCode(request_country($request) ?? '')?->name;
3434+3535+ \Mail::to($user)
3636+ ->queue(new UserVerificationMail(
3737+ compact('keys', 'user', 'requestCountry')
3838+ ));
3939+ }
4040+4141+ public static function logAttempt(string $source, string $type, string $reason = null): void
4242+ {
4343+ \Datadog::increment(
4444+ \Config::get('datadog-helper.prefix_web').'.verification.attempts',
4545+ 1,
4646+ compact('reason', 'source', 'type')
4747+ );
4848+ }
4949+5050+ public static function markVerified(SessionVerificationInterface $session)
5151+ {
5252+ $session->markVerified();
5353+ UserSessionEvent::newVerified($session->userId(), $session->getKeyForEvent())->broadcast();
5454+ }
5555+}
+105
app/Libraries/SessionVerification/State.php
···11+<?php
22+33+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44+// See the LICENCE file in the repository root for full licence text.
55+66+declare(strict_types=1);
77+88+namespace App\Libraries\SessionVerification;
99+1010+use App\Exceptions\UserVerificationException;
1111+use App\Interfaces\SessionVerificationInterface;
1212+use App\Libraries\SignedRandomString;
1313+use Carbon\CarbonImmutable;
1414+1515+class State
1616+{
1717+ private const KEY_VALID_DURATION = 5 * 3600;
1818+1919+ public readonly \DateTimeInterface $expiresAt;
2020+ public readonly string $key;
2121+ public readonly string $linkKey;
2222+ public int $tries = 0;
2323+2424+ private function __construct(
2525+ private readonly string $sessionClass,
2626+ private readonly string $sessionId,
2727+ ) {
2828+ // 1 byte = 8 bits = 2^8 = 16^2 = 2 hex characters
2929+ $this->key = bin2hex(random_bytes(\Config::get('osu.user.verification_key_length_hex') / 2));
3030+ $this->linkKey = SignedRandomString::create(32);
3131+ $this->expiresAt = CarbonImmutable::now()->addSeconds(static::KEY_VALID_DURATION);
3232+ }
3333+3434+ public static function create(SessionVerificationInterface $session): static
3535+ {
3636+ $state = new static($session::class, $session->getKey());
3737+ $state->save(true);
3838+3939+ return $state;
4040+ }
4141+4242+ public static function fromSession(SessionVerificationInterface $session): ?static
4343+ {
4444+ return \Cache::get(static::cacheKey($session::class, $session->getKey()));
4545+ }
4646+4747+ public static function fromVerifyLink(string $linkKey): ?static
4848+ {
4949+ if (!SignedRandomString::isValid($linkKey)) {
5050+ return null;
5151+ }
5252+5353+ $cacheKey = \Cache::get(static::cacheLinkKey($linkKey));
5454+5555+ return $cacheKey === null ? null : \Cache::get($cacheKey);
5656+ }
5757+5858+ private static function cacheKey(string $class, string $id): string
5959+ {
6060+ return "session_verification:{$class}:{$id}";
6161+ }
6262+6363+ private static function cacheLinkKey(string $linkKey): string
6464+ {
6565+ return "session_verification_link:{$linkKey}";
6666+ }
6767+6868+ public function delete(): void
6969+ {
7070+ \Cache::delete(static::cacheKey($this->sessionClass, $this->sessionId));
7171+ \Cache::delete(static::cacheLinkKey($state->linkKey));
7272+ }
7373+7474+ public function findSession(): ?SessionVerificationInterface
7575+ {
7676+ return $this->sessionClass::findForVerification($this->sessionId);
7777+ }
7878+7979+ public function verify(string $inputKey): void
8080+ {
8181+ $this->tries++;
8282+8383+ if ($this->expiresAt->isPast()) {
8484+ throw new UserVerificationException('expired', true);
8585+ }
8686+8787+ if (!hash_equals($this->key, $inputKey)) {
8888+ if ($this->tries >= \Config::get('osu.user.verification_key_tries_limit')) {
8989+ throw new UserVerificationException('retries_exceeded', true);
9090+ } else {
9191+ $this->save(false);
9292+ throw new UserVerificationException('incorrect_key', false);
9393+ }
9494+ }
9595+ }
9696+9797+ private function save(bool $saveLinkKey): void
9898+ {
9999+ $cacheKey = static::cacheKey($this->sessionClass, $this->sessionId);
100100+ \Cache::put($cacheKey, $this, $this->expiresAt);
101101+ if ($saveLinkKey) {
102102+ \Cache::put(static::cacheLinkKey($this->linkKey), $cacheKey, $this->expiresAt);
103103+ }
104104+ }
105105+}
-165
app/Libraries/UserVerification.php
···11-<?php
22-33-// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44-// See the LICENCE file in the repository root for full licence text.
55-66-namespace App\Libraries;
77-88-use App\Exceptions\UserVerificationException;
99-use App\Mail\UserVerification as UserVerificationMail;
1010-use App\Models\Country;
1111-use App\Models\LoginAttempt;
1212-use Datadog;
1313-use Mail;
1414-1515-class UserVerification
1616-{
1717- private $request;
1818- private $state;
1919- private $user;
2020-2121- public static function fromCurrentRequest()
2222- {
2323- $request = request();
2424- $attributes = $request->attributes;
2525- $verification = $attributes->get('user_verification');
2626-2727- if ($verification === null) {
2828- $verification = new static(
2929- auth()->user(),
3030- $request,
3131- UserVerificationState::fromCurrentRequest()
3232- );
3333- $attributes->set('user_verification', $verification);
3434- }
3535-3636- return $verification;
3737- }
3838-3939- public static function logAttempt(string $source, string $type, string $reason = null): void
4040- {
4141- Datadog::increment(
4242- config('datadog-helper.prefix_web').'.verification.attempts',
4343- 1,
4444- compact('reason', 'source', 'type')
4545- );
4646- }
4747-4848- private function __construct($user, $request, $state)
4949- {
5050- $this->user = $user;
5151- $this->request = $request;
5252- $this->state = $state;
5353- }
5454-5555- public function initiate()
5656- {
5757- $statusCode = 401;
5858- app('route-section')->setError("{$statusCode}-verification");
5959-6060- // Workaround race condition causing $this->issue() to be called in parallel.
6161- // Mainly observed when logging in as privileged user.
6262- if ($this->request->ajax()) {
6363- $routeData = app('route-section')->getOriginal();
6464- if ($routeData['controller'] === 'notifications_controller' && $routeData['action'] === 'index') {
6565- return response(['error' => 'verification'], $statusCode);
6666- }
6767- }
6868-6969- $email = $this->user->user_email;
7070-7171- if (!$this->state->issued()) {
7272- static::logAttempt('input', 'new');
7373-7474- $this->issue();
7575- }
7676-7777- if ($this->request->ajax()) {
7878- return response([
7979- 'authentication' => 'verify',
8080- 'box' => view(
8181- 'users._verify_box',
8282- compact('email')
8383- )->render(),
8484- ], $statusCode);
8585- } else {
8686- return ext_view('users.verify', compact('email'), null, $statusCode);
8787- }
8888- }
8989-9090- public function isDone()
9191- {
9292- return $this->state->isDone();
9393- }
9494-9595- public function issue()
9696- {
9797- $user = $this->user;
9898-9999- if (!is_valid_email_format($user->user_email)) {
100100- return;
101101- }
102102-103103- $keys = $this->state->issue();
104104-105105- LoginAttempt::logAttempt($this->request->getClientIp(), $this->user, 'verify');
106106-107107- $requestCountry = Country
108108- ::where('acronym', request_country($this->request))
109109- ->pluck('name')
110110- ->first();
111111-112112- Mail::to($user)
113113- ->queue(new UserVerificationMail(
114114- compact('keys', 'user', 'requestCountry')
115115- ));
116116- }
117117-118118- public function markVerified()
119119- {
120120- $this->state->markVerified();
121121- }
122122-123123- public function markVerifiedAndRespond()
124124- {
125125- $this->markVerified();
126126-127127- return response([], 200);
128128- }
129129-130130- public function reissue()
131131- {
132132- if ($this->state->isDone()) {
133133- return $this->markVerifiedAndRespond();
134134- }
135135-136136- $this->issue();
137137-138138- return response(['message' => osu_trans('user_verification.errors.reissued')], 200);
139139- }
140140-141141- public function verify()
142142- {
143143- $key = str_replace(' ', '', $this->request->input('verification_key'));
144144-145145- try {
146146- $this->state->verify($key);
147147- } catch (UserVerificationException $e) {
148148- static::logAttempt('input', 'fail', $e->reasonKey());
149149-150150- if ($e->reasonKey() === 'incorrect_key') {
151151- LoginAttempt::logAttempt($this->request->getClientIp(), $this->user, 'verify-mismatch', $key);
152152- }
153153-154154- if ($e->shouldReissue()) {
155155- $this->issue();
156156- }
157157-158158- return error_popup($e->getMessage());
159159- }
160160-161161- static::logAttempt('input', 'success');
162162-163163- return $this->markVerifiedAndRespond();
164164- }
165165-}
-143
app/Libraries/UserVerificationState.php
···11-<?php
22-33-// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44-// See the LICENCE file in the repository root for full licence text.
55-66-namespace App\Libraries;
77-88-use App\Events\UserSessionEvent;
99-use App\Exceptions\UserVerificationException;
1010-use App\Libraries\Session\Store as SessionStore;
1111-use App\Models\User;
1212-1313-class UserVerificationState
1414-{
1515- private function __construct(private User $user, private SessionStore $session)
1616- {
1717- }
1818-1919- public static function fromCurrentRequest()
2020- {
2121- return new static(\Auth::user(), \Session::instance());
2222- }
2323-2424- public static function fromVerifyLink($linkKey)
2525- {
2626- if (!SignedRandomString::isValid($linkKey)) {
2727- return null;
2828- }
2929-3030- $params = cache()->get("verification:{$linkKey}");
3131-3232- if ($params !== null) {
3333- $state = static::load($params);
3434-3535- // As it's from verify link, make sure the state is waiting for verification.
3636- if ($state->issued()) {
3737- return $state;
3838- }
3939- }
4040- }
4141-4242- public static function load($params)
4343- {
4444- return new static(
4545- User::find($params['userId']),
4646- SessionStore::findOrNew($params['sessionId']),
4747- );
4848- }
4949-5050- public function dump()
5151- {
5252- return [
5353- 'userId' => $this->user->getKey(),
5454- 'sessionId' => $this->session->getId(),
5555- ];
5656- }
5757-5858- public function issue()
5959- {
6060- $previousLinkKey = $this->session->get('verification_link_key');
6161-6262- if (present($previousLinkKey)) {
6363- cache()->forget("verification:{$previousLinkKey}");
6464- }
6565-6666- // 1 byte = 2^8 bits = 16^2 bits = 2 hex characters
6767- $key = bin2hex(random_bytes(config('osu.user.verification_key_length_hex') / 2));
6868- $linkKey = SignedRandomString::create(32);
6969- $expires = now()->addHours(5);
7070-7171- $this->session->put('verification_key', $key);
7272- $this->session->put('verification_link_key', $linkKey);
7373- $this->session->put('verification_expire_date', $expires);
7474- $this->session->put('verification_tries', 0);
7575- $this->session->save();
7676-7777- cache()->put("verification:{$linkKey}", $this->dump(), $expires);
7878-7979- return [
8080- 'link' => $linkKey,
8181- 'main' => $key,
8282- ];
8383- }
8484-8585- public function issued()
8686- {
8787- return present($this->session->get('verification_key'));
8888- }
8989-9090- public function isDone()
9191- {
9292- if ($this->user === null) {
9393- return true;
9494- }
9595-9696- if ($this->session->get('verified')) {
9797- return true;
9898- }
9999-100100- return false;
101101- }
102102-103103- public function markVerified()
104104- {
105105- $this->session->forget('verification_expire_date');
106106- $this->session->forget('verification_tries');
107107- $this->session->forget('verification_key');
108108- $this->session->put('verified', true);
109109- $this->session->save();
110110-111111- UserSessionEvent::newVerified($this->user->getKey(), $this->session->getKeyForEvent())->broadcast();
112112- }
113113-114114- public function verify($inputKey)
115115- {
116116- if ($this->isDone()) {
117117- return;
118118- }
119119-120120- $expireDate = $this->session->get('verification_expire_date');
121121- $tries = $this->session->get('verification_tries');
122122- $key = $this->session->get('verification_key');
123123-124124- if (!present($expireDate) || !present($tries) || !present($key)) {
125125- throw new UserVerificationException('expired', true);
126126- }
127127-128128- if ($expireDate->isPast()) {
129129- throw new UserVerificationException('expired', true);
130130- }
131131-132132- if ($tries > config('osu.user.verification_key_tries_limit')) {
133133- throw new UserVerificationException('retries_exceeded', true);
134134- }
135135-136136- if (!hash_equals($key, $inputKey)) {
137137- $this->session->put('verification_tries', $tries + 1);
138138- $this->session->save();
139139-140140- throw new UserVerificationException('incorrect_key', false);
141141- }
142142- }
143143-}