メール認証

守矢諏訪子 <suwako@076.moe>、2026-11-10 20:52
コミット f6cdbdeb7a49aed9c9959f96063cbb3161ae7087 ・親 e6dd74f ・ファイルを見る

10個のファイル、+452 -200

@@ -41,6 +41,7 @@
4141 'title' => 'Little Beast',
4242 'description' => 'PHP Framework',
4343 'tags' => 'game,tech,developer',
44+ 'url' => '',
4445 ]);
4546 define('MAILINFO', [
4647 'from' => 'hogehoge@ho.ge',
@@ -74,5 +75,6 @@
7475 define('AUTH_ENABLED', false);
7576 define('OPENPROVIDER_ENABLED', false);
7677 define('AUTH_REGISTER_ENABLED', false);
78+define('AUTH_VERIFY_EMAIL', false); // 登録時にメールアドレスの確認を必須にする(MAILER_ENABLEDも必要)
7779 define('COPYRIGHT_YEAR', '2018-'.date('Y'));
7880 define('DEBUG_MODE', false);
変更 cron.php
@@ -52,6 +52,7 @@
5252
5353 use Site\Controller\BlogPost;
5454 use Std\Lib\Activitypub;
55+use Std\Lib\Auth;
5556 use Std\Lib\Cron;
5657 use Std\Lib\CronTab;
5758
@@ -68,6 +69,11 @@
6869 $cron->register('apfederate', fn() => (new Activitypub((new BlogPost())->getPosts('/blog/', null)))->federate());
6970 // プロフィール(アクター)の更新をフォロワーに送る
7071 $cron->register('apupdate', fn() => (new Activitypub())->sendActorUpdate());
72+}
73+
74+if (AUTH_ENABLED && AUTH_VERIFY_EMAIL) {
75+ // メールアドレスを7日以内に確認しなかったアカウントを削除する
76+ $cron->register('authpurge', fn() => (new Auth())->purgeUnverified());
7177 }
7278
7379 ////////////////////////////////////////////////////////////////
変更 route.php
@@ -85,6 +85,7 @@
8585 $routes[] = Route::add('GET', 'register', User::class.'@register');
8686 }
8787 $routes[] = Route::add('GET', 'logout', User::class.'@logout');
88+ $routes[] = Route::add('GET', 'verify/{token}', User::class.'@verify');
8889 $routes[] = Route::add('GET', 'profile/{name}', User::class.'@profile');
8990
9091 $routes[] = Route::add('GET', 'memberonly', Page::class.'@memberonly');
@@ -180,11 +180,15 @@
180180 }
181181
182182 $auth = new Auth;
183- $res = $auth->mkUser($a[0], $a[1], $a[2], $a[3]);
183+ $res = $auth->mkUser($a[0] ?? null, $a[1] ?? null, $a[2] ?? null, $a[3] ?? null);
184184 if (!$res->isSuccess) {
185185 $error = $res->message;
186- $nyuU = $a[0];
187- $nyuE = $a[3];
186+ $nyuU = $a[0] ?? '';
187+ $nyuE = $a[3] ?? '';
188+ } else if ($res->data['verify'] ?? false) {
189+ // 確認メールのリンクを開くまでログインしない
190+ $this->renderVerify('登録', $res->message, '');
191+ return;
188192 } else {
189193 $auth = new Auth($a[0]);
190194 $auth->setToken($a[0], $a[1]);
@@ -210,6 +214,36 @@
210214 } catch (\Exception $e) {
211215 throw new \Exception($e->getMessage());
212216 }
217+ }
218+
219+ /**
220+ * 確認メールのリンク(/verify/{token})
221+ *
222+ * @param array $params パラメータ配列
223+ * @return void
224+ */
225+ public function verify(array $params): void {
226+ if (!AUTH_ENABLED) return;
227+ try {
228+ $auth = new Auth;
229+ $res = $auth->verifyEmailToken((string)($params['token'] ?? ''));
230+ if ($res->isSuccess) $this->renderVerify('メールアドレスの確認', $res->message, '');
231+ else $this->renderVerify('メールアドレスの確認', '', $res->message);
232+ } catch (\Exception $e) {
233+ throw new \Exception($e->getMessage());
234+ }
235+ }
236+
237+ private function renderVerify(string $pagetit, string $message, string $error): void {
238+ $tmpl = new Template('/');
239+ $tmpl->assign('pagetit', $pagetit);
240+ $tmpl->assign('curPage', 'auth');
241+ $tmpl->assign('custCss', false);
242+ $tmpl->assign('menu', $this->getMenu());
243+ $tmpl->assign('description', '');
244+ $tmpl->assign('message', $message);
245+ $tmpl->assign('error', $error);
246+ $tmpl->render('verify');
213247 }
214248
215249 public function profile(array $params): void {
@@ -53,6 +53,8 @@
5353 protected string $assDir = ROOT."/public/static/user/";
5454 protected int $minPassLen = 20;
5555 protected int $tokenDuration = 31536000; // 1年間
56+ protected int $verifyDuration = 86400; // 確認メールのリンクの有効期限(24時間)
57+ protected int $verifyResendInterval = 600; // 確認メールの再送の間隔(10分)
5658 protected string|int|null $algo = PASSWORD_ARGON2ID;
5759
5860 public function __construct(?string $username = null) {
@@ -68,6 +70,7 @@
6870 $this->pubUser = $this->user;
6971 unset($this->pubUser->password);
7072 unset($this->pubUser->tokens);
73+ unset($this->pubUser->verifyToken);
7174 return $this->pubUser;
7275 }
7376
@@ -76,6 +79,7 @@
7679 if (!$user) return null;
7780 unset($user->password);
7881 unset($user->tokens);
82+ unset($user->verifyToken, $user->verifyExpire, $user->verifySent);
7983 $myself = $this->getUserData();
8084 if (!$myself || ($myself->id != $user->id && $myself->role < Roles::STAFF)) $user->email = '(秘密)';
8185 $user->name = namecolor($user);
@@ -111,6 +115,7 @@
111115 public function setToken(string $username, #[SensitiveParameter] string $password): Result {
112116 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
113117 $userData = $this->getUserData();
118+ if ($userData === null) return Result::Error('エラー:ユーザー名又はパスワードが一致していません。');
114119 if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = [];
115120
116121 $verify = $this->verifyLogin($username, $password);
@@ -118,7 +123,14 @@
118123 return Result::Error($verify->message);
119124 }
120125
121- $ip = $_SERVER['REMOTE_ADDR'];
126+ // メールアドレスが未確認のアカウントはログイン出来ない(確認機能の前に作ったアカウントにはverifiedが無いので、確認済みとする)
127+ if (($userData->verified ?? true) === false) {
128+ $resent = $this->resendVerification($userData);
129+ return Result::Error('エラー:メールアドレスが未確認です。届いたメールのリンクを開いて下さい。'
130+ .($resent ? '確認メールを再送しました。' : ''), ['unverified' => true]);
131+ }
132+
133+ $ip = $_SERVER['REMOTE_ADDR'] ?? '';
122134 $res = $this->purgeOldTokens($userData);
123135 if (!$res->isSuccess) return Result::Error($res->message);
124136 $userData = $res->data['user'];
@@ -146,7 +158,7 @@
146158 if (!setcookie('kerozen', $token, [
147159 'expires' => $expire,
148160 'path' => '/',
149- 'domain' => $_SERVER['HTTP_HOST'],
161+ 'domain' => $_SERVER['HTTP_HOST'] ?? '',
150162 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
151163 'httponly' => true,
152164 'samesite' => 'Strict'
@@ -188,7 +200,7 @@
188200 setcookie('kerozen', '', [
189201 'expires' => 0,
190202 'path' => '/',
191- 'domain' => $_SERVER['HTTP_HOST'],
203+ 'domain' => $_SERVER['HTTP_HOST'] ?? '',
192204 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
193205 'httponly' => true,
194206 'samesite' => 'Strict'
@@ -324,6 +336,16 @@
324336 $user->role = Roles::MEMBER;
325337 $user->tokens = [];
326338
339+ // メールアドレスの確認(リンクを開くまでログイン出来ない)
340+ $verifyToken = null;
341+ if ($this->requiresVerification()) {
342+ $verifyToken = bin2hex(random_bytes(32));
343+ $user->verified = false;
344+ $user->verifyToken = hash('sha256', $verifyToken);
345+ $user->verifyExpire = time() + $this->verifyDuration;
346+ $user->verifySent = time();
347+ }
348+
327349 $path = "{$this->dataDir}{$lastId}.{$username}.json";
328350 $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
329351
@@ -333,9 +355,157 @@
333355 return Result::Error('エラー:ユーザーデータの保存に失敗。');
334356 }
335357
336- return Result::Success();
358+ if ($verifyToken !== null) {
359+ $sent = $this->sendVerificationMail($user, $verifyToken);
360+ if (!$sent->isSuccess) {
361+ // メールが届かないアカウントを残さない(同じユーザー名でもう一度登録出来る様に)
362+ unlink($path);
363+ @rmdir($this->assDir.$username);
364+ return Result::Error('エラー:確認メールの送信に失敗しました。暫くしてから再度お試し下さい。');
365+ }
366+ return Result::Success("確認メールを「{$email}」に送信しました。メールのリンクを開いて、登録を完了して下さい。", ['verify' => true]);
367+ }
368+
369+ return Result::Success('', ['verify' => false]);
337370 }
338371
372+ /**
373+ * 登録時にメールアドレスの確認が必要か
374+ *
375+ * @return bool
376+ */
377+ public function requiresVerification(): bool {
378+ return AUTH_VERIFY_EMAIL && MAILER_ENABLED;
379+ }
380+
381+ /**
382+ * 確認メールのリンクのトークンでメールアドレスを確認する
383+ *
384+ * @param string $token リンクのトークン
385+ * @return Result data:username
386+ */
387+ public function verifyEmailToken(string $token): Result {
388+ if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
389+ $invalid = 'エラー:リンクが無効か、有効期限が切れています。ログインすると確認メールを再送します。';
390+ if (!preg_match('/^[0-9a-f]{64}$/', $token)) return Result::Error($invalid);
391+ $hashed = hash('sha256', $token);
392+
393+ foreach ($this->userFiles() as $path) {
394+ $data = json_decode((string)file_get_contents($path));
395+ if (!$data instanceof \stdClass || !isset($data->verifyToken)) continue;
396+ if (!hash_equals((string)$data->verifyToken, $hashed)) continue;
397+ if (($data->verifyExpire ?? 0) < time()) return Result::Error($invalid);
398+
399+ $data->verified = true;
400+ unset($data->verifyToken, $data->verifyExpire, $data->verifySent);
401+ $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
402+ if (file_put_contents($path, $json, LOCK_EX) === false) {
403+ logger(LogType::Auth, '【verifyEmailToken】JSONファイルを変更出来なかった。パス:'.$path);
404+ return Result::Error('エラー:ユーザーデータの保存に失敗。');
405+ }
406+ return Result::Success('メールアドレスを確認しました。ログインして下さい。', ['username' => $data->username]);
407+ }
408+
409+ return Result::Error($invalid);
410+ }
411+
412+ /**
413+ * メールアドレスが確認されていない古いアカウントを削除する(cronのタスク用)
414+ * 削除すると、同じユーザー名とメールアドレスでもう一度登録出来る
415+ *
416+ * @param int $maxAge 登録からこの秒数が経ったら削除する(既定は7日)
417+ * @return Result data:deleted(削除したユーザー名)
418+ */
419+ public function purgeUnverified(int $maxAge = 604800): Result {
420+ if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
421+ $deleted = [];
422+
423+ foreach ($this->userFiles() as $path) {
424+ $data = json_decode((string)file_get_contents($path));
425+ if (!$data instanceof \stdClass || ($data->verified ?? true) !== false) continue;
426+ if (($data->regDate ?? time()) + $maxAge > time()) continue;
427+
428+ if (!unlink($path)) {
429+ logger(LogType::Auth, '【purgeUnverified】削除に失敗。パス:'.$path);
430+ continue;
431+ }
432+ $dir = $this->assDir.$data->username;
433+ if (is_dir($dir)) {
434+ foreach (glob($dir.'/*') ?: [] as $f) @unlink($f);
435+ @rmdir($dir);
436+ }
437+ $deleted[] = $data->username;
438+ }
439+
440+ return Result::Success('未確認のアカウント'.count($deleted).'件を削除しました。', ['deleted' => $deleted]);
441+ }
442+
443+ /**
444+ * 確認メールを送信する(テストで上書き出来る様にprotected)
445+ *
446+ * @param \stdClass $user ユーザーデータ
447+ * @param string $token リンクのトークン(ハッシュ化する前)
448+ * @return Result
449+ */
450+ protected function sendVerificationMail(\stdClass $user, #[SensitiveParameter] string $token): Result {
451+ $url = $this->siteUrl().'/verify/'.$token;
452+ $hours = intdiv($this->verifyDuration, 3600);
453+ $body = "{$user->username}さん、登録ありがとうございます。\n\n"
454+ ."以下のリンクを開いて、メールアドレスを確認して下さい({$hours}時間有効):\n"
455+ ."{$url}\n\n"
456+ ."心当たりが無い場合は、このメールを無視して下さい。\n";
457+
458+ return Mailer::deliver($user->email, '【'.SITEINFO['title'].'】メールアドレスの確認', $body, $user->username);
459+ }
460+
461+ /**
462+ * サイトのURL(リンク用)
463+ * SITEINFOの「url」を推奨。無ければリクエストのHost(Hostヘッダーは偽装出来るので注意)
464+ *
465+ * @return string 末尾の「/」なし
466+ */
467+ protected function siteUrl(): string {
468+ $url = rtrim((string)(SITEINFO['url'] ?? ''), '/');
469+ if ($url !== '') return $url;
470+ $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
471+ return ($https ? 'https' : 'http').'://'.($_SERVER['HTTP_HOST'] ?? 'localhost');
472+ }
473+
474+ /**
475+ * 未確認のアカウントに確認メールを再送する(前回からverifyResendInterval以上経った場合だけ)
476+ *
477+ * @param \stdClass $userData ユーザーデータ
478+ * @return bool 再送したらtrue
479+ */
480+ private function resendVerification(\stdClass $userData): bool {
481+ if (!$this->requiresVerification()) return false;
482+ if (($userData->verifySent ?? 0) + $this->verifyResendInterval > time()) return false;
483+
484+ $token = bin2hex(random_bytes(32));
485+ $userData->verifyToken = hash('sha256', $token);
486+ $userData->verifyExpire = time() + $this->verifyDuration;
487+ $userData->verifySent = time();
488+
489+ $path = $this->dataDir.$userData->id.'.'.$userData->username.'.json';
490+ $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
491+ if (file_put_contents($path, $json, LOCK_EX) === false) return false;
492+
493+ return $this->sendVerificationMail($userData, $token)->isSuccess;
494+ }
495+
496+ /**
497+ * ユーザーのJSONファイルの一覧
498+ *
499+ * @return array パス
500+ */
501+ private function userFiles(): array {
502+ $out = [];
503+ foreach (scandir($this->dataDir) ?: [] as $f) {
504+ if (preg_match('/^\d+\.[A-Za-z0-9]+\.json$/', $f)) $out[] = $this->dataDir.$f;
505+ }
506+ return $out;
507+ }
508+
339509 ////////////////////
340510
341511 private function verifyLogin(string $username, #[SensitiveParameter] string $password): Result {
@@ -422,6 +592,8 @@
422592 break;
423593 }
424594 }
595+ // 以前はファイルが無くてもディレクトリ自体を読もうとした(ログインしていない時のgetUser()等)
596+ if ($userFile === '') return null;
425597 $path = $this->dataDir.$userFile;
426598 unset($file, $userFile);
427599
@@ -41,225 +41,242 @@
4141
4242 use Std\Lib\Tester;
4343 use Std\Lib\Auth;
44+use Result;
4445
45-if (AUTH_ENABLED) {
46- $test = new Tester([
47- 'colorOutput' => true,
48- 'verboseOutput' => true
49- ]);
46+$test = new Tester([
47+ 'colorOutput' => true,
48+ 'verboseOutput' => true
49+]);
5050
51- if (AUTH_REGISTER_ENABLED) {
52- $test->describe('ユーザー登録', function($test): void {
53- $test->it('アカウントを作成出来るはず', function($test): void {
54- $user = "admin1234";
55- $pass = "testTEST1234!#$%@[]:;";
56- $passVerify = "testTEST1234!#$%@[]:;";
57- $email = "test@076.co.jp";
51+// 本物のユーザーデータを触らない様に、一時ディレクトリを使う
52+$base = sys_get_temp_dir().'/lbauth_'.bin2hex(random_bytes(6));
53+mkdir("{$base}/user", 0700, true);
54+mkdir("{$base}/ass", 0700, true);
55+touch("{$base}/user/.kara");
5856
59- $auth = new Auth();
60- $res = $auth->mkUser($user, $pass, $passVerify, $email);
57+// メールは送らずに記録する
58+// メールアドレスのMXレコードを確認するので、DNSが使えない場合は登録のテストをスキップする
59+$dns = checkdnsrr('076.co.jp', 'MX');
60+$mails = new \ArrayObject();
61+$make = function(?string $username = null, bool $verify = false, bool $mailFails = false) use ($base, $mails): Auth {
62+ return new class($username, $base, $verify, $mailFails, $mails) extends Auth {
63+ public function __construct(?string $username, string $base, private bool $verify, private bool $mailFails, private \ArrayObject $mails) {
64+ $this->dataDir = "{$base}/user/";
65+ $this->assDir = "{$base}/ass/";
66+ parent::__construct($username);
67+ }
68+ public function requiresVerification(): bool { return $this->verify; }
69+ protected function siteUrl(): string { return 'https://example.com'; }
70+ protected function sendVerificationMail(\stdClass $user, string $token): Result {
71+ if ($this->mailFails) return Result::Error('送信失敗');
72+ $this->mails[] = ['to' => $user->email, 'token' => $token];
73+ return Result::Success();
74+ }
75+ };
76+};
77+$userFile = function(string $username) use ($base): ?string {
78+ foreach (scandir("{$base}/user") as $f) if (preg_match('/^\d+\.'.preg_quote($username, '/').'\.json$/', $f)) return "{$base}/user/{$f}";
79+ return null;
80+};
81+$read = fn(string $username) => json_decode(file_get_contents($userFile($username)));
82+$pass = 'testTEST1234!#$%@[]:;';
6183
62- $expect = true;
84+$test->describe('ユーザー登録', function($test) use ($make, $read, $userFile, $pass, $base, $dns): void {
85+ if (!AUTH_ENABLED || !AUTH_REGISTER_ENABLED) {
86+ $test->skipAll('AUTH_ENABLED又はAUTH_REGISTER_ENABLEDが無効');
87+ } else if (!$dns) {
88+ $test->skipAll('DNSが使えない(076.co.jpのMXレコードを受け取れない)');
89+ }
6390
64- $test->assertNotNull($res);
65- $test->assertEquals($res->isSuccess, $expect);
66- });
91+ $test->it('アカウントを作成出来るはず', function($test) use ($make, $read, $pass, $base): void {
92+ $res = $make()->mkUser('admin1234', $pass, $pass, 'test@076.co.jp');
93+ $test->assertTrue($res->isSuccess, (string)$res->message);
94+ $test->assertFalse($res->data['verify']);
6795
68- $test->it('空フィールドでアカウントを作成出来ないはず', function($test): void {
69- $user = "";
70- $pass = "testTEST1234!#$%@[]:;";
71- $passVerify = "testTEST1234!#$%@[]:;";
72- $email = "test@076.ne.jp";
96+ $u = $read('admin1234');
97+ $test->assertEquals(1, $u->id);
98+ $test->assertEquals('test@076.co.jp', $u->email);
99+ $test->assertTrue(password_verify($pass, $u->password));
100+ $test->assertFalse(isset($u->verified));
101+ $test->assertTrue(is_dir("{$base}/ass/admin1234"));
102+ });
73103
74- $auth = new Auth();
75- $res = $auth->mkUser($user, $pass, $passVerify, $email);
104+ $test->it('空フィールドでアカウントを作成出来ないはず', function($test) use ($make, $pass): void {
105+ $auth = $make();
106+ $test->assertEquals('エラー:ユーザー名をご入力下さい。<br />', $auth->mkUser('', $pass, $pass, 'a@076.co.jp')->message);
107+ $test->assertEquals('エラー:パスワードをご入力下さい。<br />', $auth->mkUser('admin5678', '', $pass, 'a@076.co.jp')->message);
108+ $test->assertEquals('エラー:メールアドレスをご入力下さい。<br />', $auth->mkUser('admin5678', $pass, $pass, '')->message);
109+ });
76110
77- $expect = 'エラー:ユーザー名をご入力下さい。'.'<br />';
111+ $test->it('既にユーザー名やメールアドレスが存在するアカウントを作成出来ないはず', function($test) use ($make, $pass): void {
112+ $auth = $make();
113+ $test->assertEquals('ユーザー「admin1234」は既に存在します。<br />', $auth->mkUser('admin1234', $pass, $pass, 'b@076.co.jp')->message);
114+ $test->assertEquals('ユーザー「test@076.co.jp」は既に存在します。<br />', $auth->mkUser('admin5678', $pass, $pass, 'test@076.co.jp')->message);
115+ });
78116
79- $test->assertNotNull($res);
80- $test->assertEquals($res->message, $expect);
117+ $test->it('一致しない、短い、又は不安なパスワードでアカウントを作成出来ないはず', function($test) use ($make, $pass): void {
118+ $auth = $make();
119+ $test->assertEquals('エラー:パスワードは一致していません。<br />', $auth->mkUser('admin5678', 'testtest1234!#$%@[]:;', $pass, 'c@076.co.jp')->message);
120+ $test->assertEquals('エラー:パスワードは20以上をご入力下さい。<br />', $auth->mkUser('admin5678', 'testTEST1234', 'testTEST1234', 'c@076.co.jp')->message);
121+ $test->assertEquals('エラー:パスワードは2つ以上の大文字、2つ以上の小文字、2つ以上の数字、及び2つ以上の特別文字を含む事が必須です。<br />',
122+ $auth->mkUser('admin5678', 'testTEST1234testTEST', 'testTEST1234testTEST', 'c@076.co.jp')->message);
123+ });
81124
82- $user = "admin5678";
83- $pass = "";
84- $res = $auth->mkUser($user, $pass, $passVerify, $email);
125+ $test->it('不正なユーザー名とメールアドレスでアカウントを作成出来ないはず', function($test) use ($make, $pass): void {
126+ $auth = $make();
127+ $test->assertEquals('エラー:ユーザー名は6文字以上をご入力下さい。<br />', $auth->mkUser('abc', $pass, $pass, 'c@076.co.jp')->message);
128+ $test->assertEquals('エラー:ユーザー名に不正な文字が含みます。<br />', $auth->mkUser('admin_../x', $pass, $pass, 'c@076.co.jp')->message);
129+ $test->assertEquals('エラー:メールアドレスは不正です。<br />', $auth->mkUser('admin5678', $pass, $pass, 'test@076')->message);
130+ $test->assertEquals('エラー:メールアドレスは不正です。<br />', $auth->mkUser('admin5678', $pass, $pass, 'test@nomx.invalid')->message);
131+ });
85132
86- $expect = 'エラー:パスワードをご入力下さい。'.'<br />';
133+ $test->it('公開プロフィールに秘密の情報を含まないはず', function($test) use ($make): void {
134+ $u = $make()->getUser('admin1234');
135+ $test->assertNotNull($u);
136+ $test->assertFalse(isset($u->password));
137+ $test->assertFalse(isset($u->tokens));
138+ $test->assertFalse(isset($u->verifyToken));
139+ $test->assertEquals('(秘密)', $u->email);
140+ $test->assertNull($make()->getUser('nobody99'));
141+ });
142+});
87143
88- $test->assertNotNull($res);
89- $test->assertEquals($res->message, $expect);
144+$test->describe('メールアドレスの確認', function($test) use ($make, $read, $userFile, $pass, $mails, $base, $dns): void {
145+ if (!AUTH_ENABLED || !AUTH_REGISTER_ENABLED) {
146+ $test->skipAll('AUTH_ENABLED又はAUTH_REGISTER_ENABLEDが無効');
147+ } else if (!$dns) {
148+ $test->skipAll('DNSが使えない(076.co.jpのMXレコードを受け取れない)');
149+ }
90150
91- $pass = "testTEST1234!#$%@[]:;";
92- $email = "";
93- $res = $auth->mkUser($user, $pass, $passVerify, $email);
151+ $test->it('登録すると確認メールを送り、未確認として保存するはず', function($test) use ($make, $read, $pass, $mails): void {
152+ $res = $make(null, true)->mkUser('verify01', $pass, $pass, 'v1@076.co.jp');
153+ $test->assertTrue($res->isSuccess);
154+ $test->assertTrue($res->data['verify']);
155+ $test->assertStringContains('確認メール', $res->message);
94156
95- $expect = 'エラー:メールアドレスをご入力下さい。'.'<br />';
157+ $test->assertCount(1, $mails);
158+ $test->assertEquals('v1@076.co.jp', $mails[0]['to']);
159+ $test->assertTrue((bool)preg_match('/^[0-9a-f]{64}$/', $mails[0]['token']));
96160
97- $test->assertNotNull($res);
98- $test->assertEquals($res->message, $expect);
99- });
161+ $u = $read('verify01');
162+ $test->assertFalse($u->verified);
163+ // トークンはハッシュ化して保存する
164+ $test->assertEquals(hash('sha256', $mails[0]['token']), $u->verifyToken);
165+ $test->assertTrue($u->verifyExpire > time());
166+ });
100167
101- $test->it('既にユーザー名やメールアドレスが存在するアカウントを作成出来ないはず', function($test): void {
102- $user = "admin1234";
103- $pass = "testTEST1234!#$%@[]:;";
104- $passVerify = "testTEST1234!#$%@[]:;";
105- $email = "test@076.ne.jp";
168+ $test->it('確認メールの送信に失敗したら登録を取り消すはず', function($test) use ($make, $userFile, $pass, $base): void {
169+ $res = $make(null, true, true)->mkUser('verify02', $pass, $pass, 'v2@076.co.jp');
170+ $test->assertFalse($res->isSuccess);
171+ $test->assertNull($userFile('verify02'));
172+ $test->assertFalse(is_dir("{$base}/ass/verify02"));
173+ });
106174
107- $auth = new Auth();
108- $res = $auth->mkUser($user, $pass, $passVerify, $email);
175+ $test->it('未確認のアカウントではログイン出来ないはず', function($test) use ($make, $pass, $mails): void {
176+ $res = $make('verify01', true)->setToken('verify01', $pass);
177+ $test->assertFalse($res->isSuccess);
178+ $test->assertTrue($res->data['unverified'] ?? false);
179+ // 10分以内は再送しない
180+ $test->assertCount(1, $mails);
181+ $test->assertFalse(str_contains($res->message, '再送'));
182+ });
109183
110- $expect = 'ユーザー「admin1234」は既に存在します。'.'<br />';
184+ $test->it('間違ったパスワードでは未確認かどうか教えないはず', function($test) use ($make): void {
185+ $res = $make('verify01', true)->setToken('verify01', 'machigatta-password');
186+ $test->assertFalse($res->isSuccess);
187+ $test->assertFalse(isset($res->data['unverified']));
188+ });
111189
112- $test->assertNotNull($res);
113- $test->assertEquals($res->message, $expect);
190+ $test->it('時間が経てばログインの時に確認メールを再送するはず', function($test) use ($make, $read, $userFile, $pass, $mails): void {
191+ $path = $userFile('verify01');
192+ $u = $read('verify01');
193+ $u->verifySent = time() - 3600;
194+ file_put_contents($path, json_encode($u));
114195
115- $user = "admin5678";
116- $email = "test@076.co.jp";
117- $res = $auth->mkUser($user, $pass, $passVerify, $email);
196+ $res = $make('verify01', true)->setToken('verify01', $pass);
197+ $test->assertFalse($res->isSuccess);
198+ $test->assertStringContains('再送', $res->message);
199+ $test->assertCount(2, $mails);
200+ $test->assertFalse($mails[0]['token'] === $mails[1]['token']);
201+ $test->assertEquals(hash('sha256', $mails[1]['token']), $read('verify01')->verifyToken);
202+ });
118203
119- $expect = 'ユーザー「test@076.co.jp」は既に存在します。'.'<br />';
204+ $test->it('不正・古いトークンでは確認出来ないはず', function($test) use ($make, $read, $userFile, $mails): void {
205+ $auth = $make();
206+ $test->assertFalse($auth->verifyEmailToken('')->isSuccess);
207+ $test->assertFalse($auth->verifyEmailToken('../../etc/passwd')->isSuccess);
208+ $test->assertFalse($auth->verifyEmailToken(str_repeat('0', 64))->isSuccess);
209+ // 再送したので、最初のトークンはもう使えない
210+ $test->assertFalse($auth->verifyEmailToken($mails[0]['token'])->isSuccess);
120211
121- $test->assertNotNull($res);
122- $test->assertEquals($res->message, $expect);
123- });
212+ // 有効期限切れ
213+ $path = $userFile('verify01');
214+ $u = $read('verify01');
215+ $expire = $u->verifyExpire;
216+ $u->verifyExpire = time() - 1;
217+ file_put_contents($path, json_encode($u));
218+ $test->assertFalse($auth->verifyEmailToken($mails[1]['token'])->isSuccess);
219+ $u->verifyExpire = $expire;
220+ file_put_contents($path, json_encode($u));
221+ });
124222
125- $test->it('一致しない、短い、又は不安なパスワードでアカウントを作成出来ないはず', function($test): void {
126- $user = "admin5678";
127- $pass = "testtest1234!#$%@[]:;";
128- $passVerify = "testTEST1234!#$%@[]:;";
129- $email = "test@076.ne.jp";
223+ $test->it('リンクのトークンで確認するとログイン出来るはず', function($test) use ($make, $read, $pass, $mails): void {
224+ $res = $make()->verifyEmailToken($mails[1]['token']);
225+ $test->assertTrue($res->isSuccess);
226+ $test->assertEquals('verify01', $res->data['username']);
130227
131- $auth = new Auth();
132- $res = $auth->mkUser($user, $pass, $passVerify, $email);
228+ $u = $read('verify01');
229+ $test->assertTrue($u->verified);
230+ $test->assertFalse(isset($u->verifyToken));
231+ $test->assertFalse(isset($u->verifyExpire));
133232
134- $expect = 'エラー:パスワードは一致していません。'.'<br />';
233+ // 2回目は使えない
234+ $test->assertFalse($make()->verifyEmailToken($mails[1]['token'])->isSuccess);
135235
136- $test->assertNotNull($res);
137- $test->assertEquals($res->message, $expect);
236+ $_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
237+ $res = @$make('verify01', true)->setToken('verify01', $pass);
238+ // CLIでは出力の後なのでクッキーを設定出来ないが、トークンは保存される
239+ $test->assertTrue($res->isSuccess || $res->message === 'エラー:クッキーを設定に失敗。', (string)$res->message);
240+ $test->assertCount(1, $read('verify01')->tokens);
241+ });
138242
139- $pass = "testTEST1234";
140- $passVerify = "testTEST1234";
141- $res = $auth->mkUser($user, $pass, $passVerify, $email);
243+ $test->it('確認機能の前に作ったアカウントは確認済みとして扱うはず', function($test) use ($make, $pass): void {
244+ $res = @$make('admin1234', true)->setToken('admin1234', $pass);
245+ $test->assertFalse(isset($res->data['unverified']));
246+ $test->assertTrue($res->isSuccess || $res->message === 'エラー:クッキーを設定に失敗。', (string)$res->message);
247+ });
142248
143- $expect = 'エラー:パスワードは20以上をご入力下さい。'.'<br />';
249+ $test->it('古い未確認のアカウントを削除するはず', function($test) use ($make, $read, $userFile, $pass, $base): void {
250+ $make(null, true)->mkUser('verify03', $pass, $pass, 'v3@076.co.jp');
251+ $make(null, true)->mkUser('verify04', $pass, $pass, 'v4@076.co.jp');
252+ $path = $userFile('verify03');
253+ $u = $read('verify03');
254+ $u->regDate = time() - 8 * 86400;
255+ file_put_contents($path, json_encode($u));
144256
145- $test->assertNotNull($res);
146- $test->assertEquals($res->message, $expect);
257+ $res = $make()->purgeUnverified();
258+ $test->assertTrue($res->isSuccess);
259+ $test->assertEquals(['verify03'], $res->data['deleted']);
260+ $test->assertNull($userFile('verify03'));
261+ $test->assertFalse(is_dir("{$base}/ass/verify03"));
262+ // 新しい未確認・確認済み・確認機能の前のアカウントは残る
263+ $test->assertNotNull($userFile('verify04'));
264+ $test->assertNotNull($userFile('verify01'));
265+ $test->assertNotNull($userFile('admin1234'));
147266
148- $pass = "testTEST1234testTEST";
149- $passVerify = "testTEST1234testTEST";
150- $res = $auth->mkUser($user, $pass, $passVerify, $email);
267+ // 削除したので同じ名前で登録出来る
268+ $test->assertTrue($make(null, true)->mkUser('verify03', $pass, $pass, 'v3@076.co.jp')->isSuccess);
269+ });
270+});
151271
152- $expect = 'エラー:パスワードは2つ以上の大文字、2つ以上の小文字、2つ以上の数字、及び2つ以上の特別文字を含む事が必須です。'.'<br />';
153-
154- $test->assertNotNull($res);
155- $test->assertEquals($res->message, $expect);
156- });
157-
158- $test->it('不正なメールアドレスでアカウントを作成出来ないはず', function($test): void {
159- $user = "admin5678";
160- $pass = "testTEST1234!#$%@[]:;";
161- $passVerify = "testTEST1234!#$%@[]:;";
162- $email = "test@076.niggerballs";
163-
164- $auth = new Auth();
165- $res = $auth->mkUser($user, $pass, $passVerify, $email);
166-
167- $expect = 'エラー:メールアドレスは不正です。'.'<br />';
168-
169- $test->assertNotNull($res);
170- $test->assertEquals($res->message, $expect);
171-
172- $email = "test@076";
173- $res = $auth->mkUser($user, $pass, $passVerify, $email);
174-
175- $expect = 'エラー:メールアドレスは不正です。'.'<br />';
176-
177- $test->assertNotNull($res);
178- $test->assertEquals($res->message, $expect);
179- });
180-
181- // ユーザーの削除
182- $users = scandir(ROOT.'/data/user');
183- $userFile = '';
184- foreach ($users as $u) {
185- if (str_contains($u, 'admin1234')) {
186- $userFile = $u;
187- break;
188- }
189- }
190- unlink(ROOT.'/data/user/'.$userFile);
191- rmdir(ROOT.'/public/static/user/admin1234');
192- });
272+// 一時ディレクトリを削除する
273+$rm = function(string $dir) use (&$rm): void {
274+ foreach (scandir($dir) ?: [] as $f) {
275+ if ($f === '.' || $f === '..') continue;
276+ is_dir("{$dir}/{$f}") ? $rm("{$dir}/{$f}") : @unlink("{$dir}/{$f}");
193277 }
278+ @rmdir($dir);
279+};
280+$rm($base);
194281
195- // TODO: CLIからクッキーを設定出来ないから、「Lib\Auth::getUserData(): Return value must be of type stdClass, null returned」って発生する・・・
196- // $test->describe('ログインとログアウト', function($test): void {
197- // $user = "admin1234";
198- // $pass = "testTEST1234!#$%@[]:;";
199- // $passVerify = "testTEST1234!#$%@[]:;";
200- // $email = "test@076.co.jp";
201-
202- // $auth = new Auth();
203- // $res = $auth->mkUser($user, $pass, $passVerify, $email);
204- // unset($auth);
205-
206- // $test->it('正しい認証情報でログイン出来るはず', function($test) use ($user, $pass): void {
207- // $auth = new Auth();
208- // $res = $auth->setToken($user, $pass);
209-
210- // $test->assertTrue($res->isSuccess);
211- // $test->assertEquals('ログイン成功', $res->message);
212-
213- // $auth2 = new Auth();
214- // $usr = $auth2->getLoggedInUser();
215-
216- // $test->assertNotNull($usr);
217- // $test->assertEquals($user, $usr->username);
218- // });
219-
220- // $test->it('間違ったパスワードでログイン出来ないはず', function($test) use ($user): void {
221- // $auth = new Auth();
222- // $res = $auth->setToken($user, 'fuseipassword');
223-
224- // $test->assertFalse($res->isSuccess);
225- // $test->assertEquals('ユーザー名又はパスワードが一致していません', $res->message);
226- // });
227-
228- // $test->it('存在しないユーザーでログイン出来ないはず', function($test) use ($pass): void {
229- // $auth = new Auth();
230- // $res = $auth->setToken('fumeiuser', $pass);
231-
232- // $test->assertFalse($res->isSuccess);
233- // $test->assertEquals('ユーザー名又はパスワードが一致していません', $res->message);
234- // });
235-
236- // $test->it('ログアウト出来るはず', function($test) use ($user, $pass): void {
237- // $auth = new Auth();
238- // $auth->setToken($user, $pass);
239-
240- // $res = $auth->logout();
241-
242- // $test->assertTrue($res->isSuccess);
243- // $test->assertEquals('ログイン成功', $res->message);
244-
245- // $auth2 = new Auth();
246- // $usr = $auth2->getLoggedInUser();
247-
248- // $test->assertNull($usr);
249- // });
250-
251- // // ユーザーの削除
252- // $users = scandir(ROOT.'/data/user');
253- // $userFile = '';
254- // foreach ($users as $u) {
255- // if (str_contains($u, 'admin1234')) {
256- // $userFile = $u;
257- // break;
258- // }
259- // }
260- // unlink(ROOT.'/data/user/'.$userFile);
261- // rmdir(ROOT.'/public/static/user/admin1234');
262- // });
263-
264- $test->printSummary();
265-}
282+$test->printSummary();
変更 util.php
@@ -36,7 +36,7 @@
3636 OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
3737 */
3838 if (!CURL_ENABLED) define('ACTIVITYPUB_ENABLED', false);
39-foreach (['MAILER_ENABLED', 'OPENPROVIDER_ENABLED', 'CRON_ENABLED'] as $c) if (!defined($c)) define($c, false);
39+foreach (['MAILER_ENABLED', 'OPENPROVIDER_ENABLED', 'CRON_ENABLED', 'AUTH_VERIFY_EMAIL'] as $c) if (!defined($c)) define($c, false);
4040
4141 enum LogType {
4242 case ActivityPub;
@@ -32,8 +32,8 @@
3232 <td></td>
3333 <td>
3434 <div style="position:absolute; left:-9999px;">
35- <input type="text" id="{{ $username }}" name="{{ $botUsername }} value="" />
36- <input type="password" id="{{ $password }}" name="{{ $botPassword }}" value="" />
35+ <input type="text" id="{{ $botUsername }}" name="{{ $botUsername }}" value="" />
36+ <input type="password" id="{{ $botPassword }}" name="{{ $botPassword }}" value="" />
3737 </div>
3838
3939 <a href="/?bot=1" style="display:none"></a>
@@ -44,7 +44,7 @@
4444 <td></td>
4545 <td>
4646 <div style="position:absolute; left:-9999px;">
47- <input type="text" id="{{ $botUsername }}" name="{{ $botUsername }} value="" />
47+ <input type="text" id="{{ $botUsername }}" name="{{ $botUsername }}" value="" />
4848 <input type="password" id="{{ $botPassword }}" name="{{ $botPassword }}" value="" />
4949 <input type="password" id="{{ $botPasswordVerify }}" name="{{ $botPasswordVerify }}" />
5050 <input type="email" id="{{ $botEmail }}" name="{{ $botEmail }}" value="" />
@@ -0,0 +1,20 @@
1+{@ include(common/header) @}
2+ <h1 class="paragraph">{{ $pagetit }}</h1>
3+
4+{@ if ($error) @}
5+ <p>
6+ <div class="errormes">
7+ {{ $error }}
8+ </div>
9+ </p>
10+{@ endif @}
11+{@ if ($message) @}
12+ <p class="paragraph">
13+ {{ $message }}
14+ </p>
15+{@ endif @}
16+
17+ <p class="paragraph">
18+ <a href="/login">ログイン</a>
19+ </p>
20+{@ include(common/footer) @}