master
ブランチ master
LittleBeast / src / Std / Test / LibAuth.php
32.1 KB、556行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, and/or sell copies of the Software, subject to the following conditions:
10
11 1. **Origin Attribution**:
12 - You must not misrepresent the origin of the Software; you must not claim
13 you created the original Software.
14 - If the Software is used in a product, you must either:
15 a. Provide clear attribution in the product's documentation, user interface,
16 or other visible areas, **OR**
17 b. Pay the original developers a fee they specify in writing.
18 2. **Usage Restriction**:
19 - The Software, or any derivative works, dependencies, or libraries
20 incorporating it, must not be used for censorship or to suppress freedom of
21 speech, expression, or creativity. Prohibited uses include, but are not
22 limited to:
23 - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24 ideas, or objective reality.
25 - Tools or systems designed to restrict access to information or
26 artistic works.
27 3. **Notice Preservation**:
28 - This license and the above copyright notice must remain intact in all copies
29 of the source code.
30
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Test;
39
40require_once __DIR__.'/../../../autoload.php';
41
42use Std\Lib\Tester;
43use Std\Lib\Auth;
44use Std\Lib\Totp;
45use 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));
54mkdir("{$base}/user", 0700, true);
55mkdir("{$base}/ass", 0700, true);
56touch("{$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();