MFAライブラリ
守矢諏訪子 <suwako@076.moe>、2026-11-11 09:26
コミット a68c17798f8347a8dbc0eb632f074c13d1bc8b01
・親 459b522
・ファイルを見る
14個のファイル、+1312 -9
変更
README-JP.md
| @@ -47,6 +47,8 @@ | ||
| 47 | 47 | * Maron テンプレートエンジン |
| 48 | 48 | * カスタム Markdown |
| 49 | 49 | * ActivityPub |
| 50 | +* 画像ライブラリ(PNG・APNG・BMP・TGA・GIF・JPEG・WebP の読み書き。GD 等の拡張は不要) | |
| 51 | +* 二段階認証(認証アプリのワンタイムパスワード、QR コード) | |
| 50 | 52 | * 多言語対応 |
| 51 | 53 | * マルチブログ対応 |
| 52 | 54 | * 100% 綺麗で正しい HTML5 と CSS3 |
| @@ -223,10 +225,15 @@ | ||
| 223 | 225 | php cron.php add "* * * * *" apdeliver |
| 224 | 226 | php cron.php add "@daily" apcleanup |
| 225 | 227 | ``` |
| 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 等)でアップロードして下さい。\ | |
| 227 | 229 | 大きいファイルを受け付ける様に、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"` もお勧めします。 |
| 228 | 230 | 他のサーバーのアイコンや画像は `/microblog/media` 経由で表示するので、CSP の `img-src` は `'self'` のままで構いません(閲覧者の IP アドレスも相手のサーバーに渡りません)。画像は `data/ap/remote/media/` に1週間キャッシュします。\ |
| 229 | 231 | ユーザーの鍵は初めて使う時に作ります。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`)で自分のパスワードを入力すると無効に出来ます。 | |
| 230 | 237 | |
| 231 | 238 | ## 必要な PHP モジュール |
| 232 | 239 | * php_gmp |
| @@ -80,5 +80,6 @@ | ||
| 80 | 80 | define('OPENPROVIDER_ENABLED', false); |
| 81 | 81 | define('AUTH_REGISTER_ENABLED', false); |
| 82 | 82 | define('AUTH_VERIFY_EMAIL', false); // 登録時にメールアドレスの確認を必須にする(MAILER_ENABLEDも必要) |
| 83 | +define('AUTH_2FA_ENABLED', false); // 二段階認証(認証アプリのコード)を使えるようにする(各ユーザーがプロフィールの編集から有効にする) | |
| 83 | 84 | define('COPYRIGHT_YEAR', '2018-'.date('Y')); |
| 84 | 85 | define('DEBUG_MODE', false); |
変更
route.php
| @@ -122,6 +122,9 @@ | ||
| 122 | 122 | $routes[] = Route::add('GET', 'profile/{name}', User::class.'@profile'); |
| 123 | 123 | $routes[] = Route::add('GET', 'profile/{name}/edit', User::class.'@editProfile'); |
| 124 | 124 | $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'); | |
| 125 | 128 | |
| 126 | 129 | $routes[] = Route::add('GET', 'memberonly', Page::class.'@memberonly'); |
| 127 | 130 | $routes[] = Route::add('GET', 'staffonly', Page::class.'@staffonly'); |
| @@ -39,6 +39,8 @@ | ||
| 39 | 39 | |
| 40 | 40 | use Site\Controller\Mods; |
| 41 | 41 | use Std\Lib\Auth; |
| 42 | +use Std\Lib\Image; | |
| 43 | +use Std\Lib\Qr; | |
| 42 | 44 | use Std\Lib\Template; |
| 43 | 45 | use Std\Lib\Fedi\Actor; |
| 44 | 46 | use Std\Lib\Fedi\Social; |
| @@ -60,6 +62,8 @@ | ||
| 60 | 62 | $doLogin = $_SERVER['REQUEST_METHOD'] === 'POST'; |
| 61 | 63 | $error = ''; |
| 62 | 64 | $nyuU = ''; |
| 65 | + // 二段階認証のコードの入力中 | |
| 66 | + $ticket = ''; | |
| 63 | 67 | |
| 64 | 68 | if ($doLogin) { |
| 65 | 69 | if (!is_string($_POST['csrf_token'] ?? null) || !\verify_csrf_token($_POST['csrf_token'])) { |
| @@ -88,7 +92,25 @@ | ||
| 88 | 92 | $username = is_string($a[0] ?? null) ? $a[0] : ''; |
| 89 | 93 | $password = is_string($a[1] ?? null) ? $a[1] : ''; |
| 90 | 94 | |
| 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 === '') { | |
| 92 | 114 | $error = $loginError; |
| 93 | 115 | } else if ($auth->isLoginThrottled($username)) { |
| 94 | 116 | $nyuU = $username; |
| @@ -97,7 +119,11 @@ | ||
| 97 | 119 | $auth = new Auth($username); |
| 98 | 120 | $res = $auth->isUserExist($username); |
| 99 | 121 | $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) { | |
| 101 | 127 | $auth->recordLoginFailure($username); |
| 102 | 128 | $nyuU = $username; |
| 103 | 129 | $error = $res->isSuccess ? $result->message : $loginError; |
| @@ -119,8 +145,9 @@ | ||
| 119 | 145 | $tmpl->assign('description', $description); |
| 120 | 146 | $tmpl->assign('error', $error); |
| 121 | 147 | $tmpl->assign('nyuU', $nyuU); |
| 148 | + $tmpl->assign('ticket', $ticket); | |
| 122 | 149 | |
| 123 | - $tmpl->render('login'); | |
| 150 | + $tmpl->render($ticket !== '' ? 'twofactor' : 'login'); | |
| 124 | 151 | } catch (\Exception $e) { |
| 125 | 152 | throw new \Exception($e->getMessage()); |
| 126 | 153 | } |
| @@ -319,8 +346,76 @@ | ||
| 319 | 346 | $tmpl->assign('roles', Roles::LABELS); |
| 320 | 347 | $tmpl->assign('fediEnabled', ACTIVITYPUB_MULTIUSER); |
| 321 | 348 | $tmpl->assign('locked', ACTIVITYPUB_MULTIUSER && $edit->fedi ? (bool)Actor::settings($name)['locked'] : false); |
| 349 | + $tmpl->assign('twoFactor', $auth->twoFactorAvailable()); | |
| 322 | 350 | $tmpl->assign('csrf', make_csrf_token()); |
| 323 | 351 | $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(); | |
| 324 | 419 | } |
| 325 | 420 | |
| 326 | 421 | public function profile(array $params): void { |
| @@ -55,6 +55,8 @@ | ||
| 55 | 55 | protected int $tokenDuration = 31536000; // 1年間 |
| 56 | 56 | protected int $verifyDuration = 86400; // 確認メールのリンクの有効期限(24時間) |
| 57 | 57 | protected int $verifyResendInterval = 600; // 確認メールの再送の間隔(10分) |
| 58 | + protected int $twoFactorDuration = 300; // ログインの2段階目(コードの入力)の有効期限(5分) | |
| 59 | + protected int $twoFactorSetupDuration = 3600; // 二段階認証の設定中の秘密鍵の有効期限(1時間) | |
| 58 | 60 | /** アップロードをis_uploaded_fileで確認する(テストではfalse) */ |
| 59 | 61 | protected bool $checkUploads = true; |
| 60 | 62 | protected string|int|null $algo = PASSWORD_ARGON2ID; |
| @@ -73,6 +75,7 @@ | ||
| 73 | 75 | unset($this->pubUser->password); |
| 74 | 76 | unset($this->pubUser->tokens); |
| 75 | 77 | unset($this->pubUser->verifyToken); |
| 78 | + unset($this->pubUser->twoFactor, $this->pubUser->twoFactorPending, $this->pubUser->twoFactorTicket); | |
| 76 | 79 | $this->pubUser->avatarUrl = avatar_url($this->pubUser); |
| 77 | 80 | return $this->pubUser; |
| 78 | 81 | } |
| @@ -83,6 +86,7 @@ | ||
| 83 | 86 | unset($user->password); |
| 84 | 87 | unset($user->tokens); |
| 85 | 88 | unset($user->verifyToken, $user->verifyExpire, $user->verifySent); |
| 89 | + unset($user->twoFactor, $user->twoFactorPending, $user->twoFactorTicket); | |
| 86 | 90 | $myself = $this->getUserData(); |
| 87 | 91 | // 以前は「role < STAFF」で比べていたので、ビットを足すと(Fediverse等)全員にメールアドレスが見えた |
| 88 | 92 | $isStaff = $myself && ($myself->role & (Roles::STAFF | Roles::ADMIN)); |
| @@ -140,6 +144,21 @@ | ||
| 140 | 144 | .($resent ? '確認メールを再送しました。' : ''), ['unverified' => true]); |
| 141 | 145 | } |
| 142 | 146 | |
| 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 { | |
| 143 | 162 | $ip = $_SERVER['REMOTE_ADDR'] ?? ''; |
| 144 | 163 | $res = $this->purgeOldTokens($userData); |
| 145 | 164 | if (!$res->isSuccess) return Result::Error($res->message); |
| @@ -220,6 +239,131 @@ | ||
| 220 | 239 | } |
| 221 | 240 | |
| 222 | 241 | /** |
| 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 | + /** | |
| 223 | 367 | * ログインの試行回数を確認する(IP毎:15分で5回、ユーザー名毎:1時間で20回) |
| 224 | 368 | * |
| 225 | 369 | * @param string $username ユーザー名 |
| @@ -538,7 +682,8 @@ | ||
| 538 | 682 | $user = json_decode((string)file_get_contents($path)); |
| 539 | 683 | if (!$user instanceof \stdClass) return null; |
| 540 | 684 | 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); | |
| 542 | 687 | $user->isSelf = $me->id == $user->id; |
| 543 | 688 | $user->isAdmin = (bool)($me->role & Roles::ADMIN); |
| 544 | 689 | $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 @@ | ||
| 184 | 184 | if (!preg_match('/^\d+\.'.$username.'\.json$/', basename($f))) continue; |
| 185 | 185 | $u = json_decode((string)file_get_contents($f)); |
| 186 | 186 | 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); | |
| 188 | 188 | return $u; |
| 189 | 189 | } |
| 190 | 190 | 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 @@ | ||
| 41 | 41 | |
| 42 | 42 | use Std\Lib\Tester; |
| 43 | 43 | use Std\Lib\Auth; |
| 44 | +use Std\Lib\Totp; | |
| 44 | 45 | use Result; |
| 45 | 46 | |
| 46 | 47 | $test = new Tester([ |
| @@ -58,14 +59,16 @@ | ||
| 58 | 59 | // メールアドレスのMXレコードを確認するので、DNSが使えない場合は登録のテストをスキップする |
| 59 | 60 | $dns = checkdnsrr('076.co.jp', 'MX'); |
| 60 | 61 | $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) { | |
| 64 | 66 | $this->dataDir = "{$base}/user/"; |
| 65 | 67 | $this->assDir = "{$base}/ass/"; |
| 66 | 68 | parent::__construct($username); |
| 67 | 69 | } |
| 68 | 70 | public function requiresVerification(): bool { return $this->verify; } |
| 71 | + public function twoFactorAvailable(): bool { return $this->twoFactor; } | |
| 69 | 72 | protected function siteUrl(): string { return 'https://example.com'; } |
| 70 | 73 | protected function sendVerificationMail(\stdClass $user, string $token): Result { |
| 71 | 74 | if ($this->mailFails) return Result::Error('送信失敗'); |
| @@ -266,6 +269,112 @@ | ||
| 266 | 269 | |
| 267 | 270 | // 削除したので同じ名前で登録出来る |
| 268 | 271 | $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)); | |
| 269 | 378 | }); |
| 270 | 379 | }); |
| 271 | 380 |
| @@ -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 @@ | ||
| 79 | 79 | <td><input type="password" id="currentPassword" name="currentPassword" autocomplete="current-password" /><br /><small>メールアドレスかパスワードを変更する場合は必須</small></td> |
| 80 | 80 | </tr> |
| 81 | 81 | {@ 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 @} | |
| 82 | 88 | {@ if ($e->isAdmin) @} |
| 83 | 89 | <tr> |
| 84 | 90 | <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) @} |