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\User;
9
10use App\Mail\PasswordReset;
11use App\Models\User;
12
13class PasswordResetData
14{
15 private const RESEND_MAIL_INTERVAL = 300;
16
17 private string $cacheKey;
18
19 private function __construct(
20 public array $attrs,
21 private readonly User $user,
22 string $username
23 ) {
24 $this->cacheKey = static::cacheKey($this->user, $username);
25 }
26
27 public static function create(?User $user, ?string $username): ?string
28 {
29 if ($user === null || $username === null) {
30 return osu_trans('password_reset.error.user_not_found');
31 }
32
33 if (static::find($user, $username) !== null) {
34 return null;
35 }
36
37 if (!is_valid_email_format($user->user_email)) {
38 return osu_trans('password_reset.error.contact_support');
39 }
40
41 if ($user->isPrivileged() && $user->user_password !== '') {
42 return osu_trans('password_reset.error.is_privileged');
43 }
44
45 $now = time();
46 $data = new static([
47 'authHash' => static::authHash($user),
48 'canResendMailAfter' => $now - 1,
49 'key' => bin2hex(random_bytes($GLOBALS['cfg']['osu']['user']['password_reset']['key_length'] / 2)),
50 'expiresAt' => $now + $GLOBALS['cfg']['osu']['user']['password_reset']['expires_hour'] * 3600,
51 'tries' => 0,
52 ], $user, $username);
53 $data->sendMail();
54 $data->save();
55
56 return null;
57 }
58
59 public static function find(User $user, string $username): ?static
60 {
61 if ($user === null) {
62 return null;
63 }
64
65 $attrs = \Cache::get(static::cacheKey($user, $username));
66
67 if ($attrs === null) {
68 return null;
69 }
70
71 return new static($attrs, $user, $username);
72 }
73
74 public static function cacheKey(User $user, string $username): string
75 {
76 $type = strpos($username, '@') === false
77 ? 'username'
78 : 'email';
79
80 return "password_reset:data:{$user->getKey()}:{$type}";
81 }
82
83 private static function authHash(User $user): string
84 {
85 return hash('sha256', $user->user_email ?? '').':'.hash('sha256', $user->user_password);
86 }
87
88 public function delete(): void
89 {
90 \Cache::delete($this->cacheKey);
91 }
92
93 public function hasMoreTries(): bool
94 {
95 return $this->attrs['tries'] < $GLOBALS['cfg']['osu']['user']['password_reset']['tries'];
96 }
97
98 public function isActive(): bool
99 {
100 return $this->attrs['expiresAt'] > time()
101 && hash_equals($this->attrs['authHash'], static::authHash($this->user));
102 }
103
104 public function isValidKey(string $key): bool
105 {
106 $isValid = hash_equals($this->attrs['key'], $key);
107
108 if (!$isValid) {
109 $this->attrs['tries']++;
110 }
111
112 return $isValid;
113 }
114
115 public function save(): void
116 {
117 \Cache::put($this->cacheKey, $this->attrs, $this->attrs['expiresAt'] - time());
118 }
119
120 public function sendMail(): bool
121 {
122 $now = time();
123
124 if ($now < $this->attrs['canResendMailAfter']) {
125 return false;
126 }
127
128 \Mail::to($this->user)->send(new PasswordReset([
129 'user' => $this->user,
130 'key' => $this->attrs['key'],
131 ]));
132 $this->attrs['canResendMailAfter'] = $now + static::RESEND_MAIL_INTERVAL;
133
134 return true;
135 }
136}