ユーザー

This commit is contained in:
2025-12-07 20:23:29 +09:00
parent 4f70e6d4e4
commit d5428d7514
11 changed files with 476 additions and 2 deletions

280
src/Site/Lib/Auth.php Normal file
View File

@@ -0,0 +1,280 @@
<?php
namespace Site\Lib;
class Auth {
private int $id;
/**
* 性別: -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 int $minPassLen = 20;
protected int $tokenDuration = 31536000; // 1年間
protected string|int|null $algo = PASSWORD_ARGON2ID;
public function __construct(?string $username = null) {
if (!AUTH_ENABLED) return;
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
$this->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();
}
};