MFAライブラリ

守矢諏訪子 <suwako@076.moe>、2026-11-11 09:26
コミット a68c17798f8347a8dbc0eb632f074c13d1bc8b01 ・親 459b522 ・ファイルを見る

14個のファイル、+1312 -9

変更 README-JP.md
@@ -47,6 +47,8 @@
4747 * Maron テンプレートエンジン
4848 * カスタム Markdown
4949 * ActivityPub
50+* 画像ライブラリ(PNG・APNG・BMP・TGA・GIF・JPEG・WebP の読み書き。GD 等の拡張は不要)
51+* 二段階認証(認証アプリのワンタイムパスワード、QR コード)
5052 * 多言語対応
5153 * マルチブログ対応
5254 * 100% 綺麗で正しい HTML5 と CSS3
@@ -223,10 +225,15 @@
223225 php cron.php add "* * * * *" apdeliver
224226 php cron.php add "@daily" apcleanup
225227 ```
226-アップロード出来るのは画像(PNG・JPEG・GIF、20 MiB まで)と動画(MP4・MKV・WebM、400 MiB まで)で、メタデータ(Exif、テキスト、MP4 の撮影日時・位置等)を取り除きます。動画は変換しないので、ブラウザで再生出来る形式(H.264 等)でアップロードして下さい。\
228+アップロード出来るのは画像(PNG・JPEG・GIF・WebP、20 MiB まで)と動画(MP4・MKV・WebM、400 MiB まで)で、メタデータ(Exif、XMP、テキスト、MP4 の撮影日時・位置等)を取り除きます。動画は変換しないので、ブラウザで再生出来る形式(H.264 等)でアップロードして下さい。\
227229 大きいファイルを受け付ける様に、PHP の `upload_max_filesize` と `post_max_size`(添付4つ分)、httpd の `connection max request body`(既定は1 MiB、超えると「413 Payload Too Large」)も設定して下さい。動画を再生出来る様に、httpd の `types` に `include "/usr/share/misc/mime.types"` もお勧めします。
228230 他のサーバーのアイコンや画像は `/microblog/media` 経由で表示するので、CSP の `img-src` は `'self'` のままで構いません(閲覧者の IP アドレスも相手のサーバーに渡りません)。画像は `data/ap/remote/media/` に1週間キャッシュします。\
229231 ユーザーの鍵は初めて使う時に作ります。chroot(OpenBSD の httpd と php-fpm 等)では `openssl.cnf` が見えないので、`data/ap/openssl.cnf`(空)を使います。
232+
233+### 二段階認証
234+`config/config.php` で `AUTH_2FA_ENABLED` を `true` にすると、各ユーザーがプロフィールの編集の「二段階認証」から有効に出来ます。`false` なら誰も使えず、有効にしていたユーザーもパスワードだけでログインします。\
235+設定画面には QR コードと秘密鍵(PC のパスワードマネージャー用)が表示されるので、認証アプリ(Aegis、Google Authenticator、KeePassXC 等)に登録して、表示された6桁のコードを入力すると有効になります。以後はパスワードの後にコードを入力します(TOTP、SHA-1、6桁、30秒。同じコードは2度使えません)。\
236+端末を失くした場合は、管理者がそのユーザーの設定画面(`/profile/ユーザー名/2fa`)で自分のパスワードを入力すると無効に出来ます。
230237
231238 ## 必要な PHP モジュール
232239 * php_gmp
@@ -80,5 +80,6 @@
8080 define('OPENPROVIDER_ENABLED', false);
8181 define('AUTH_REGISTER_ENABLED', false);
8282 define('AUTH_VERIFY_EMAIL', false); // 登録時にメールアドレスの確認を必須にする(MAILER_ENABLEDも必要)
83+define('AUTH_2FA_ENABLED', false); // 二段階認証(認証アプリのコード)を使えるようにする(各ユーザーがプロフィールの編集から有効にする)
8384 define('COPYRIGHT_YEAR', '2018-'.date('Y'));
8485 define('DEBUG_MODE', false);
変更 route.php
@@ -122,6 +122,9 @@
122122 $routes[] = Route::add('GET', 'profile/{name}', User::class.'@profile');
123123 $routes[] = Route::add('GET', 'profile/{name}/edit', User::class.'@editProfile');
124124 $routes[] = Route::add('POST', 'profile/{name}/edit', User::class.'@editProfile');
125+ $routes[] = Route::add('GET', 'profile/{name}/2fa', User::class.'@twoFactor');
126+ $routes[] = Route::add('POST', 'profile/{name}/2fa', User::class.'@twoFactor');
127+ $routes[] = Route::add('GET', 'profile/{name}/2fa/qr', User::class.'@twoFactorQr');
125128
126129 $routes[] = Route::add('GET', 'memberonly', Page::class.'@memberonly');
127130 $routes[] = Route::add('GET', 'staffonly', Page::class.'@staffonly');
@@ -39,6 +39,8 @@
3939
4040 use Site\Controller\Mods;
4141 use Std\Lib\Auth;
42+use Std\Lib\Image;
43+use Std\Lib\Qr;
4244 use Std\Lib\Template;
4345 use Std\Lib\Fedi\Actor;
4446 use Std\Lib\Fedi\Social;
@@ -60,6 +62,8 @@
6062 $doLogin = $_SERVER['REQUEST_METHOD'] === 'POST';
6163 $error = '';
6264 $nyuU = '';
65+ // 二段階認証のコードの入力中
66+ $ticket = '';
6367
6468 if ($doLogin) {
6569 if (!is_string($_POST['csrf_token'] ?? null) || !\verify_csrf_token($_POST['csrf_token'])) {
@@ -88,7 +92,25 @@
8892 $username = is_string($a[0] ?? null) ? $a[0] : '';
8993 $password = is_string($a[1] ?? null) ? $a[1] : '';
9094
91- if ($username === '' || $password === '') {
95+ if (is_string($_POST['ticket'] ?? null)) {
96+ // 2段階目:認証アプリのコード
97+ $nyuU = is_string($_POST['username'] ?? null) ? $_POST['username'] : '';
98+ if ($auth->isLoginThrottled($nyuU)) {
99+ $error = 'エラー:ログインの試行回数が多過ぎます。暫くしてから再度お試し下さい。';
100+ $ticket = $_POST['ticket'];
101+ } else {
102+ $auth = new Auth($nyuU);
103+ $result = $auth->verifyTwoFactor($nyuU, $_POST['ticket'], is_string($_POST['code'] ?? null) ? $_POST['code'] : '');
104+ if ($result->isSuccess) {
105+ $auth->clearLoginFailures();
106+ header('Location: /');
107+ exit();
108+ }
109+ $auth->recordLoginFailure($nyuU);
110+ $error = $result->message;
111+ $ticket = $result->data['ticket'] ?? '';
112+ }
113+ } else if ($username === '' || $password === '') {
92114 $error = $loginError;
93115 } else if ($auth->isLoginThrottled($username)) {
94116 $nyuU = $username;
@@ -97,7 +119,11 @@
97119 $auth = new Auth($username);
98120 $res = $auth->isUserExist($username);
99121 $result = $res->isSuccess ? $auth->setToken($username, $password) : \Result::Error($loginError);
100- if (!$result->isSuccess) {
122+ if (!empty($result->data['twoFactor'])) {
123+ // パスワードは合っている。コードの画面へ
124+ $nyuU = $username;
125+ $ticket = $result->data['ticket'];
126+ } else if (!$result->isSuccess) {
101127 $auth->recordLoginFailure($username);
102128 $nyuU = $username;
103129 $error = $res->isSuccess ? $result->message : $loginError;
@@ -119,8 +145,9 @@
119145 $tmpl->assign('description', $description);
120146 $tmpl->assign('error', $error);
121147 $tmpl->assign('nyuU', $nyuU);
148+ $tmpl->assign('ticket', $ticket);
122149
123- $tmpl->render('login');
150+ $tmpl->render($ticket !== '' ? 'twofactor' : 'login');
124151 } catch (\Exception $e) {
125152 throw new \Exception($e->getMessage());
126153 }
@@ -319,8 +346,76 @@
319346 $tmpl->assign('roles', Roles::LABELS);
320347 $tmpl->assign('fediEnabled', ACTIVITYPUB_MULTIUSER);
321348 $tmpl->assign('locked', ACTIVITYPUB_MULTIUSER && $edit->fedi ? (bool)Actor::settings($name)['locked'] : false);
349+ $tmpl->assign('twoFactor', $auth->twoFactorAvailable());
322350 $tmpl->assign('csrf', make_csrf_token());
323351 $tmpl->render('editprofile');
352+ }
353+
354+ /**
355+ * 二段階認証の設定(QRコードと秘密鍵、有効・無効)
356+ */
357+ public function twoFactor(array $params): void {
358+ if (!AUTH_ENABLED) return;
359+ $name = (string)($params['name'] ?? '');
360+ $auth = new Auth;
361+ $user = $auth->getLoggedInUser();
362+ if (!$user) {
363+ header('Location: /login');
364+ exit();
365+ }
366+
367+ $error = '';
368+ $message = '';
369+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
370+ if (!is_string($_POST['csrf_token'] ?? null) || !\verify_csrf_token($_POST['csrf_token'])) {
371+ $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
372+ } else {
373+ $code = (string)($_POST['code'] ?? '');
374+ $res = ($_POST['action'] ?? '') === 'disable'
375+ ? $auth->disableTwoFactor($name, (string)($_POST['password'] ?? ''), $code)
376+ : $auth->enableTwoFactor($name, $code);
377+ if ($res->isSuccess) $message = (string)$res->message;
378+ else $error = (string)$res->message;
379+ }
380+ }
381+ $status = $auth->twoFactorStatus($name);
382+ if (!$status->isSuccess) {
383+ header('Location: /404');
384+ exit();
385+ }
386+ $s = (object)$status->data;
387+
388+ $tmpl = new Template('/');
389+ $tmpl->assign('pagetit', '二段階認証');
390+ $tmpl->assign('curPage', 'auth');
391+ $tmpl->assign('custCss', false);
392+ $tmpl->assign('menu', $this->getMenu());
393+ $tmpl->assign('description', '');
394+ $tmpl->assign('user', $user);
395+ $tmpl->assign('s', $s);
396+ // 手で入力し易い様に4文字毎に区切る
397+ $tmpl->assign('secretGroups', isset($s->secret) ? trim(chunk_split($s->secret, 4, ' ')) : '');
398+ $tmpl->assign('error', $error);
399+ $tmpl->assign('message', $message);
400+ $tmpl->assign('csrf', make_csrf_token());
401+ $tmpl->render('twofactorsetup');
402+ }
403+
404+ /**
405+ * 設定中の秘密鍵のQRコード(PNG、本人だけ)
406+ */
407+ public function twoFactorQr(array $params): void {
408+ if (!AUTH_ENABLED) return;
409+ $auth = new Auth;
410+ $status = $auth->getLoggedInUser() ? $auth->twoFactorStatus((string)($params['name'] ?? '')) : null;
411+ if (!$status || !isset($status->data['uri'])) {
412+ http_response_code(404);
413+ exit();
414+ }
415+ header('Content-Type: image/png');
416+ header('Cache-Control: no-store');
417+ echo Image::encode((new Qr($status->data['uri']))->bitmap(4), 'image/png');
418+ exit();
324419 }
325420
326421 public function profile(array $params): void {
@@ -55,6 +55,8 @@
5555 protected int $tokenDuration = 31536000; // 1年間
5656 protected int $verifyDuration = 86400; // 確認メールのリンクの有効期限(24時間)
5757 protected int $verifyResendInterval = 600; // 確認メールの再送の間隔(10分)
58+ protected int $twoFactorDuration = 300; // ログインの2段階目(コードの入力)の有効期限(5分)
59+ protected int $twoFactorSetupDuration = 3600; // 二段階認証の設定中の秘密鍵の有効期限(1時間)
5860 /** アップロードをis_uploaded_fileで確認する(テストではfalse) */
5961 protected bool $checkUploads = true;
6062 protected string|int|null $algo = PASSWORD_ARGON2ID;
@@ -73,6 +75,7 @@
7375 unset($this->pubUser->password);
7476 unset($this->pubUser->tokens);
7577 unset($this->pubUser->verifyToken);
78+ unset($this->pubUser->twoFactor, $this->pubUser->twoFactorPending, $this->pubUser->twoFactorTicket);
7679 $this->pubUser->avatarUrl = avatar_url($this->pubUser);
7780 return $this->pubUser;
7881 }
@@ -83,6 +86,7 @@
8386 unset($user->password);
8487 unset($user->tokens);
8588 unset($user->verifyToken, $user->verifyExpire, $user->verifySent);
89+ unset($user->twoFactor, $user->twoFactorPending, $user->twoFactorTicket);
8690 $myself = $this->getUserData();
8791 // 以前は「role < STAFF」で比べていたので、ビットを足すと(Fediverse等)全員にメールアドレスが見えた
8892 $isStaff = $myself && ($myself->role & (Roles::STAFF | Roles::ADMIN));
@@ -140,6 +144,21 @@
140144 .($resent ? '確認メールを再送しました。' : ''), ['unverified' => true]);
141145 }
142146
147+ // 二段階認証:パスワードが合っても、認証アプリのコードを確かめるまでログインしない
148+ if ($this->twoFactorAvailable() && !empty($userData->twoFactor->secret)) {
149+ $ticket = BaseN::encode64(random_bytes(32), true, false);
150+ $userData->twoFactorTicket = (object)['hash' => hash('sha256', $ticket), 'expire' => time() + $this->twoFactorDuration, 'tries' => 0];
151+ if (!$this->saveUser($userData, 'setToken')) return Result::Error('エラー:ユーザーデータの保存に失敗。');
152+ return Result::Error('認証アプリのコードを入力して下さい。', ['twoFactor' => true, 'ticket' => $ticket]);
153+ }
154+
155+ return $this->startSession($userData);
156+ }
157+
158+ /**
159+ * ログインのトークンを作り、クッキーに入れる
160+ */
161+ private function startSession(\stdClass $userData): Result {
143162 $ip = $_SERVER['REMOTE_ADDR'] ?? '';
144163 $res = $this->purgeOldTokens($userData);
145164 if (!$res->isSuccess) return Result::Error($res->message);
@@ -220,6 +239,131 @@
220239 }
221240
222241 /**
242+ * 二段階認証を使えるか(config.phpのAUTH_2FA_ENABLED。無効なら誰も使えず、ログインでもコードを聞かない)
243+ */
244+ public function twoFactorAvailable(): bool {
245+ return AUTH_ENABLED && defined('AUTH_2FA_ENABLED') && AUTH_2FA_ENABLED;
246+ }
247+
248+ /**
249+ * ログインの2段階目:setTokenが返したチケットと、認証アプリのコードを確かめてログインする
250+ *
251+ * @param string $username ユーザー名(new Auth($username)と同じ)
252+ * @param string $ticket setTokenのdataのticket
253+ * @param string $code 6桁のコード
254+ * @return Result 間違えてもまだ試せる場合は、dataにticket(5回間違えたら最初からやり直し)
255+ */
256+ public function verifyTwoFactor(string $username, #[SensitiveParameter] string $ticket, string $code): Result {
257+ if (!$this->twoFactorAvailable()) return Result::Error('エラー:二段階認証は無効です。');
258+ $userData = $this->id > 0 ? $this->getUserData() : null;
259+ $t = $userData->twoFactorTicket ?? null;
260+ if (!$userData || $userData->username !== $username || !$t instanceof \stdClass || ($t->expire ?? 0) < time()
261+ || !hash_equals((string)($t->hash ?? ''), hash('sha256', $ticket))) {
262+ return Result::Error('エラー:有効期限が切れました。もう一度ログインして下さい。');
263+ }
264+ $secret = (string)($userData->twoFactor->secret ?? '');
265+ $step = $secret === '' ? null : Totp::verify($secret, $code, (int)($userData->twoFactor->lastStep ?? -1));
266+ if ($step === null) {
267+ $t->tries = (int)($t->tries ?? 0) + 1;
268+ $retry = $t->tries < 5;
269+ if (!$retry) unset($userData->twoFactorTicket);
270+ $this->saveUser($userData, 'verifyTwoFactor');
271+ return $retry
272+ ? Result::Error('エラー:コードが一致していません。', ['ticket' => $ticket])
273+ : Result::Error('エラー:コードを間違えた回数が多過ぎます。もう一度ログインして下さい。');
274+ }
275+ // 同じコードをもう一度使えない様に
276+ $userData->twoFactor->lastStep = $step;
277+ unset($userData->twoFactorTicket);
278+ if (!$this->saveUser($userData, 'verifyTwoFactor')) return Result::Error('エラー:ユーザーデータの保存に失敗。');
279+ return $this->startSession($userData);
280+ }
281+
282+ /**
283+ * 二段階認証の設定画面のデータ
284+ *
285+ * 本人がまだ有効にしていなければ秘密鍵を作り(有効にするか期限が切れるまで同じ物)、QRコード用のURIも返す。
286+ *
287+ * @return Result data:enabled、self(本人か)、username、secret・uri(本人で未設定の時だけ)
288+ */
289+ public function twoFactorStatus(string $username): Result {
290+ [$res, $me, $user] = $this->twoFactorTarget($username);
291+ if (!$res->isSuccess) return $res;
292+ $data = ['enabled' => !empty($user->twoFactor->secret), 'self' => $me->id == $user->id, 'username' => $user->username];
293+ if ($data['self'] && !$data['enabled']) {
294+ $p = $user->twoFactorPending ?? null;
295+ if (!$p instanceof \stdClass || ($p->expire ?? 0) < time()) {
296+ $p = (object)['secret' => Totp::secret(), 'expire' => time() + $this->twoFactorSetupDuration];
297+ $user->twoFactorPending = $p;
298+ if (!$this->saveUser($user, 'twoFactorStatus')) return Result::Error('エラー:ユーザーデータの保存に失敗。');
299+ }
300+ $data['secret'] = $p->secret;
301+ $data['uri'] = Totp::uri($p->secret, $user->username, (string)SITEINFO['title']);
302+ }
303+ return Result::Success('', $data);
304+ }
305+
306+ /**
307+ * 設定画面の秘密鍵で作ったコードを確かめて、二段階認証を有効にする(本人だけ)
308+ */
309+ public function enableTwoFactor(string $username, string $code): Result {
310+ [$res, $me, $user] = $this->twoFactorTarget($username);
311+ if (!$res->isSuccess) return $res;
312+ if ($me->id != $user->id) return Result::Error('エラー:二段階認証は本人だけが有効に出来ます。');
313+ if (!empty($user->twoFactor->secret)) return Result::Error('エラー:二段階認証は既に有効です。');
314+ $p = $user->twoFactorPending ?? null;
315+ if (!$p instanceof \stdClass || ($p->expire ?? 0) < time()) return Result::Error('エラー:有効期限が切れました。新しいQRコードを読み取って下さい。');
316+ $step = Totp::verify((string)$p->secret, $code);
317+ if ($step === null) return Result::Error('エラー:コードが一致していません。端末の時計が合っているかもご確認下さい。');
318+ $user->twoFactor = (object)['secret' => $p->secret, 'lastStep' => $step, 'since' => time()];
319+ unset($user->twoFactorPending);
320+ if (!$this->saveUser($user, 'enableTwoFactor')) return Result::Error('エラー:ユーザーデータの保存に失敗。');
321+ return Result::Success('二段階認証を有効にしました。次回のログインから認証アプリのコードが必要です。');
322+ }
323+
324+ /**
325+ * 二段階認証を無効にする
326+ *
327+ * 本人はパスワードとコード、管理者が他のユーザーの分を無効にする場合(端末を失くした時等)は管理者のパスワード。
328+ */
329+ public function disableTwoFactor(string $username, #[SensitiveParameter] string $password, string $code = ''): Result {
330+ [$res, $me, $user] = $this->twoFactorTarget($username);
331+ if (!$res->isSuccess) return $res;
332+ if (empty($user->twoFactor->secret)) return Result::Error('エラー:二段階認証は有効ではありません。');
333+ if (!password_verify($password, (string)$me->password)) return Result::Error('エラー:パスワードが一致していません。');
334+ if ($me->id == $user->id && Totp::verify((string)$user->twoFactor->secret, $code, (int)($user->twoFactor->lastStep ?? -1)) === null) {
335+ return Result::Error('エラー:コードが一致していません。');
336+ }
337+ unset($user->twoFactor, $user->twoFactorTicket);
338+ if (!$this->saveUser($user, 'disableTwoFactor')) return Result::Error('エラー:ユーザーデータの保存に失敗。');
339+ return Result::Success('二段階認証を無効にしました。');
340+ }
341+
342+ /**
343+ * 二段階認証を設定する相手(自分、又は管理者なら他のユーザー)
344+ *
345+ * @return array [Result, 自分, ユーザー]
346+ */
347+ private function twoFactorTarget(string $username): array {
348+ if (!$this->twoFactorAvailable()) return [Result::Error('エラー:二段階認証は無効です。'), null, null];
349+ $me = $this->id > 0 ? $this->getUserData() : null;
350+ $path = $this->userPath($username);
351+ $user = $path !== null ? json_decode((string)file_get_contents($path)) : null;
352+ if (!$me || !$user instanceof \stdClass) return [Result::Error('エラー:ユーザーが見つかりません。'), null, null];
353+ if ($me->id != $user->id && !($me->role & Roles::ADMIN)) return [Result::Error('エラー:権限がありません。'), null, null];
354+ return [Result::Success(), $me, $user];
355+ }
356+
357+ /** ユーザーのJSONファイルを書き換える */
358+ private function saveUser(\stdClass $user, string $where): bool {
359+ $path = $this->dataDir.$user->id.'.'.$user->username.'.json';
360+ $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
361+ if (file_put_contents($path, $json, LOCK_EX) !== false) return true;
362+ logger(LogType::Auth, "【{$where}】JSONファイルを変更出来なかった。chownをご確認下さい。パス:{$path}");
363+ return false;
364+ }
365+
366+ /**
223367 * ログインの試行回数を確認する(IP毎:15分で5回、ユーザー名毎:1時間で20回)
224368 *
225369 * @param string $username ユーザー名
@@ -538,7 +682,8 @@
538682 $user = json_decode((string)file_get_contents($path));
539683 if (!$user instanceof \stdClass) return null;
540684 if ($me->id != $user->id && !($me->role & Roles::ADMIN)) return null;
541- unset($user->password, $user->tokens, $user->verifyToken);
685+ $user->twoFactorOn = !empty($user->twoFactor->secret);
686+ unset($user->password, $user->tokens, $user->verifyToken, $user->twoFactor, $user->twoFactorPending, $user->twoFactorTicket);
542687 $user->isSelf = $me->id == $user->id;
543688 $user->isAdmin = (bool)($me->role & Roles::ADMIN);
544689 $user->fedi = can_federate($user);
@@ -0,0 +1,119 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, 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+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Lib;
39+
40+/**
41+ * Base32とBase64(RFC 4648)
42+ *
43+ * Base32は二段階認証の秘密鍵(大文字・小文字、空白、ハイフン、パディングの有無は問わずに読む)、
44+ * Base64はURL用の変種(「-」と「_」)も使える。
45+ */
46+final class BaseN {
47+ private const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
48+ private const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
49+ private const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
50+
51+ public static function encode32(string $data, bool $pad = true): string {
52+ return self::encode($data, self::B32, 5, $pad ? 8 : 1);
53+ }
54+
55+ /**
56+ * @throws \InvalidArgumentException Base32ではない文字、又は有り得ない長さ
57+ */
58+ public static function decode32(string $text): string {
59+ $text = rtrim(strtoupper(str_replace([' ', '-', "\t", "\r", "\n"], '', $text)), '=');
60+ // 8文字(40ビット)の内、1・3・6文字で終わる事は無い
61+ if (in_array(strlen($text) % 8, [1, 3, 6], true)) throw new \InvalidArgumentException('Base32の長さが不正です。');
62+ return self::decode($text, self::B32, 5);
63+ }
64+
65+ /**
66+ * @param bool $url URL用(「+/」の代わりに「-_」)
67+ */
68+ public static function encode64(string $data, bool $url = false, bool $pad = true): string {
69+ return self::encode($data, $url ? self::B64URL : self::B64, 6, $pad ? 4 : 1);
70+ }
71+
72+ /**
73+ * @throws \InvalidArgumentException Base64ではない文字、又は有り得ない長さ
74+ */
75+ public static function decode64(string $text, bool $url = false): string {
76+ $text = rtrim(str_replace([' ', "\t", "\r", "\n"], '', $text), '=');
77+ if (strlen($text) % 4 === 1) throw new \InvalidArgumentException('Base64の長さが不正です。');
78+ return self::decode($text, $url ? self::B64URL : self::B64, 6);
79+ }
80+
81+ /**
82+ * @param int $bits 1文字のビット数
83+ * @param int $block パディングで揃える文字数(1なら付けない)
84+ */
85+ private static function encode(string $data, string $alphabet, int $bits, int $block): string {
86+ $out = '';
87+ $acc = 0;
88+ $n = 0;
89+ $mask = (1 << $bits) - 1;
90+ for ($i = 0, $len = strlen($data); $i < $len; $i++) {
91+ $acc = (($acc << 8) | ord($data[$i])) & 0xffff;
92+ $n += 8;
93+ while ($n >= $bits) {
94+ $n -= $bits;
95+ $out .= $alphabet[($acc >> $n) & $mask];
96+ }
97+ }
98+ if ($n > 0) $out .= $alphabet[($acc << ($bits - $n)) & $mask];
99+ $rest = strlen($out) % $block;
100+ return $rest ? $out.str_repeat('=', $block - $rest) : $out;
101+ }
102+
103+ private static function decode(string $text, string $alphabet, int $bits): string {
104+ $out = '';
105+ $acc = 0;
106+ $n = 0;
107+ for ($i = 0, $len = strlen($text); $i < $len; $i++) {
108+ $v = strpos($alphabet, $text[$i]);
109+ if ($v === false) throw new \InvalidArgumentException('不正な文字があります。');
110+ $acc = (($acc << $bits) | $v) & 0xffff;
111+ $n += $bits;
112+ if ($n >= 8) {
113+ $n -= 8;
114+ $out .= chr(($acc >> $n) & 0xff);
115+ }
116+ }
117+ return $out;
118+ }
119+}
@@ -184,7 +184,7 @@
184184 if (!preg_match('/^\d+\.'.$username.'\.json$/', basename($f))) continue;
185185 $u = json_decode((string)file_get_contents($f));
186186 if (!$u instanceof \stdClass || ($u->username ?? '') !== $username) continue;
187- unset($u->password, $u->tokens, $u->verifyToken);
187+ unset($u->password, $u->tokens, $u->verifyToken, $u->twoFactor, $u->twoFactorPending, $u->twoFactorTicket);
188188 return $u;
189189 }
190190 return null;
@@ -0,0 +1,391 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, 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+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Lib;
39+
40+use Std\Lib\Image\Bitmap;
41+
42+/**
43+ * QRコード(ISO/IEC 18004)
44+ *
45+ * 型番1~40、誤り訂正L・M・Q・H、数字・英数字・バイト(UTF-8)のモード、8つのマスクから減点の一番少ない物。
46+ * 画像にはbitmap()(PNG等はImage::encodeで)。
47+ */
48+final class Qr {
49+ /** 誤り訂正のレベル => [形式情報のビット, 1ブロックの誤り訂正の数(型番1~40), ブロックの数] */
50+ private const EC = [
51+ 'L' => [1, [7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
52+ [1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25]],
53+ 'M' => [0, [10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
54+ [1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49]],
55+ 'Q' => [3, [13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
56+ [1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68]],
57+ 'H' => [2, [17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
58+ [1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81]],
59+ ];
60+ private const ALNUM = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
61+
62+ public int $version;
63+ public int $size;
64+ public int $mask;
65+ public string $level;
66+ /** 行毎のモジュール(trueが黒) */
67+ public array $modules = [];
68+ /** 機能パターン(データを置かない所) */
69+ private array $function = [];
70+
71+ /**
72+ * @param string $text 中身(数字だけ、英大文字等だけなら小さいモードを使う)
73+ * @param string $level 誤り訂正(L:7%、M:15%、Q:25%、H:30%)
74+ * @param int $minVersion 最小の型番
75+ * @throws \InvalidArgumentException 入り切らない場合
76+ */
77+ public function __construct(string $text, string $level = 'M', int $minVersion = 1) {
78+ if (!isset(self::EC[$level])) throw new \InvalidArgumentException('誤り訂正のレベルはL・M・Q・Hです。');
79+ $this->level = $level;
80+ [$mode, $count] = match (true) {
81+ preg_match('/^\d*$/', $text) === 1 => [1, strlen($text)],
82+ strspn($text, self::ALNUM) === strlen($text) => [2, strlen($text)],
83+ default => [4, strlen($text)],
84+ };
85+ for ($v = max(1, $minVersion); $v <= 40; $v++) {
86+ $bits = self::segment($text, $mode, $count, $v);
87+ if ($bits !== null && strlen($bits) <= self::dataCodewords($v, $level) * 8) break;
88+ }
89+ if ($v > 40) throw new \InvalidArgumentException('QRコードに入り切りません。');
90+ $this->version = $v;
91+ $this->size = $v * 4 + 17;
92+
93+ // 終端(4ビットまで)、8ビットに揃え、残りは0xEC・0x11の繰り返し
94+ $cap = self::dataCodewords($v, $level) * 8;
95+ $bits .= str_repeat('0', min(4, $cap - strlen($bits)));
96+ $bits .= str_repeat('0', (8 - strlen($bits) % 8) % 8);
97+ for ($pad = 0xec; strlen($bits) < $cap; $pad ^= 0xec ^ 0x11) $bits .= sprintf('%08b', $pad);
98+ $data = array_map('bindec', str_split($bits, 8));
99+
100+ $this->modules = array_fill(0, $this->size, array_fill(0, $this->size, false));
101+ $this->function = $this->modules;
102+ $this->drawFunctions();
103+ $this->drawCodewords(self::interleave($data, $v, $level));
104+
105+ // 減点の一番少ないマスク
106+ $best = null;
107+ for ($m = 0; $m < 8; $m++) {
108+ $this->applyMask($m);
109+ $this->drawFormat($m);
110+ $p = $this->penalty();
111+ if ($best === null || $p < $best[0]) $best = [$p, $m];
112+ $this->applyMask($m);
113+ }
114+ $this->mask = $best[1];
115+ $this->applyMask($this->mask);
116+ $this->drawFormat($this->mask);
117+ unset($this->function);
118+ }
119+
120+ /**
121+ * 画像にする
122+ *
123+ * @param int $scale 1モジュールの画素数
124+ * @param int $margin 周りの白(モジュール、規格では4以上)
125+ */
126+ public function bitmap(int $scale = 4, int $margin = 4): Bitmap {
127+ $w = ($this->size + 2 * $margin) * $scale;
128+ $white = str_repeat("\xff\xff\xff\xff", $w);
129+ $data = str_repeat($white, $margin * $scale);
130+ foreach ($this->modules as $row) {
131+ $line = str_repeat("\xff\xff\xff\xff", $margin * $scale);
132+ foreach ($row as $dark) $line .= str_repeat($dark ? "\0\0\0\xff" : "\xff\xff\xff\xff", $scale);
133+ $line .= str_repeat("\xff\xff\xff\xff", $margin * $scale);
134+ $data .= str_repeat($line, $scale);
135+ }
136+ $data .= str_repeat($white, $margin * $scale);
137+ return new Bitmap($w, $w, $data);
138+ }
139+
140+ /**
141+ * モード、文字数、データのビット列(文字数が型番の欄に入らなければnull)
142+ */
143+ private static function segment(string $text, int $mode, int $count, int $v): ?string {
144+ $cbits = match ($mode) {
145+ 1 => $v < 10 ? 10 : ($v < 27 ? 12 : 14),
146+ 2 => $v < 10 ? 9 : ($v < 27 ? 11 : 13),
147+ default => $v < 10 ? 8 : 16,
148+ };
149+ if ($count >= 1 << $cbits) return null;
150+ $bits = sprintf('%04b', $mode).str_pad(decbin($count), $cbits, '0', STR_PAD_LEFT);
151+ if ($mode === 1) {
152+ foreach (str_split($text, 3) as $g) if ($g !== '') $bits .= str_pad(decbin((int)$g), [1 => 4, 2 => 7, 3 => 10][strlen($g)], '0', STR_PAD_LEFT);
153+ } else if ($mode === 2) {
154+ foreach (str_split($text, 2) as $g) {
155+ if ($g === '') continue;
156+ $bits .= strlen($g) === 2
157+ ? str_pad(decbin(strpos(self::ALNUM, $g[0]) * 45 + strpos(self::ALNUM, $g[1])), 11, '0', STR_PAD_LEFT)
158+ : str_pad(decbin(strpos(self::ALNUM, $g)), 6, '0', STR_PAD_LEFT);
159+ }
160+ } else {
161+ for ($i = 0; $i < $count; $i++) $bits .= sprintf('%08b', ord($text[$i]));
162+ }
163+ return $bits;
164+ }
165+
166+ /** データを置けるモジュールの数(機能パターンと形式・型番情報を除く) */
167+ private static function rawModules(int $v): int {
168+ $n = (16 * $v + 128) * $v + 64;
169+ if ($v >= 2) {
170+ $align = intdiv($v, 7) + 2;
171+ $n -= (25 * $align - 10) * $align - 55;
172+ if ($v >= 7) $n -= 36;
173+ }
174+ return $n;
175+ }
176+
177+ private static function dataCodewords(int $v, string $level): int {
178+ [, $ecc, $blocks] = self::EC[$level];
179+ return intdiv(self::rawModules($v), 8) - $ecc[$v - 1] * $blocks[$v - 1];
180+ }
181+
182+ /**
183+ * ブロックに分けて誤り訂正を付け、交互に並べる
184+ */
185+ private static function interleave(array $data, int $v, string $level): array {
186+ [, $eccs, $nblocks] = self::EC[$level];
187+ $ecc = $eccs[$v - 1];
188+ $n = $nblocks[$v - 1];
189+ $total = intdiv(self::rawModules($v), 8);
190+ $short = $n - $total % $n;
191+ $shortLen = intdiv($total, $n);
192+ $gen = self::generator($ecc);
193+ $blocks = [];
194+ $pos = 0;
195+ for ($i = 0; $i < $n; $i++) {
196+ $len = $shortLen - $ecc + ($i < $short ? 0 : 1);
197+ $d = array_slice($data, $pos, $len);
198+ $pos += $len;
199+ $blocks[] = [$d, self::remainder($d, $gen)];
200+ }
201+ $out = [];
202+ for ($i = 0; $i <= $shortLen - $ecc; $i++) {
203+ foreach ($blocks as [$d]) if (isset($d[$i])) $out[] = $d[$i];
204+ }
205+ for ($i = 0; $i < $ecc; $i++) {
206+ foreach ($blocks as [, $e]) $out[] = $e[$i];
207+ }
208+ return $out;
209+ }
210+
211+ /** GF(2^8)(x^8 + x^4 + x^3 + x^2 + 1)の掛け算 */
212+ private static function mul(int $x, int $y): int {
213+ $z = 0;
214+ for ($i = 7; $i >= 0; $i--) {
215+ $z = ($z << 1) ^ (($z >> 7) * 0x11d);
216+ $z ^= (($y >> $i) & 1) * $x;
217+ }
218+ return $z;
219+ }
220+
221+ /** リード・ソロモンの生成多項式(最高次の係数は省く) */
222+ private static function generator(int $degree): array {
223+ $g = array_fill(0, $degree, 0);
224+ $g[$degree - 1] = 1;
225+ $root = 1;
226+ for ($i = 0; $i < $degree; $i++) {
227+ for ($j = 0; $j < $degree; $j++) {
228+ $g[$j] = self::mul($g[$j], $root);
229+ if ($j + 1 < $degree) $g[$j] ^= $g[$j + 1];
230+ }
231+ $root = self::mul($root, 0x02);
232+ }
233+ return $g;
234+ }
235+
236+ private static function remainder(array $data, array $gen): array {
237+ $r = array_fill(0, count($gen), 0);
238+ foreach ($data as $b) {
239+ $f = $b ^ array_shift($r);
240+ $r[] = 0;
241+ foreach ($gen as $i => $c) $r[$i] ^= self::mul($c, $f);
242+ }
243+ return $r;
244+ }
245+
246+ private function set(int $x, int $y, bool $dark): void {
247+ $this->modules[$y][$x] = $dark;
248+ $this->function[$y][$x] = true;
249+ }
250+
251+ /** 位置検出、タイミング、位置合わせのパターン、型番情報(形式情報の場所は先に取っておく) */
252+ private function drawFunctions(): void {
253+ $s = $this->size;
254+ for ($i = 0; $i < $s; $i++) {
255+ $this->set(6, $i, $i % 2 === 0);
256+ $this->set($i, 6, $i % 2 === 0);
257+ }
258+ foreach ([[3, 3], [$s - 4, 3], [3, $s - 4]] as [$cx, $cy]) {
259+ for ($dy = -4; $dy <= 4; $dy++) {
260+ for ($dx = -4; $dx <= 4; $dx++) {
261+ $x = $cx + $dx;
262+ $y = $cy + $dy;
263+ if ($x < 0 || $y < 0 || $x >= $s || $y >= $s) continue;
264+ $d = max(abs($dx), abs($dy));
265+ $this->set($x, $y, $d !== 2 && $d !== 4);
266+ }
267+ }
268+ }
269+ // 位置合わせ(位置検出と重なる3つの角以外)
270+ if ($this->version > 1) {
271+ $n = intdiv($this->version, 7) + 2;
272+ $step = intdiv($this->version * 8 + $n * 3 + 5, $n * 4 - 4) * 2;
273+ $pos = [6];
274+ for ($i = $n - 1; $i >= 1; $i--) $pos[$i] = $s - 7 - ($n - 1 - $i) * $step;
275+ ksort($pos);
276+ foreach ($pos as $i => $x) {
277+ foreach ($pos as $j => $y) {
278+ if (($i === 0 && $j === 0) || ($i === 0 && $j === $n - 1) || ($i === $n - 1 && $j === 0)) continue;
279+ for ($dy = -2; $dy <= 2; $dy++) for ($dx = -2; $dx <= 2; $dx++) $this->set($x + $dx, $y + $dy, max(abs($dx), abs($dy)) !== 1);
280+ }
281+ }
282+ }
283+ $this->drawFormat(0);
284+ if ($this->version >= 7) {
285+ $rem = $this->version;
286+ for ($i = 0; $i < 12; $i++) $rem = ($rem << 1) ^ (($rem >> 11) * 0x1f25);
287+ $bits = ($this->version << 12) | $rem;
288+ for ($i = 0; $i < 18; $i++) {
289+ $dark = (($bits >> $i) & 1) === 1;
290+ $a = $s - 11 + $i % 3;
291+ $b = intdiv($i, 3);
292+ $this->set($a, $b, $dark);
293+ $this->set($b, $a, $dark);
294+ }
295+ }
296+ }
297+
298+ /** 形式情報(誤り訂正のレベルとマスク)の2つの写し */
299+ private function drawFormat(int $mask): void {
300+ $data = (self::EC[$this->level][0] << 3) | $mask;
301+ $rem = $data;
302+ for ($i = 0; $i < 10; $i++) $rem = ($rem << 1) ^ (($rem >> 9) * 0x537);
303+ $bits = (($data << 10) | $rem) ^ 0x5412;
304+ $bit = fn(int $i): bool => (($bits >> $i) & 1) === 1;
305+ $s = $this->size;
306+ for ($i = 0; $i <= 5; $i++) $this->set(8, $i, $bit($i));
307+ $this->set(8, 7, $bit(6));
308+ $this->set(8, 8, $bit(7));
309+ $this->set(7, 8, $bit(8));
310+ for ($i = 9; $i < 15; $i++) $this->set(14 - $i, 8, $bit($i));
311+ for ($i = 0; $i < 8; $i++) $this->set($s - 1 - $i, 8, $bit($i));
312+ for ($i = 8; $i < 15; $i++) $this->set(8, $s - 15 + $i, $bit($i));
313+ $this->set(8, $s - 8, true);
314+ }
315+
316+ /** 右下から2列ずつ、上下にジグザグに置く */
317+ private function drawCodewords(array $data): void {
318+ $s = $this->size;
319+ $total = count($data) * 8;
320+ $i = 0;
321+ for ($right = $s - 1; $right >= 1; $right -= 2) {
322+ if ($right === 6) $right = 5;
323+ $up = (($right + 1) & 2) === 0;
324+ for ($vert = 0; $vert < $s; $vert++) {
325+ $y = $up ? $s - 1 - $vert : $vert;
326+ for ($j = 0; $j < 2; $j++) {
327+ $x = $right - $j;
328+ if ($this->function[$y][$x]) continue;
329+ // 残りのモジュールは0(白)
330+ if ($i < $total) $this->modules[$y][$x] = (($data[$i >> 3] >> (7 - ($i & 7))) & 1) === 1;
331+ $i++;
332+ }
333+ }
334+ }
335+ }
336+
337+ /** マスク(2回掛けると元に戻る) */
338+ private function applyMask(int $m): void {
339+ for ($y = 0; $y < $this->size; $y++) {
340+ for ($x = 0; $x < $this->size; $x++) {
341+ if ($this->function[$y][$x]) continue;
342+ $flip = match ($m) {
343+ 0 => ($x + $y) % 2 === 0,
344+ 1 => $y % 2 === 0,
345+ 2 => $x % 3 === 0,
346+ 3 => ($x + $y) % 3 === 0,
347+ 4 => (intdiv($x, 3) + intdiv($y, 2)) % 2 === 0,
348+ 5 => $x * $y % 2 + $x * $y % 3 === 0,
349+ 6 => ($x * $y % 2 + $x * $y % 3) % 2 === 0,
350+ default => (($x + $y) % 2 + $x * $y % 3) % 2 === 0,
351+ };
352+ if ($flip) $this->modules[$y][$x] = !$this->modules[$y][$x];
353+ }
354+ }
355+ }
356+
357+ /**
358+ * 減点(同じ色の5つ以上の並び、2x2の塊、位置検出に似た模様、黒の割合)
359+ */
360+ private function penalty(): int {
361+ $s = $this->size;
362+ $p = 0;
363+ $lines = [];
364+ for ($y = 0; $y < $s; $y++) {
365+ $lines[] = implode('', array_map(fn($d) => $d ? '1' : '0', $this->modules[$y]));
366+ }
367+ $cols = [];
368+ for ($x = 0; $x < $s; $x++) {
369+ $c = '';
370+ for ($y = 0; $y < $s; $y++) $c .= $lines[$y][$x];
371+ $cols[] = $c;
372+ }
373+ $dark = 0;
374+ foreach ([...$lines, ...$cols] as $k => $line) {
375+ if ($k < $s) $dark += substr_count($line, '1');
376+ if (preg_match_all('/0{5,}|1{5,}/', $line, $m)) foreach ($m[0] as $run) $p += strlen($run) - 2;
377+ // 1:1:3:1:1の両側に4つの白(外側は白とみなす)
378+ $padded = '0000'.$line.'0000';
379+ $p += 40 * (preg_match_all('/(?=00001011101)/', $padded) + preg_match_all('/(?=10111010000)/', $padded));
380+ }
381+ for ($y = 0; $y < $s - 1; $y++) {
382+ for ($x = 0; $x < $s - 1; $x++) {
383+ $c = $lines[$y][$x];
384+ if ($c === $lines[$y][$x + 1] && $c === $lines[$y + 1][$x] && $c === $lines[$y + 1][$x + 1]) $p += 3;
385+ }
386+ }
387+ $total = $s * $s;
388+ $p += 10 * max(0, intdiv(abs($dark * 20 - $total * 10) + $total - 1, $total) - 1);
389+ return $p;
390+ }
391+}
@@ -0,0 +1,113 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, 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+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Lib;
39+
40+/**
41+ * ワンタイムパスワード(HOTP:RFC 4226、TOTP:RFC 6238)
42+ *
43+ * 認証アプリ(Google Authenticator、Aegis、KeePassXC等)と同じ既定(SHA-1、6桁、30秒)を使う。
44+ * 秘密鍵はBase32の文字列で扱う。
45+ */
46+final class Totp {
47+ public const DIGITS = 6;
48+ public const PERIOD = 30;
49+
50+ /**
51+ * 新しい秘密鍵(既定は160ビット、Base32で32文字)
52+ */
53+ public static function secret(int $bytes = 20): string {
54+ return BaseN::encode32(random_bytes($bytes), false);
55+ }
56+
57+ /**
58+ * HOTPの値
59+ *
60+ * @param string $key 秘密鍵(Base32ではなく元のバイト列)
61+ * @param string $algo sha1・sha256・sha512
62+ */
63+ public static function hotp(#[\SensitiveParameter] string $key, int $counter, int $digits = self::DIGITS, string $algo = 'sha1'): string {
64+ $h = hash_hmac($algo, pack('J', $counter), $key, true);
65+ $o = ord($h[strlen($h) - 1]) & 0x0f;
66+ $bin = ((ord($h[$o]) & 0x7f) << 24) | (ord($h[$o + 1]) << 16) | (ord($h[$o + 2]) << 8) | ord($h[$o + 3]);
67+ return str_pad((string)($bin % (10 ** $digits)), $digits, '0', STR_PAD_LEFT);
68+ }
69+
70+ /**
71+ * 時刻のTOTPの値
72+ *
73+ * @param string $secret Base32の秘密鍵
74+ * @param int|null $time UNIX時間(既定は今)
75+ */
76+ public static function code(#[\SensitiveParameter] string $secret, ?int $time = null, int $digits = self::DIGITS, int $period = self::PERIOD, string $algo = 'sha1'): string {
77+ return self::hotp(BaseN::decode32($secret), intdiv($time ?? time(), $period), $digits, $algo);
78+ }
79+
80+ /**
81+ * コードを確かめる(時計のずれの為、前後$window個の時間も認める)
82+ *
83+ * @param int $lastStep 前回使った時間の番号(同じコードを2度使えない様に、それ以前は認めない)
84+ * @return int|null 一致した時間の番号(次のlastStepとして保存する)、一致しなければnull
85+ */
86+ public static function verify(#[\SensitiveParameter] string $secret, string $code, int $lastStep = -1, ?int $time = null, int $window = 1): ?int {
87+ $code = preg_replace('/\s+/', '', $code);
88+ if (!preg_match('/^\d{'.self::DIGITS.'}$/', $code)) return null;
89+ try {
90+ $key = BaseN::decode32($secret);
91+ } catch (\InvalidArgumentException $e) {
92+ return null;
93+ }
94+ $now = intdiv($time ?? time(), self::PERIOD);
95+ $found = null;
96+ // 一致しても最後まで比べる(時間で分からない様に)
97+ for ($step = $now - $window; $step <= $now + $window; $step++) {
98+ if (hash_equals(self::hotp($key, $step), $code) && $step > $lastStep && $found === null) $found = $step;
99+ }
100+ return $found;
101+ }
102+
103+ /**
104+ * 認証アプリに登録するURI(QRコードにする)
105+ *
106+ * @param string $account ユーザー名等
107+ * @param string $issuer サイトの名前
108+ */
109+ public static function uri(#[\SensitiveParameter] string $secret, string $account, string $issuer): string {
110+ $label = rawurlencode($issuer).':'.rawurlencode($account);
111+ return 'otpauth://totp/'.$label.'?secret='.rtrim($secret, '=').'&issuer='.rawurlencode($issuer).'&algorithm=SHA1&digits='.self::DIGITS.'&period='.self::PERIOD;
112+ }
113+}
@@ -41,6 +41,7 @@
4141
4242 use Std\Lib\Tester;
4343 use Std\Lib\Auth;
44+use Std\Lib\Totp;
4445 use Result;
4546
4647 $test = new Tester([
@@ -58,14 +59,16 @@
5859 // メールアドレスのMXレコードを確認するので、DNSが使えない場合は登録のテストをスキップする
5960 $dns = checkdnsrr('076.co.jp', 'MX');
6061 $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) {
62+// $twoFactor:config.phpのAUTH_2FA_ENABLEDの代わり
63+$make = function(?string $username = null, bool $verify = false, bool $mailFails = false, bool $twoFactor = true) use ($base, $mails): Auth {
64+ return new class($username, $base, $verify, $mailFails, $mails, $twoFactor) extends Auth {
65+ public function __construct(?string $username, string $base, private bool $verify, private bool $mailFails, private \ArrayObject $mails, private bool $twoFactor) {
6466 $this->dataDir = "{$base}/user/";
6567 $this->assDir = "{$base}/ass/";
6668 parent::__construct($username);
6769 }
6870 public function requiresVerification(): bool { return $this->verify; }
71+ public function twoFactorAvailable(): bool { return $this->twoFactor; }
6972 protected function siteUrl(): string { return 'https://example.com'; }
7073 protected function sendVerificationMail(\stdClass $user, string $token): Result {
7174 if ($this->mailFails) return Result::Error('送信失敗');
@@ -266,6 +269,112 @@
266269
267270 // 削除したので同じ名前で登録出来る
268271 $test->assertTrue($make(null, true)->mkUser('verify03', $pass, $pass, 'v3@076.co.jp')->isSuccess);
272+ });
273+});
274+
275+$test->describe('二段階認証', function($test) use ($make, $read, $userFile, $pass, $dns): void {
276+ if (!AUTH_ENABLED || !AUTH_REGISTER_ENABLED) {
277+ $test->skipAll('AUTH_ENABLED又はAUTH_REGISTER_ENABLEDが無効');
278+ } else if (!$dns) {
279+ $test->skipAll('DNSが使えない(076.co.jpのMXレコードを受け取れない)');
280+ }
281+ $_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
282+ // CLIでは出力の後なのでクッキーを設定出来ないが、トークンは保存される
283+ $loggedIn = fn(Result $r) => $r->isSuccess || $r->message === 'エラー:クッキーを設定に失敗。';
284+
285+ $test->it('config.phpで無効なら誰も使えないはず', function($test) use ($make): void {
286+ $test->assertFalse($make('verify01', false, false, false)->twoFactorStatus('verify01')->isSuccess);
287+ $test->assertFalse($make('verify01', false, false, false)->enableTwoFactor('verify01', '123456')->isSuccess);
288+ });
289+
290+ $test->it('秘密鍵とQRコードのURIを出し、正しいコードで有効になるはず', function($test) use ($make, $read): void {
291+ $s = $make('verify01')->twoFactorStatus('verify01');
292+ $test->assertTrue($s->isSuccess, (string)$s->message);
293+ $test->assertFalse($s->data['enabled']);
294+ $secret = $s->data['secret'];
295+ $test->assertEquals(32, strlen($secret));
296+ $test->assertTrue(str_starts_with($s->data['uri'], 'otpauth://totp/'));
297+ $test->assertStringContains('secret='.$secret, $s->data['uri']);
298+ // 開き直しても同じ秘密鍵
299+ $test->assertEquals($secret, $make('verify01')->twoFactorStatus('verify01')->data['secret']);
300+
301+ $test->assertFalse($make('verify01')->enableTwoFactor('verify01', '000000')->isSuccess);
302+ $res = $make('verify01')->enableTwoFactor('verify01', Totp::code($secret));
303+ $test->assertTrue($res->isSuccess, (string)$res->message);
304+ $u = $read('verify01');
305+ $test->assertEquals($secret, $u->twoFactor->secret);
306+ $test->assertFalse(isset($u->twoFactorPending));
307+ $test->assertTrue($make('verify01')->twoFactorStatus('verify01')->data['enabled']);
308+ $test->assertFalse(isset($make('verify01')->twoFactorStatus('verify01')->data['secret']));
309+ });
310+
311+ $test->it('プロフィールに秘密鍵を含まないはず', function($test) use ($make): void {
312+ $test->assertFalse(isset($make('verify01')->getUser('verify01')->twoFactor));
313+ $e = $make('verify01')->getEditableUser('verify01');
314+ $test->assertTrue($e->twoFactorOn);
315+ $test->assertFalse(isset($e->twoFactor));
316+ $test->assertFalse(isset($make('verify01')->getLoggedInUser()->twoFactor));
317+ });
318+
319+ $test->it('パスワードの後にコードが必要で、同じコードは2度使えないはず', function($test) use ($make, $read, $pass, $loggedIn): void {
320+ $tokens = count($read('verify01')->tokens ?? []);
321+ $res = $make('verify01')->setToken('verify01', $pass);
322+ $test->assertFalse($res->isSuccess);
323+ $test->assertTrue($res->data['twoFactor'] ?? false);
324+ $ticket = $res->data['ticket'];
325+ $test->assertCount($tokens, $read('verify01')->tokens ?? []);
326+ // 間違ったパスワードではコードの画面にならない
327+ $test->assertFalse(isset($make('verify01')->setToken('verify01', 'machigatta-password')->data['twoFactor']));
328+
329+ $secret = $read('verify01')->twoFactor->secret;
330+ $bad = $make('verify01')->verifyTwoFactor('verify01', $ticket, '000000');
331+ $test->assertFalse($bad->isSuccess);
332+ $test->assertEquals($ticket, $bad->data['ticket']);
333+ $test->assertFalse($make('verify01')->verifyTwoFactor('verify01', 'machigatta', Totp::code($secret, time() + 30))->isSuccess);
334+ $test->assertFalse($make('admin1234')->verifyTwoFactor('admin1234', $ticket, Totp::code($secret, time() + 30))->isSuccess);
335+
336+ // 有効にした時のコードは使えないので、次の30秒のコード
337+ $code = Totp::code($secret, time() + 30);
338+ $ok = @$make('verify01')->verifyTwoFactor('verify01', $ticket, $code);
339+ $test->assertTrue($loggedIn($ok), (string)$ok->message);
340+ $test->assertCount($tokens + 1, $read('verify01')->tokens);
341+ $test->assertFalse(isset($read('verify01')->twoFactorTicket));
342+ // チケットも同じコードも、もう使えない
343+ $test->assertFalse($make('verify01')->verifyTwoFactor('verify01', $ticket, $code)->isSuccess);
344+ $ticket2 = $make('verify01')->setToken('verify01', $pass)->data['ticket'];
345+ $test->assertFalse($make('verify01')->verifyTwoFactor('verify01', $ticket2, $code)->isSuccess);
346+ });
347+
348+ $test->it('5回間違えたらやり直しのはず', function($test) use ($make, $read, $pass): void {
349+ $ticket = $make('verify01')->setToken('verify01', $pass)->data['ticket'];
350+ for ($i = 0; $i < 4; $i++) $test->assertTrue(isset($make('verify01')->verifyTwoFactor('verify01', $ticket, '000000')->data['ticket']));
351+ $test->assertFalse(isset($make('verify01')->verifyTwoFactor('verify01', $ticket, '000000')->data['ticket']));
352+ $code = Totp::code($read('verify01')->twoFactor->secret, time() + 30);
353+ $test->assertFalse($make('verify01')->verifyTwoFactor('verify01', $ticket, $code)->isSuccess);
354+ });
355+
356+ $test->it('無効にするには本人はパスワードとコード、管理者は自分のパスワードが必要なはず', function($test) use ($make, $read, $userFile, $pass, $loggedIn): void {
357+ $admin = $read('admin1234');
358+ $admin->role |= \Roles::ADMIN;
359+ file_put_contents($userFile('admin1234'), json_encode($admin));
360+ $test->assertFalse($make('verify04')->twoFactorStatus('verify01')->isSuccess, '他のユーザーは見れない');
361+ $test->assertFalse($make('verify01')->disableTwoFactor('verify01', $pass, '000000')->isSuccess);
362+ $test->assertFalse($make('verify01')->disableTwoFactor('verify01', 'machigatta-password', Totp::code($read('verify01')->twoFactor->secret, time() + 60))->isSuccess);
363+ $test->assertFalse($make('admin1234')->disableTwoFactor('verify01', 'machigatta-password')->isSuccess);
364+ $test->assertFalse($make('admin1234')->enableTwoFactor('verify01', '123456')->isSuccess, '管理者でも他のユーザーの分は有効に出来ない');
365+ $res = $make('admin1234')->disableTwoFactor('verify01', $pass);
366+ $test->assertTrue($res->isSuccess, (string)$res->message);
367+ $test->assertFalse(isset($read('verify01')->twoFactor));
368+ // 無効になったので、パスワードだけでログイン出来る
369+ $test->assertTrue($loggedIn(@$make('verify01')->setToken('verify01', $pass)));
370+ });
371+
372+ $test->it('config.phpで無効にすると、有効にしたユーザーもコード無しでログインするはず', function($test) use ($make, $read, $pass, $loggedIn): void {
373+ $secret = $make('verify01')->twoFactorStatus('verify01')->data['secret'];
374+ $test->assertTrue($make('verify01')->enableTwoFactor('verify01', Totp::code($secret))->isSuccess);
375+ $res = @$make('verify01', false, false, false)->setToken('verify01', $pass);
376+ $test->assertFalse(isset($res->data['twoFactor']));
377+ $test->assertTrue($loggedIn($res));
269378 });
270379 });
271380
@@ -0,0 +1,196 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, 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+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Test;
39+
40+require_once __DIR__.'/../../../autoload.php';
41+
42+use Std\Lib\Tester;
43+use Std\Lib\BaseN;
44+use Std\Lib\Totp;
45+use Std\Lib\Qr;
46+use Std\Lib\Image\Png;
47+
48+$test = new Tester([
49+ 'colorOutput' => true,
50+ 'verboseOutput' => true
51+]);
52+
53+$test->describe('Base32・Base64', function($test): void {
54+ $test->it('RFC 4648の例と同じになるはず', function($test): void {
55+ $cases = ['' => ['', ''], 'f' => ['MY======', 'Zg=='], 'fo' => ['MZXQ====', 'Zm8='], 'foo' => ['MZXW6===', 'Zm9v'],
56+ 'foob' => ['MZXW6YQ=', 'Zm9vYg=='], 'fooba' => ['MZXW6YTB', 'Zm9vYmE='], 'foobar' => ['MZXW6YTBOI======', 'Zm9vYmFy']];
57+ foreach ($cases as $in => [$b32, $b64]) {
58+ $in = (string)$in;
59+ $test->assertEquals($b32, BaseN::encode32($in), "「{$in}」のBase32");
60+ $test->assertEquals($b64, BaseN::encode64($in), "「{$in}」のBase64");
61+ $test->assertEquals($in, BaseN::decode32($b32));
62+ $test->assertEquals($in, BaseN::decode64($b64));
63+ }
64+ });
65+
66+ $test->it('任意のバイト列を元に戻せて、PHPのbase64と同じになるはず', function($test): void {
67+ for ($i = 1; $i < 200; $i += 7) {
68+ $d = random_bytes($i);
69+ $test->assertEquals(base64_encode($d), BaseN::encode64($d));
70+ $test->assertEquals($d, BaseN::decode64(BaseN::encode64($d, true, false), true));
71+ $test->assertEquals($d, BaseN::decode32(BaseN::encode32($d, false)));
72+ }
73+ $test->assertEquals(rtrim(strtr(base64_encode("\xfb\xff\xfe"), '+/', '-_'), '='), BaseN::encode64("\xfb\xff\xfe", true, false));
74+ });
75+
76+ $test->it('秘密鍵は小文字・空白・ハイフンがあっても読めるはず', function($test): void {
77+ $test->assertEquals('Hello!', BaseN::decode32('jbsw y3dp-ee'));
78+ });
79+
80+ $test->it('不正な文字や長さは例外のはず', function($test): void {
81+ $test->assertThrows(fn() => BaseN::decode32('ABC1'), \InvalidArgumentException::class);
82+ $test->assertThrows(fn() => BaseN::decode32('ABC'), \InvalidArgumentException::class);
83+ $test->assertThrows(fn() => BaseN::decode64('Zm9v!'), \InvalidArgumentException::class);
84+ $test->assertThrows(fn() => BaseN::decode64('Zm9vY'), \InvalidArgumentException::class);
85+ $test->assertThrows(fn() => BaseN::decode64('Zm-v', false), \InvalidArgumentException::class);
86+ });
87+});
88+
89+$test->describe('ワンタイムパスワード', function($test): void {
90+ $test->it('RFC 4226(HOTP)の例と同じになるはず', function($test): void {
91+ $exp = ['755224', '287082', '359152', '969429', '338314', '254676', '287922', '162583', '399871', '520489'];
92+ foreach ($exp as $i => $code) $test->assertEquals($code, Totp::hotp('12345678901234567890', $i));
93+ });
94+
95+ $test->it('RFC 6238(TOTP)の例と同じになるはず', function($test): void {
96+ $keys = ['sha1' => '12345678901234567890', 'sha256' => '12345678901234567890123456789012', 'sha512' => str_repeat('1234567890', 6).'1234'];
97+ $exp = [
98+ 59 => ['94287082', '46119246', '90693936'],
99+ 1111111109 => ['07081804', '68084774', '25091201'],
100+ 1111111111 => ['14050471', '67062674', '99943326'],
101+ 1234567890 => ['89005924', '91819424', '93441116'],
102+ 2000000000 => ['69279037', '90698825', '38618901'],
103+ 20000000000 => ['65353130', '77737706', '47863826'],
104+ ];
105+ foreach ($exp as $t => $codes) {
106+ $i = 0;
107+ foreach ($keys as $algo => $key) $test->assertEquals($codes[$i++], Totp::code(BaseN::encode32($key), $t, 8, 30, $algo), "{$t}の{$algo}");
108+ }
109+ });
110+
111+ $test->it('前後30秒のずれを認め、同じコードは2度使えないはず', function($test): void {
112+ $s = Totp::secret();
113+ $test->assertEquals(32, strlen($s));
114+ $t = 1700000000;
115+ $code = Totp::code($s, $t);
116+ $step = intdiv($t, 30);
117+ $test->assertEquals($step, Totp::verify($s, $code, -1, $t));
118+ $test->assertEquals($step, Totp::verify($s, substr($code, 0, 3).' '.substr($code, 3), -1, $t + 30));
119+ $test->assertNull(Totp::verify($s, $code, -1, $t + 90));
120+ $test->assertNull(Totp::verify($s, $code, $step, $t));
121+ $test->assertNull(Totp::verify($s, '12345', -1, $t));
122+ $test->assertNull(Totp::verify('不正な鍵', '123456', -1, $t));
123+ });
124+
125+ $test->it('認証アプリ用のURIを作るはず', function($test): void {
126+ $test->assertEquals('otpauth://totp/Little%20Beast:suwako?secret=JBSWY3DPEHPK3PXP&issuer=Little%20Beast&algorithm=SHA1&digits=6&period=30',
127+ Totp::uri('JBSWY3DPEHPK3PXP', 'suwako', 'Little Beast'));
128+ });
129+});
130+
131+$test->describe('QRコード', function($test): void {
132+ // OpenCVで読めると確かめたモジュールのMD5(黒が1、行毎)
133+ $md5 = fn(Qr $q) => md5(implode('', array_map(fn($r) => implode('', array_map(fn($d) => $d ? '1' : '0', $r)), $q->modules)));
134+
135+ $test->it('型番とモードを選び、他のソフトで読めるのと同じ物を作るはず', function($test) use ($md5): void {
136+ $cases = [
137+ ['otpauth://totp/Little%20Beast:suwako?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Little%20Beast&algorithm=SHA1&digits=6&period=30', 'M', 8, '4d0a562a70f409cde3042c6414f73f7f'],
138+ ['HELLO WORLD', 'Q', 1, 'd63a9f9c0768b31980447c643e266e38'],
139+ ['01234567', 'H', 1, '08740c4c6bbf74c89a068540444b8a25'],
140+ ];
141+ foreach ($cases as [$text, $level, $version, $hash]) {
142+ $q = new Qr($text, $level);
143+ $test->assertEquals($version, $q->version);
144+ $test->assertEquals($version * 4 + 17, $q->size);
145+ $test->assertEquals($hash, $md5($q));
146+ }
147+ });
148+
149+ $test->it('入る量で型番が決まるはず', function($test): void {
150+ // 型番1・Lは数字41桁、英数字25文字、バイト17文字まで
151+ $test->assertEquals(1, (new Qr(str_repeat('7', 41), 'L'))->version);
152+ $test->assertEquals(2, (new Qr(str_repeat('7', 42), 'L'))->version);
153+ $test->assertEquals(1, (new Qr(str_repeat('A', 25), 'L'))->version);
154+ $test->assertEquals(2, (new Qr(str_repeat('A', 26), 'L'))->version);
155+ $test->assertEquals(1, (new Qr(str_repeat('a', 17), 'L'))->version);
156+ $test->assertEquals(2, (new Qr(str_repeat('a', 18), 'L'))->version);
157+ // 型番40・Lは2953バイトまで
158+ $test->assertEquals(40, (new Qr(str_repeat('a', 2953), 'L'))->version);
159+ $test->assertThrows(fn() => new Qr(str_repeat('a', 2954), 'L'), \InvalidArgumentException::class);
160+ $test->assertThrows(fn() => new Qr('a', 'X'), \InvalidArgumentException::class);
161+ });
162+
163+ $test->it('位置検出パターンと、正しい形式情報があるはず', function($test): void {
164+ foreach (['L', 'M', 'Q', 'H'] as $level) {
165+ $q = new Qr('二段階認証', $level, 7);
166+ $s = $q->size;
167+ foreach ([[0, 0], [$s - 7, 0], [0, $s - 7]] as [$x0, $y0]) {
168+ for ($i = 0; $i < 7; $i++) {
169+ $test->assertTrue($q->modules[$y0][$x0 + $i] && $q->modules[$y0 + 6][$x0 + $i] && $q->modules[$y0 + 3][$x0 + 3]);
170+ }
171+ }
172+ // 左上の形式情報:レベルとマスクの5ビット+BCH(マスク0x5412)
173+ $bits = 0;
174+ $pos = [[8, 0], [8, 1], [8, 2], [8, 3], [8, 4], [8, 5], [8, 7], [8, 8], [7, 8], [5, 8], [4, 8], [3, 8], [2, 8], [1, 8], [0, 8]];
175+ foreach ($pos as $i => [$x, $y]) if ($q->modules[$y][$x]) $bits |= 1 << $i;
176+ $bits ^= 0x5412;
177+ $data = $bits >> 10;
178+ $rem = $data;
179+ for ($i = 0; $i < 10; $i++) $rem = ($rem << 1) ^ (($rem >> 9) * 0x537);
180+ $test->assertEquals((($data << 10) | $rem) & 0x7fff, $bits, "{$level}の形式情報");
181+ $test->assertEquals(['L' => 1, 'M' => 0, 'Q' => 3, 'H' => 2][$level], $data >> 3);
182+ $test->assertEquals($q->mask, $data & 7);
183+ }
184+ });
185+
186+ $test->it('PNGにすると周りに白があり、1モジュールが指定の画素数になるはず', function($test): void {
187+ $q = new Qr('HELLO WORLD', 'Q');
188+ $b = (new Png())->decode((new Png())->encode($q->bitmap(3, 4)));
189+ $test->assertEquals((21 + 8) * 3, $b->width);
190+ $test->assertEquals([255, 255, 255, 255], $b->get(0, 0));
191+ $test->assertEquals([0, 0, 0, 255], $b->get(12, 12));
192+ $test->assertEquals([0, 0, 0, 255], $b->get(14, 14));
193+ });
194+});
195+
196+$test->printSummary();
@@ -79,6 +79,12 @@
7979 <td><input type="password" id="currentPassword" name="currentPassword" autocomplete="current-password" /><br /><small>メールアドレスかパスワードを変更する場合は必須</small></td>
8080 </tr>
8181 {@ endif @}
82+{@ if ($twoFactor && ($e->isSelf || $e->twoFactorOn)) @}
83+ <tr>
84+ <td>二段階認証:</td>
85+ <td>{{ $e->twoFactorOn ? '有効' : '無効' }}(<a href="/profile/{{ $e->username }}/2fa">設定</a>)</td>
86+ </tr>
87+{@ endif @}
8288 {@ if ($e->isAdmin) @}
8389 <tr>
8490 <td>ロール(管理者):</td>
@@ -0,0 +1,32 @@
1+{@ include(common/header) @}
2+ <h1 class="paragraph">二段階認証</h1>
3+ <p class="paragraph">
4+ 認証アプリ(又はパスワードマネージャー)に表示されている6桁のコードを入力して下さい。
5+ </p>
6+
7+{@ if ($error) @}
8+ <div class="errormes">
9+ {{ $error }}
10+ </div>
11+{@ endif @}
12+
13+ <form action="/login" method="POST">
14+ <table>
15+ <tbody>
16+ <tr>
17+ <td><label for="code">コード:</label></td>
18+ <td><input type="text" id="code" name="code" value="" inputmode="numeric" maxlength="7" autocomplete="one-time-code" autofocus="autofocus" /></td>
19+ </tr>
20+ <tr>
21+ <td></td>
22+ <td>
23+ <input type="hidden" name="username" value="{{ $nyuU }}" />
24+ <input type="hidden" name="ticket" value="{{ $ticket }}" />
25+ <input type="hidden" name="csrf_token" value="{{ make_csrf_token() }}" />
26+ <button>確認</button> <a href="/login">最初から</a>
27+ </td>
28+ </tr>
29+ </tbody>
30+ </table>
31+ </form>
32+{@ include(common/footer) @}
@@ -0,0 +1,86 @@
1+{@ include(common/header) @}
2+ <h1 class="paragraph">二段階認証({{ $s->username }})</h1>
3+{@ if ($error) @}
4+ <div class="errormes">
5+ {{ $error }}
6+ </div>
7+{@ endif @}
8+{@ if ($message) @}
9+ <p class="paragraph">{{ $message }}</p>
10+{@ endif @}
11+{@ if ($s->enabled) @}
12+ <p class="paragraph">
13+ 二段階認証は有効です。ログインの時に、パスワードの後で認証アプリのコードを入力します。
14+ </p>
15+
16+ <form action="/profile/{{ $s->username }}/2fa" method="POST">
17+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
18+ <input type="hidden" name="action" value="disable" />
19+ <table>
20+ <tbody>
21+ <tr>
22+ <td><label for="password">{{ $s->self ? 'パスワード' : '管理者のパスワード' }}:</label></td>
23+ <td><input type="password" id="password" name="password" autocomplete="current-password" /></td>
24+ </tr>
25+{@ if ($s->self) @}
26+ <tr>
27+ <td><label for="code">コード:</label></td>
28+ <td><input type="text" id="code" name="code" value="" inputmode="numeric" maxlength="7" autocomplete="one-time-code" /></td>
29+ </tr>
30+{@ endif @}
31+ <tr>
32+ <td></td>
33+ <td><button>二段階認証を無効にする</button></td>
34+ </tr>
35+ </tbody>
36+ </table>
37+ </form>
38+{@ elif ($s->self) @}
39+ <p class="paragraph">
40+ 認証アプリでQRコードを読み取るか、パスワードマネージャーに秘密鍵を登録して、表示された6桁のコードを入力して下さい。
41+ </p>
42+
43+ <p class="paragraph"><img src="/profile/{{ $s->username }}/2fa/qr" alt="二段階認証のQRコード" /></p>
44+
45+ <table>
46+ <tbody>
47+ <tr>
48+ <td><label for="secret">秘密鍵:</label></td>
49+ <td>
50+ <code>{{ $secretGroups }}</code><br />
51+ <input type="text" id="secret" value="{{ $s->secret }}" size="40" readonly="readonly" />
52+ </td>
53+ </tr>
54+ <tr>
55+ <td>種類:</td>
56+ <td>TOTP(時間)、SHA-1、6桁、30秒</td>
57+ </tr>
58+ <tr>
59+ <td><label for="uri">URI:</label></td>
60+ <td><input type="text" id="uri" value="{{ $s->uri }}" size="60" readonly="readonly" /></td>
61+ </tr>
62+ </tbody>
63+ </table>
64+
65+ <form action="/profile/{{ $s->username }}/2fa" method="POST">
66+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
67+ <input type="hidden" name="action" value="enable" />
68+ <table>
69+ <tbody>
70+ <tr>
71+ <td><label for="code">コード:</label></td>
72+ <td><input type="text" id="code" name="code" value="" inputmode="numeric" maxlength="7" autocomplete="one-time-code" /></td>
73+ </tr>
74+ <tr>
75+ <td></td>
76+ <td><button>有効にする</button></td>
77+ </tr>
78+ </tbody>
79+ </table>
80+ </form>
81+{@ else @}
82+ <p class="paragraph">二段階認証は有効ではありません(本人だけが有効に出来ます)。</p>
83+{@ endif @}
84+
85+ <p class="paragraph"><a href="/profile/{{ $s->username }}/edit">プロフィールの編集に戻る</a></p>
86+{@ include(common/footer) @}