ユーザー

諏訪子 <suwako@076.moe>、2025-12-07 20:23
コミット d5428d751445db5e3ca1d6872f4ab14a32010f1e ・親 4f70e6d ・ファイルを見る

11個のファイル、+475 -1

変更 .gitignore
@@ -1,5 +1,6 @@
11 data/*.pem
22 data/*.txt
3+data/user/*.json
34 log/*.txt
45 config/config.php
56 public/static/*.pem
@@ -37,4 +37,6 @@
3737 define('ACTIVITYPUB_ENABLED', false);
3838 define('MYSQL_ENABLED', false);
3939 define('CSV_ENABLED', false);
40+define('AUTH_ENABLED', false);
41+define('AUTH_REGISTER_ENABLED', false);
4042 define('COPYRIGHT_YEAR', '2018-'.date('Y'));
@@ -55,6 +55,14 @@
5555 padding-bottom: 10px;
5656 }
5757
58+.errormes {
59+ background: #ee4030;
60+ color: #fcfcfc;
61+ border: 4px inset #b61729;
62+ border-radius: 2px;
63+ padding: 2px;
64+}
65+
5866 h1.paragraph, p.paragraph {
5967 text-align: left;
6068 }
変更 route.php
@@ -11,6 +11,7 @@
1111 use Site\Controller\Home;
1212 use Site\Controller\Notfound;
1313 use Site\Controller\Page;
14+use Site\Controller\User;
1415
1516 define('ROOT', realpath(__DIR__));
1617
@@ -33,6 +34,12 @@
3334 Fediverse::class.'@apactivity');
3435 $routes[] = Route::add('POST', 'ap/inbox', Fediverse::class.'@apinbox');
3536 $routes[] = Route::add('GET', 'ap/actor', Fediverse::class.'@apactor');
37+}
38+
39+if (AUTH_ENABLED) {
40+ $routes[] = Route::add('POST', 'login', User::class.'@login');
41+ $routes[] = Route::add('GET', 'login', User::class.'@login');
42+ $routes[] = Route::add('GET', 'logout', User::class.'@logout');
3643 }
3744
3845 /* if (RSS_ENABLED) {} */
@@ -62,4 +62,3 @@
6262 }
6363 }
6464 }
65-?>
@@ -0,0 +1,76 @@
1+<?php
2+namespace Site\Controller;
3+
4+use Site\Controller\Mods;
5+use Site\Lib\Auth;
6+use Site\Lib\Template;
7+
8+class User {
9+ use Mods;
10+
11+ public function login(array $params): void {
12+ try {
13+ $auth = new Auth;
14+ $user = $auth->getLoggedInUser();
15+ if ($user) {
16+ header('Location: /');
17+ exit();
18+ }
19+
20+ $doLogin = count($_POST) > 0;
21+ $error = '';
22+
23+ if ($doLogin) {
24+ $a = [];
25+ if (count($_POST) === 2) {
26+ $i = 0;
27+ foreach ($_POST as $p) {
28+ $a[(int)$i] = $p;
29+ $i++;
30+ }
31+ }
32+ $auth = new Auth($a[0]);
33+ $res = $auth->isUserExist($a[0]);
34+ kys($res);
35+ if (!$res->isSuccess) {
36+ $error = $res->message;
37+ } else {
38+ $auth->setToken($a[0], $a[1]);
39+ header('Location: /');
40+ exit();
41+ }
42+ }
43+ $tmpl = new Template('/');
44+ $pagetit = 'サインイン';
45+ $description = 'サイトにサインイン';
46+
47+ $tmpl->assign('pagetit', $pagetit);
48+ $tmpl->assign('curPage', 'about');
49+ $tmpl->assign('custCss', false);
50+ $tmpl->assign('menu', $this->getMenu());
51+ $tmpl->assign('description', $description);
52+ $tmpl->assign('error', $error);
53+
54+ $tmpl->render('login');
55+ } catch (\Exception $e) {
56+ throw new \Exception($e->getMessage());
57+ }
58+ }
59+
60+ public function logout(array $params): void {
61+ try {
62+ $auth = new Auth();
63+ $user = $auth->getLoggedInUser();
64+ if (!$user) {
65+ header('Location: /');
66+ exit();
67+ }
68+
69+ $auth->logout();
70+ header('Location: /');
71+ exit();
72+ } catch (\Exception $e) {
73+ throw new \Exception($e->getMessage());
74+ }
75+ }
76+}
@@ -0,0 +1,280 @@
1+<?php
2+namespace Site\Lib;
3+
4+class Auth {
5+ private int $id;
6+
7+ /**
8+ * 性別: -1 = 不明, 0 = 男性, 1 = 女性
9+ * ロール: -1 = BAN, 0 = ユーザー, 1 = スタッフ
10+ * トークン: string token, int expDate
11+ *
12+ * パスワードとメールアドレスはArgon2IDでハッシュする
13+ */
14+ private \stdClass $user;
15+ private \stdClass $pubUser;
16+ private ?string $token;
17+
18+ protected string $dataDir = ROOT."/data/user/";
19+ protected int $minPassLen = 20;
20+ protected int $tokenDuration = 31536000; // 1年間
21+ protected string|int|null $algo = PASSWORD_ARGON2ID;
22+
23+ public function __construct(?string $username = null) {
24+ if (!AUTH_ENABLED) return;
25+ $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
26+ $this->token = \getcookie('kerozen');
27+ $this->id = $this->getUserId($username, $this->token);
28+ if ($this->id > 0) $this->user = $this->getUserData();
29+ }
30+
31+ public function getLoggedInUser(): \stdClass|null {
32+ if ($this->id === 0) return null;
33+ $this->pubUser = $this->user;
34+ unset($this->pubUser->password);
35+ unset($this->pubUser->tokens);
36+ return $this->pubUser;
37+ }
38+
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+
58+ public function setToken(string $username, string $password): \Result {
59+ $userData = $this->getUserData();
60+ if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = [];
61+
62+ $verify = $this->verifyLogin($username, $password);
63+ if (!$verify->isSuccess) {
64+ return \Result::error($verify->message);
65+ }
66+
67+ $ip = $_SERVER['REMOTE_ADDR'];
68+ if (!empty($_SERVER['HTTP_X_FORMARDED_FOR'])) {
69+ $ipList = explode(',', $_SERVER['HTTP_X_FORMARDED_FOR']);
70+ $ip = trim($ipList[0]);
71+ }
72+
73+ $userData = $this->purgeOldTokens($userData);
74+ $expire = 0;
75+ $tokenExist = false;
76+
77+ foreach ($userData->tokens as $t) {
78+ if ($t->ip === $ip) {
79+ $tokenExist = true;
80+ break;
81+ }
82+ }
83+
84+ if (!$tokenExist) {
85+ $token = bin2hex(random_bytes(64));
86+ $expire = time() + $this->tokenDuration;
87+ $newToken = [
88+ 'token' => password_hash($token, $algo),
89+ 'ip' => $ip,
90+ 'createDate' => time(),
91+ 'expDate' => $expire,
92+ 'lastDate' => time(),
93+ 'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '不明'
94+ ];
95+
96+ $userData->tokens[] = $newToken;
97+ }
98+
99+ $path = $this->dataDir.$this->id.'.'.$userData->username.'.json';
100+ $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
101+
102+ if (file_put_contents($path, $json) === false) {
103+ return \Result::error('エラー:ユーザーデータの保存に失敗。');
104+ }
105+
106+ if (!$tokenExist) {
107+ $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
108+ $domain = $_SERVER['SERVER_NAME'];
109+
110+ if (!setcookie('kerozen', $token, [
111+ 'expires' => $expire,
112+ 'path' => '/',
113+ 'domain' => $domain,
114+ 'secure' => $secure,
115+ 'httponly' => true,
116+ 'samesite' => 'Strict'
117+ ])) {
118+ return \Result::error('エラー:クッキーを設定に失敗。');
119+ }
120+ }
121+
122+ return \Result::success('ログイン成功');
123+ }
124+
125+ public function getToken(): string {
126+ $userData = $this->getUserData();
127+ if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = [];
128+ $userData = $this->purgeOldTokens($userData);
129+ }
130+
131+ public function logout(?string $token = null): \Result {
132+ $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
133+ $domain = $_SERVER['SERVER_NAME'];
134+ $userData = $this->getUserData();
135+
136+ if (!$token) {
137+ $userData->tokens = array_filter($userData->tokens, function($t): bool {
138+ return ($t->expires ?? 0) > time();
139+ });
140+ }
141+
142+ setcookie('kerozen', null, [
143+ 'expires' => 0,
144+ 'path' => '/',
145+ 'domain' => $domain,
146+ 'secure' => $secure,
147+ 'httponly' => true,
148+ 'samesite' => 'Strict'
149+ ]);
150+
151+ return \Result::success();
152+ }
153+
154+ public function isUserExist(?string $username): \Result {
155+ if (null === $username) return \Result::Error('エラー:ユーザー名をご入力下さい。');
156+ $userList = scandir($this->dataDir);
157+
158+ foreach ($userList as $list) {
159+ if ($list === '.' || $list === '..') continue;
160+ $file = str_replace('.json', '', $list);
161+ $user = explode('.', $file)[1];
162+ if ($username === $user) return \Result::Success("ユーザー「{$username}」は既に存在します。");
163+ }
164+
165+ return \Result::Error("エラー:ユーザー「{$username}」は存在しません。");
166+ }
167+
168+ ////////////////////
169+
170+ private function purgeOldTokens(\stdClass $userData): \stdClass {
171+ // 有効期限切れたトークンの削除
172+ $out = $userData;
173+ $out->tokens = array_filter($userData->tokens, function($t): bool {
174+ return ($t->expDate ?? 0) > time();
175+ });
176+
177+ $path = $this->dataDir.$userData->id.'.'.$userData->username.'.json';
178+ $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
179+
180+ if (file_put_contents($path, $json) === false) {
181+ return \Result::error('エラー:ユーザーデータの保存に失敗。');
182+ }
183+
184+ return $out;
185+ }
186+
187+ private function getUserId(?string $username = null, ?string $token): int {
188+ if (!$username && !$token) return 0;
189+ $file = scandir($this->dataDir);
190+ $matches = [];
191+ $id = 0;
192+
193+ foreach ($file as $f) {
194+ if ($f === '.' || $f === '..') continue;
195+
196+ $path = "{$this->dataDir}{$f}";
197+ if (!is_file($path)) continue;
198+ $content = file_get_contents($path);
199+ if (str_contains($content, $token)) {
200+ $matches[] = $path;
201+ }
202+ }
203+ $id = (int)preg_replace('/^(.*?)\.(.*?)$/', "$1", str_replace($this->dataDir, '', $matches[0]));
204+
205+ return $id;
206+ }
207+
208+ private function getUserData(): \stdClass {
209+ $file = scandir($this->dataDir);
210+ $userFile = "";
211+ $matches = [];
212+
213+ foreach ($file as $f) {
214+ if ($f === '.' || $f === '..') continue;
215+ if (preg_match('/^'.$this->id.'\.(.*?)\.json$/', $f, $matches)) {
216+ $userFile = $matches[0];
217+ break;
218+ }
219+ }
220+ $path = $this->dataDir.$userFile;
221+ unset($file, $userFile);
222+
223+ if (!file_exists($path) || !is_readable($path)) {
224+ kys('エラー');
225+ }
226+
227+ $fp = fopen($path, 'r');
228+ assert_not_null($fp);
229+ if (!$fp) kys('ユーザーが存在しない。');
230+ $lines = "";
231+ while (($buf = fgets($fp, 4096)) !== false) $lines .= $buf.PHP_EOL;
232+ if (!feof($fp)) kys("エラー:不明なエラー");
233+ fclose($fp);
234+ unset($path);
235+ return json_decode($lines);
236+ }
237+
238+ private function isEmailExist(?string $email): \Result {
239+ if (null === $password) return \Result::Error('エラー:パスワードをご入力下さい。');
240+ }
241+
242+ private function verifyUsername(?string $username): \Result {
243+ if (null === $username) return \Result::Error('エラー:ユーザー名をご入力下さい。');
244+ if (strlen($username) < 6) return \Result::Error('エラー:ユーザー名は6文字以上をご入力下さい。');
245+
246+ $accepted = 'A-Za-z0-9';
247+
248+ if (preg_match('/[^'.preg_quote($accepted, '/').']/', $password)) {
249+ return \Result::Error('エラー:ユーザー名に不正な文字が含みます。');
250+ }
251+ return \Result::Success();
252+ }
253+
254+ private function verifyPassword(?string $password): \Result {
255+ return \Result::Success();
256+ }
257+
258+ private function checkPasswordStandards(?string $password): \Result {
259+ if (null === $password) return \Result::Error('エラー:パスワードをご入力下さい。');
260+ if (strlen($password) < $this->minPassLen) return \Result::Error("エラー:パスワード数は{$this->minPassLen}以上をご入力下さい。");
261+
262+ $specChar = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~]/';
263+ $accepted = 'A-Za-z0-9'.$specChar;
264+
265+ if (preg_match('/[^'.preg_quote($accepted, '/').']/', $password)) {
266+ return \Result::Error('エラー:パスワードに不正な文字が含みます。');
267+ }
268+
269+ $countUpper = preg_match_all('/[A-Z]/', $password) < 2;
270+ $countLower = preg_match_all('/[a-z]/', $password) < 2;
271+ $countDigit = preg_match_all('/[0-9]/', $password) < 2;
272+ $countSymbol = preg_match_all('/['.$specChar.']/', $password) < 2;
273+
274+ if ($countUpper || $countLower || $countDigit || $countSymbol) {
275+ return \Result::Error('エラー:パスワードは2つ以上の大文字、2つ以上の小文字、2つ以上の数字、及び2つ以上の特別文字を含む事が必須です。');
276+ }
277+
278+ return \Result::Success();
279+ }
280+};
変更 util.php
@@ -6,6 +6,24 @@
66 case Csv;
77 }
88
9+class Result {
10+ public bool $isSuccess;
11+ public ?string $message;
12+
13+ public function __construct(bool $isSuccess, ?string $message = null) {
14+ $this->isSuccess = $isSuccess;
15+ $this->message = $message;
16+ }
17+
18+ public static function Success(?string $message = null): self {
19+ return new self(true, $message);
20+ }
21+
22+ public static function Error(string $message): self {
23+ return new self(false, $message);
24+ }
25+}
26+
927 function uuid(): string {
1028 $data = random_bytes(16);
1129 $data[6] = chr(ord($data[6]) & 0x0f | 0x40);
@@ -89,5 +107,39 @@
89107 return '¥ '.number_format($amount, 0);
90108 default:
91109 return '¥ '.number_format($amount, 0);
110+ }
111+}
112+
113+function randstr(): string {
114+ srand(floor(time() / (60*60*24)));
115+ $len = rand() % 20;
116+ return bin2hex(random_bytes($len / 2));
117+}
118+
119+function assert_null(mixed $assertion, Throwable|string|null $description = null): bool {
120+ if (ini_get('zend.assertions') < 1) trigger_error('assert機能性が無効です。有効するには、php.iniで「zend.assertions = 1」に設定して下さい。', E_USER_WARNING);
121+ if (is_null($assertion)) return true;
122+ assert(false, $description ?? 'Assertion failed: value is not null');
123+ return false;
124+}
125+
126+function assert_not_null(mixed $assertion, Throwable|string|null $description = null): bool {
127+ if (ini_get('zend.assertions') < 1) trigger_error('assert機能性が無効です。有効するには、php.iniで「zend.assertions = 1」に設定して下さい。', E_USER_WARNING);
128+ if (!is_null($assertion)) return true;
129+ assert(false, $description ?? 'Assertion failed: value is null');
130+ return false;
131+}
132+
133+function assert_unless_success(Result $assertion, Throwable|string|null $description = null): bool {
134+ if (ini_get('zend.assertions') < 1) trigger_error('assert機能性が無効です。有効するには、php.iniで「zend.assertions = 1」に設定して下さい。', E_USER_WARNING);
135+ if ($assertion->isSuccess) return true;
136+ assert(false, $description ?? $assertion->message ?? 'Assertion failed: Result is not successful');
137+ return false;
138+}
139+
140+if (AUTH_ENABLED) {
141+ function getcookie(string $name): string|null {
142+ if (!$_COOKIE[$name]) return null;
143+ return $_COOKIE[$name];
92144 }
93145 }
@@ -56,6 +56,18 @@
5656 <a class="{{ $m['class'] }}{{ $curPage == $m['page'] ? ' active' : '' }}" href="{{ $m['href'] }}">{{ $m['text'] }}</a>
5757 {@ endif @}
5858 {@ endforeach @}
59+{@ if (AUTH_ENABLED) @}
60+ <div>
61+{@ if (isset($user) && isset($user->id) && $user->id > 0) @}
62+ お帰りなしゃ~い、{{ $user->displayname ?? $user->username }}さん! (<a href="/logout">ログアウト</a>)
63+{@ else @}
64+ <a href="/login">ログイン</a>
65+{@ if (AUTH_REGISTER_ENABLED) @}
66+ | <a href="/register">登録</a>
67+{@ endif @}
68+{@ endif @}
69+ </div>
70+{@ endif @}
5971 </nav>
6072 </header>
6173 <main>
@@ -0,0 +1,37 @@
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="/login" method="POST">
17+ {$ $username = randstr() $}
18+ {$ $password = randstr() $}
19+ <table>
20+ <tbody>
21+ <tr>
22+ <td><label for="{{ $username }}">ユーザー名:</label></td>
23+ <td><input type="text" id="{{ $username }}" name="{{ $username }}" /></td>
24+ </tr>
25+ <tr>
26+ <td><label for="{{ $password }}">パスワード:</label></td>
27+ <td><input type="password" id="{{ $password }}" name="{{ $password }}" /></td>
28+ </tr>
29+ <tr>
30+ <td></td>
31+ <td><button>サインイン</button></td>
32+ </tr>
33+ </tbody>
34+ </table>
35+ </form>
36+ </p>
37+{@ include(common/footer) @}