| | @@ -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 | +}; |