| 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\Auth; |
| 44 | use Std\Lib\Totp; |
| 45 | use Result; |
| 46 | |
| 47 | $test = new Tester([ |
| 48 | 'colorOutput' => true, |
| 49 | 'verboseOutput' => true |
| 50 | ]); |
| 51 | |
| 52 | // 本物のユーザーデータを触らない様に、一時ディレクトリを使う |
| 53 | $base = sys_get_temp_dir().'/lbauth_'.bin2hex(random_bytes(6)); |
| 54 | mkdir("{$base}/user", 0700, true); |
| 55 | mkdir("{$base}/ass", 0700, true); |
| 56 | touch("{$base}/user/.kara"); |
| 57 | |
| 58 | // メールは送らずに記録する |
| 59 | // メールアドレスのMXレコードを確認するので、DNSが使えない場合は登録のテストをスキップする |
| 60 | $dns = checkdnsrr('076.co.jp', 'MX'); |
| 61 | $mails = new \ArrayObject(); |
| 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) { |
| 66 | $this->dataDir = "{$base}/user/"; |
| 67 | $this->assDir = "{$base}/ass/"; |
| 68 | parent::__construct($username); |
| 69 | } |
| 70 | public function requiresVerification(): bool { return $this->verify; } |
| 71 | public function twoFactorAvailable(): bool { return $this->twoFactor; } |
| 72 | protected function siteUrl(): string { return 'https://example.com'; } |
| 73 | protected function sendVerificationMail(\stdClass $user, string $token): Result { |
| 74 | if ($this->mailFails) return Result::Error('送信失敗'); |
| 75 | $this->mails[] = ['to' => $user->email, 'token' => $token]; |
| 76 | return Result::Success(); |
| 77 | } |
| 78 | }; |
| 79 | }; |
| 80 | $userFile = function(string $username) use ($base): ?string { |
| 81 | foreach (scandir("{$base}/user") as $f) if (preg_match('/^\d+\.'.preg_quote($username, '/').'\.json$/', $f)) return "{$base}/user/{$f}"; |
| 82 | return null; |
| 83 | }; |
| 84 | $read = fn(string $username) => json_decode(file_get_contents($userFile($username))); |
| 85 | $pass = 'testTEST1234!#$%@[]:;'; |
| 86 | |
| 87 | $test->describe('ユーザー登録', function($test) use ($make, $read, $userFile, $pass, $base, $dns): void { |
| 88 | if (!AUTH_ENABLED || !AUTH_REGISTER_ENABLED) { |
| 89 | $test->skipAll('AUTH_ENABLED又はAUTH_REGISTER_ENABLEDが無効'); |
| 90 | } else if (!$dns) { |
| 91 | $test->skipAll('DNSが使えない(076.co.jpのMXレコードを受け取れない)'); |
| 92 | } |
| 93 | |
| 94 | $test->it('アカウントを作成出来るはず', function($test) use ($make, $read, $pass, $base): void { |
| 95 | $res = $make()->mkUser('admin1234', $pass, $pass, 'test@076.co.jp'); |
| 96 | $test->assertTrue($res->isSuccess, (string)$res->message); |
| 97 | $test->assertFalse($res->data['verify']); |
| 98 | |
| 99 | $u = $read('admin1234'); |
| 100 | $test->assertEquals(1, $u->id); |
| 101 | $test->assertEquals('test@076.co.jp', $u->email); |
| 102 | $test->assertTrue(password_verify($pass, $u->password)); |
| 103 | $test->assertFalse(isset($u->verified)); |
| 104 | $test->assertTrue(is_dir("{$base}/ass/admin1234")); |
| 105 | }); |
| 106 | |
| 107 | $test->it('空フィールドでアカウントを作成出来ないはず', function($test) use ($make, $pass): void { |
| 108 | $auth = $make(); |
| 109 | $test->assertEquals('エラー:ユーザー名をご入力下さい。<br />', $auth->mkUser('', $pass, $pass, 'a@076.co.jp')->message); |
| 110 | $test->assertEquals('エラー:パスワードをご入力下さい。<br />', $auth->mkUser('admin5678', '', $pass, 'a@076.co.jp')->message); |
| 111 | $test->assertEquals('エラー:メールアドレスをご入力下さい。<br />', $auth->mkUser('admin5678', $pass, $pass, '')->message); |
| 112 | }); |
| 113 | |
| 114 | $test->it('既にユーザー名やメールアドレスが存在するアカウントを作成出来ないはず', function($test) use ($make, $pass): void { |
| 115 | $auth = $make(); |
| 116 | $test->assertEquals('ユーザー「admin1234」は既に存在します。<br />', $auth->mkUser('admin1234', $pass, $pass, 'b@076.co.jp')->message); |
| 117 | $test->assertEquals('ユーザー「test@076.co.jp」は既に存在します。<br />', $auth->mkUser('admin5678', $pass, $pass, 'test@076.co.jp')->message); |
| 118 | }); |
| 119 | |
| 120 | $test->it('一致しない、短い、又は不安なパスワードでアカウントを作成出来ないはず', function($test) use ($make, $pass): void { |
| 121 | $auth = $make(); |
| 122 | $test->assertEquals('エラー:パスワードは一致していません。<br />', $auth->mkUser('admin5678', 'testtest1234!#$%@[]:;', $pass, 'c@076.co.jp')->message); |
| 123 | $test->assertEquals('エラー:パスワードは20以上をご入力下さい。<br />', $auth->mkUser('admin5678', 'testTEST1234', 'testTEST1234', 'c@076.co.jp')->message); |
| 124 | $test->assertEquals('エラー:パスワードは2つ以上の大文字、2つ以上の小文字、2つ以上の数字、及び2つ以上の特別文字を含む事が必須です。<br />', |
| 125 | $auth->mkUser('admin5678', 'testTEST1234testTEST', 'testTEST1234testTEST', 'c@076.co.jp')->message); |
| 126 | }); |
| 127 | |
| 128 | $test->it('不正なユーザー名とメールアドレスでアカウントを作成出来ないはず', function($test) use ($make, $pass): void { |
| 129 | $auth = $make(); |
| 130 | $test->assertEquals('エラー:ユーザー名は6文字以上をご入力下さい。<br />', $auth->mkUser('abc', $pass, $pass, 'c@076.co.jp')->message); |
| 131 | $test->assertEquals('エラー:ユーザー名に不正な文字が含みます。<br />', $auth->mkUser('admin_../x', $pass, $pass, 'c@076.co.jp')->message); |
| 132 | $test->assertEquals('エラー:メールアドレスは不正です。<br />', $auth->mkUser('admin5678', $pass, $pass, 'test@076')->message); |
| 133 | $test->assertEquals('エラー:メールアドレスは不正です。<br />', $auth->mkUser('admin5678', $pass, $pass, 'test@nomx.invalid')->message); |
| 134 | }); |
| 135 | |
| 136 | $test->it('公開プロフィールに秘密の情報を含まないはず', function($test) use ($make): void { |
| 137 | $u = $make()->getUser('admin1234'); |
| 138 | $test->assertNotNull($u); |
| 139 | $test->assertFalse(isset($u->password)); |
| 140 | $test->assertFalse(isset($u->tokens)); |
| 141 | $test->assertFalse(isset($u->verifyToken)); |
| 142 | $test->assertEquals('(秘密)', $u->email); |
| 143 | $test->assertNull($make()->getUser('nobody99')); |
| 144 | }); |
| 145 | }); |
| 146 | |
| 147 | $test->describe('メールアドレスの確認', function($test) use ($make, $read, $userFile, $pass, $mails, $base, $dns): void { |
| 148 | if (!AUTH_ENABLED || !AUTH_REGISTER_ENABLED) { |
| 149 | $test->skipAll('AUTH_ENABLED又はAUTH_REGISTER_ENABLEDが無効'); |
| 150 | } else if (!$dns) { |
| 151 | $test->skipAll('DNSが使えない(076.co.jpのMXレコードを受け取れない)'); |
| 152 | } |
| 153 | |
| 154 | $test->it('登録すると確認メールを送り、未確認として保存するはず', function($test) use ($make, $read, $pass, $mails): void { |
| 155 | $res = $make(null, true)->mkUser('verify01', $pass, $pass, 'v1@076.co.jp'); |
| 156 | $test->assertTrue($res->isSuccess); |
| 157 | $test->assertTrue($res->data['verify']); |
| 158 | $test->assertStringContains('確認メール', $res->message); |
| 159 | |
| 160 | $test->assertCount(1, $mails); |
| 161 | $test->assertEquals('v1@076.co.jp', $mails[0]['to']); |
| 162 | $test->assertTrue((bool)preg_match('/^[0-9a-f]{64}$/', $mails[0]['token'])); |
| 163 | |
| 164 | $u = $read('verify01'); |
| 165 | $test->assertFalse($u->verified); |
| 166 | // トークンはハッシュ化して保存する |
| 167 | $test->assertEquals(hash('sha256', $mails[0]['token']), $u->verifyToken); |
| 168 | $test->assertTrue($u->verifyExpire > time()); |
| 169 | }); |
| 170 | |
| 171 | $test->it('確認メールの送信に失敗したら登録を取り消すはず', function($test) use ($make, $userFile, $pass, $base): void { |
| 172 | $res = $make(null, true, true)->mkUser('verify02', $pass, $pass, 'v2@076.co.jp'); |
| 173 | $test->assertFalse($res->isSuccess); |
| 174 | $test->assertNull($userFile('verify02')); |
| 175 | $test->assertFalse(is_dir("{$base}/ass/verify02")); |
| 176 | }); |
| 177 | |
| 178 | $test->it('未確認のアカウントではログイン出来ないはず', function($test) use ($make, $pass, $mails): void { |
| 179 | $res = $make('verify01', true)->setToken('verify01', $pass); |
| 180 | $test->assertFalse($res->isSuccess); |
| 181 | $test->assertTrue($res->data['unverified'] ?? false); |
| 182 | // 10分以内は再送しない |
| 183 | $test->assertCount(1, $mails); |
| 184 | $test->assertFalse(str_contains($res->message, '再送')); |
| 185 | }); |
| 186 | |
| 187 | $test->it('間違ったパスワードでは未確認かどうか教えないはず', function($test) use ($make): void { |
| 188 | $res = $make('verify01', true)->setToken('verify01', 'machigatta-password'); |
| 189 | $test->assertFalse($res->isSuccess); |
| 190 | $test->assertFalse(isset($res->data['unverified'])); |
| 191 | }); |
| 192 | |
| 193 | $test->it('時間が経てばログインの時に確認メールを再送するはず', function($test) use ($make, $read, $userFile, $pass, $mails): void { |
| 194 | $path = $userFile('verify01'); |
| 195 | $u = $read('verify01'); |
| 196 | $u->verifySent = time() - 3600; |
| 197 | file_put_contents($path, json_encode($u)); |
| 198 | |
| 199 | $res = $make('verify01', true)->setToken('verify01', $pass); |
| 200 | $test->assertFalse($res->isSuccess); |
| 201 | $test->assertStringContains('再送', $res->message); |
| 202 | $test->assertCount(2, $mails); |
| 203 | $test->assertFalse($mails[0]['token'] === $mails[1]['token']); |
| 204 | $test->assertEquals(hash('sha256', $mails[1]['token']), $read('verify01')->verifyToken); |
| 205 | }); |
| 206 | |
| 207 | $test->it('不正・古いトークンでは確認出来ないはず', function($test) use ($make, $read, $userFile, $mails): void { |
| 208 | $auth = $make(); |
| 209 | $test->assertFalse($auth->verifyEmailToken('')->isSuccess); |
| 210 | $test->assertFalse($auth->verifyEmailToken('../../etc/passwd')->isSuccess); |
| 211 | $test->assertFalse($auth->verifyEmailToken(str_repeat('0', 64))->isSuccess); |
| 212 | // 再送したので、最初のトークンはもう使えない |
| 213 | $test->assertFalse($auth->verifyEmailToken($mails[0]['token'])->isSuccess); |
| 214 | |
| 215 | // 有効期限切れ |
| 216 | $path = $userFile('verify01'); |
| 217 | $u = $read('verify01'); |
| 218 | $expire = $u->verifyExpire; |
| 219 | $u->verifyExpire = time() - 1; |
| 220 | file_put_contents($path, json_encode($u)); |
| 221 | $test->assertFalse($auth->verifyEmailToken($mails[1]['token'])->isSuccess); |
| 222 | $u->verifyExpire = $expire; |
| 223 | file_put_contents($path, json_encode($u)); |
| 224 | }); |
| 225 | |
| 226 | $test->it('リンクのトークンで確認するとログイン出来るはず', function($test) use ($make, $read, $pass, $mails): void { |
| 227 | $res = $make()->verifyEmailToken($mails[1]['token']); |
| 228 | $test->assertTrue($res->isSuccess); |
| 229 | $test->assertEquals('verify01', $res->data['username']); |
| 230 | |
| 231 | $u = $read('verify01'); |
| 232 | $test->assertTrue($u->verified); |
| 233 | $test->assertFalse(isset($u->verifyToken)); |
| 234 | $test->assertFalse(isset($u->verifyExpire)); |
| 235 | |
| 236 | // 2回目は使えない |
| 237 | $test->assertFalse($make()->verifyEmailToken($mails[1]['token'])->isSuccess); |
| 238 | |
| 239 | $_SERVER['REMOTE_ADDR'] ??= '127.0.0.1'; |
| 240 | $res = @$make('verify01', true)->setToken('verify01', $pass); |
| 241 | // CLIでは出力の後なのでクッキーを設定出来ないが、トークンは保存される |
| 242 | $test->assertTrue($res->isSuccess || $res->message === 'エラー:クッキーを設定に失敗。', (string)$res->message); |
| 243 | $test->assertCount(1, $read('verify01')->tokens); |
| 244 | }); |
| 245 | |
| 246 | $test->it('確認機能の前に作ったアカウントは確認済みとして扱うはず', function($test) use ($make, $pass): void { |
| 247 | $res = @$make('admin1234', true)->setToken('admin1234', $pass); |
| 248 | $test->assertFalse(isset($res->data['unverified'])); |
| 249 | $test->assertTrue($res->isSuccess || $res->message === 'エラー:クッキーを設定に失敗。', (string)$res->message); |
| 250 | }); |
| 251 | |
| 252 | $test->it('古い未確認のアカウントを削除するはず', function($test) use ($make, $read, $userFile, $pass, $base): void { |
| 253 | $make(null, true)->mkUser('verify03', $pass, $pass, 'v3@076.co.jp'); |
| 254 | $make(null, true)->mkUser('verify04', $pass, $pass, 'v4@076.co.jp'); |
| 255 | $path = $userFile('verify03'); |
| 256 | $u = $read('verify03'); |
| 257 | $u->regDate = time() - 8 * 86400; |
| 258 | file_put_contents($path, json_encode($u)); |
| 259 | |
| 260 | $res = $make()->purgeUnverified(); |
| 261 | $test->assertTrue($res->isSuccess); |
| 262 | $test->assertEquals(['verify03'], $res->data['deleted']); |
| 263 | $test->assertNull($userFile('verify03')); |
| 264 | $test->assertFalse(is_dir("{$base}/ass/verify03")); |
| 265 | // 新しい未確認・確認済み・確認機能の前のアカウントは残る |
| 266 | $test->assertNotNull($userFile('verify04')); |
| 267 | $test->assertNotNull($userFile('verify01')); |
| 268 | $test->assertNotNull($userFile('admin1234')); |
| 269 | |
| 270 | // 削除したので同じ名前で登録出来る |
| 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)); |
| 378 | }); |
| 379 | }); |
| 380 | |
| 381 | $test->describe('表示の設定', function($test) use ($make, $read, $base): void { |
| 382 | if (!AUTH_ENABLED) $test->skipAll('AUTH_ENABLEDが無効'); |
| 383 | |
| 384 | // 登録(DNSが必要)を使わずにユーザーを作る |
| 385 | foreach (['prefuser01' => 901, 'prefuser02' => 902] as $name => $id) { |
| 386 | file_put_contents("{$base}/user/{$id}.{$name}.json", json_encode(['id' => $id, 'username' => $name, 'role' => \Roles::MEMBER, 'tokens' => []])); |
| 387 | } |
| 388 | |
| 389 | $test->it('ログイン中のユーザーのプロフィールに保存するはず', function($test) use ($make, $read): void { |
| 390 | $res = $make('prefuser01')->savePrefs(['theme' => 'loli', 'glowring_off' => 1]); |
| 391 | $test->assertTrue($res->isSuccess, (string)$res->message); |
| 392 | $test->assertEquals('loli', $read('prefuser01')->prefs->theme); |
| 393 | $test->assertEquals('1', $read('prefuser01')->prefs->glowring_off); |
| 394 | $test->assertFalse(isset($read('prefuser02')->prefs), '他のユーザーは変わらない'); |
| 395 | }); |
| 396 | |
| 397 | $test->it('他の設定を残したまま変更・削除出来るはず', function($test) use ($make, $read): void { |
| 398 | $test->assertTrue($make('prefuser01')->savePrefs(['theme' => 'void'])->isSuccess); |
| 399 | $test->assertEquals('void', $read('prefuser01')->prefs->theme); |
| 400 | $test->assertEquals('1', $read('prefuser01')->prefs->glowring_off); |
| 401 | $test->assertTrue($make('prefuser01')->savePrefs(['glowring_off' => null])->isSuccess); |
| 402 | $test->assertFalse(isset($read('prefuser01')->prefs->glowring_off)); |
| 403 | $test->assertEquals(901, $read('prefuser01')->id, 'ユーザーデータは消えない'); |
| 404 | }); |
| 405 | |
| 406 | $test->it('ログインしていなければ保存しないはず', function($test) use ($make): void { |
| 407 | $test->assertFalse($make()->savePrefs(['theme' => 'tux'])->isSuccess); |
| 408 | }); |
| 409 | }); |
| 410 | |
| 411 | $test->describe('管理画面', function($test) use ($make, $read, $userFile, $pass, $base): void { |
| 412 | if (!AUTH_ENABLED) $test->skipAll('AUTH_ENABLEDが無効'); |
| 413 | |
| 414 | // data/user/が無い所から始める(新しいサイト) |
| 415 | $empty = "{$base}/empty"; |
| 416 | $makeIn = function(?string $username = null) use ($empty): Auth { |
| 417 | return new class($username, $empty) extends Auth { |
| 418 | public function __construct(?string $username, string $dir) { |
| 419 | $this->dataDir = "{$dir}/user/"; |
| 420 | $this->assDir = "{$dir}/ass/"; |
| 421 | parent::__construct($username); |
| 422 | } |
| 423 | public function requiresVerification(): bool { return false; } |
| 424 | }; |
| 425 | }; |
| 426 | $readIn = function(string $name) use ($empty): ?\stdClass { |
| 427 | foreach (glob("{$empty}/user/*.{$name}.json") ?: [] as $f) return json_decode((string)file_get_contents($f)); |
| 428 | return null; |
| 429 | }; |
| 430 | $_SERVER['REMOTE_ADDR'] ??= '127.0.0.1'; |
| 431 | |
| 432 | $test->it('管理者が居なければ、コードで最初の管理者を作れるはず', function($test) use ($makeIn, $readIn, $pass, $empty): void { |
| 433 | $auth = $makeIn(); |
| 434 | $test->assertFalse($auth->hasAdmin()); |
| 435 | $test->assertEquals([], $auth->listUsers(), 'ログインしていなければ空'); |
| 436 | $test->assertFalse($auth->setupFirstAdmin('x', 'boss0001', $pass, $pass, 'boss@example.com', '')->isSuccess, 'コードが無ければ作れない'); |
| 437 | $res = $auth->setupFirstAdmin('chigau', 'boss0001', $pass, $pass, 'boss@example.com', 'himitsu-code'); |
| 438 | $test->assertFalse($res->isSuccess); |
| 439 | $test->assertStringContains('コード', (string)$res->message); |
| 440 | $test->assertFalse(is_dir("{$empty}/user")); |
| 441 | |
| 442 | $res = @$makeIn()->setupFirstAdmin('himitsu-code', 'boss0001', $pass, $pass, 'boss@example.com', 'himitsu-code'); |
| 443 | // CLIでは出力の後なのでクッキーを設定出来ないが、アカウントとトークンは保存される |
| 444 | $test->assertTrue($res->isSuccess || $res->message === 'エラー:クッキーを設定に失敗。', (string)$res->message); |
| 445 | $u = $readIn('boss0001'); |
| 446 | $test->assertEquals(1, $u->id); |
| 447 | $test->assertEquals(\Roles::MEMBER | \Roles::ADMIN, $u->role); |
| 448 | $test->assertTrue(password_verify($pass, $u->password)); |
| 449 | $test->assertCount(1, $u->tokens); |
| 450 | $test->assertTrue(is_dir("{$empty}/ass/boss0001")); |
| 451 | $test->assertTrue($makeIn('boss0001')->isAdmin()); |
| 452 | $test->assertTrue($makeIn()->hasAdmin()); |
| 453 | $test->assertFalse($makeIn()->setupFirstAdmin('himitsu-code', 'boss0002', $pass, $pass, 'b2@example.com', 'himitsu-code')->isSuccess, '2人目は作れない'); |
| 454 | $makeIn()->clearLoginFailures(); |
| 455 | }); |
| 456 | |
| 457 | $test->it('管理者はユーザーを作れるはず(他のユーザーは作れない)', function($test) use ($makeIn, $readIn, $pass): void { |
| 458 | $admin = $makeIn('boss0001'); |
| 459 | $res = $admin->adminCreateUser('member01', $pass, $pass, 'm1@example.com'); |
| 460 | $test->assertTrue($res->isSuccess, (string)$res->message); |
| 461 | $test->assertEquals(2, $readIn('member01')->id); |
| 462 | $test->assertEquals(\Roles::MEMBER, $readIn('member01')->role); |
| 463 | $test->assertFalse(isset($readIn('member01')->verified)); |
| 464 | $res = $admin->adminCreateUser('staff001', $pass, $pass, 's1@example.com', [\Roles::MEMBER, \Roles::STAFF, 1 << 30]); |
| 465 | $test->assertTrue($res->isSuccess, (string)$res->message); |
| 466 | $test->assertEquals(\Roles::MEMBER | \Roles::STAFF, $readIn('staff001')->role, '不明なビットは無視'); |
| 467 | $test->assertFalse($admin->adminCreateUser('member01', $pass, $pass, 'm9@example.com')->isSuccess, '同じユーザー名'); |
| 468 | $test->assertFalse($admin->adminCreateUser('member09', $pass, $pass, 'm1@example.com')->isSuccess, '同じメールアドレス'); |
| 469 | $test->assertFalse($admin->adminCreateUser('member09', 'short', 'short', 'm9@example.com')->isSuccess, '弱いパスワード'); |
| 470 | $test->assertFalse($admin->adminCreateUser('mem', $pass, $pass, 'm9@example.com')->isSuccess, '短いユーザー名'); |
| 471 | $test->assertFalse($admin->adminCreateUser('member09', $pass, $pass, 'not-an-email')->isSuccess); |
| 472 | $test->assertFalse($makeIn('member01')->adminCreateUser('member09', $pass, $pass, 'm9@example.com')->isSuccess, '管理者以外'); |
| 473 | $test->assertNull($readIn('member09')); |
| 474 | }); |
| 475 | |
| 476 | $test->it('管理者だけがユーザーの一覧を見れるはず', function($test) use ($makeIn): void { |
| 477 | $list = $makeIn('boss0001')->listUsers(); |
| 478 | $test->assertEquals(['boss0001', 'member01', 'staff001'], array_column($list, 'username')); |
| 479 | $test->assertEquals(1, $list[0]->sessions); |
| 480 | $test->assertEquals(['メンバー', '管理者'], $list[0]->roleLabels); |
| 481 | $test->assertFalse(isset($list[0]->password) || isset($list[0]->tokens)); |
| 482 | $test->assertEquals([], $makeIn('member01')->listUsers()); |
| 483 | $test->assertNull($makeIn('member01')->getAccount('boss0001')); |
| 484 | $test->assertEquals('m1@example.com', $makeIn('boss0001')->getAccount('member01')->email); |
| 485 | $test->assertNull($makeIn('boss0001')->getAccount('nobody01')); |
| 486 | }); |
| 487 | |
| 488 | $test->it('管理者はアカウントを変更出来るはず(大事な変更には管理者のパスワード)', function($test) use ($makeIn, $readIn, $pass, $empty): void { |
| 489 | $path = glob("{$empty}/user/*.member01.json")[0]; |
| 490 | $u = $readIn('member01'); |
| 491 | $u->tokens = [(object)['token' => str_repeat('a', 64), 'expDate' => time() + 3600, 'createDate' => time()]]; |
| 492 | $u->verified = false; |
| 493 | $u->verifyToken = 'x'; |
| 494 | $u->pendingEmail = 'new@example.com'; |
| 495 | $u->twoFactor = (object)['secret' => 'ABCDEFGH', 'lastStep' => 0]; |
| 496 | file_put_contents($path, json_encode($u, JSON_PRETTY_PRINT)); |
| 497 | $admin = $makeIn('boss0001'); |
| 498 | $new = 'NewPASS56&*longenough!'; |
| 499 | |
| 500 | $test->assertFalse($makeIn('member01')->adminUpdateAccount('staff001', ['logoutAll' => true])->isSuccess, '管理者以外'); |
| 501 | $test->assertFalse($admin->adminUpdateAccount('nobody01', ['logoutAll' => true])->isSuccess); |
| 502 | $res = $admin->adminUpdateAccount('member01', ['email' => 'm1b@example.com'], 'machigatta'); |
| 503 | $test->assertStringContains('管理者のパスワード', (string)$res->message); |
| 504 | $test->assertFalse($admin->adminUpdateAccount('member01', ['disableTwoFactor' => true])->isSuccess); |
| 505 | $test->assertFalse($admin->adminUpdateAccount('member01', ['password' => $new, 'passwordVerify' => 'x'], $pass)->isSuccess); |
| 506 | $test->assertFalse($admin->adminUpdateAccount('member01', ['email' => 's1@example.com'], $pass)->isSuccess, '他のユーザーのメールアドレス'); |
| 507 | $test->assertEquals('m1@example.com', $readIn('member01')->email, '失敗したら何も変えない'); |
| 508 | |
| 509 | $res = $admin->adminUpdateAccount('member01', ['email' => 'm1b@example.com', 'password' => $new, 'passwordVerify' => $new, 'disableTwoFactor' => true], $pass); |
| 510 | $test->assertTrue($res->isSuccess, (string)$res->message); |
| 511 | $u = $readIn('member01'); |
| 512 | $test->assertEquals('m1b@example.com', $u->email); |
| 513 | $test->assertFalse(isset($u->pendingEmail)); |
| 514 | $test->assertTrue(password_verify($new, $u->password)); |
| 515 | $test->assertCount(0, $u->tokens, 'パスワードを変えたら全部のログインを取り消す'); |
| 516 | $test->assertFalse(isset($u->twoFactor)); |
| 517 | |
| 518 | // 確認済みにする(パスワードは不要) |
| 519 | $res = $admin->adminUpdateAccount('member01', ['verify' => true]); |
| 520 | $test->assertTrue($res->isSuccess, (string)$res->message); |
| 521 | $u = $readIn('member01'); |
| 522 | $test->assertTrue($u->verified); |
| 523 | $test->assertFalse(isset($u->verifyToken)); |
| 524 | $test->assertEquals('変更はありません。', $admin->adminUpdateAccount('member01', ['verify' => true, 'logoutAll' => true])->message); |
| 525 | |
| 526 | // ロール |
| 527 | $res = $admin->adminUpdateAccount('member01', ['roles' => [\Roles::MEMBER, \Roles::FEDIVERSE]]); |
| 528 | $test->assertTrue($res->isSuccess, (string)$res->message); |
| 529 | $test->assertEquals(\Roles::MEMBER | \Roles::FEDIVERSE, $readIn('member01')->role); |
| 530 | $test->assertTrue($readIn('member01')->fediDefault); |
| 531 | $test->assertTrue($admin->adminUpdateAccount('member01', ['roles' => []])->isSuccess); |
| 532 | $test->assertEquals(\Roles::BANNED, $readIn('member01')->role); |
| 533 | $test->assertEquals(['BAN'], $admin->getAccount('member01')->roleLabels); |
| 534 | $res = $admin->adminUpdateAccount('boss0001', ['roles' => [\Roles::MEMBER]]); |
| 535 | $test->assertStringContains('最後の管理者', (string)$res->message); |
| 536 | $test->assertTrue($makeIn('boss0001')->isAdmin()); |
| 537 | |
| 538 | // 全部のログインを取り消す |
| 539 | $res = $admin->adminUpdateAccount('boss0001', ['logoutAll' => true]); |
| 540 | $test->assertTrue($res->isSuccess, (string)$res->message); |
| 541 | $test->assertCount(0, $readIn('boss0001')->tokens); |
| 542 | }); |
| 543 | |
| 544 | }); |
| 545 | |
| 546 | // 一時ディレクトリを削除する |
| 547 | $rm = function(string $dir) use (&$rm): void { |
| 548 | foreach (scandir($dir) ?: [] as $f) { |
| 549 | if ($f === '.' || $f === '..') continue; |
| 550 | is_dir("{$dir}/{$f}") ? $rm("{$dir}/{$f}") : @unlink("{$dir}/{$f}"); |
| 551 | } |
| 552 | @rmdir($dir); |
| 553 | }; |
| 554 | $rm($base); |
| 555 | |
| 556 | $test->printSummary(); |