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
6declare(strict_types=1);
7
8namespace App\Libraries\SessionVerification;
9
10use App\Exceptions\UserVerificationException;
11use App\Interfaces\SessionVerificationInterface;
12use App\Libraries\SignedRandomString;
13use Carbon\CarbonImmutable;
14
15class State
16{
17 private const KEY_VALID_DURATION = 600;
18
19 public readonly CarbonImmutable $expiresAt;
20 public readonly string $key;
21 public readonly string $linkKey;
22 public int $tries = 0;
23
24 private function __construct(
25 private readonly string $sessionClass,
26 private readonly string $sessionId,
27 ) {
28 // 1 byte = 8 bits = 2^8 = 16^2 = 2 hex characters
29 $this->key = bin2hex(random_bytes($GLOBALS['cfg']['osu']['user']['verification_key_length_hex'] / 2));
30 $this->linkKey = SignedRandomString::create(32);
31 $this->expiresAt = CarbonImmutable::now()->addSeconds(static::KEY_VALID_DURATION);
32 }
33
34 public static function create(SessionVerificationInterface $session): static
35 {
36 $state = new static($session::class, $session->getKey());
37 $state->save(true);
38
39 return $state;
40 }
41
42 public static function fromSession(SessionVerificationInterface $session): ?static
43 {
44 return \Cache::get(static::cacheKey($session::class, $session->getKey()));
45 }
46
47 public static function fromVerifyLink(string $linkKey): ?static
48 {
49 if (!SignedRandomString::isValid($linkKey)) {
50 return null;
51 }
52
53 $cacheKey = \Cache::get(static::cacheLinkKey($linkKey));
54
55 return $cacheKey === null ? null : \Cache::get($cacheKey);
56 }
57
58 private static function cacheKey(string $class, string $id): string
59 {
60 return "session_verification:{$class}:{$id}";
61 }
62
63 private static function cacheLinkKey(string $linkKey): string
64 {
65 return "session_verification_link:{$linkKey}";
66 }
67
68 public function delete(): void
69 {
70 \Cache::delete(static::cacheKey($this->sessionClass, $this->sessionId));
71 \Cache::delete(static::cacheLinkKey($this->linkKey));
72 }
73
74 public function findSession(): ?SessionVerificationInterface
75 {
76 return $this->sessionClass::findForVerification($this->sessionId);
77 }
78
79 public function verify(string $inputKey): void
80 {
81 $this->tries++;
82
83 if ($this->expiresAt->isPast()) {
84 throw new UserVerificationException('expired', true);
85 }
86
87 if (!hash_equals($this->key, $inputKey)) {
88 if ($this->tries >= $GLOBALS['cfg']['osu']['user']['verification_key_tries_limit']) {
89 throw new UserVerificationException('retries_exceeded', true);
90 } else {
91 $this->save(false);
92 throw new UserVerificationException('incorrect_key', false);
93 }
94 }
95 }
96
97 private function save(bool $saveLinkKey): void
98 {
99 $cacheKey = static::cacheKey($this->sessionClass, $this->sessionId);
100 \Cache::put($cacheKey, $this, $this->expiresAt);
101 if ($saveLinkKey) {
102 \Cache::put(static::cacheLinkKey($this->linkKey), $cacheKey, $this->expiresAt);
103 }
104 }
105}