ログイン
++ ログインして下さい。 +
+ +{@ if ($error) @} ++
+
+ +{@ include(common/footer) @} \ No newline at end of filediff --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 ($error) @} ++
+
+ +{@ include(common/footer) @} \ No newline at end of file