master
ブランチ master
LittleBeast / src / Std / Lib / Auth.php
62.8 KB、1363行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, and/or sell copies of the Software, subject to the following conditions:
10
11 1. **Origin Attribution**:
12 - You must not misrepresent the origin of the Software; you must not claim
13 you created the original Software.
14 - If the Software is used in a product, you must either:
15 a. Provide clear attribution in the product's documentation, user interface,
16 or other visible areas, **OR**
17 b. Pay the original developers a fee they specify in writing.
18 2. **Usage Restriction**:
19 - The Software, or any derivative works, dependencies, or libraries
20 incorporating it, must not be used for censorship or to suppress freedom of
21 speech, expression, or creativity. Prohibited uses include, but are not
22 limited to:
23 - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24 ideas, or objective reality.
25 - Tools or systems designed to restrict access to information or
26 artistic works.
27 3. **Notice Preservation**:
28 - This license and the above copyright notice must remain intact in all copies
29 of the source code.
30
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Lib;
39
40use Gender;
41use LogType;
42use Result;
43use Roles;
44
45class Auth {
46 private int $id;
47
48 private \stdClass $user;
49 private \stdClass $pubUser;
50 private ?string $token;
51
52 protected string $dataDir = ROOT."/data/user/";
53 /** アイコンの保存先(空ならASS_DOMAINのuser/) */
54 protected string $assDir = '';
55 protected int $minPassLen = 20;
56 protected int $tokenDuration = 31536000; // 1年間
57 protected int $verifyDuration = 86400; // 確認メールのリンクの有効期限(24時間)
58 protected int $verifyResendInterval = 600; // 確認メールの再送の間隔(10分)
59 protected int $twoFactorDuration = 300; // ログインの2段階目(コードの入力)の有効期限(5分)
60 protected int $twoFactorSetupDuration = 3600; // 二段階認証の設定中の秘密鍵の有効期限(1時間)
61 /** アップロードをis_uploaded_fileで確認する(テストではfalse) */
62 protected bool $checkUploads = true;
63 protected string|int|null $algo = PASSWORD_ARGON2ID;
64
65 public function __construct(?string $username = null) {
66 if ($this->assDir === '') $this->assDir = ROOT.ass_path('user').'/';
67 if (!AUTH_ENABLED) return;
68 $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
69 $this->token = \getcookie('kerozen');
70 $this->id = $this->getUserId($username, $this->token);
71 if ($this->id > 0) $this->user = $this->getUserData();
72 }
73
74 public function getLoggedInUser(): \stdClass|null {
75 if ($this->id === 0) return null;
76 $this->pubUser = $this->user;
77 unset($this->pubUser->password);
78 unset($this->pubUser->tokens);
79 unset($this->pubUser->verifyToken);
80 unset($this->pubUser->twoFactor, $this->pubUser->twoFactorPending, $this->pubUser->twoFactorTicket);
81 $this->pubUser->avatarUrl = avatar_url($this->pubUser);
82 return $this->pubUser;
83 }
84
85 public function getUser(string $name): \stdClass|null {
86 $user = $this->getUserDataFromName($name);
87 if (!$user) return null;
88 unset($user->password);
89 unset($user->tokens);
90 unset($user->verifyToken, $user->verifyExpire, $user->verifySent);
91 unset($user->twoFactor, $user->twoFactorPending, $user->twoFactorTicket);
92 $myself = $this->getUserData();
93 // 以前は「role < STAFF」で比べていたので、ビットを足すと(Fediverse等)全員にメールアドレスが見えた
94 $isStaff = $myself && ($myself->role & (Roles::STAFF | Roles::ADMIN));
95 if (!$myself || ($myself->id != $user->id && !$isStaff)) $user->email = '(秘密)';
96 $user->canEdit = $myself && ($myself->id == $user->id || ($myself->role & Roles::ADMIN));
97 $user->fediverse = can_federate($user) && defined('FEDIINFO')
98 ? '@'.$user->username.'@'.parse_url(\Std\Lib\Fedi\Fedi::base(), PHP_URL_HOST) : '';
99 $user->bioHtml = nl2br(htmlspecialchars((string)($user->bio ?? ''), ENT_QUOTES, 'UTF-8'), false);
100 $user->avatar = avatar_url($user);
101 $user->name = namecolor($user);
102 $user->regDate = date('Y年m月d日', $user->regDate);
103 $user->gender = $user->gender === Gender::MALE ? '男' : ($user->gender === Gender::FEMALE ? '女' : '不明');
104
105 switch (true) {
106 // 以前は「role & BANNED」(BANNEDは0なので常に偽)だった
107 case ($user->role === Roles::BANNED):
108 $user->role = 'BANされた';
109 break;
110 case ($user->role & Roles::ADMIN):
111 $user->role = '管理者';
112 break;
113 case ($user->role & Roles::STAFF):
114 $user->role = '076スタジオの会社員';
115 break;
116 case ($user->role & (Roles::FSUBSCRIBER | Roles::PSUBSCRIBER | Roles::NSUBSCRIBER | Roles::SUBSCRIBER)):
117 $user->role = '支援者♡';
118 break;
119 default:
120 $user->role = 'ユーザー';
121 break;
122 }
123
124 $user->altName = $user->displayname ?: $user->username;
125 unset($user->username);
126 unset($user->displayname);
127 unset($user->namecolor);
128 return $user;
129 }
130
131 public function setToken(string $username, #[SensitiveParameter] string $password): Result {
132 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
133 $userData = $this->getUserData();
134 if ($userData === null) return Result::Error('エラー:ユーザー名又はパスワードが一致していません。');
135 if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = [];
136
137 $verify = $this->verifyLogin($username, $password);
138 if (!$verify->isSuccess) {
139 return Result::Error($verify->message);
140 }
141
142 // メールアドレスが未確認のアカウントはログイン出来ない(確認機能の前に作ったアカウントにはverifiedが無いので、確認済みとする)
143 if (($userData->verified ?? true) === false) {
144 $resent = $this->resendVerification($userData);
145 return Result::Error('エラー:メールアドレスが未確認です。届いたメールのリンクを開いて下さい。'
146 .($resent ? '確認メールを再送しました。' : ''), ['unverified' => true]);
147 }
148
149 // 二段階認証:パスワードが合っても、認証アプリのコードを確かめるまでログインしない
150 if ($this->twoFactorAvailable() && !empty($userData->twoFactor->secret)) {
151 $ticket = BaseN::encode64(random_bytes(32), true, false);
152 $userData->twoFactorTicket = (object)['hash' => hash('sha256', $ticket), 'expire' => time() + $this->twoFactorDuration, 'tries' => 0];
153 if (!$this->saveUser($userData, 'setToken')) return Result::Error('エラー:ユーザーデータの保存に失敗。');
154 return Result::Error('認証アプリのコードを入力して下さい。', ['twoFactor' => true, 'ticket' => $ticket]);
155 }
156
157 return $this->startSession($userData);
158 }
159
160 /**
161 * ログインのトークンを作り、クッキーに入れる
162 */
163 private function startSession(\stdClass $userData): Result {
164 $ip = $_SERVER['REMOTE_ADDR'] ?? '';
165 $res = $this->purgeOldTokens($userData);
166 if (!$res->isSuccess) return Result::Error($res->message);
167 $userData = $res->data['user'];
168 $token = bin2hex(random_bytes(64));
169 $expire = time() + $this->tokenDuration;
170 $newToken = [
171 'token' => hash('sha256', $token),
172 'ip' => $ip,
173 'createDate' => time(),
174 'expDate' => $expire,
175 'lastDate' => time(),
176 'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '不明'
177 ];
178
179 $userData->tokens[] = $newToken;
180
181 $path = $this->dataDir.$this->id.'.'.$userData->username.'.json';
182 $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
183
184 if (file_put_contents($path, $json) === false) {
185 logger(LogType::Auth, '【setToken】JSONファイルにユーザーデータを保存出来なかった。パス:'.$path);
186 return Result::Error('エラー:ユーザーデータの保存に失敗。');
187 }
188
189 if (!setcookie('kerozen', $token, [
190 'expires' => $expire,
191 'path' => '/',
192 'domain' => $_SERVER['HTTP_HOST'] ?? '',
193 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
194 'httponly' => true,
195 'samesite' => 'Strict'
196 ])) {
197 logger(LogType::Auth, '【setToken】クッキーを設定出来なかった。');
198 return Result::Error('エラー:クッキーを設定に失敗。');
199 }
200
201 return Result::Success('ログイン成功');
202 }
203
204 public function getToken(): ?string {
205 if (!AUTH_ENABLED) return '';
206 $userData = $this->getUserData();
207 if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = [];
208 $res = $this->purgeOldTokens($userData);
209 if (!$res->isSuccess) return null;
210 $userData = $res->data['user'];
211 }
212
213 public function logout(?string $token = null): Result {
214 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
215 $userData = $this->getUserData();
216 $token = $token ?? $this->token;
217
218 if ($userData instanceof \stdClass && $token) {
219 $hashed = hash('sha256', $token);
220 $userData->tokens = array_values(array_filter($userData->tokens ?? [], function($t) use ($hashed): bool {
221 return isset($t->token) && !hash_equals((string)$t->token, $hashed) && ($t->expDate ?? 0) > time();
222 }));
223
224 $path = $this->dataDir.$this->id.'.'.$userData->username.'.json';
225 $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
226 if (file_put_contents($path, $json) === false) {
227 logger(\LogType::Auth, '【logout】JSONファイルを変更出来なかった。chownをご確認下さい。パス:'.$path);
228 }
229 }
230
231 setcookie('kerozen', '', [
232 'expires' => 0,
233 'path' => '/',
234 'domain' => $_SERVER['HTTP_HOST'] ?? '',
235 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
236 'httponly' => true,
237 'samesite' => 'Strict'
238 ]);
239
240 return Result::Success();
241 }
242
243 /**
244 * 二段階認証を使えるか(config.phpのAUTH_2FA_ENABLED。無効なら誰も使えず、ログインでもコードを聞かない)
245 */
246 public function twoFactorAvailable(): bool {
247 return AUTH_ENABLED && defined('AUTH_2FA_ENABLED') && AUTH_2FA_ENABLED;
248 }
249
250 /**
251 * ログインの2段階目:setTokenが返したチケットと、認証アプリのコードを確かめてログインする
252 *
253 * @param string $username ユーザー名(new Auth($username)と同じ)
254 * @param string $ticket setTokenのdataのticket
255 * @param string $code 6桁のコード
256 * @return Result 間違えてもまだ試せる場合は、dataにticket(5回間違えたら最初からやり直し)
257 */
258 public function verifyTwoFactor(string $username, #[SensitiveParameter] string $ticket, string $code): Result {
259 if (!$this->twoFactorAvailable()) return Result::Error('エラー:二段階認証は無効です。');
260 $userData = $this->id > 0 ? $this->getUserData() : null;
261 $t = $userData->twoFactorTicket ?? null;
262 if (!$userData || $userData->username !== $username || !$t instanceof \stdClass || ($t->expire ?? 0) < time()
263 || !hash_equals((string)($t->hash ?? ''), hash('sha256', $ticket))) {
264 return Result::Error('エラー:有効期限が切れました。もう一度ログインして下さい。');
265 }
266 $secret = (string)($userData->twoFactor->secret ?? '');
267 $step = $secret === '' ? null : Totp::verify($secret, $code, (int)($userData->twoFactor->lastStep ?? -1));
268 if ($step === null) {
269 $t->tries = (int)($t->tries ?? 0) + 1;
270 $retry = $t->tries < 5;
271 if (!$retry) unset($userData->twoFactorTicket);
272 $this->saveUser($userData, 'verifyTwoFactor');
273 return $retry
274 ? Result::Error('エラー:コードが一致していません。', ['ticket' => $ticket])
275 : Result::Error('エラー:コードを間違えた回数が多過ぎます。もう一度ログインして下さい。');
276 }
277 // 同じコードをもう一度使えない様に
278 $userData->twoFactor->lastStep = $step;
279 unset($userData->twoFactorTicket);
280 if (!$this->saveUser($userData, 'verifyTwoFactor')) return Result::Error('エラー:ユーザーデータの保存に失敗。');
281 return $this->startSession($userData);
282 }
283
284 /**
285 * 二段階認証の設定画面のデータ
286 *
287 * 本人がまだ有効にしていなければ秘密鍵を作り(有効にするか期限が切れるまで同じ物)、QRコード用のURIも返す。
288 *
289 * @return Result data:enabled、self(本人か)、username、secret・uri(本人で未設定の時だけ)
290 */
291 public function twoFactorStatus(string $username): Result {
292 [$res, $me, $user] = $this->twoFactorTarget($username);
293 if (!$res->isSuccess) return $res;
294 $data = ['enabled' => !empty($user->twoFactor->secret), 'self' => $me->id == $user->id, 'username' => $user->username];
295 if ($data['self'] && !$data['enabled']) {
296 $p = $user->twoFactorPending ?? null;
297 if (!$p instanceof \stdClass || ($p->expire ?? 0) < time()) {
298 $p = (object)['secret' => Totp::secret(), 'expire' => time() + $this->twoFactorSetupDuration];
299 $user->twoFactorPending = $p;
300 if (!$this->saveUser($user, 'twoFactorStatus')) return Result::Error('エラー:ユーザーデータの保存に失敗。');
301 }
302 $data['secret'] = $p->secret;
303 $data['uri'] = Totp::uri($p->secret, $user->username, (string)SITEINFO['title']);
304 }
305 return Result::Success('', $data);
306 }
307
308 /**
309 * 設定画面の秘密鍵で作ったコードを確かめて、二段階認証を有効にする(本人だけ)
310 */
311 public function enableTwoFactor(string $username, string $code): Result {
312 [$res, $me, $user] = $this->twoFactorTarget($username);
313 if (!$res->isSuccess) return $res;
314 if ($me->id != $user->id) return Result::Error('エラー:二段階認証は本人だけが有効に出来ます。');
315 if (!empty($user->twoFactor->secret)) return Result::Error('エラー:二段階認証は既に有効です。');
316 $p = $user->twoFactorPending ?? null;
317 if (!$p instanceof \stdClass || ($p->expire ?? 0) < time()) return Result::Error('エラー:有効期限が切れました。新しいQRコードを読み取って下さい。');
318 $step = Totp::verify((string)$p->secret, $code);
319 if ($step === null) return Result::Error('エラー:コードが一致していません。端末の時計が合っているかもご確認下さい。');
320 $user->twoFactor = (object)['secret' => $p->secret, 'lastStep' => $step, 'since' => time()];
321 unset($user->twoFactorPending);
322 if (!$this->saveUser($user, 'enableTwoFactor')) return Result::Error('エラー:ユーザーデータの保存に失敗。');
323 return Result::Success('二段階認証を有効にしました。次回のログインから認証アプリのコードが必要です。');
324 }
325
326 /**
327 * 二段階認証を無効にする
328 *
329 * 本人はパスワードとコード、管理者が他のユーザーの分を無効にする場合(端末を失くした時等)は管理者のパスワード。
330 */
331 public function disableTwoFactor(string $username, #[SensitiveParameter] string $password, string $code = ''): Result {
332 [$res, $me, $user] = $this->twoFactorTarget($username);
333 if (!$res->isSuccess) return $res;
334 if (empty($user->twoFactor->secret)) return Result::Error('エラー:二段階認証は有効ではありません。');
335 if (!password_verify($password, (string)$me->password)) return Result::Error('エラー:パスワードが一致していません。');
336 if ($me->id == $user->id && Totp::verify((string)$user->twoFactor->secret, $code, (int)($user->twoFactor->lastStep ?? -1)) === null) {
337 return Result::Error('エラー:コードが一致していません。');
338 }
339 unset($user->twoFactor, $user->twoFactorTicket);
340 if (!$this->saveUser($user, 'disableTwoFactor')) return Result::Error('エラー:ユーザーデータの保存に失敗。');
341 return Result::Success('二段階認証を無効にしました。');
342 }
343
344 /**
345 * 二段階認証を設定する相手(自分、又は管理者なら他のユーザー)
346 *
347 * @return array [Result, 自分, ユーザー]
348 */
349 private function twoFactorTarget(string $username): array {
350 if (!$this->twoFactorAvailable()) return [Result::Error('エラー:二段階認証は無効です。'), null, null];
351 $me = $this->id > 0 ? $this->getUserData() : null;
352 $path = $this->userPath($username);
353 $user = $path !== null ? json_decode((string)file_get_contents($path)) : null;
354 if (!$me || !$user instanceof \stdClass) return [Result::Error('エラー:ユーザーが見つかりません。'), null, null];
355 if ($me->id != $user->id && !($me->role & Roles::ADMIN)) return [Result::Error('エラー:権限がありません。'), null, null];
356 return [Result::Success(), $me, $user];
357 }
358
359 /**
360 * ログイン中のユーザーの表示の設定(テーマ等)をプロフィールに保存する
361 *
362 * @param array $prefs 名前 => 値(nullなら消す)
363 * @return Result
364 */
365 public function savePrefs(array $prefs): Result {
366 if (!AUTH_ENABLED || $this->id === 0) return Result::Error('エラー:ログインしていません。');
367 $user = $this->getUserData();
368 if (!$user instanceof \stdClass) return Result::Error('エラー:ユーザーが見つかりません。');
369 $cur = (array)($user->prefs ?? []);
370 foreach ($prefs as $k => $v) {
371 if ($v === null) unset($cur[$k]);
372 else $cur[$k] = (string)$v;
373 }
374 $user->prefs = (object)$cur;
375 if (!$this->saveUser($user, 'savePrefs')) return Result::Error('エラー:ユーザーデータの保存に失敗。');
376 return Result::Success();
377 }
378
379 /** ユーザーのJSONファイルを書き換える */
380 private function saveUser(\stdClass $user, string $where): bool {
381 $path = $this->dataDir.$user->id.'.'.$user->username.'.json';
382 $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
383 if (file_put_contents($path, $json, LOCK_EX) !== false) return true;
384 logger(LogType::Auth, "【{$where}】JSONファイルを変更出来なかった。chownをご確認下さい。パス:{$path}");
385 return false;
386 }
387
388 /**
389 * ログインの試行回数を確認する(IP毎:15分で5回、ユーザー名毎:1時間で20回)
390 *
391 * @param string $username ユーザー名
392 * @return bool 制限中ならtrue
393 */
394 public function isLoginThrottled(string $username): bool {
395 $now = time();
396 $ip = $this->countFailures('ip-'.($_SERVER['REMOTE_ADDR'] ?? ''), $now - 900);
397 $usr = $this->countFailures('user-'.$username, $now - 3600);
398 return $ip >= 5 || $usr >= 20;
399 }
400
401 /**
402 * ログインの失敗を記録する
403 *
404 * @param string $username ユーザー名
405 * @return void
406 */
407 public function recordLoginFailure(string $username): void {
408 $this->addFailure('ip-'.($_SERVER['REMOTE_ADDR'] ?? ''), time() - 900);
409 $this->addFailure('user-'.$username, time() - 3600);
410 }
411
412 /**
413 * ログイン成功時に、そのIPの失敗記録を消す
414 *
415 * @return void
416 */
417 public function clearLoginFailures(): void {
418 $path = $this->throttlePath('ip-'.($_SERVER['REMOTE_ADDR'] ?? ''));
419 if (is_file($path)) unlink($path);
420 }
421
422 private function throttlePath(string $key): string {
423 $dir = ROOT.'/data/ratelimit/';
424 if (!is_dir($dir)) mkdir($dir, 0700, true);
425 return $dir.hash('sha256', $key).'.json';
426 }
427
428 private function countFailures(string $key, int $since): int {
429 $path = $this->throttlePath($key);
430 if (!is_file($path)) return 0;
431 $list = json_decode((string)file_get_contents($path), true);
432 if (!is_array($list)) return 0;
433 return count(array_filter($list, fn($t) => is_int($t) && $t > $since));
434 }
435
436 private function addFailure(string $key, int $since): void {
437 $fp = fopen($this->throttlePath($key), 'c+');
438 if (!$fp) return;
439 flock($fp, LOCK_EX);
440 $list = json_decode((string)stream_get_contents($fp), true);
441 $list = is_array($list) ? array_values(array_filter($list, fn($t) => is_int($t) && $t > $since)) : [];
442 $list[] = time();
443 ftruncate($fp, 0);
444 rewind($fp);
445 fwrite($fp, json_encode($list));
446 flock($fp, LOCK_UN);
447 fclose($fp);
448 }
449
450 public function isUserExist(?string $username = null): Result {
451 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
452 if (null === $username) return Result::Error('エラー:ユーザー名をご入力下さい。');
453 $userList = @scandir($this->dataDir) ?: [];
454
455 foreach ($userList as $list) {
456 if ($list === '.kara' || $list === '.' || $list === '..') continue;
457 $file = str_replace('.json', '', $list);
458 $user = explode('.', $file)[1];
459 if ($username === $user) return Result::Success("ユーザー「{$username}」は既に存在します。");
460 }
461
462 return Result::Error('');
463 }
464
465 public function mkUser(?string $username = null, #[SensitiveParameter] ?string $password = null, ?string $passwordVerify = null, ?string $email = null): Result {
466 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
467 if (!AUTH_REGISTER_ENABLED) return Result::Error('ユーザー登録は無効です。');
468 $resUsr = $this->verifyUsername($username);
469 $resPwd = $this->verifyPassword($password, $passwordVerify);
470 $resEml = $this->verifyEmail($email);
471
472 $err = '';
473
474 if (!$resUsr->isSuccess) $err .= $resUsr->message."<br />";
475 if (!$resPwd->isSuccess) $err .= $resPwd->message."<br />";
476 if (!$resEml->isSuccess) $err .= $resEml->message."<br />";
477
478 if ($err != '') return Result::Error($err);
479 $err = '';
480
481 $user = $this->newUser($username, $password, $email, Roles::MEMBER);
482
483 // メールアドレスの確認(リンクを開くまでログイン出来ない)
484 $verifyToken = null;
485 if ($this->requiresVerification()) {
486 $verifyToken = bin2hex(random_bytes(32));
487 $user->verified = false;
488 $user->verifyToken = hash('sha256', $verifyToken);
489 $user->verifyExpire = time() + $this->verifyDuration;
490 $user->verifySent = time();
491 }
492
493 $res = $this->storeNewUser($user, 'mkUser');
494 if (!$res->isSuccess) return $res;
495 $path = $res->data['path'];
496
497 if ($verifyToken !== null) {
498 $sent = $this->sendVerificationMail($user, $verifyToken);
499 if (!$sent->isSuccess) {
500 // メールが届かないアカウントを残さない(同じユーザー名でもう一度登録出来る様に)
501 unlink($path);
502 @rmdir($this->assDir.$username);
503 return Result::Error('エラー:確認メールの送信に失敗しました。暫くしてから再度お試し下さい。');
504 }
505 return Result::Success("確認メールを「{$email}」に送信しました。メールのリンクを開いて、登録を完了して下さい。", ['verify' => true]);
506 }
507
508 return Result::Success('', ['verify' => false]);
509 }
510
511 /**
512 * プロフィールを編集する
513 *
514 * 本人:表示名、性別、名前の色、自己紹介、アイコン、パスワード(現在のパスワードが必要)、メールアドレス(同じ)
515 * 管理者:他のユーザーの表示名等とロール(パスワードとメールアドレスは本人だけ)
516 *
517 * @param string $username 編集するユーザー
518 * @param array $in displayname, gender, namecolor(名前のインラインCSS、空なら性別の色), bio, removeAvatar,
519 * currentPassword, newPassword, newPasswordVerify, email, roles(管理者:ビットの配列)
520 * @param array|null $avatar $_FILES['avatar']
521 * @return Result
522 */
523 public function updateProfile(string $username, array $in, ?array $avatar = null): Result {
524 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
525 $me = $this->id > 0 ? $this->getUserData() : null;
526 $path = $this->userPath($username);
527 if (!$me || $path === null) return Result::Error('エラー:ユーザーが見つかりません。');
528 $user = json_decode((string)file_get_contents($path));
529 if (!$user instanceof \stdClass) return Result::Error('エラー:ユーザーが見つかりません。');
530 $self = $me->id == $user->id;
531 $admin = (bool)($me->role & Roles::ADMIN);
532 if (!$self && !$admin) return Result::Error('エラー:権限がありません。');
533
534 $errors = [];
535 $notices = [];
536
537 // 表示名
538 if (array_key_exists('displayname', $in)) {
539 $d = trim(preg_replace('/[\x00-\x1f\x7f]/u', '', (string)$in['displayname']));
540 if (mb_strlen($d) > 50) $errors[] = 'エラー:表示名は50文字までです。';
541 else $user->displayname = $d;
542 }
543 // 性別
544 if (array_key_exists('gender', $in)) {
545 $g = (int)$in['gender'];
546 $user->gender = in_array($g, [\Gender::UNKNOWN, \Gender::MALE, \Gender::FEMALE], true) ? $g : \Gender::UNKNOWN;
547 }
548 // 名前のスタイル(インラインCSS。namecolor()でエスケープしてstyle属性に入れる)
549 if (array_key_exists('namecolor', $in)) {
550 $c = trim(preg_replace('/[\x00-\x1f\x7f]/u', '', (string)$in['namecolor']));
551 if (mb_strlen($c) > 500) $errors[] = 'エラー:名前のスタイルは500文字までです。';
552 else $user->namecolor = $c;
553 }
554 // 自己紹介
555 if (array_key_exists('bio', $in)) {
556 $b = trim(str_replace(["\r\n", "\r"], "\n", (string)$in['bio']));
557 if (mb_strlen($b) > 500) $errors[] = 'エラー:自己紹介は500文字までです。';
558 else $user->bio = $b;
559 }
560
561 // アイコン
562 $oldAvatar = (string)($user->avatar ?? '');
563 $newAvatar = null;
564 if ($avatar && ($avatar['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
565 $res = \Std\Lib\Fedi\Media::save($avatar, 'user/'.$user->username, 'avatar-', $this->checkUploads);
566 if (!$res->isSuccess) $errors[] = 'エラー:'.$res->message;
567 else $newAvatar = $res->data['name'];
568 } else if (!empty($in['removeAvatar'])) {
569 $user->avatar = '';
570 }
571
572 // パスワード(本人だけ)
573 if ($self && (string)($in['newPassword'] ?? '') !== '') {
574 if (!password_verify((string)($in['currentPassword'] ?? ''), $user->password)) {
575 $errors[] = 'エラー:現在のパスワードが一致していません。';
576 } else {
577 $res = $this->verifyPassword((string)$in['newPassword'], (string)($in['newPasswordVerify'] ?? ''));
578 if (!$res->isSuccess) $errors[] = $res->message;
579 else {
580 $user->password = password_hash((string)$in['newPassword'], $this->algo);
581 // 他の端末のログインを取り消す(このログインは残す)
582 $current = $this->token ? hash('sha256', $this->token) : '';
583 $user->tokens = array_values(array_filter($user->tokens ?? [], fn($t) => hash_equals((string)($t->token ?? ''), $current)));
584 $notices[] = 'パスワードを変更しました。';
585 }
586 }
587 }
588
589 // メールアドレス(本人だけ)
590 $sendVerify = null;
591 $email = trim((string)($in['email'] ?? ''));
592 if ($self && $email !== '' && $email !== $user->email) {
593 if (!password_verify((string)($in['currentPassword'] ?? ''), $user->password)) {
594 $errors[] = 'エラー:メールアドレスを変更するには現在のパスワードを入力して下さい。';
595 } else {
596 $res = $this->verifyEmail($email);
597 if (!$res->isSuccess) $errors[] = $res->message;
598 else if ($this->requiresVerification()) {
599 // 確認するまでは前のメールアドレスのまま
600 $sendVerify = bin2hex(random_bytes(32));
601 $user->pendingEmail = $email;
602 $user->verifyToken = hash('sha256', $sendVerify);
603 $user->verifyExpire = time() + $this->verifyDuration;
604 $user->verifySent = time();
605 } else {
606 $user->email = $email;
607 $notices[] = 'メールアドレスを変更しました。';
608 }
609 }
610 }
611
612 // ロール(管理者だけ)
613 if ($admin && isset($in['roles']) && is_array($in['roles'])) {
614 $err = $this->applyRoles($user, $in['roles']);
615 if ($err !== null) $errors[] = $err;
616 }
617
618 if (!empty($errors)) {
619 if ($newAvatar !== null) \Std\Lib\Fedi\Media::remove(ass_path('user/'.$user->username.'/'.$newAvatar));
620 return Result::Error(implode('<br />', array_map(fn($e) => htmlspecialchars($e, ENT_QUOTES, 'UTF-8'), $errors)));
621 }
622
623 if ($newAvatar !== null) $user->avatar = $newAvatar;
624 $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
625 if (file_put_contents($path, $json, LOCK_EX) === false) {
626 logger(LogType::Auth, '【updateProfile】JSONファイルを変更出来なかった。パス:'.$path);
627 return Result::Error('エラー:ユーザーデータの保存に失敗。');
628 }
629 if ($oldAvatar !== '' && $oldAvatar !== ($user->avatar ?? '')) {
630 \Std\Lib\Fedi\Media::remove(ass_path('user/'.$user->username.'/'.$oldAvatar));
631 }
632
633 if ($sendVerify !== null) {
634 $mailUser = clone $user;
635 $mailUser->email = $email;
636 if ($this->sendVerificationMail($mailUser, $sendVerify)->isSuccess) {
637 $notices[] = "確認メールを「{$email}」に送信しました。リンクを開くとメールアドレスが変わります。";
638 } else {
639 $notices[] = '確認メールの送信に失敗しました。メールアドレスは変わっていません。';
640 }
641 }
642
643 return Result::Success(implode(' ', $notices) ?: 'プロフィールを保存しました。', ['user' => $user]);
644 }
645
646 /**
647 * プロフィールの編集画面用のデータ(パスワード等を除く)
648 *
649 * @param string $username ユーザー
650 * @return \stdClass|null 編集出来ない場合はnull
651 */
652 public function getEditableUser(string $username): ?\stdClass {
653 $me = $this->id > 0 ? $this->getUserData() : null;
654 $path = $this->userPath($username);
655 if (!$me || $path === null) return null;
656 $user = json_decode((string)file_get_contents($path));
657 if (!$user instanceof \stdClass) return null;
658 if ($me->id != $user->id && !($me->role & Roles::ADMIN)) return null;
659 $user->twoFactorOn = !empty($user->twoFactor->secret);
660 unset($user->password, $user->tokens, $user->verifyToken, $user->twoFactor, $user->twoFactorPending, $user->twoFactorTicket);
661 $user->isSelf = $me->id == $user->id;
662 $user->isAdmin = (bool)($me->role & Roles::ADMIN);
663 $user->fedi = can_federate($user);
664 $user->avatarUrl = avatar_url($user);
665 return $user;
666 }
667
668 /** ユーザー名からJSONファイルのパス */
669 private function userPath(string $username): ?string {
670 if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return null;
671 foreach ($this->userFiles() as $path) {
672 if (preg_match('/^\d+\.'.$username.'\.json$/', basename($path))) return $path;
673 }
674 return null;
675 }
676
677 /**
678 * ロールを設定する(管理者だけ)
679 *
680 * @param \stdClass $user ユーザーのデータ(変更する)
681 * @param array $roles ビットの配列(Roles::LABELSに無いビットは無視)
682 * @return string|null エラー
683 */
684 private function applyRoles(\stdClass $user, array $roles): ?string {
685 $role = Roles::BANNED;
686 foreach ($roles as $bit) {
687 $bit = (int)$bit;
688 if (isset(Roles::LABELS[$bit])) $role |= $bit;
689 }
690 // 最後の管理者は管理者を外せない
691 if (((int)($user->role ?? 0) & Roles::ADMIN) && !($role & Roles::ADMIN) && $this->countAdmins() <= 1) {
692 return 'エラー:最後の管理者のロールは外せません。';
693 }
694 $user->role = $role;
695 // Fediverseのロールの既定(全員に付与)を使わず、以後はビットで判断する
696 $user->fediDefault = true;
697 return null;
698 }
699
700 private function countAdmins(): int {
701 $n = 0;
702 foreach ($this->userFiles() as $path) {
703 $d = json_decode((string)file_get_contents($path));
704 if ($d instanceof \stdClass && (($d->role ?? 0) & Roles::ADMIN)) $n++;
705 }
706 return $n;
707 }
708
709 /**
710 * 登録時にメールアドレスの確認が必要か
711 *
712 * @return bool
713 */
714 public function requiresVerification(): bool {
715 return AUTH_VERIFY_EMAIL && MAILER_ENABLED;
716 }
717
718 /**
719 * 確認メールのリンクのトークンでメールアドレスを確認する
720 *
721 * @param string $token リンクのトークン
722 * @return Result data:username
723 */
724 public function verifyEmailToken(string $token): Result {
725 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
726 $invalid = 'エラー:リンクが無効か、有効期限が切れています。ログインすると確認メールを再送します。';
727 if (!preg_match('/^[0-9a-f]{64}$/', $token)) return Result::Error($invalid);
728 $hashed = hash('sha256', $token);
729
730 foreach ($this->userFiles() as $path) {
731 $data = json_decode((string)file_get_contents($path));
732 if (!$data instanceof \stdClass || !isset($data->verifyToken)) continue;
733 if (!hash_equals((string)$data->verifyToken, $hashed)) continue;
734 if (($data->verifyExpire ?? 0) < time()) return Result::Error($invalid);
735
736 $data->verified = true;
737 // メールアドレスの変更の確認
738 if (isset($data->pendingEmail)) {
739 $data->email = $data->pendingEmail;
740 unset($data->pendingEmail);
741 }
742 unset($data->verifyToken, $data->verifyExpire, $data->verifySent);
743 $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
744 if (file_put_contents($path, $json, LOCK_EX) === false) {
745 logger(LogType::Auth, '【verifyEmailToken】JSONファイルを変更出来なかった。パス:'.$path);
746 return Result::Error('エラー:ユーザーデータの保存に失敗。');
747 }
748 return Result::Success('メールアドレスを確認しました。ログインして下さい。', ['username' => $data->username]);
749 }
750
751 return Result::Error($invalid);
752 }
753
754 /**
755 * メールアドレスが確認されていない古いアカウントを削除する(cronのタスク用)
756 * 削除すると、同じユーザー名とメールアドレスでもう一度登録出来る
757 *
758 * @param int $maxAge 登録からこの秒数が経ったら削除する(既定は7日)
759 * @return Result data:deleted(削除したユーザー名)
760 */
761 public function purgeUnverified(int $maxAge = 604800): Result {
762 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
763 $deleted = [];
764
765 foreach ($this->userFiles() as $path) {
766 $data = json_decode((string)file_get_contents($path));
767 if (!$data instanceof \stdClass || ($data->verified ?? true) !== false) continue;
768 if (($data->regDate ?? time()) + $maxAge > time()) continue;
769
770 if (!unlink($path)) {
771 logger(LogType::Auth, '【purgeUnverified】削除に失敗。パス:'.$path);
772 continue;
773 }
774 $dir = $this->assDir.$data->username;
775 if (is_dir($dir)) {
776 foreach (glob($dir.'/*') ?: [] as $f) @unlink($f);
777 @rmdir($dir);
778 }
779 $deleted[] = $data->username;
780 }
781
782 return Result::Success('未確認のアカウント'.count($deleted).'件を削除しました。', ['deleted' => $deleted]);
783 }
784
785 /**
786 * パスワードだけを確かめる(Gitのプッシュ等、ログインはしない)
787 *
788 * 二段階認証が有効なユーザー、未確認・BANのユーザーはnull(Gitler等ではアクセストークンを使う)。
789 *
790 * @return \stdClass|null ユーザー(パスワード等を除く)
791 */
792 public function checkPassword(string $username, #[SensitiveParameter] string $password): ?\stdClass {
793 if (!AUTH_ENABLED || $password === '' || $this->isLoginThrottled($username)) return null;
794 $path = $this->userPath($username);
795 $user = $path !== null ? json_decode((string)file_get_contents($path)) : null;
796 if (!$user instanceof \stdClass || !password_verify($password, (string)($user->password ?? ''))) {
797 $this->recordLoginFailure($username);
798 return null;
799 }
800 if (($user->verified ?? true) === false || (int)($user->role ?? 0) === Roles::BANNED) return null;
801 if ($this->twoFactorAvailable() && !empty($user->twoFactor->secret)) return null;
802 unset($user->password, $user->tokens, $user->verifyToken, $user->twoFactor, $user->twoFactorPending, $user->twoFactorTicket);
803 return $user;
804 }
805
806 //////////////////// 管理画面
807
808 /**
809 * ログイン中のユーザーが管理者か
810 */
811 public function isAdmin(): bool {
812 $me = $this->id > 0 ? $this->getUserData() : null;
813 return $me instanceof \stdClass && ((int)($me->role ?? 0) & Roles::ADMIN);
814 }
815
816 /**
817 * 管理者のアカウントが有るか(無ければ管理画面で最初の管理者を作れる)
818 */
819 public function hasAdmin(): bool {
820 return $this->countAdmins() > 0;
821 }
822
823 /**
824 * 全部のユーザー(管理者だけ)。パスワード・トークン等は含まない
825 *
826 * @return array stdClass(id、username、displayname、email、pendingEmail、role、roleLabels、regDate、verified、
827 * twoFactorOn、sessions(有効なログインの数)、lastLogin、avatarUrl)、IDの順
828 */
829 public function listUsers(): array {
830 if (!AUTH_ENABLED || !$this->isAdmin()) return [];
831 $out = [];
832 foreach ($this->userFiles() as $path) {
833 $u = json_decode((string)file_get_contents($path));
834 if (!$u instanceof \stdClass || !isset($u->id, $u->username)) continue;
835 $out[] = $this->accountSummary($u);
836 }
837 usort($out, fn($a, $b) => $a->id <=> $b->id);
838 return $out;
839 }
840
841 /**
842 * ユーザー1人(管理者だけ、listUsers()と同じ形)
843 */
844 public function getAccount(string $username): ?\stdClass {
845 if (!AUTH_ENABLED || !$this->isAdmin()) return null;
846 $path = $this->userPath($username);
847 $u = $path !== null ? json_decode((string)file_get_contents($path)) : null;
848 return $u instanceof \stdClass ? $this->accountSummary($u) : null;
849 }
850
851 private function accountSummary(\stdClass $u): \stdClass {
852 $now = time();
853 $tokens = array_values(array_filter((array)($u->tokens ?? []), fn($t) => is_object($t) && ($t->expDate ?? 0) > $now));
854 $last = 0;
855 foreach ($tokens as $t) $last = max($last, (int)($t->lastDate ?? $t->createDate ?? 0));
856 $role = (int)($u->role ?? 0);
857 $labels = [];
858 foreach (Roles::LABELS as $bit => $label) if ($role & $bit) $labels[] = $label;
859 return (object)[
860 'id' => (int)$u->id,
861 'username' => (string)$u->username,
862 'displayname' => (string)($u->displayname ?? ''),
863 'email' => (string)($u->email ?? ''),
864 'pendingEmail' => (string)($u->pendingEmail ?? ''),
865 'role' => $role,
866 'roleLabels' => $role === Roles::BANNED ? ['BAN'] : $labels,
867 'regDate' => (int)($u->regDate ?? 0),
868 'verified' => ($u->verified ?? true) !== false,
869 'twoFactorOn' => !empty($u->twoFactor->secret),
870 'sessions' => count($tokens),
871 'lastLogin' => $last ?: null,
872 'avatarUrl' => avatar_url($u),
873 ];
874 }
875
876 /**
877 * 管理者がアカウントを変更する(プロフィールの欄はupdateProfile())
878 *
879 * メールアドレス・パスワード・二段階認証の変更には管理者自身のパスワードが必要。
880 *
881 * @param string $username 変更するユーザー
882 * @param array $in email、password・passwordVerify(新しいパスワード、全部のログインを取り消す)、verify(メールアドレスを確認済みにする)、
883 * disableTwoFactor、logoutAll(全部のログインを取り消す)、roles(ビットの配列)
884 * @param string $adminPassword 管理者のパスワード
885 * @return Result
886 */
887 public function adminUpdateAccount(string $username, array $in, #[SensitiveParameter] string $adminPassword = ''): Result {
888 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
889 $me = $this->id > 0 ? $this->getUserData() : null;
890 if (!$me || !((int)($me->role ?? 0) & Roles::ADMIN)) return Result::Error('エラー:権限がありません。');
891 $path = $this->userPath($username);
892 $user = $path !== null ? json_decode((string)file_get_contents($path)) : null;
893 if (!$user instanceof \stdClass) return Result::Error('エラー:ユーザーが見つかりません。');
894
895 $email = trim((string)($in['email'] ?? ''));
896 $changeEmail = $email !== '' && $email !== (string)($user->email ?? '');
897 $password = (string)($in['password'] ?? '');
898 $disable2fa = !empty($in['disableTwoFactor']) && !empty($user->twoFactor->secret);
899 if (($changeEmail || $password !== '' || $disable2fa) && !password_verify($adminPassword, (string)$me->password)) {
900 return Result::Error('エラー:管理者のパスワードが一致していません。');
901 }
902
903 $errors = [];
904 $done = [];
905 if ($changeEmail) {
906 $res = $this->verifyEmail($email, false);
907 if (!$res->isSuccess) $errors[] = $res->message;
908 else {
909 $user->email = $email;
910 unset($user->pendingEmail);
911 $done[] = 'メールアドレスを変更しました。';
912 }
913 }
914 if ($password !== '') {
915 $res = $this->verifyPassword($password, (string)($in['passwordVerify'] ?? ''));
916 if (!$res->isSuccess) $errors[] = $res->message;
917 else {
918 $user->password = password_hash($password, $this->algo);
919 $user->tokens = [];
920 unset($user->twoFactorTicket);
921 $done[] = 'パスワードを変更し、全部のログインを取り消しました。';
922 }
923 }
924 if (!empty($in['verify']) && (($user->verified ?? true) === false || isset($user->pendingEmail))) {
925 $user->verified = true;
926 if (isset($user->pendingEmail) && !$changeEmail) $user->email = $user->pendingEmail;
927 unset($user->pendingEmail, $user->verifyToken, $user->verifyExpire, $user->verifySent);
928 $done[] = 'メールアドレスを確認済みにしました。';
929 }
930 if ($disable2fa) {
931 unset($user->twoFactor, $user->twoFactorPending, $user->twoFactorTicket);
932 $done[] = '二段階認証を無効にしました。';
933 }
934 if (!empty($in['logoutAll']) && !empty($user->tokens)) {
935 $user->tokens = [];
936 $done[] = '全部のログインを取り消しました。';
937 }
938 if (isset($in['roles']) && is_array($in['roles'])) {
939 $old = (int)($user->role ?? 0);
940 $err = $this->applyRoles($user, $in['roles']);
941 if ($err !== null) $errors[] = $err;
942 else if ($old !== $user->role) $done[] = 'ロールを変更しました。';
943 }
944
945 if (!empty($errors)) return Result::Error(implode(' ', $errors));
946 if (empty($done)) return Result::Success('変更はありません。');
947 if (!$this->saveUser($user, 'adminUpdateAccount')) return Result::Error('エラー:ユーザーデータの保存に失敗。');
948 logger(LogType::Auth, '【adminUpdateAccount】'.date('Y-m-d H:i:s')." {$me->username}が{$user->username}を変更:".implode(' ', $done));
949 return Result::Success(implode(' ', $done));
950 }
951
952 /**
953 * 管理者がユーザーを作る(登録が無効でも、メールアドレスの確認なし)
954 *
955 * @param array $roles ビットの配列
956 * @return Result data:username
957 */
958 public function adminCreateUser(string $username, #[SensitiveParameter] string $password, #[SensitiveParameter] string $passwordVerify, string $email, array $roles = [Roles::MEMBER]): Result {
959 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
960 if (!$this->isAdmin()) return Result::Error('エラー:権限がありません。');
961 return $this->createAccount($username, $password, $passwordVerify, $email, $roles, 'adminCreateUser');
962 }
963
964 /**
965 * 最初の管理者を作ってログインする(管理者が1人も居ない時だけ、config.phpのADMIN_SETUP_CODEが必要)
966 *
967 * @param string $code 入力されたコード
968 * @param string|null $setupCode 正しいコード(nullならADMIN_SETUP_CODE)
969 * @return Result
970 */
971 public function setupFirstAdmin(string $code, string $username, #[SensitiveParameter] string $password, #[SensitiveParameter] string $passwordVerify, string $email, #[SensitiveParameter] ?string $setupCode = null): Result {
972 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
973 $setupCode ??= defined('ADMIN_SETUP_CODE') ? (string)ADMIN_SETUP_CODE : '';
974 if ($setupCode === '' || $this->hasAdmin()) return Result::Error('エラー:最初の管理者は作れません。');
975 if ($this->isLoginThrottled('admin-setup')) return Result::Error('エラー:試行回数が多過ぎます。暫くしてから再度お試し下さい。');
976 if (!hash_equals($setupCode, $code)) {
977 $this->recordLoginFailure('admin-setup');
978 return Result::Error('エラー:セットアップのコードが一致していません。');
979 }
980 $res = $this->createAccount($username, $password, $passwordVerify, $email, [Roles::MEMBER, Roles::ADMIN], 'setupFirstAdmin');
981 if (!$res->isSuccess) return $res;
982 $user = json_decode((string)file_get_contents($res->data['path']));
983 $this->id = (int)$user->id;
984 $this->user = $user;
985 logger(LogType::Auth, '【setupFirstAdmin】'.date('Y-m-d H:i:s')." 最初の管理者「{$username}」を作成");
986 $login = $this->startSession($user);
987 return $login->isSuccess ? Result::Success('管理者を作成しました。') : $login;
988 }
989
990 private function createAccount(string $username, #[SensitiveParameter] string $password, #[SensitiveParameter] string $passwordVerify, string $email, array $roles, string $where): Result {
991 $errors = [];
992 foreach ([$this->verifyUsername($username), $this->verifyPassword($password, $passwordVerify), $this->verifyEmail($email, false)] as $res) {
993 if (!$res->isSuccess) $errors[] = $res->message;
994 }
995 if (!empty($errors)) return Result::Error(implode(' ', $errors));
996 $role = Roles::BANNED;
997 foreach ($roles as $bit) if (isset(Roles::LABELS[(int)$bit])) $role |= (int)$bit;
998 $user = $this->newUser($username, $password, $email, $role);
999 $res = $this->storeNewUser($user, $where);
1000 if (!$res->isSuccess) return $res;
1001 return Result::Success("ユーザー「{$username}」を作成しました。", ['username' => $username, 'path' => $res->data['path']]);
1002 }
1003
1004 /**
1005 * 確認メールを送信する(テストで上書き出来る様にprotected)
1006 *
1007 * @param \stdClass $user ユーザーデータ
1008 * @param string $token リンクのトークン(ハッシュ化する前)
1009 * @return Result
1010 */
1011 protected function sendVerificationMail(\stdClass $user, #[SensitiveParameter] string $token): Result {
1012 $url = $this->siteUrl().'/verify/'.$token;
1013 $hours = intdiv($this->verifyDuration, 3600);
1014 $body = "{$user->username}さん、登録ありがとうございます。\n\n"
1015 ."以下のリンクを開いて、メールアドレスを確認して下さい({$hours}時間有効):\n"
1016 ."{$url}\n\n"
1017 ."心当たりが無い場合は、このメールを無視して下さい。\n";
1018
1019 return Mailer::deliver($user->email, '【'.SITEINFO['title'].'】メールアドレスの確認', $body, $user->username);
1020 }
1021
1022 /**
1023 * サイトのURL(リンク用)
1024 * SITEINFOの「url」を推奨。無ければリクエストのHost(Hostヘッダーは偽装出来るので注意)
1025 *
1026 * @return string 末尾の「/」なし
1027 */
1028 protected function siteUrl(): string {
1029 $url = rtrim((string)(SITEINFO['url'] ?? ''), '/');
1030 if ($url !== '') return $url;
1031 $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
1032 return ($https ? 'https' : 'http').'://'.($_SERVER['HTTP_HOST'] ?? 'localhost');
1033 }
1034
1035 /**
1036 * 未確認のアカウントに確認メールを再送する(前回からverifyResendInterval以上経った場合だけ)
1037 *
1038 * @param \stdClass $userData ユーザーデータ
1039 * @return bool 再送したらtrue
1040 */
1041 private function resendVerification(\stdClass $userData): bool {
1042 if (!$this->requiresVerification()) return false;
1043 if (($userData->verifySent ?? 0) + $this->verifyResendInterval > time()) return false;
1044
1045 $token = bin2hex(random_bytes(32));
1046 $userData->verifyToken = hash('sha256', $token);
1047 $userData->verifyExpire = time() + $this->verifyDuration;
1048 $userData->verifySent = time();
1049
1050 $path = $this->dataDir.$userData->id.'.'.$userData->username.'.json';
1051 $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
1052 if (file_put_contents($path, $json, LOCK_EX) === false) return false;
1053
1054 return $this->sendVerificationMail($userData, $token)->isSuccess;
1055 }
1056
1057 /**
1058 * ユーザーのJSONファイルの一覧
1059 *
1060 * @return array パス
1061 */
1062 private function userFiles(): array {
1063 $out = [];
1064 foreach (@scandir($this->dataDir) ?: [] as $f) {
1065 if (preg_match('/^\d+\.[A-Za-z0-9]+\.json$/', $f)) $out[] = $this->dataDir.$f;
1066 }
1067 return $out;
1068 }
1069
1070 /**
1071 * 新しいユーザーのデータ
1072 */
1073 private function newUser(string $username, #[SensitiveParameter] string $password, string $email, int $role): \stdClass {
1074 $user = new \stdClass;
1075 $user->id = 0;
1076 $user->username = $username;
1077 $user->password = password_hash($password, $this->algo);
1078 $user->avatar = '';
1079 $user->email = $email;
1080 $user->regDate = time();
1081 $user->namecolor = '';
1082 $user->displayname = '';
1083 $user->gender = Gender::UNKNOWN;
1084 $user->role = $role;
1085 $user->tokens = [];
1086 return $user;
1087 }
1088
1089 /**
1090 * 新しいユーザーを保存する(IDを付け、アイコンのディレクトリも作る。data/が空・無くても良い)
1091 *
1092 * @param \stdClass $user newUser()のデータ(idを設定する)
1093 * @param string $where ログ用
1094 * @return Result data:path
1095 */
1096 private function storeNewUser(\stdClass $user, string $where): Result {
1097 if (!is_dir($this->dataDir) && !@mkdir($this->dataDir, 0755, true) && !is_dir($this->dataDir)) {
1098 logger(LogType::Auth, "【{$where}】ユーザーのディレクトリを作成出来なかった。chownをご確認下さい。パス:{$this->dataDir}");
1099 return Result::Error('エラー:ユーザーデータの保存に失敗。');
1100 }
1101 $assDir = $this->assDir.$user->username;
1102 // 以前は親のディレクトリ(user/)が無いと失敗した
1103 if (!is_dir($assDir) && !@mkdir($assDir, 0755, true)) {
1104 logger(LogType::Auth, "【{$where}】アセットディレクトリを作成出来なかった。chownをご確認下さい。パス:{$assDir}");
1105 return Result::Error('エラー:ユーザーのアイコンディレクトリの作成に失敗。');
1106 }
1107
1108 // 同時に登録しても同じIDにならない様に
1109 $lock = @fopen($this->dataDir.'.lock', 'c');
1110 if ($lock) flock($lock, LOCK_EX);
1111 try {
1112 $last = 0;
1113 foreach ($this->userFiles() as $f) $last = max($last, (int)strtok(basename($f), '.'));
1114 $user->id = $last + 1;
1115 $path = "{$this->dataDir}{$user->id}.{$user->username}.json";
1116 $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
1117 if (file_put_contents($path, $json, LOCK_EX) === false) {
1118 logger(LogType::Auth, "【{$where}】JSONファイルを作成出来なかった。パス:{$path}");
1119 @rmdir($assDir);
1120 return Result::Error('エラー:ユーザーデータの保存に失敗。');
1121 }
1122 } finally {
1123 if ($lock) {
1124 flock($lock, LOCK_UN);
1125 fclose($lock);
1126 }
1127 }
1128 return Result::Success('', ['path' => $path]);
1129 }
1130
1131 ////////////////////
1132
1133 private function verifyLogin(string $username, #[SensitiveParameter] string $password): Result {
1134 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
1135 $userData = $this->getUserData();
1136 if ($username !== $userData->username || !password_verify($password, $userData->password)) {
1137 return Result::Error('エラー:ユーザー名又はパスワードが一致していません。');
1138 }
1139
1140 if (password_needs_rehash($userData->password, $this->algo)) {
1141 $userData->password = password_hash($password, $this->algo);
1142 $path = $this->dataDir.$this->id.'.'.$userData->username.'.json';
1143 $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
1144
1145 if (file_put_contents($path, $json) === false) {
1146 logger(LogType::Auth, '【verifyLogin】JSONファイルを変更出来なかった。chownをご確認下さい。パス:'.$path);
1147 return Result::Error('エラー:ユーザーデータの保存に失敗。');
1148 }
1149 }
1150
1151 return Result::Success();
1152 }
1153
1154 private function purgeOldTokens(\stdClass $userData): Result {
1155 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
1156 // 有効期限切れたトークンの削除
1157 $out = $userData;
1158 $out->tokens = array_values(array_filter($userData->tokens, function($t): bool {
1159 return ($t->expDate ?? 0) > time();
1160 }));
1161
1162 $path = $this->dataDir.$userData->id.'.'.$userData->username.'.json';
1163 $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
1164
1165 if (file_put_contents($path, $json) === false) {
1166 logger(LogType::Auth, '【purgeOldTokens】JSONファイルを変更出来なかった。chownをご確認下さい。パス:'.$path);
1167 return Result::Error('エラー:ユーザーデータの保存に失敗。');
1168 }
1169
1170 return Result::Success('', ['user' => $out]);
1171 }
1172
1173 private function getUserId(?string $username = null, ?string $token = null): int {
1174 if (!AUTH_ENABLED) return 0;
1175 if (!$username && !$token) return 0;
1176 if (!$username && !preg_match('/^[0-9a-f]{128}$/', $token)) return 0;
1177
1178 $file = @scandir($this->dataDir) ?: [];
1179 $now = time();
1180 $hashed = $token ? hash('sha256', $token) : '';
1181
1182 foreach ($file as $f) {
1183 if (!preg_match('/^(\d+)\.[A-Za-z0-9]+\.json$/', $f, $m)) continue;
1184
1185 $path = "{$this->dataDir}{$f}";
1186 if (!is_file($path)) continue;
1187 $data = json_decode(file_get_contents($path));
1188 if (!$data instanceof \stdClass) continue;
1189
1190 if ($username) {
1191 if (isset($data->username) && $data->username === $username) return (int)$m[1];
1192 continue;
1193 }
1194
1195 foreach (($data->tokens ?? []) as $t) {
1196 if (!isset($t->token, $t->expDate)) continue;
1197 if ($t->expDate > $now && hash_equals((string)$t->token, $hashed)) return (int)$m[1];
1198 }
1199 }
1200
1201 return 0;
1202 }
1203
1204 private function getUserData(): ?\stdClass {
1205 if (!AUTH_ENABLED) return new \stdClass;
1206 $file = @scandir($this->dataDir) ?: [];
1207 $userFile = "";
1208 $matches = [];
1209
1210 foreach ($file as $f) {
1211 if ($f === '.kara' || $f === '.' || $f === '..') continue;
1212 if (preg_match('/^'.$this->id.'\.(.*?)\.json$/', $f, $matches)) {
1213 $userFile = $matches[0];
1214 break;
1215 }
1216 }
1217 // 以前はファイルが無くてもディレクトリ自体を読もうとした(ログインしていない時のgetUser()等)
1218 if ($userFile === '') return null;
1219 $path = $this->dataDir.$userFile;
1220 unset($file, $userFile);
1221
1222 if (!file_exists($path) || !is_readable($path)) {
1223 kys('エラー');
1224 }
1225
1226 $fp = fopen($path, 'r');
1227 assert_not_null($fp);
1228 if (!$fp) kys('ユーザーが存在しない。');
1229 $lines = "";
1230 while (($buf = fgets($fp, 4096)) !== false) $lines .= $buf.PHP_EOL;
1231 if (!feof($fp)) kys("エラー:不明なエラー");
1232 fclose($fp);
1233 unset($path);
1234 return json_decode($lines);
1235 }
1236
1237 private function getUserDataFromName(string $name): \stdClass|null {
1238 if (!AUTH_ENABLED) return new \stdClass;
1239 $file = @scandir($this->dataDir) ?: [];
1240 $userFile = "";
1241 $matches = [];
1242
1243 foreach ($file as $f) {
1244 if ($f === '.kara' || $f === '.' || $f === '..') continue;
1245 if (preg_match('/^(.*?)\.(.*?)\.json$/', $f, $matches)) {
1246 if (isset($matches[2]) && $matches[2] === $name) {
1247 $userFile = $matches[0];
1248 break;
1249 }
1250 }
1251 }
1252 if (!$userFile) return null;
1253 $path = $this->dataDir.$userFile;
1254 unset($file, $userFile);
1255
1256 if (!file_exists($path) || !is_readable($path)) {
1257 kys('エラー');
1258 }
1259
1260 $fp = fopen($path, 'r');
1261 assert_not_null($fp);
1262 if (!$fp) kys('ユーザーが存在しない。');
1263 $lines = "";
1264 while (($buf = fgets($fp, 4096)) !== false) $lines .= $buf.PHP_EOL;
1265 if (!feof($fp)) kys("エラー:不明なエラー");
1266 fclose($fp);
1267 unset($path);
1268 return json_decode($lines);
1269 }
1270
1271 private function isEmailExist(?string $email = null): Result {
1272 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
1273 $userList = @scandir($this->dataDir) ?: [];
1274 $matches = [];
1275
1276 foreach ($userList as $f) {
1277 if ($f === '.kara' || $f === '.' || $f === '..') continue;
1278 $path = "{$this->dataDir}{$f}";
1279 if (!is_file($path)) continue;
1280 $content = file_get_contents($path);
1281 $file = str_replace('.json', '', $f);
1282 if (str_contains($content, '"email": "'.$email.'",')) {
1283 $matches[] = $email;
1284 }
1285 if (count($matches) > 0) return Result::Error("ユーザー「{$email}」は既に存在します。");
1286 }
1287
1288 return Result::Success('エラー:メールアドレスが存在しません。');
1289 }
1290
1291 private function verifyEmail(?string $email = null, bool $checkMx = true): Result {
1292 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
1293 if (null === $email || '' === $email) return Result::Error('エラー:メールアドレスをご入力下さい。');
1294 if (strpos($email, '@') === false) return Result::Error('エラー:メールアドレスは不正です。');
1295
1296 $domain = explode('@', $email)[1];
1297 if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
1298 return Result::Error('エラー:メールアドレスは不正です。');
1299 }
1300
1301 if ($checkMx && !checkdnsrr($domain, 'MX')) {
1302 return Result::Error('エラー:メールアドレスは不正です。');
1303 }
1304
1305 $res = $this->isEmailExist($email);
1306 if (!$res->isSuccess) return Result::Error($res->message);
1307
1308 return Result::Success();
1309 }
1310
1311 private function verifyUsername(?string $username): Result {
1312 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
1313 if (null === $username || '' === $username) return Result::Error('エラー:ユーザー名をご入力下さい。');
1314 if (strlen($username) < 6) return Result::Error('エラー:ユーザー名は6文字以上をご入力下さい。');
1315
1316 $res = $this->isUserExist($username);
1317 if ($res->isSuccess) return Result::Error($res->message);
1318 // ブログのアクターと同じ名前は使えない(WebFingerで区別出来ない)
1319 if (defined('FEDIINFO') && strcasecmp($username, (string)(FEDIINFO['actor'] ?? '')) === 0) {
1320 return Result::Error("ユーザー「{$username}」は既に存在します。");
1321 }
1322
1323 $accepted = 'A-Za-z0-9';
1324 if (preg_match("/[^{$accepted}]/", $username)) {
1325 return Result::Error('エラー:ユーザー名に不正な文字が含みます。');
1326 }
1327
1328 return Result::Success();
1329 }
1330
1331 private function verifyPassword(#[SensitiveParameter] ?string $password = null, #[SensitiveParameter] ?string $passwordVerify = null): Result {
1332 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
1333 if (null === $password || '' === $password) return Result::Error('エラー:パスワードをご入力下さい。');
1334 if ($password !== $passwordVerify) return Result::Error('エラー:パスワードは一致していません。');
1335
1336 $res = $this->checkPasswordStandards($password);
1337 if (!$res->isSuccess) {
1338 return Result::Error($res->message);
1339 }
1340
1341 return Result::Success();
1342 }
1343
1344 private function checkPasswordStandards(#[SensitiveParameter] ?string $password = null): Result {
1345 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
1346 if (strlen($password) < $this->minPassLen) return Result::Error("エラー:パスワードは{$this->minPassLen}以上をご入力下さい。");
1347
1348 if (!\countmatch($password)) {
1349 return Result::Error('エラー:パスワードに不正な文字が含みます。');
1350 }
1351
1352 $countUpper = preg_match_all('/[A-Z]/', $password) < 2;
1353 $countLower = preg_match_all('/[a-z]/', $password) < 2;
1354 $countDigit = preg_match_all('/[0-9]/', $password) < 2;
1355 $countSymbol = \count_special_chars($password) < 2;
1356
1357 if ($countUpper || $countLower || $countDigit || $countSymbol) {
1358 return Result::Error('エラー:パスワードは2つ以上の大文字、2つ以上の小文字、2つ以上の数字、及び2つ以上の特別文字を含む事が必須です。');
1359 }
1360
1361 return Result::Success();
1362 }
1363};