ユーザーの申し込み

諏訪子 <suwako@076.moe>、2025-12-08 00:56
コミット c7537ab36f8a1fe8424d1c0036c0702ae5cbdd41 ・親 f6389ee ・ファイルを見る

7個のファイル、+306 -43

変更 .gitignore
@@ -2,5 +2,7 @@
22 data/*.txt
33 data/user/*.json
44 log/*.txt
5+public/static/user
6+!public/static/user/.kara
57 config/config.php
68 public/static/*.pem
変更 route.php
@@ -39,6 +39,10 @@
3939 if (AUTH_ENABLED) {
4040 $routes[] = Route::add('POST', 'login', User::class.'@login');
4141 $routes[] = Route::add('GET', 'login', User::class.'@login');
42+ if (AUTH_REGISTER_ENABLED) {
43+ $routes[] = Route::add('POST', 'register', User::class.'@register');
44+ $routes[] = Route::add('GET', 'register', User::class.'@register');
45+ }
4246 $routes[] = Route::add('GET', 'logout', User::class.'@logout');
4347 }
4448
@@ -44,7 +44,7 @@
4444 $description = 'サイトにサインイン';
4545
4646 $tmpl->assign('pagetit', $pagetit);
47- $tmpl->assign('curPage', 'about');
47+ $tmpl->assign('curPage', 'auth');
4848 $tmpl->assign('custCss', false);
4949 $tmpl->assign('menu', $this->getMenu());
5050 $tmpl->assign('description', $description);
@@ -58,7 +58,7 @@
5858
5959 public function logout(array $params): void {
6060 try {
61- $auth = new Auth();
61+ $auth = new Auth;
6262 $user = $auth->getLoggedInUser();
6363 if (!$user) {
6464 header('Location: /');
@@ -68,6 +68,64 @@
6868 $auth->logout();
6969 header('Location: /');
7070 exit();
71+ } catch (\Exception $e) {
72+ throw new \Exception($e->getMessage());
73+ }
74+ }
75+
76+ public function register(array $params): void {
77+ if (!AUTH_REGISTER_ENABLED) return;
78+ try {
79+ $auth = new Auth;
80+ $user = $auth->getLoggedInUser();
81+ if ($user) {
82+ header('Location: /');
83+ exit();
84+ }
85+
86+ $doRegister = $_SERVER['REQUEST_METHOD'] === 'POST';
87+ $error = '';
88+ $nyuU = '';
89+ $nyuE = '';
90+
91+ if ($doRegister) {
92+ $a = [];
93+ if (count($_POST) === 4) {
94+ $i = 0;
95+ foreach ($_POST as $p) {
96+ $a[(int)$i] = $p;
97+ $i++;
98+ }
99+ }
100+
101+ $auth = new Auth;
102+ $res = $auth->mkUser($a[0], $a[1], $a[2], $a[3]);
103+ if (!$res->isSuccess) {
104+ $error = $res->message;
105+ $nyuU = $a[0];
106+ $nyuE = $a[3];
107+ } else {
108+ $auth = new Auth($a[0]);
109+ $auth->setToken($a[0], $a[1]);
110+ header('Location: /');
111+ exit();
112+ }
113+ }
114+
115+ $tmpl = new Template('/');
116+ $pagetit = '登録';
117+ $description = 'サイトに登録';
118+
119+ $tmpl->assign('pagetit', $pagetit);
120+ $tmpl->assign('curPage', 'auth');
121+ $tmpl->assign('custCss', false);
122+ $tmpl->assign('menu', $this->getMenu());
123+ $tmpl->assign('description', $description);
124+ $tmpl->assign('error', $error);
125+ $tmpl->assign('nyuU', $nyuU);
126+ $tmpl->assign('nyuE', $nyuE);
127+
128+ $tmpl->render('register');
71129 } catch (\Exception $e) {
72130 throw new \Exception($e->getMessage());
73131 }
@@ -7,15 +7,13 @@
77 /**
88 * 性別: -1 = 不明, 0 = 男性, 1 = 女性
99 * ロール: -1 = BAN, 0 = ユーザー, 1 = スタッフ
10- * トークン: string token, int expDate
11- *
12- * パスワードとメールアドレスはArgon2IDでハッシュする
1310 */
1411 private \stdClass $user;
1512 private \stdClass $pubUser;
1613 private ?string $token;
1714
1815 protected string $dataDir = ROOT."/data/user/";
16+ protected string $assDir = ROOT."/public/static/user/";
1917 protected int $minPassLen = 20;
2018 protected int $tokenDuration = 31536000; // 1年間
2119 protected string|int|null $algo = PASSWORD_ARGON2ID;
@@ -36,25 +34,6 @@
3634 return $this->pubUser;
3735 }
3836
39- private function verifyLogin(string $username, string $password): \Result {
40- $userData = $this->getUserData();
41- if ($username !== $userData->username || !password_verify($password, $userData->password)) {
42- return \Result::error('エラー:ユーザー名又はパスワードが一致していません。');
43- }
44-
45- if (password_needs_rehash($userData->password, $algo)) {
46- $userData->password = password_hash($password, $algo);
47- $path = $this->dataDir.$this->id.'.'.$userData->username.'.json';
48- $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
49-
50- if (file_put_contents($path, $json) === false) {
51- return \Result::error('エラー:ユーザーデータの保存に失敗。');
52- }
53- }
54-
55- return \Result::success();
56- }
57-
5837 public function setToken(string $username, string $password): \Result {
5938 $userData = $this->getUserData();
6039 if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = [];
@@ -156,7 +135,7 @@
156135 $userList = scandir($this->dataDir);
157136
158137 foreach ($userList as $list) {
159- if ($list === '.' || $list === '..') continue;
138+ if ($list === '.kara' || $list === '.' || $list === '..') continue;
160139 $file = str_replace('.json', '', $list);
161140 $user = explode('.', $file)[1];
162141 if ($username === $user) return \Result::Success("ユーザー「{$username}」は既に存在します。");
@@ -165,8 +144,86 @@
165144 return \Result::Error("エラー:ユーザー「{$username}」は存在しません。");
166145 }
167146
147+ public function mkUser(?string $username = null, ?string $password = null, ?string $passwordVerify = null, ?string $email = null): \Result {
148+ if (!AUTH_REGISTER_ENABLED) return \Result::Error('ユーザー登録は無効です。');
149+ $resUsr = $this->verifyUsername($username);
150+ $resPwd = $this->verifyPassword($password, $passwordVerify);
151+ $resEml = $this->verifyEmail($email);
152+
153+ $err = '';
154+
155+ if (!$resUsr->isSuccess) $err .= $resUsr->message."<br />";
156+ if (!$resPwd->isSuccess) $err .= $resPwd->message."<br />";
157+ if (!$resEml->isSuccess) $err .= $resEml->message."<br />";
158+
159+ if ($err != '') return \Result::Error($err);
160+ $err = '';
161+
162+ if (!mkdir($this->assDir.$username, 0755)) {
163+ return \Result::Error('エラー:ユーザーのアイコンディレクトリの作成に失敗。');
164+ }
165+
166+ $file = scandir($this->dataDir);
167+ $lastId = 0;
168+ if ($file) {
169+ foreach ($file as $f) {
170+ if ($f === '.kara' || $f === '.' || $f === '..') continue;
171+ $base = substr($f, 0, -5); // .jsonの削除
172+ $dot = strpos($base, '.');
173+ if (!$dot) continue;
174+ $id = substr($base, 0, $dot);
175+ if (ctype_digit($id)) {
176+ if ((int)$id > $lastId) $lastId = $id;
177+ }
178+ }
179+ $lastId++;
180+ }
181+
182+ $user = new \stdClass;
183+ $user->id = $lastId;
184+ $user->username = $username;
185+ $user->password = password_hash($password, $algo);
186+ $user->avatar = '';
187+ $user->email = $email;
188+ $user->regDate = time();
189+ $user->namecolor = '';
190+ $user->displayname = '';
191+ $user->gender = -1;
192+ $user->role = 0;
193+ $user->tokens = [];
194+
195+ $path = "{$this->dataDir}{$lastId}.{$username}.json";
196+ $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
197+
198+ if (file_put_contents($path, $json) === false) {
199+ rmdir($this->assDir.$username);
200+ return \Result::Error('エラー:ユーザーデータの保存に失敗。');
201+ }
202+
203+ return \Result::Success();
204+ }
205+
168206 ////////////////////
169207
208+ private function verifyLogin(string $username, string $password): \Result {
209+ $userData = $this->getUserData();
210+ if ($username !== $userData->username || !password_verify($password, $userData->password)) {
211+ return \Result::Error('エラー:ユーザー名又はパスワードが一致していません。');
212+ }
213+
214+ if (password_needs_rehash($userData->password, $algo)) {
215+ $userData->password = password_hash($password, $algo);
216+ $path = $this->dataDir.$this->id.'.'.$userData->username.'.json';
217+ $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
218+
219+ if (file_put_contents($path, $json) === false) {
220+ return \Result::Error('エラー:ユーザーデータの保存に失敗。');
221+ }
222+ }
223+
224+ return \Result::Success();
225+ }
226+
170227 private function purgeOldTokens(\stdClass $userData): \stdClass|\Result {
171228 // 有効期限切れたトークンの削除
172229 $out = $userData;
@@ -178,7 +235,7 @@
178235 $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
179236
180237 if (file_put_contents($path, $json) === false) {
181- return \Result::error('エラー:ユーザーデータの保存に失敗。');
238+ return \Result::Error('エラー:ユーザーデータの保存に失敗。');
182239 }
183240
184241 return $out;
@@ -191,7 +248,7 @@
191248 $id = 0;
192249
193250 foreach ($file as $f) {
194- if ($f === '.' || $f === '..') continue;
251+ if ($f === '.kara' || $f === '.' || $f === '..') continue;
195252
196253 $path = "{$this->dataDir}{$f}";
197254 if (!is_file($path)) continue;
@@ -215,7 +272,7 @@
215272 $matches = [];
216273
217274 foreach ($file as $f) {
218- if ($f === '.' || $f === '..') continue;
275+ if ($f === '.kara' || $f === '.' || $f === '..') continue;
219276 if (preg_match('/^'.$this->id.'\.(.*?)\.json$/', $f, $matches)) {
220277 $userFile = $matches[0];
221278 break;
@@ -239,41 +296,82 @@
239296 return json_decode($lines);
240297 }
241298
242- private function isEmailExist(?string $email): \Result {
243- if (null === $password) return \Result::Error('エラー:パスワードをご入力下さい。');
299+ private function isEmailExist(?string $email = null): \Result {
300+ $userList = scandir($this->dataDir);
301+ $matches = [];
302+
303+ foreach ($userList as $f) {
304+ if ($f === '.kara' || $f === '.' || $f === '..') continue;
305+ $path = "{$this->dataDir}{$f}";
306+ if (!is_file($path)) continue;
307+ $content = file_get_contents($path);
308+ $file = str_replace('.json', '', $f);
309+ if (str_contains($content, '"email": "'.$email.'",')) {
310+ $matches[] = $email;
311+ }
312+ if (count($matches) > 0) return \Result::Error("ユーザー「{$email}」は既に存在します。");
313+ }
314+
315+ return \Result::Success('エラー:メールアドレスが存在しません。');
244316 }
245317
318+ private function verifyEmail(?string $email = null): \Result {
319+ if (null === $email || '' === $email) return \Result::Error('エラー:メールアドレスをご入力下さい。');
320+ if (strpos($email, '@') === false) return \Result::Error('エラー:メールアドレスは不正です。');
321+
322+ $domain = explode('@', $email)[1];
323+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
324+ return \Result::Error('エラー:メールアドレスは不正です。');
325+ }
326+
327+ if (!checkdnsrr($domain, 'MX')) {
328+ return \Result::Error('エラー:メールアドレスは不正です。');
329+ }
330+
331+ $res = $this->isEmailExist($email);
332+ if (!$res->isSuccess) return \Result::Error($res->message);
333+
334+ return \Result::Success();
335+ }
336+
246337 private function verifyUsername(?string $username): \Result {
247- if (null === $username) return \Result::Error('エラー:ユーザー名をご入力下さい。');
338+ if (null === $username || '' === $username) return \Result::Error('エラー:ユーザー名をご入力下さい。');
248339 if (strlen($username) < 6) return \Result::Error('エラー:ユーザー名は6文字以上をご入力下さい。');
249340
250- $accepted = 'A-Za-z0-9';
341+ $res = $this->isUserExist($username);
342+ if ($res->isSuccess) return \Result::Error($res->message);
251343
252- if (preg_match('/[^'.preg_quote($accepted, '/').']/', $password)) {
344+ $accepted = 'A-Za-z0-9';
345+ if (preg_match("/[^{$accepted}]/", $username)) {
253346 return \Result::Error('エラー:ユーザー名に不正な文字が含みます。');
254347 }
348+
255349 return \Result::Success();
256350 }
257351
258- private function verifyPassword(?string $password): \Result {
352+ private function verifyPassword(?string $password = null, ?string $passwordVerify = null): \Result {
353+ if (null === $password || '' === $password) return \Result::Error('エラー:パスワードをご入力下さい。');
354+ if ($password !== $passwordVerify) return \Result::Error('エラー:パスワードは一致していません。');
355+
356+ $res = $this->checkPasswordStandards($password);
357+ if (!$res->isSuccess) {
358+ return \Result::Error($res->message);
359+ }
360+
259361 return \Result::Success();
260362 }
261363
262- private function checkPasswordStandards(?string $password): \Result {
263- if (null === $password) return \Result::Error('エラー:パスワードをご入力下さい。');
264- if (strlen($password) < $this->minPassLen) return \Result::Error("エラー:パスワード数は{$this->minPassLen}以上をご入力下さい。");
364+ private function checkPasswordStandards(?string $password = null): \Result {
365+ if (strlen($password) < $this->minPassLen) return \Result::Error("エラー:パスワードは{$this->minPassLen}以上をご入力下さい。");
265366
266- $specChar = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~]/';
267- $accepted = 'A-Za-z0-9'.$specChar;
268-
269- if (preg_match('/[^'.preg_quote($accepted, '/').']/', $password)) {
367+ if (!\countmatch($password)) {
270368 return \Result::Error('エラー:パスワードに不正な文字が含みます。');
271369 }
272370
273371 $countUpper = preg_match_all('/[A-Z]/', $password) < 2;
274372 $countLower = preg_match_all('/[a-z]/', $password) < 2;
275373 $countDigit = preg_match_all('/[0-9]/', $password) < 2;
276- $countSymbol = preg_match_all('/['.$specChar.']/', $password) < 2;
374+ $countSymbol = \count_special_chars($password) < 2;
277375
278376 if ($countUpper || $countLower || $countDigit || $countSymbol) {
279377 return \Result::Error('エラー:パスワードは2つ以上の大文字、2つ以上の小文字、2つ以上の数字、及び2つ以上の特別文字を含む事が必須です。');
変更 util.php
@@ -151,7 +151,7 @@
151151 $gender = 'color: '.($userData->gender === 0 ? $male : ($userData->gender === 1 ? $female : $ungender)).';';
152152 $style = $userData->namecolor ?: ($userData->role >= 0 ? $gender : $ban);
153153
154- $showname = $userData->displayname ?? $userData->username;
154+ $showname = $userData->displayname ?: $userData->username;
155155
156156 $color = "<span style=\"{$style}\">{$showname}</span>";
157157 if ($userData->role === 1) $color .= "<span style=\"font-size: x-small; background: #10c074; border: 1px solid #fcfcfc; border-radius: 10px; padding: 0 0.5em;\">✓</span>";
@@ -160,4 +160,58 @@
160160
161161 return $color.$suffix;
162162 }
163+}
164+
165+function count_special_chars(string $str): int {
166+ $count = 0;
167+ $len = strlen($str);
168+
169+ for ($i = 0; $i < $len; ++$i) {
170+ if (str_contains('!', $str[$i])) ++$count;
171+ if (str_contains('"', $str[$i])) ++$count;
172+ if (str_contains('#', $str[$i])) ++$count;
173+ if (str_contains('$', $str[$i])) ++$count;
174+ if (str_contains('%', $str[$i])) ++$count;
175+ if (str_contains('&', $str[$i])) ++$count;
176+ if (str_contains('\'', $str[$i])) ++$count;
177+ if (str_contains('(', $str[$i])) ++$count;
178+ if (str_contains(')', $str[$i])) ++$count;
179+ if (str_contains('-', $str[$i])) ++$count;
180+ if (str_contains('=', $str[$i])) ++$count;
181+ if (str_contains('^', $str[$i])) ++$count;
182+ if (str_contains('~', $str[$i])) ++$count;
183+ if (str_contains('\\', $str[$i])) ++$count;
184+ if (str_contains('|', $str[$i])) ++$count;
185+ if (str_contains('[', $str[$i])) ++$count;
186+ if (str_contains(']', $str[$i])) ++$count;
187+ if (str_contains(':', $str[$i])) ++$count;
188+ if (str_contains('@', $str[$i])) ++$count;
189+ if (str_contains('`', $str[$i])) ++$count;
190+ if (str_contains('*', $str[$i])) ++$count;
191+ if (str_contains('{', $str[$i])) ++$count;
192+ if (str_contains('}', $str[$i])) ++$count;
193+ if (str_contains(';', $str[$i])) ++$count;
194+ if (str_contains('+', $str[$i])) ++$count;
195+ if (str_contains(',', $str[$i])) ++$count;
196+ if (str_contains('<', $str[$i])) ++$count;
197+ if (str_contains('.', $str[$i])) ++$count;
198+ if (str_contains('>', $str[$i])) ++$count;
199+ if (str_contains('/', $str[$i])) ++$count;
200+ if (str_contains('?', $str[$i])) ++$count;
201+ if (str_contains('_', $str[$i])) ++$count;
202+ }
203+
204+ return $count;
205+}
206+
207+function countmatch(string $str): bool {
208+ $len = strlen($str);
209+
210+ $numUpper = preg_match_all('/[A-Z]/', $str) ?? 0;
211+ $numLower = preg_match_all('/[a-z]/', $str) ?? 0;
212+ $numDigit = preg_match_all('/[0-9]/', $str) ?? 0;
213+ $numSymbol = count_special_chars($str);
214+ $sum = $numUpper + $numLower + $numDigit + $numSymbol;
215+
216+ return $len == $sum;
163217 }
@@ -0,0 +1,47 @@
1+{@ include(common/header) @}
2+ <h1 class="paragraph">登録</h1>
3+ <p class="paragraph">
4+ 登録して下さい。
5+ </p>
6+
7+{@ if ($error) @}
8+ <p>
9+ <div class="errormes">
10+ {{{ $error }}}
11+ </div>
12+ </p>
13+{@ endif @}
14+
15+ <p>
16+ <form action="/register" method="POST">
17+ {$ $username = randstr() $}
18+ {$ $password = randstr() $}
19+ {$ $passwordVerify = randstr() $}
20+ {$ $email = randstr() $}
21+ <table>
22+ <tbody>
23+ <tr>
24+ <td><label for="{{ $username }}">ユーザー名:</label></td>
25+ <td><input type="text" id="{{ $username }}" name="{{ $username }}" value="{{ $nyuU }}" /></td>
26+ </tr>
27+ <tr>
28+ <td><label for="{{ $password }}">パスワード:</label></td>
29+ <td><input type="password" id="{{ $password }}" name="{{ $password }}" /></td>
30+ </tr>
31+ <tr>
32+ <td><label for="{{ $passwordVerify }}">パスワード(確認):</label></td>
33+ <td><input type="password" id="{{ $passwordVerify }}" name="{{ $passwordVerify }}" /></td>
34+ </tr>
35+ <tr>
36+ <td><label for="{{ $email }}">メールアドレス:</label></td>
37+ <td><input type="email" id="{{ $email }}" name="{{ $email }}" value="{{ $nyuE }}" /></td>
38+ </tr>
39+ <tr>
40+ <td></td>
41+ <td><button>登録</button></td>
42+ </tr>
43+ </tbody>
44+ </table>
45+ </form>
46+ </p>
47+{@ include(common/footer) @}