diff --git a/.gitignore b/.gitignore index 57f86eb..d47b847 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ data/*.pem data/*.txt data/user/*.json log/*.txt +public/static/user +!public/static/user/.kara config/config.php public/static/*.pem diff --git a/public/static/user/.kara b/public/static/user/.kara new file mode 100644 index 0000000..e69de29 diff --git a/route.php b/route.php index 3fde0bf..e41bc84 100644 --- a/route.php +++ b/route.php @@ -39,6 +39,10 @@ if (ACTIVITYPUB_ENABLED) { if (AUTH_ENABLED) { $routes[] = Route::add('POST', 'login', User::class.'@login'); $routes[] = Route::add('GET', 'login', User::class.'@login'); + if (AUTH_REGISTER_ENABLED) { + $routes[] = Route::add('POST', 'register', User::class.'@register'); + $routes[] = Route::add('GET', 'register', User::class.'@register'); + } $routes[] = Route::add('GET', 'logout', User::class.'@logout'); } diff --git a/src/Site/Controller/User.php b/src/Site/Controller/User.php index 1dace71..b69b41a 100644 --- a/src/Site/Controller/User.php +++ b/src/Site/Controller/User.php @@ -44,7 +44,7 @@ class User { $description = 'サイトにサインイン'; $tmpl->assign('pagetit', $pagetit); - $tmpl->assign('curPage', 'about'); + $tmpl->assign('curPage', 'auth'); $tmpl->assign('custCss', false); $tmpl->assign('menu', $this->getMenu()); $tmpl->assign('description', $description); @@ -58,7 +58,7 @@ class User { public function logout(array $params): void { try { - $auth = new Auth(); + $auth = new Auth; $user = $auth->getLoggedInUser(); if (!$user) { header('Location: /'); @@ -72,4 +72,62 @@ class User { throw new \Exception($e->getMessage()); } } + + public function register(array $params): void { + if (!AUTH_REGISTER_ENABLED) return; + try { + $auth = new Auth; + $user = $auth->getLoggedInUser(); + if ($user) { + header('Location: /'); + exit(); + } + + $doRegister = $_SERVER['REQUEST_METHOD'] === 'POST'; + $error = ''; + $nyuU = ''; + $nyuE = ''; + + if ($doRegister) { + $a = []; + if (count($_POST) === 4) { + $i = 0; + foreach ($_POST as $p) { + $a[(int)$i] = $p; + $i++; + } + } + + $auth = new Auth; + $res = $auth->mkUser($a[0], $a[1], $a[2], $a[3]); + if (!$res->isSuccess) { + $error = $res->message; + $nyuU = $a[0]; + $nyuE = $a[3]; + } else { + $auth = new Auth($a[0]); + $auth->setToken($a[0], $a[1]); + header('Location: /'); + exit(); + } + } + + $tmpl = new Template('/'); + $pagetit = '登録'; + $description = 'サイトに登録'; + + $tmpl->assign('pagetit', $pagetit); + $tmpl->assign('curPage', 'auth'); + $tmpl->assign('custCss', false); + $tmpl->assign('menu', $this->getMenu()); + $tmpl->assign('description', $description); + $tmpl->assign('error', $error); + $tmpl->assign('nyuU', $nyuU); + $tmpl->assign('nyuE', $nyuE); + + $tmpl->render('register'); + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + } } diff --git a/src/Site/Lib/Auth.php b/src/Site/Lib/Auth.php index 6e11955..e291207 100644 --- a/src/Site/Lib/Auth.php +++ b/src/Site/Lib/Auth.php @@ -7,15 +7,13 @@ class Auth { /** * 性別: -1 = 不明, 0 = 男性, 1 = 女性 * ロール: -1 = BAN, 0 = ユーザー, 1 = スタッフ - * トークン: string token, int expDate - * - * パスワードとメールアドレスはArgon2IDでハッシュする */ private \stdClass $user; private \stdClass $pubUser; private ?string $token; protected string $dataDir = ROOT."/data/user/"; + protected string $assDir = ROOT."/public/static/user/"; protected int $minPassLen = 20; protected int $tokenDuration = 31536000; // 1年間 protected string|int|null $algo = PASSWORD_ARGON2ID; @@ -36,25 +34,6 @@ class Auth { 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 = []; @@ -156,7 +135,7 @@ class Auth { $userList = scandir($this->dataDir); foreach ($userList as $list) { - if ($list === '.' || $list === '..') continue; + if ($list === '.kara' || $list === '.' || $list === '..') continue; $file = str_replace('.json', '', $list); $user = explode('.', $file)[1]; if ($username === $user) return \Result::Success("ユーザー「{$username}」は既に存在します。"); @@ -165,8 +144,86 @@ class Auth { return \Result::Error("エラー:ユーザー「{$username}」は存在しません。"); } + public function mkUser(?string $username = null, ?string $password = null, ?string $passwordVerify = null, ?string $email = null): \Result { + if (!AUTH_REGISTER_ENABLED) return \Result::Error('ユーザー登録は無効です。'); + $resUsr = $this->verifyUsername($username); + $resPwd = $this->verifyPassword($password, $passwordVerify); + $resEml = $this->verifyEmail($email); + + $err = ''; + + if (!$resUsr->isSuccess) $err .= $resUsr->message."
"; + if (!$resPwd->isSuccess) $err .= $resPwd->message."
"; + if (!$resEml->isSuccess) $err .= $resEml->message."
"; + + if ($err != '') return \Result::Error($err); + $err = ''; + + if (!mkdir($this->assDir.$username, 0755)) { + return \Result::Error('エラー:ユーザーのアイコンディレクトリの作成に失敗。'); + } + + $file = scandir($this->dataDir); + $lastId = 0; + if ($file) { + foreach ($file as $f) { + if ($f === '.kara' || $f === '.' || $f === '..') continue; + $base = substr($f, 0, -5); // .jsonの削除 + $dot = strpos($base, '.'); + if (!$dot) continue; + $id = substr($base, 0, $dot); + if (ctype_digit($id)) { + if ((int)$id > $lastId) $lastId = $id; + } + } + $lastId++; + } + + $user = new \stdClass; + $user->id = $lastId; + $user->username = $username; + $user->password = password_hash($password, $algo); + $user->avatar = ''; + $user->email = $email; + $user->regDate = time(); + $user->namecolor = ''; + $user->displayname = ''; + $user->gender = -1; + $user->role = 0; + $user->tokens = []; + + $path = "{$this->dataDir}{$lastId}.{$username}.json"; + $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if (file_put_contents($path, $json) === false) { + rmdir($this->assDir.$username); + return \Result::Error('エラー:ユーザーデータの保存に失敗。'); + } + + return \Result::Success(); + } + //////////////////// + 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(); + } + private function purgeOldTokens(\stdClass $userData): \stdClass|\Result { // 有効期限切れたトークンの削除 $out = $userData; @@ -178,7 +235,7 @@ class Auth { $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::Error('エラー:ユーザーデータの保存に失敗。'); } return $out; @@ -191,7 +248,7 @@ class Auth { $id = 0; foreach ($file as $f) { - if ($f === '.' || $f === '..') continue; + if ($f === '.kara' || $f === '.' || $f === '..') continue; $path = "{$this->dataDir}{$f}"; if (!is_file($path)) continue; @@ -215,7 +272,7 @@ class Auth { $matches = []; foreach ($file as $f) { - if ($f === '.' || $f === '..') continue; + if ($f === '.kara' || $f === '.' || $f === '..') continue; if (preg_match('/^'.$this->id.'\.(.*?)\.json$/', $f, $matches)) { $userFile = $matches[0]; break; @@ -239,41 +296,82 @@ class Auth { return json_decode($lines); } - private function isEmailExist(?string $email): \Result { - if (null === $password) return \Result::Error('エラー:パスワードをご入力下さい。'); + private function isEmailExist(?string $email = null): \Result { + $userList = scandir($this->dataDir); + $matches = []; + + foreach ($userList as $f) { + if ($f === '.kara' || $f === '.' || $f === '..') continue; + $path = "{$this->dataDir}{$f}"; + if (!is_file($path)) continue; + $content = file_get_contents($path); + $file = str_replace('.json', '', $f); + if (str_contains($content, '"email": "'.$email.'",')) { + $matches[] = $email; + } + if (count($matches) > 0) return \Result::Error("ユーザー「{$email}」は既に存在します。"); + } + + return \Result::Success('エラー:メールアドレスが存在しません。'); + } + + private function verifyEmail(?string $email = null): \Result { + if (null === $email || '' === $email) return \Result::Error('エラー:メールアドレスをご入力下さい。'); + if (strpos($email, '@') === false) return \Result::Error('エラー:メールアドレスは不正です。'); + + $domain = explode('@', $email)[1]; + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return \Result::Error('エラー:メールアドレスは不正です。'); + } + + if (!checkdnsrr($domain, 'MX')) { + return \Result::Error('エラー:メールアドレスは不正です。'); + } + + $res = $this->isEmailExist($email); + if (!$res->isSuccess) return \Result::Error($res->message); + + return \Result::Success(); } private function verifyUsername(?string $username): \Result { - if (null === $username) return \Result::Error('エラー:ユーザー名をご入力下さい。'); + if (null === $username || '' === $username) return \Result::Error('エラー:ユーザー名をご入力下さい。'); if (strlen($username) < 6) return \Result::Error('エラー:ユーザー名は6文字以上をご入力下さい。'); - $accepted = 'A-Za-z0-9'; + $res = $this->isUserExist($username); + if ($res->isSuccess) return \Result::Error($res->message); - if (preg_match('/[^'.preg_quote($accepted, '/').']/', $password)) { + $accepted = 'A-Za-z0-9'; + if (preg_match("/[^{$accepted}]/", $username)) { return \Result::Error('エラー:ユーザー名に不正な文字が含みます。'); } + return \Result::Success(); } - private function verifyPassword(?string $password): \Result { + private function verifyPassword(?string $password = null, ?string $passwordVerify = null): \Result { + if (null === $password || '' === $password) return \Result::Error('エラー:パスワードをご入力下さい。'); + if ($password !== $passwordVerify) return \Result::Error('エラー:パスワードは一致していません。'); + + $res = $this->checkPasswordStandards($password); + if (!$res->isSuccess) { + return \Result::Error($res->message); + } + 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}以上をご入力下さい。"); + private function checkPasswordStandards(?string $password = null): \Result { + if (strlen($password) < $this->minPassLen) return \Result::Error("エラー:パスワードは{$this->minPassLen}以上をご入力下さい。"); - $specChar = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~]/'; - $accepted = 'A-Za-z0-9'.$specChar; - - if (preg_match('/[^'.preg_quote($accepted, '/').']/', $password)) { + if (!\countmatch($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; + $countSymbol = \count_special_chars($password) < 2; if ($countUpper || $countLower || $countDigit || $countSymbol) { return \Result::Error('エラー:パスワードは2つ以上の大文字、2つ以上の小文字、2つ以上の数字、及び2つ以上の特別文字を含む事が必須です。'); diff --git a/util.php b/util.php index 03473e1..05e5b48 100644 --- a/util.php +++ b/util.php @@ -151,7 +151,7 @@ if (AUTH_ENABLED) { $gender = 'color: '.($userData->gender === 0 ? $male : ($userData->gender === 1 ? $female : $ungender)).';'; $style = $userData->namecolor ?: ($userData->role >= 0 ? $gender : $ban); - $showname = $userData->displayname ?? $userData->username; + $showname = $userData->displayname ?: $userData->username; $color = "{$showname}"; if ($userData->role === 1) $color .= ""; @@ -160,4 +160,58 @@ if (AUTH_ENABLED) { return $color.$suffix; } +} + +function count_special_chars(string $str): int { + $count = 0; + $len = strlen($str); + + for ($i = 0; $i < $len; ++$i) { + if (str_contains('!', $str[$i])) ++$count; + if (str_contains('"', $str[$i])) ++$count; + if (str_contains('#', $str[$i])) ++$count; + if (str_contains('$', $str[$i])) ++$count; + if (str_contains('%', $str[$i])) ++$count; + if (str_contains('&', $str[$i])) ++$count; + if (str_contains('\'', $str[$i])) ++$count; + if (str_contains('(', $str[$i])) ++$count; + if (str_contains(')', $str[$i])) ++$count; + if (str_contains('-', $str[$i])) ++$count; + if (str_contains('=', $str[$i])) ++$count; + if (str_contains('^', $str[$i])) ++$count; + if (str_contains('~', $str[$i])) ++$count; + if (str_contains('\\', $str[$i])) ++$count; + if (str_contains('|', $str[$i])) ++$count; + if (str_contains('[', $str[$i])) ++$count; + if (str_contains(']', $str[$i])) ++$count; + if (str_contains(':', $str[$i])) ++$count; + if (str_contains('@', $str[$i])) ++$count; + if (str_contains('`', $str[$i])) ++$count; + if (str_contains('*', $str[$i])) ++$count; + if (str_contains('{', $str[$i])) ++$count; + if (str_contains('}', $str[$i])) ++$count; + if (str_contains(';', $str[$i])) ++$count; + if (str_contains('+', $str[$i])) ++$count; + if (str_contains(',', $str[$i])) ++$count; + if (str_contains('<', $str[$i])) ++$count; + if (str_contains('.', $str[$i])) ++$count; + if (str_contains('>', $str[$i])) ++$count; + if (str_contains('/', $str[$i])) ++$count; + if (str_contains('?', $str[$i])) ++$count; + if (str_contains('_', $str[$i])) ++$count; + } + + return $count; +} + +function countmatch(string $str): bool { + $len = strlen($str); + + $numUpper = preg_match_all('/[A-Z]/', $str) ?? 0; + $numLower = preg_match_all('/[a-z]/', $str) ?? 0; + $numDigit = preg_match_all('/[0-9]/', $str) ?? 0; + $numSymbol = count_special_chars($str); + $sum = $numUpper + $numLower + $numDigit + $numSymbol; + + return $len == $sum; } \ No newline at end of file diff --git a/view/register.maron b/view/register.maron new file mode 100644 index 0000000..eb50d6d --- /dev/null +++ b/view/register.maron @@ -0,0 +1,47 @@ +{@ include(common/header) @} +

登録

+

+ 登録して下さい。 +

+ +{@ if ($error) @} +

+

+ {{{ $error }}} +
+

+{@ endif @} + +

+

+ {$ $username = randstr() $} + {$ $password = randstr() $} + {$ $passwordVerify = randstr() $} + {$ $email = randstr() $} + + + + + + + + + + + + + + + + + + + + + + + +
+
+

+{@ include(common/footer) @} \ No newline at end of file