From d5428d751445db5e3ca1d6872f4ab14a32010f1e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=AB=8F=E8=A8=AA=E5=AD=90?=
Date: Sun, 7 Dec 2025 20:23:29 +0900
Subject: [PATCH] =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 1 +
config/config.sample.php | 2 +
data/user/.kara | 0
public/static/style.css | 8 +
route.php | 7 +
src/Site/Controller/Page.php | 3 +-
src/Site/Controller/User.php | 76 ++++++++++
src/Site/Lib/Auth.php | 280 +++++++++++++++++++++++++++++++++++
util.php | 52 +++++++
view/common/header.maron | 12 ++
view/login.maron | 37 +++++
11 files changed, 476 insertions(+), 2 deletions(-)
create mode 100644 data/user/.kara
create mode 100644 src/Site/Controller/User.php
create mode 100644 src/Site/Lib/Auth.php
create mode 100644 view/login.maron
diff --git a/.gitignore b/.gitignore
index f4aa3fd..57f86eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
data/*.pem
data/*.txt
+data/user/*.json
log/*.txt
config/config.php
public/static/*.pem
diff --git a/config/config.sample.php b/config/config.sample.php
index 2ab84b6..114c6a9 100644
--- a/config/config.sample.php
+++ b/config/config.sample.php
@@ -37,4 +37,6 @@ define('RSS_ENABLED', false);
define('ACTIVITYPUB_ENABLED', false);
define('MYSQL_ENABLED', false);
define('CSV_ENABLED', false);
+define('AUTH_ENABLED', false);
+define('AUTH_REGISTER_ENABLED', false);
define('COPYRIGHT_YEAR', '2018-'.date('Y'));
diff --git a/data/user/.kara b/data/user/.kara
new file mode 100644
index 0000000..e69de29
diff --git a/public/static/style.css b/public/static/style.css
index 9dac7eb..5370408 100644
--- a/public/static/style.css
+++ b/public/static/style.css
@@ -55,6 +55,14 @@ header, main, footer {
padding-bottom: 10px;
}
+.errormes {
+ background: #ee4030;
+ color: #fcfcfc;
+ border: 4px inset #b61729;
+ border-radius: 2px;
+ padding: 2px;
+}
+
h1.paragraph, p.paragraph {
text-align: left;
}
diff --git a/route.php b/route.php
index a871fcf..3fde0bf 100644
--- a/route.php
+++ b/route.php
@@ -11,6 +11,7 @@ use Site\Controller\Fediverse;
use Site\Controller\Home;
use Site\Controller\Notfound;
use Site\Controller\Page;
+use Site\Controller\User;
define('ROOT', realpath(__DIR__));
@@ -35,6 +36,12 @@ if (ACTIVITYPUB_ENABLED) {
$routes[] = Route::add('GET', 'ap/actor', Fediverse::class.'@apactor');
}
+if (AUTH_ENABLED) {
+ $routes[] = Route::add('POST', 'login', User::class.'@login');
+ $routes[] = Route::add('GET', 'login', User::class.'@login');
+ $routes[] = Route::add('GET', 'logout', User::class.'@logout');
+}
+
/* if (RSS_ENABLED) {} */
if (ATOM_ENABLED) {
diff --git a/src/Site/Controller/Page.php b/src/Site/Controller/Page.php
index a785922..6d83354 100644
--- a/src/Site/Controller/Page.php
+++ b/src/Site/Controller/Page.php
@@ -61,5 +61,4 @@ class Page {
throw new \Exception($e->getMessage());
}
}
-}
-?>
+}
\ No newline at end of file
diff --git a/src/Site/Controller/User.php b/src/Site/Controller/User.php
new file mode 100644
index 0000000..0e5f0e0
--- /dev/null
+++ b/src/Site/Controller/User.php
@@ -0,0 +1,76 @@
+getLoggedInUser();
+ if ($user) {
+ header('Location: /');
+ exit();
+ }
+
+ $doLogin = count($_POST) > 0;
+ $error = '';
+
+ if ($doLogin) {
+ $a = [];
+ if (count($_POST) === 2) {
+ $i = 0;
+ foreach ($_POST as $p) {
+ $a[(int)$i] = $p;
+ $i++;
+ }
+ }
+ $auth = new Auth($a[0]);
+ $res = $auth->isUserExist($a[0]);
+ kys($res);
+ if (!$res->isSuccess) {
+ $error = $res->message;
+ } else {
+ $auth->setToken($a[0], $a[1]);
+ header('Location: /');
+ exit();
+ }
+ }
+ $tmpl = new Template('/');
+ $pagetit = 'サインイン';
+ $description = 'サイトにサインイン';
+
+ $tmpl->assign('pagetit', $pagetit);
+ $tmpl->assign('curPage', 'about');
+ $tmpl->assign('custCss', false);
+ $tmpl->assign('menu', $this->getMenu());
+ $tmpl->assign('description', $description);
+ $tmpl->assign('error', $error);
+
+ $tmpl->render('login');
+ } catch (\Exception $e) {
+ throw new \Exception($e->getMessage());
+ }
+ }
+
+ public function logout(array $params): void {
+ try {
+ $auth = new Auth();
+ $user = $auth->getLoggedInUser();
+ if (!$user) {
+ header('Location: /');
+ exit();
+ }
+
+ $auth->logout();
+ header('Location: /');
+ exit();
+ } catch (\Exception $e) {
+ throw new \Exception($e->getMessage());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Site/Lib/Auth.php b/src/Site/Lib/Auth.php
new file mode 100644
index 0000000..4e6b12f
--- /dev/null
+++ b/src/Site/Lib/Auth.php
@@ -0,0 +1,280 @@
+token = \getcookie('kerozen');
+ $this->id = $this->getUserId($username, $this->token);
+ if ($this->id > 0) $this->user = $this->getUserData();
+ }
+
+ public function getLoggedInUser(): \stdClass|null {
+ if ($this->id === 0) return null;
+ $this->pubUser = $this->user;
+ unset($this->pubUser->password);
+ unset($this->pubUser->tokens);
+ return $this->pubUser;
+ }
+
+ private function verifyLogin(string $username, string $password): \Result {
+ $userData = $this->getUserData();
+ if ($username !== $userData->username || !password_verify($password, $userData->password)) {
+ return \Result::error('エラー:ユーザー名又はパスワードが一致していません。');
+ }
+
+ if (password_needs_rehash($userData->password, $algo)) {
+ $userData->password = password_hash($password, $algo);
+ $path = $this->dataDir.$this->id.'.'.$userData->username.'.json';
+ $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+ if (file_put_contents($path, $json) === false) {
+ return \Result::error('エラー:ユーザーデータの保存に失敗。');
+ }
+ }
+
+ return \Result::success();
+ }
+
+ public function setToken(string $username, string $password): \Result {
+ $userData = $this->getUserData();
+ if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = [];
+
+ $verify = $this->verifyLogin($username, $password);
+ if (!$verify->isSuccess) {
+ return \Result::error($verify->message);
+ }
+
+ $ip = $_SERVER['REMOTE_ADDR'];
+ if (!empty($_SERVER['HTTP_X_FORMARDED_FOR'])) {
+ $ipList = explode(',', $_SERVER['HTTP_X_FORMARDED_FOR']);
+ $ip = trim($ipList[0]);
+ }
+
+ $userData = $this->purgeOldTokens($userData);
+ $expire = 0;
+ $tokenExist = false;
+
+ foreach ($userData->tokens as $t) {
+ if ($t->ip === $ip) {
+ $tokenExist = true;
+ break;
+ }
+ }
+
+ if (!$tokenExist) {
+ $token = bin2hex(random_bytes(64));
+ $expire = time() + $this->tokenDuration;
+ $newToken = [
+ 'token' => password_hash($token, $algo),
+ 'ip' => $ip,
+ 'createDate' => time(),
+ 'expDate' => $expire,
+ 'lastDate' => time(),
+ 'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '不明'
+ ];
+
+ $userData->tokens[] = $newToken;
+ }
+
+ $path = $this->dataDir.$this->id.'.'.$userData->username.'.json';
+ $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+ if (file_put_contents($path, $json) === false) {
+ return \Result::error('エラー:ユーザーデータの保存に失敗。');
+ }
+
+ if (!$tokenExist) {
+ $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
+ $domain = $_SERVER['SERVER_NAME'];
+
+ if (!setcookie('kerozen', $token, [
+ 'expires' => $expire,
+ 'path' => '/',
+ 'domain' => $domain,
+ 'secure' => $secure,
+ 'httponly' => true,
+ 'samesite' => 'Strict'
+ ])) {
+ return \Result::error('エラー:クッキーを設定に失敗。');
+ }
+ }
+
+ return \Result::success('ログイン成功');
+ }
+
+ public function getToken(): string {
+ $userData = $this->getUserData();
+ if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = [];
+ $userData = $this->purgeOldTokens($userData);
+ }
+
+ public function logout(?string $token = null): \Result {
+ $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
+ $domain = $_SERVER['SERVER_NAME'];
+ $userData = $this->getUserData();
+
+ if (!$token) {
+ $userData->tokens = array_filter($userData->tokens, function($t): bool {
+ return ($t->expires ?? 0) > time();
+ });
+ }
+
+ setcookie('kerozen', null, [
+ 'expires' => 0,
+ 'path' => '/',
+ 'domain' => $domain,
+ 'secure' => $secure,
+ 'httponly' => true,
+ 'samesite' => 'Strict'
+ ]);
+
+ return \Result::success();
+ }
+
+ public function isUserExist(?string $username): \Result {
+ if (null === $username) return \Result::Error('エラー:ユーザー名をご入力下さい。');
+ $userList = scandir($this->dataDir);
+
+ foreach ($userList as $list) {
+ if ($list === '.' || $list === '..') continue;
+ $file = str_replace('.json', '', $list);
+ $user = explode('.', $file)[1];
+ if ($username === $user) return \Result::Success("ユーザー「{$username}」は既に存在します。");
+ }
+
+ return \Result::Error("エラー:ユーザー「{$username}」は存在しません。");
+ }
+
+ ////////////////////
+
+ private function purgeOldTokens(\stdClass $userData): \stdClass {
+ // 有効期限切れたトークンの削除
+ $out = $userData;
+ $out->tokens = array_filter($userData->tokens, function($t): bool {
+ return ($t->expDate ?? 0) > time();
+ });
+
+ $path = $this->dataDir.$userData->id.'.'.$userData->username.'.json';
+ $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+ if (file_put_contents($path, $json) === false) {
+ return \Result::error('エラー:ユーザーデータの保存に失敗。');
+ }
+
+ return $out;
+ }
+
+ private function getUserId(?string $username = null, ?string $token): int {
+ if (!$username && !$token) return 0;
+ $file = scandir($this->dataDir);
+ $matches = [];
+ $id = 0;
+
+ foreach ($file as $f) {
+ if ($f === '.' || $f === '..') continue;
+
+ $path = "{$this->dataDir}{$f}";
+ if (!is_file($path)) continue;
+ $content = file_get_contents($path);
+ if (str_contains($content, $token)) {
+ $matches[] = $path;
+ }
+ }
+ $id = (int)preg_replace('/^(.*?)\.(.*?)$/', "$1", str_replace($this->dataDir, '', $matches[0]));
+
+ return $id;
+ }
+
+ private function getUserData(): \stdClass {
+ $file = scandir($this->dataDir);
+ $userFile = "";
+ $matches = [];
+
+ foreach ($file as $f) {
+ if ($f === '.' || $f === '..') continue;
+ if (preg_match('/^'.$this->id.'\.(.*?)\.json$/', $f, $matches)) {
+ $userFile = $matches[0];
+ break;
+ }
+ }
+ $path = $this->dataDir.$userFile;
+ unset($file, $userFile);
+
+ if (!file_exists($path) || !is_readable($path)) {
+ kys('エラー');
+ }
+
+ $fp = fopen($path, 'r');
+ assert_not_null($fp);
+ if (!$fp) kys('ユーザーが存在しない。');
+ $lines = "";
+ while (($buf = fgets($fp, 4096)) !== false) $lines .= $buf.PHP_EOL;
+ if (!feof($fp)) kys("エラー:不明なエラー");
+ fclose($fp);
+ unset($path);
+ return json_decode($lines);
+ }
+
+ private function isEmailExist(?string $email): \Result {
+ if (null === $password) return \Result::Error('エラー:パスワードをご入力下さい。');
+ }
+
+ private function verifyUsername(?string $username): \Result {
+ if (null === $username) return \Result::Error('エラー:ユーザー名をご入力下さい。');
+ if (strlen($username) < 6) return \Result::Error('エラー:ユーザー名は6文字以上をご入力下さい。');
+
+ $accepted = 'A-Za-z0-9';
+
+ if (preg_match('/[^'.preg_quote($accepted, '/').']/', $password)) {
+ return \Result::Error('エラー:ユーザー名に不正な文字が含みます。');
+ }
+ return \Result::Success();
+ }
+
+ private function verifyPassword(?string $password): \Result {
+ return \Result::Success();
+ }
+
+ private function checkPasswordStandards(?string $password): \Result {
+ if (null === $password) return \Result::Error('エラー:パスワードをご入力下さい。');
+ if (strlen($password) < $this->minPassLen) return \Result::Error("エラー:パスワード数は{$this->minPassLen}以上をご入力下さい。");
+
+ $specChar = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~]/';
+ $accepted = 'A-Za-z0-9'.$specChar;
+
+ if (preg_match('/[^'.preg_quote($accepted, '/').']/', $password)) {
+ return \Result::Error('エラー:パスワードに不正な文字が含みます。');
+ }
+
+ $countUpper = preg_match_all('/[A-Z]/', $password) < 2;
+ $countLower = preg_match_all('/[a-z]/', $password) < 2;
+ $countDigit = preg_match_all('/[0-9]/', $password) < 2;
+ $countSymbol = preg_match_all('/['.$specChar.']/', $password) < 2;
+
+ if ($countUpper || $countLower || $countDigit || $countSymbol) {
+ return \Result::Error('エラー:パスワードは2つ以上の大文字、2つ以上の小文字、2つ以上の数字、及び2つ以上の特別文字を含む事が必須です。');
+ }
+
+ return \Result::Success();
+ }
+};
\ No newline at end of file
diff --git a/util.php b/util.php
index 9f24308..6ee4e98 100644
--- a/util.php
+++ b/util.php
@@ -6,6 +6,24 @@ enum LogType {
case Csv;
}
+class Result {
+ public bool $isSuccess;
+ public ?string $message;
+
+ public function __construct(bool $isSuccess, ?string $message = null) {
+ $this->isSuccess = $isSuccess;
+ $this->message = $message;
+ }
+
+ public static function Success(?string $message = null): self {
+ return new self(true, $message);
+ }
+
+ public static function Error(string $message): self {
+ return new self(false, $message);
+ }
+}
+
function uuid(): string {
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
@@ -90,4 +108,38 @@ function to_money($amount, $lang) {
default:
return '¥ '.number_format($amount, 0);
}
+}
+
+function randstr(): string {
+ srand(floor(time() / (60*60*24)));
+ $len = rand() % 20;
+ return bin2hex(random_bytes($len / 2));
+}
+
+function assert_null(mixed $assertion, Throwable|string|null $description = null): bool {
+ if (ini_get('zend.assertions') < 1) trigger_error('assert機能性が無効です。有効するには、php.iniで「zend.assertions = 1」に設定して下さい。', E_USER_WARNING);
+ if (is_null($assertion)) return true;
+ assert(false, $description ?? 'Assertion failed: value is not null');
+ return false;
+}
+
+function assert_not_null(mixed $assertion, Throwable|string|null $description = null): bool {
+ if (ini_get('zend.assertions') < 1) trigger_error('assert機能性が無効です。有効するには、php.iniで「zend.assertions = 1」に設定して下さい。', E_USER_WARNING);
+ if (!is_null($assertion)) return true;
+ assert(false, $description ?? 'Assertion failed: value is null');
+ return false;
+}
+
+function assert_unless_success(Result $assertion, Throwable|string|null $description = null): bool {
+ if (ini_get('zend.assertions') < 1) trigger_error('assert機能性が無効です。有効するには、php.iniで「zend.assertions = 1」に設定して下さい。', E_USER_WARNING);
+ if ($assertion->isSuccess) return true;
+ assert(false, $description ?? $assertion->message ?? 'Assertion failed: Result is not successful');
+ return false;
+}
+
+if (AUTH_ENABLED) {
+ function getcookie(string $name): string|null {
+ if (!$_COOKIE[$name]) return null;
+ return $_COOKIE[$name];
+ }
}
\ No newline at end of file
diff --git a/view/common/header.maron b/view/common/header.maron
index 616742a..bcee022 100644
--- a/view/common/header.maron
+++ b/view/common/header.maron
@@ -56,6 +56,18 @@
{{ $m['text'] }}
{@ endif @}
{@ endforeach @}
+{@ if (AUTH_ENABLED) @}
+
+{@ if (isset($user) && isset($user->id) && $user->id > 0) @}
+ お帰りなしゃ~い、{{ $user->displayname ?? $user->username }}さん! (
ログアウト)
+{@ else @}
+
ログイン
+{@ if (AUTH_REGISTER_ENABLED) @}
+ |
登録
+{@ endif @}
+{@ endif @}
+
+{@ endif @}
\ No newline at end of file
diff --git a/view/login.maron b/view/login.maron
new file mode 100644
index 0000000..4be3ba7
--- /dev/null
+++ b/view/login.maron
@@ -0,0 +1,37 @@
+{@ include(common/header) @}
+ ログイン
+
+ ログインして下さい。
+
+
+{@ if ($error) @}
+
+
+ {{ $error }}
+
+
+{@ endif @}
+
+
+
+
+{@ include(common/footer) @}
\ No newline at end of file