assDir === '') $this->assDir = ROOT.ass_path('user').'/'; 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); unset($this->pubUser->verifyToken); unset($this->pubUser->twoFactor, $this->pubUser->twoFactorPending, $this->pubUser->twoFactorTicket); $this->pubUser->avatarUrl = avatar_url($this->pubUser); return $this->pubUser; } public function getUser(string $name): \stdClass|null { $user = $this->getUserDataFromName($name); if (!$user) return null; unset($user->password); unset($user->tokens); unset($user->verifyToken, $user->verifyExpire, $user->verifySent); unset($user->twoFactor, $user->twoFactorPending, $user->twoFactorTicket); $myself = $this->getUserData(); // 以前は「role < STAFF」で比べていたので、ビットを足すと(Fediverse等)全員にメールアドレスが見えた $isStaff = $myself && ($myself->role & (Roles::STAFF | Roles::ADMIN)); if (!$myself || ($myself->id != $user->id && !$isStaff)) $user->email = '(秘密)'; $user->canEdit = $myself && ($myself->id == $user->id || ($myself->role & Roles::ADMIN)); $user->fediverse = can_federate($user) && defined('FEDIINFO') ? '@'.$user->username.'@'.parse_url(\Std\Lib\Fedi\Fedi::base(), PHP_URL_HOST) : ''; $user->bioHtml = nl2br(htmlspecialchars((string)($user->bio ?? ''), ENT_QUOTES, 'UTF-8'), false); $user->avatar = avatar_url($user); $user->name = namecolor($user); $user->regDate = date('Y年m月d日', $user->regDate); $user->gender = $user->gender === Gender::MALE ? '男' : ($user->gender === Gender::FEMALE ? '女' : '不明'); switch (true) { // 以前は「role & BANNED」(BANNEDは0なので常に偽)だった case ($user->role === Roles::BANNED): $user->role = 'BANされた'; break; case ($user->role & Roles::ADMIN): $user->role = '管理者'; break; case ($user->role & Roles::STAFF): $user->role = '076スタジオの会社員'; break; case ($user->role & (Roles::FSUBSCRIBER | Roles::PSUBSCRIBER | Roles::NSUBSCRIBER | Roles::SUBSCRIBER)): $user->role = '支援者♡'; break; default: $user->role = 'ユーザー'; break; } $user->altName = $user->displayname ?: $user->username; unset($user->username); unset($user->displayname); unset($user->namecolor); return $user; } public function setToken(string $username, #[SensitiveParameter] string $password): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); $userData = $this->getUserData(); if ($userData === null) return Result::Error('エラー:ユーザー名又はパスワードが一致していません。'); if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = []; $verify = $this->verifyLogin($username, $password); if (!$verify->isSuccess) { return Result::Error($verify->message); } // メールアドレスが未確認のアカウントはログイン出来ない(確認機能の前に作ったアカウントにはverifiedが無いので、確認済みとする) if (($userData->verified ?? true) === false) { $resent = $this->resendVerification($userData); return Result::Error('エラー:メールアドレスが未確認です。届いたメールのリンクを開いて下さい。' .($resent ? '確認メールを再送しました。' : ''), ['unverified' => true]); } // 二段階認証:パスワードが合っても、認証アプリのコードを確かめるまでログインしない if ($this->twoFactorAvailable() && !empty($userData->twoFactor->secret)) { $ticket = BaseN::encode64(random_bytes(32), true, false); $userData->twoFactorTicket = (object)['hash' => hash('sha256', $ticket), 'expire' => time() + $this->twoFactorDuration, 'tries' => 0]; if (!$this->saveUser($userData, 'setToken')) return Result::Error('エラー:ユーザーデータの保存に失敗。'); return Result::Error('認証アプリのコードを入力して下さい。', ['twoFactor' => true, 'ticket' => $ticket]); } return $this->startSession($userData); } /** * ログインのトークンを作り、クッキーに入れる */ private function startSession(\stdClass $userData): Result { $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $res = $this->purgeOldTokens($userData); if (!$res->isSuccess) return Result::Error($res->message); $userData = $res->data['user']; $token = bin2hex(random_bytes(64)); $expire = time() + $this->tokenDuration; $newToken = [ 'token' => hash('sha256', $token), '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) { logger(LogType::Auth, '【setToken】JSONファイルにユーザーデータを保存出来なかった。パス:'.$path); return Result::Error('エラー:ユーザーデータの保存に失敗。'); } if (!setcookie('kerozen', $token, [ 'expires' => $expire, 'path' => '/', 'domain' => $_SERVER['HTTP_HOST'] ?? '', 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), 'httponly' => true, 'samesite' => 'Strict' ])) { logger(LogType::Auth, '【setToken】クッキーを設定出来なかった。'); return Result::Error('エラー:クッキーを設定に失敗。'); } return Result::Success('ログイン成功'); } public function getToken(): ?string { if (!AUTH_ENABLED) return ''; $userData = $this->getUserData(); if (!isset($userData->tokens) || !is_array($userData->tokens)) $userData->tokens = []; $res = $this->purgeOldTokens($userData); if (!$res->isSuccess) return null; $userData = $res->data['user']; } public function logout(?string $token = null): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); $userData = $this->getUserData(); $token = $token ?? $this->token; if ($userData instanceof \stdClass && $token) { $hashed = hash('sha256', $token); $userData->tokens = array_values(array_filter($userData->tokens ?? [], function($t) use ($hashed): bool { return isset($t->token) && !hash_equals((string)$t->token, $hashed) && ($t->expDate ?? 0) > time(); })); $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) { logger(\LogType::Auth, '【logout】JSONファイルを変更出来なかった。chownをご確認下さい。パス:'.$path); } } setcookie('kerozen', '', [ 'expires' => 0, 'path' => '/', 'domain' => $_SERVER['HTTP_HOST'] ?? '', 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), 'httponly' => true, 'samesite' => 'Strict' ]); return Result::Success(); } /** * 二段階認証を使えるか(config.phpのAUTH_2FA_ENABLED。無効なら誰も使えず、ログインでもコードを聞かない) */ public function twoFactorAvailable(): bool { return AUTH_ENABLED && defined('AUTH_2FA_ENABLED') && AUTH_2FA_ENABLED; } /** * ログインの2段階目:setTokenが返したチケットと、認証アプリのコードを確かめてログインする * * @param string $username ユーザー名(new Auth($username)と同じ) * @param string $ticket setTokenのdataのticket * @param string $code 6桁のコード * @return Result 間違えてもまだ試せる場合は、dataにticket(5回間違えたら最初からやり直し) */ public function verifyTwoFactor(string $username, #[SensitiveParameter] string $ticket, string $code): Result { if (!$this->twoFactorAvailable()) return Result::Error('エラー:二段階認証は無効です。'); $userData = $this->id > 0 ? $this->getUserData() : null; $t = $userData->twoFactorTicket ?? null; if (!$userData || $userData->username !== $username || !$t instanceof \stdClass || ($t->expire ?? 0) < time() || !hash_equals((string)($t->hash ?? ''), hash('sha256', $ticket))) { return Result::Error('エラー:有効期限が切れました。もう一度ログインして下さい。'); } $secret = (string)($userData->twoFactor->secret ?? ''); $step = $secret === '' ? null : Totp::verify($secret, $code, (int)($userData->twoFactor->lastStep ?? -1)); if ($step === null) { $t->tries = (int)($t->tries ?? 0) + 1; $retry = $t->tries < 5; if (!$retry) unset($userData->twoFactorTicket); $this->saveUser($userData, 'verifyTwoFactor'); return $retry ? Result::Error('エラー:コードが一致していません。', ['ticket' => $ticket]) : Result::Error('エラー:コードを間違えた回数が多過ぎます。もう一度ログインして下さい。'); } // 同じコードをもう一度使えない様に $userData->twoFactor->lastStep = $step; unset($userData->twoFactorTicket); if (!$this->saveUser($userData, 'verifyTwoFactor')) return Result::Error('エラー:ユーザーデータの保存に失敗。'); return $this->startSession($userData); } /** * 二段階認証の設定画面のデータ * * 本人がまだ有効にしていなければ秘密鍵を作り(有効にするか期限が切れるまで同じ物)、QRコード用のURIも返す。 * * @return Result data:enabled、self(本人か)、username、secret・uri(本人で未設定の時だけ) */ public function twoFactorStatus(string $username): Result { [$res, $me, $user] = $this->twoFactorTarget($username); if (!$res->isSuccess) return $res; $data = ['enabled' => !empty($user->twoFactor->secret), 'self' => $me->id == $user->id, 'username' => $user->username]; if ($data['self'] && !$data['enabled']) { $p = $user->twoFactorPending ?? null; if (!$p instanceof \stdClass || ($p->expire ?? 0) < time()) { $p = (object)['secret' => Totp::secret(), 'expire' => time() + $this->twoFactorSetupDuration]; $user->twoFactorPending = $p; if (!$this->saveUser($user, 'twoFactorStatus')) return Result::Error('エラー:ユーザーデータの保存に失敗。'); } $data['secret'] = $p->secret; $data['uri'] = Totp::uri($p->secret, $user->username, (string)SITEINFO['title']); } return Result::Success('', $data); } /** * 設定画面の秘密鍵で作ったコードを確かめて、二段階認証を有効にする(本人だけ) */ public function enableTwoFactor(string $username, string $code): Result { [$res, $me, $user] = $this->twoFactorTarget($username); if (!$res->isSuccess) return $res; if ($me->id != $user->id) return Result::Error('エラー:二段階認証は本人だけが有効に出来ます。'); if (!empty($user->twoFactor->secret)) return Result::Error('エラー:二段階認証は既に有効です。'); $p = $user->twoFactorPending ?? null; if (!$p instanceof \stdClass || ($p->expire ?? 0) < time()) return Result::Error('エラー:有効期限が切れました。新しいQRコードを読み取って下さい。'); $step = Totp::verify((string)$p->secret, $code); if ($step === null) return Result::Error('エラー:コードが一致していません。端末の時計が合っているかもご確認下さい。'); $user->twoFactor = (object)['secret' => $p->secret, 'lastStep' => $step, 'since' => time()]; unset($user->twoFactorPending); if (!$this->saveUser($user, 'enableTwoFactor')) return Result::Error('エラー:ユーザーデータの保存に失敗。'); return Result::Success('二段階認証を有効にしました。次回のログインから認証アプリのコードが必要です。'); } /** * 二段階認証を無効にする * * 本人はパスワードとコード、管理者が他のユーザーの分を無効にする場合(端末を失くした時等)は管理者のパスワード。 */ public function disableTwoFactor(string $username, #[SensitiveParameter] string $password, string $code = ''): Result { [$res, $me, $user] = $this->twoFactorTarget($username); if (!$res->isSuccess) return $res; if (empty($user->twoFactor->secret)) return Result::Error('エラー:二段階認証は有効ではありません。'); if (!password_verify($password, (string)$me->password)) return Result::Error('エラー:パスワードが一致していません。'); if ($me->id == $user->id && Totp::verify((string)$user->twoFactor->secret, $code, (int)($user->twoFactor->lastStep ?? -1)) === null) { return Result::Error('エラー:コードが一致していません。'); } unset($user->twoFactor, $user->twoFactorTicket); if (!$this->saveUser($user, 'disableTwoFactor')) return Result::Error('エラー:ユーザーデータの保存に失敗。'); return Result::Success('二段階認証を無効にしました。'); } /** * 二段階認証を設定する相手(自分、又は管理者なら他のユーザー) * * @return array [Result, 自分, ユーザー] */ private function twoFactorTarget(string $username): array { if (!$this->twoFactorAvailable()) return [Result::Error('エラー:二段階認証は無効です。'), null, null]; $me = $this->id > 0 ? $this->getUserData() : null; $path = $this->userPath($username); $user = $path !== null ? json_decode((string)file_get_contents($path)) : null; if (!$me || !$user instanceof \stdClass) return [Result::Error('エラー:ユーザーが見つかりません。'), null, null]; if ($me->id != $user->id && !($me->role & Roles::ADMIN)) return [Result::Error('エラー:権限がありません。'), null, null]; return [Result::Success(), $me, $user]; } /** * ログイン中のユーザーの表示の設定(テーマ等)をプロフィールに保存する * * @param array $prefs 名前 => 値(nullなら消す) * @return Result */ public function savePrefs(array $prefs): Result { if (!AUTH_ENABLED || $this->id === 0) return Result::Error('エラー:ログインしていません。'); $user = $this->getUserData(); if (!$user instanceof \stdClass) return Result::Error('エラー:ユーザーが見つかりません。'); $cur = (array)($user->prefs ?? []); foreach ($prefs as $k => $v) { if ($v === null) unset($cur[$k]); else $cur[$k] = (string)$v; } $user->prefs = (object)$cur; if (!$this->saveUser($user, 'savePrefs')) return Result::Error('エラー:ユーザーデータの保存に失敗。'); return Result::Success(); } /** ユーザーのJSONファイルを書き換える */ private function saveUser(\stdClass $user, string $where): bool { $path = $this->dataDir.$user->id.'.'.$user->username.'.json'; $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (file_put_contents($path, $json, LOCK_EX) !== false) return true; logger(LogType::Auth, "【{$where}】JSONファイルを変更出来なかった。chownをご確認下さい。パス:{$path}"); return false; } /** * ログインの試行回数を確認する(IP毎:15分で5回、ユーザー名毎:1時間で20回) * * @param string $username ユーザー名 * @return bool 制限中ならtrue */ public function isLoginThrottled(string $username): bool { $now = time(); $ip = $this->countFailures('ip-'.($_SERVER['REMOTE_ADDR'] ?? ''), $now - 900); $usr = $this->countFailures('user-'.$username, $now - 3600); return $ip >= 5 || $usr >= 20; } /** * ログインの失敗を記録する * * @param string $username ユーザー名 * @return void */ public function recordLoginFailure(string $username): void { $this->addFailure('ip-'.($_SERVER['REMOTE_ADDR'] ?? ''), time() - 900); $this->addFailure('user-'.$username, time() - 3600); } /** * ログイン成功時に、そのIPの失敗記録を消す * * @return void */ public function clearLoginFailures(): void { $path = $this->throttlePath('ip-'.($_SERVER['REMOTE_ADDR'] ?? '')); if (is_file($path)) unlink($path); } private function throttlePath(string $key): string { $dir = ROOT.'/data/ratelimit/'; if (!is_dir($dir)) mkdir($dir, 0700, true); return $dir.hash('sha256', $key).'.json'; } private function countFailures(string $key, int $since): int { $path = $this->throttlePath($key); if (!is_file($path)) return 0; $list = json_decode((string)file_get_contents($path), true); if (!is_array($list)) return 0; return count(array_filter($list, fn($t) => is_int($t) && $t > $since)); } private function addFailure(string $key, int $since): void { $fp = fopen($this->throttlePath($key), 'c+'); if (!$fp) return; flock($fp, LOCK_EX); $list = json_decode((string)stream_get_contents($fp), true); $list = is_array($list) ? array_values(array_filter($list, fn($t) => is_int($t) && $t > $since)) : []; $list[] = time(); ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($list)); flock($fp, LOCK_UN); fclose($fp); } public function isUserExist(?string $username = null): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); if (null === $username) return Result::Error('エラー:ユーザー名をご入力下さい。'); $userList = @scandir($this->dataDir) ?: []; foreach ($userList as $list) { if ($list === '.kara' || $list === '.' || $list === '..') continue; $file = str_replace('.json', '', $list); $user = explode('.', $file)[1]; if ($username === $user) return Result::Success("ユーザー「{$username}」は既に存在します。"); } return Result::Error(''); } public function mkUser(?string $username = null, #[SensitiveParameter] ?string $password = null, ?string $passwordVerify = null, ?string $email = null): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); 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 = ''; $user = $this->newUser($username, $password, $email, Roles::MEMBER); // メールアドレスの確認(リンクを開くまでログイン出来ない) $verifyToken = null; if ($this->requiresVerification()) { $verifyToken = bin2hex(random_bytes(32)); $user->verified = false; $user->verifyToken = hash('sha256', $verifyToken); $user->verifyExpire = time() + $this->verifyDuration; $user->verifySent = time(); } $res = $this->storeNewUser($user, 'mkUser'); if (!$res->isSuccess) return $res; $path = $res->data['path']; if ($verifyToken !== null) { $sent = $this->sendVerificationMail($user, $verifyToken); if (!$sent->isSuccess) { // メールが届かないアカウントを残さない(同じユーザー名でもう一度登録出来る様に) unlink($path); @rmdir($this->assDir.$username); return Result::Error('エラー:確認メールの送信に失敗しました。暫くしてから再度お試し下さい。'); } return Result::Success("確認メールを「{$email}」に送信しました。メールのリンクを開いて、登録を完了して下さい。", ['verify' => true]); } return Result::Success('', ['verify' => false]); } /** * プロフィールを編集する * * 本人:表示名、性別、名前の色、自己紹介、アイコン、パスワード(現在のパスワードが必要)、メールアドレス(同じ) * 管理者:他のユーザーの表示名等とロール(パスワードとメールアドレスは本人だけ) * * @param string $username 編集するユーザー * @param array $in displayname, gender, namecolor(名前のインラインCSS、空なら性別の色), bio, removeAvatar, * currentPassword, newPassword, newPasswordVerify, email, roles(管理者:ビットの配列) * @param array|null $avatar $_FILES['avatar'] * @return Result */ public function updateProfile(string $username, array $in, ?array $avatar = null): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); $me = $this->id > 0 ? $this->getUserData() : null; $path = $this->userPath($username); if (!$me || $path === null) return Result::Error('エラー:ユーザーが見つかりません。'); $user = json_decode((string)file_get_contents($path)); if (!$user instanceof \stdClass) return Result::Error('エラー:ユーザーが見つかりません。'); $self = $me->id == $user->id; $admin = (bool)($me->role & Roles::ADMIN); if (!$self && !$admin) return Result::Error('エラー:権限がありません。'); $errors = []; $notices = []; // 表示名 if (array_key_exists('displayname', $in)) { $d = trim(preg_replace('/[\x00-\x1f\x7f]/u', '', (string)$in['displayname'])); if (mb_strlen($d) > 50) $errors[] = 'エラー:表示名は50文字までです。'; else $user->displayname = $d; } // 性別 if (array_key_exists('gender', $in)) { $g = (int)$in['gender']; $user->gender = in_array($g, [\Gender::UNKNOWN, \Gender::MALE, \Gender::FEMALE], true) ? $g : \Gender::UNKNOWN; } // 名前のスタイル(インラインCSS。namecolor()でエスケープしてstyle属性に入れる) if (array_key_exists('namecolor', $in)) { $c = trim(preg_replace('/[\x00-\x1f\x7f]/u', '', (string)$in['namecolor'])); if (mb_strlen($c) > 500) $errors[] = 'エラー:名前のスタイルは500文字までです。'; else $user->namecolor = $c; } // 自己紹介 if (array_key_exists('bio', $in)) { $b = trim(str_replace(["\r\n", "\r"], "\n", (string)$in['bio'])); if (mb_strlen($b) > 500) $errors[] = 'エラー:自己紹介は500文字までです。'; else $user->bio = $b; } // アイコン $oldAvatar = (string)($user->avatar ?? ''); $newAvatar = null; if ($avatar && ($avatar['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { $res = \Std\Lib\Fedi\Media::save($avatar, 'user/'.$user->username, 'avatar-', $this->checkUploads); if (!$res->isSuccess) $errors[] = 'エラー:'.$res->message; else $newAvatar = $res->data['name']; } else if (!empty($in['removeAvatar'])) { $user->avatar = ''; } // パスワード(本人だけ) if ($self && (string)($in['newPassword'] ?? '') !== '') { if (!password_verify((string)($in['currentPassword'] ?? ''), $user->password)) { $errors[] = 'エラー:現在のパスワードが一致していません。'; } else { $res = $this->verifyPassword((string)$in['newPassword'], (string)($in['newPasswordVerify'] ?? '')); if (!$res->isSuccess) $errors[] = $res->message; else { $user->password = password_hash((string)$in['newPassword'], $this->algo); // 他の端末のログインを取り消す(このログインは残す) $current = $this->token ? hash('sha256', $this->token) : ''; $user->tokens = array_values(array_filter($user->tokens ?? [], fn($t) => hash_equals((string)($t->token ?? ''), $current))); $notices[] = 'パスワードを変更しました。'; } } } // メールアドレス(本人だけ) $sendVerify = null; $email = trim((string)($in['email'] ?? '')); if ($self && $email !== '' && $email !== $user->email) { if (!password_verify((string)($in['currentPassword'] ?? ''), $user->password)) { $errors[] = 'エラー:メールアドレスを変更するには現在のパスワードを入力して下さい。'; } else { $res = $this->verifyEmail($email); if (!$res->isSuccess) $errors[] = $res->message; else if ($this->requiresVerification()) { // 確認するまでは前のメールアドレスのまま $sendVerify = bin2hex(random_bytes(32)); $user->pendingEmail = $email; $user->verifyToken = hash('sha256', $sendVerify); $user->verifyExpire = time() + $this->verifyDuration; $user->verifySent = time(); } else { $user->email = $email; $notices[] = 'メールアドレスを変更しました。'; } } } // ロール(管理者だけ) if ($admin && isset($in['roles']) && is_array($in['roles'])) { $err = $this->applyRoles($user, $in['roles']); if ($err !== null) $errors[] = $err; } if (!empty($errors)) { if ($newAvatar !== null) \Std\Lib\Fedi\Media::remove(ass_path('user/'.$user->username.'/'.$newAvatar)); return Result::Error(implode('
', array_map(fn($e) => htmlspecialchars($e, ENT_QUOTES, 'UTF-8'), $errors))); } if ($newAvatar !== null) $user->avatar = $newAvatar; $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (file_put_contents($path, $json, LOCK_EX) === false) { logger(LogType::Auth, '【updateProfile】JSONファイルを変更出来なかった。パス:'.$path); return Result::Error('エラー:ユーザーデータの保存に失敗。'); } if ($oldAvatar !== '' && $oldAvatar !== ($user->avatar ?? '')) { \Std\Lib\Fedi\Media::remove(ass_path('user/'.$user->username.'/'.$oldAvatar)); } if ($sendVerify !== null) { $mailUser = clone $user; $mailUser->email = $email; if ($this->sendVerificationMail($mailUser, $sendVerify)->isSuccess) { $notices[] = "確認メールを「{$email}」に送信しました。リンクを開くとメールアドレスが変わります。"; } else { $notices[] = '確認メールの送信に失敗しました。メールアドレスは変わっていません。'; } } return Result::Success(implode(' ', $notices) ?: 'プロフィールを保存しました。', ['user' => $user]); } /** * プロフィールの編集画面用のデータ(パスワード等を除く) * * @param string $username ユーザー * @return \stdClass|null 編集出来ない場合はnull */ public function getEditableUser(string $username): ?\stdClass { $me = $this->id > 0 ? $this->getUserData() : null; $path = $this->userPath($username); if (!$me || $path === null) return null; $user = json_decode((string)file_get_contents($path)); if (!$user instanceof \stdClass) return null; if ($me->id != $user->id && !($me->role & Roles::ADMIN)) return null; $user->twoFactorOn = !empty($user->twoFactor->secret); unset($user->password, $user->tokens, $user->verifyToken, $user->twoFactor, $user->twoFactorPending, $user->twoFactorTicket); $user->isSelf = $me->id == $user->id; $user->isAdmin = (bool)($me->role & Roles::ADMIN); $user->fedi = can_federate($user); $user->avatarUrl = avatar_url($user); return $user; } /** ユーザー名からJSONファイルのパス */ private function userPath(string $username): ?string { if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return null; foreach ($this->userFiles() as $path) { if (preg_match('/^\d+\.'.$username.'\.json$/', basename($path))) return $path; } return null; } /** * ロールを設定する(管理者だけ) * * @param \stdClass $user ユーザーのデータ(変更する) * @param array $roles ビットの配列(Roles::LABELSに無いビットは無視) * @return string|null エラー */ private function applyRoles(\stdClass $user, array $roles): ?string { $role = Roles::BANNED; foreach ($roles as $bit) { $bit = (int)$bit; if (isset(Roles::LABELS[$bit])) $role |= $bit; } // 最後の管理者は管理者を外せない if (((int)($user->role ?? 0) & Roles::ADMIN) && !($role & Roles::ADMIN) && $this->countAdmins() <= 1) { return 'エラー:最後の管理者のロールは外せません。'; } $user->role = $role; // Fediverseのロールの既定(全員に付与)を使わず、以後はビットで判断する $user->fediDefault = true; return null; } private function countAdmins(): int { $n = 0; foreach ($this->userFiles() as $path) { $d = json_decode((string)file_get_contents($path)); if ($d instanceof \stdClass && (($d->role ?? 0) & Roles::ADMIN)) $n++; } return $n; } /** * 登録時にメールアドレスの確認が必要か * * @return bool */ public function requiresVerification(): bool { return AUTH_VERIFY_EMAIL && MAILER_ENABLED; } /** * 確認メールのリンクのトークンでメールアドレスを確認する * * @param string $token リンクのトークン * @return Result data:username */ public function verifyEmailToken(string $token): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); $invalid = 'エラー:リンクが無効か、有効期限が切れています。ログインすると確認メールを再送します。'; if (!preg_match('/^[0-9a-f]{64}$/', $token)) return Result::Error($invalid); $hashed = hash('sha256', $token); foreach ($this->userFiles() as $path) { $data = json_decode((string)file_get_contents($path)); if (!$data instanceof \stdClass || !isset($data->verifyToken)) continue; if (!hash_equals((string)$data->verifyToken, $hashed)) continue; if (($data->verifyExpire ?? 0) < time()) return Result::Error($invalid); $data->verified = true; // メールアドレスの変更の確認 if (isset($data->pendingEmail)) { $data->email = $data->pendingEmail; unset($data->pendingEmail); } unset($data->verifyToken, $data->verifyExpire, $data->verifySent); $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (file_put_contents($path, $json, LOCK_EX) === false) { logger(LogType::Auth, '【verifyEmailToken】JSONファイルを変更出来なかった。パス:'.$path); return Result::Error('エラー:ユーザーデータの保存に失敗。'); } return Result::Success('メールアドレスを確認しました。ログインして下さい。', ['username' => $data->username]); } return Result::Error($invalid); } /** * メールアドレスが確認されていない古いアカウントを削除する(cronのタスク用) * 削除すると、同じユーザー名とメールアドレスでもう一度登録出来る * * @param int $maxAge 登録からこの秒数が経ったら削除する(既定は7日) * @return Result data:deleted(削除したユーザー名) */ public function purgeUnverified(int $maxAge = 604800): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); $deleted = []; foreach ($this->userFiles() as $path) { $data = json_decode((string)file_get_contents($path)); if (!$data instanceof \stdClass || ($data->verified ?? true) !== false) continue; if (($data->regDate ?? time()) + $maxAge > time()) continue; if (!unlink($path)) { logger(LogType::Auth, '【purgeUnverified】削除に失敗。パス:'.$path); continue; } $dir = $this->assDir.$data->username; if (is_dir($dir)) { foreach (glob($dir.'/*') ?: [] as $f) @unlink($f); @rmdir($dir); } $deleted[] = $data->username; } return Result::Success('未確認のアカウント'.count($deleted).'件を削除しました。', ['deleted' => $deleted]); } /** * パスワードだけを確かめる(Gitのプッシュ等、ログインはしない) * * 二段階認証が有効なユーザー、未確認・BANのユーザーはnull(Gitler等ではアクセストークンを使う)。 * * @return \stdClass|null ユーザー(パスワード等を除く) */ public function checkPassword(string $username, #[SensitiveParameter] string $password): ?\stdClass { if (!AUTH_ENABLED || $password === '' || $this->isLoginThrottled($username)) return null; $path = $this->userPath($username); $user = $path !== null ? json_decode((string)file_get_contents($path)) : null; if (!$user instanceof \stdClass || !password_verify($password, (string)($user->password ?? ''))) { $this->recordLoginFailure($username); return null; } if (($user->verified ?? true) === false || (int)($user->role ?? 0) === Roles::BANNED) return null; if ($this->twoFactorAvailable() && !empty($user->twoFactor->secret)) return null; unset($user->password, $user->tokens, $user->verifyToken, $user->twoFactor, $user->twoFactorPending, $user->twoFactorTicket); return $user; } //////////////////// 管理画面 /** * ログイン中のユーザーが管理者か */ public function isAdmin(): bool { $me = $this->id > 0 ? $this->getUserData() : null; return $me instanceof \stdClass && ((int)($me->role ?? 0) & Roles::ADMIN); } /** * 管理者のアカウントが有るか(無ければ管理画面で最初の管理者を作れる) */ public function hasAdmin(): bool { return $this->countAdmins() > 0; } /** * 全部のユーザー(管理者だけ)。パスワード・トークン等は含まない * * @return array stdClass(id、username、displayname、email、pendingEmail、role、roleLabels、regDate、verified、 * twoFactorOn、sessions(有効なログインの数)、lastLogin、avatarUrl)、IDの順 */ public function listUsers(): array { if (!AUTH_ENABLED || !$this->isAdmin()) return []; $out = []; foreach ($this->userFiles() as $path) { $u = json_decode((string)file_get_contents($path)); if (!$u instanceof \stdClass || !isset($u->id, $u->username)) continue; $out[] = $this->accountSummary($u); } usort($out, fn($a, $b) => $a->id <=> $b->id); return $out; } /** * ユーザー1人(管理者だけ、listUsers()と同じ形) */ public function getAccount(string $username): ?\stdClass { if (!AUTH_ENABLED || !$this->isAdmin()) return null; $path = $this->userPath($username); $u = $path !== null ? json_decode((string)file_get_contents($path)) : null; return $u instanceof \stdClass ? $this->accountSummary($u) : null; } private function accountSummary(\stdClass $u): \stdClass { $now = time(); $tokens = array_values(array_filter((array)($u->tokens ?? []), fn($t) => is_object($t) && ($t->expDate ?? 0) > $now)); $last = 0; foreach ($tokens as $t) $last = max($last, (int)($t->lastDate ?? $t->createDate ?? 0)); $role = (int)($u->role ?? 0); $labels = []; foreach (Roles::LABELS as $bit => $label) if ($role & $bit) $labels[] = $label; return (object)[ 'id' => (int)$u->id, 'username' => (string)$u->username, 'displayname' => (string)($u->displayname ?? ''), 'email' => (string)($u->email ?? ''), 'pendingEmail' => (string)($u->pendingEmail ?? ''), 'role' => $role, 'roleLabels' => $role === Roles::BANNED ? ['BAN'] : $labels, 'regDate' => (int)($u->regDate ?? 0), 'verified' => ($u->verified ?? true) !== false, 'twoFactorOn' => !empty($u->twoFactor->secret), 'sessions' => count($tokens), 'lastLogin' => $last ?: null, 'avatarUrl' => avatar_url($u), ]; } /** * 管理者がアカウントを変更する(プロフィールの欄はupdateProfile()) * * メールアドレス・パスワード・二段階認証の変更には管理者自身のパスワードが必要。 * * @param string $username 変更するユーザー * @param array $in email、password・passwordVerify(新しいパスワード、全部のログインを取り消す)、verify(メールアドレスを確認済みにする)、 * disableTwoFactor、logoutAll(全部のログインを取り消す)、roles(ビットの配列) * @param string $adminPassword 管理者のパスワード * @return Result */ public function adminUpdateAccount(string $username, array $in, #[SensitiveParameter] string $adminPassword = ''): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); $me = $this->id > 0 ? $this->getUserData() : null; if (!$me || !((int)($me->role ?? 0) & Roles::ADMIN)) return Result::Error('エラー:権限がありません。'); $path = $this->userPath($username); $user = $path !== null ? json_decode((string)file_get_contents($path)) : null; if (!$user instanceof \stdClass) return Result::Error('エラー:ユーザーが見つかりません。'); $email = trim((string)($in['email'] ?? '')); $changeEmail = $email !== '' && $email !== (string)($user->email ?? ''); $password = (string)($in['password'] ?? ''); $disable2fa = !empty($in['disableTwoFactor']) && !empty($user->twoFactor->secret); if (($changeEmail || $password !== '' || $disable2fa) && !password_verify($adminPassword, (string)$me->password)) { return Result::Error('エラー:管理者のパスワードが一致していません。'); } $errors = []; $done = []; if ($changeEmail) { $res = $this->verifyEmail($email, false); if (!$res->isSuccess) $errors[] = $res->message; else { $user->email = $email; unset($user->pendingEmail); $done[] = 'メールアドレスを変更しました。'; } } if ($password !== '') { $res = $this->verifyPassword($password, (string)($in['passwordVerify'] ?? '')); if (!$res->isSuccess) $errors[] = $res->message; else { $user->password = password_hash($password, $this->algo); $user->tokens = []; unset($user->twoFactorTicket); $done[] = 'パスワードを変更し、全部のログインを取り消しました。'; } } if (!empty($in['verify']) && (($user->verified ?? true) === false || isset($user->pendingEmail))) { $user->verified = true; if (isset($user->pendingEmail) && !$changeEmail) $user->email = $user->pendingEmail; unset($user->pendingEmail, $user->verifyToken, $user->verifyExpire, $user->verifySent); $done[] = 'メールアドレスを確認済みにしました。'; } if ($disable2fa) { unset($user->twoFactor, $user->twoFactorPending, $user->twoFactorTicket); $done[] = '二段階認証を無効にしました。'; } if (!empty($in['logoutAll']) && !empty($user->tokens)) { $user->tokens = []; $done[] = '全部のログインを取り消しました。'; } if (isset($in['roles']) && is_array($in['roles'])) { $old = (int)($user->role ?? 0); $err = $this->applyRoles($user, $in['roles']); if ($err !== null) $errors[] = $err; else if ($old !== $user->role) $done[] = 'ロールを変更しました。'; } if (!empty($errors)) return Result::Error(implode(' ', $errors)); if (empty($done)) return Result::Success('変更はありません。'); if (!$this->saveUser($user, 'adminUpdateAccount')) return Result::Error('エラー:ユーザーデータの保存に失敗。'); logger(LogType::Auth, '【adminUpdateAccount】'.date('Y-m-d H:i:s')." {$me->username}が{$user->username}を変更:".implode(' ', $done)); return Result::Success(implode(' ', $done)); } /** * 管理者がユーザーを作る(登録が無効でも、メールアドレスの確認なし) * * @param array $roles ビットの配列 * @return Result data:username */ public function adminCreateUser(string $username, #[SensitiveParameter] string $password, #[SensitiveParameter] string $passwordVerify, string $email, array $roles = [Roles::MEMBER]): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); if (!$this->isAdmin()) return Result::Error('エラー:権限がありません。'); return $this->createAccount($username, $password, $passwordVerify, $email, $roles, 'adminCreateUser'); } /** * 最初の管理者を作ってログインする(管理者が1人も居ない時だけ、config.phpのADMIN_SETUP_CODEが必要) * * @param string $code 入力されたコード * @param string|null $setupCode 正しいコード(nullならADMIN_SETUP_CODE) * @return Result */ public function setupFirstAdmin(string $code, string $username, #[SensitiveParameter] string $password, #[SensitiveParameter] string $passwordVerify, string $email, #[SensitiveParameter] ?string $setupCode = null): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); $setupCode ??= defined('ADMIN_SETUP_CODE') ? (string)ADMIN_SETUP_CODE : ''; if ($setupCode === '' || $this->hasAdmin()) return Result::Error('エラー:最初の管理者は作れません。'); if ($this->isLoginThrottled('admin-setup')) return Result::Error('エラー:試行回数が多過ぎます。暫くしてから再度お試し下さい。'); if (!hash_equals($setupCode, $code)) { $this->recordLoginFailure('admin-setup'); return Result::Error('エラー:セットアップのコードが一致していません。'); } $res = $this->createAccount($username, $password, $passwordVerify, $email, [Roles::MEMBER, Roles::ADMIN], 'setupFirstAdmin'); if (!$res->isSuccess) return $res; $user = json_decode((string)file_get_contents($res->data['path'])); $this->id = (int)$user->id; $this->user = $user; logger(LogType::Auth, '【setupFirstAdmin】'.date('Y-m-d H:i:s')." 最初の管理者「{$username}」を作成"); $login = $this->startSession($user); return $login->isSuccess ? Result::Success('管理者を作成しました。') : $login; } private function createAccount(string $username, #[SensitiveParameter] string $password, #[SensitiveParameter] string $passwordVerify, string $email, array $roles, string $where): Result { $errors = []; foreach ([$this->verifyUsername($username), $this->verifyPassword($password, $passwordVerify), $this->verifyEmail($email, false)] as $res) { if (!$res->isSuccess) $errors[] = $res->message; } if (!empty($errors)) return Result::Error(implode(' ', $errors)); $role = Roles::BANNED; foreach ($roles as $bit) if (isset(Roles::LABELS[(int)$bit])) $role |= (int)$bit; $user = $this->newUser($username, $password, $email, $role); $res = $this->storeNewUser($user, $where); if (!$res->isSuccess) return $res; return Result::Success("ユーザー「{$username}」を作成しました。", ['username' => $username, 'path' => $res->data['path']]); } /** * 確認メールを送信する(テストで上書き出来る様にprotected) * * @param \stdClass $user ユーザーデータ * @param string $token リンクのトークン(ハッシュ化する前) * @return Result */ protected function sendVerificationMail(\stdClass $user, #[SensitiveParameter] string $token): Result { $url = $this->siteUrl().'/verify/'.$token; $hours = intdiv($this->verifyDuration, 3600); $body = "{$user->username}さん、登録ありがとうございます。\n\n" ."以下のリンクを開いて、メールアドレスを確認して下さい({$hours}時間有効):\n" ."{$url}\n\n" ."心当たりが無い場合は、このメールを無視して下さい。\n"; return Mailer::deliver($user->email, '【'.SITEINFO['title'].'】メールアドレスの確認', $body, $user->username); } /** * サイトのURL(リンク用) * SITEINFOの「url」を推奨。無ければリクエストのHost(Hostヘッダーは偽装出来るので注意) * * @return string 末尾の「/」なし */ protected function siteUrl(): string { $url = rtrim((string)(SITEINFO['url'] ?? ''), '/'); if ($url !== '') return $url; $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; return ($https ? 'https' : 'http').'://'.($_SERVER['HTTP_HOST'] ?? 'localhost'); } /** * 未確認のアカウントに確認メールを再送する(前回からverifyResendInterval以上経った場合だけ) * * @param \stdClass $userData ユーザーデータ * @return bool 再送したらtrue */ private function resendVerification(\stdClass $userData): bool { if (!$this->requiresVerification()) return false; if (($userData->verifySent ?? 0) + $this->verifyResendInterval > time()) return false; $token = bin2hex(random_bytes(32)); $userData->verifyToken = hash('sha256', $token); $userData->verifyExpire = time() + $this->verifyDuration; $userData->verifySent = 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, LOCK_EX) === false) return false; return $this->sendVerificationMail($userData, $token)->isSuccess; } /** * ユーザーのJSONファイルの一覧 * * @return array パス */ private function userFiles(): array { $out = []; foreach (@scandir($this->dataDir) ?: [] as $f) { if (preg_match('/^\d+\.[A-Za-z0-9]+\.json$/', $f)) $out[] = $this->dataDir.$f; } return $out; } /** * 新しいユーザーのデータ */ private function newUser(string $username, #[SensitiveParameter] string $password, string $email, int $role): \stdClass { $user = new \stdClass; $user->id = 0; $user->username = $username; $user->password = password_hash($password, $this->algo); $user->avatar = ''; $user->email = $email; $user->regDate = time(); $user->namecolor = ''; $user->displayname = ''; $user->gender = Gender::UNKNOWN; $user->role = $role; $user->tokens = []; return $user; } /** * 新しいユーザーを保存する(IDを付け、アイコンのディレクトリも作る。data/が空・無くても良い) * * @param \stdClass $user newUser()のデータ(idを設定する) * @param string $where ログ用 * @return Result data:path */ private function storeNewUser(\stdClass $user, string $where): Result { if (!is_dir($this->dataDir) && !@mkdir($this->dataDir, 0755, true) && !is_dir($this->dataDir)) { logger(LogType::Auth, "【{$where}】ユーザーのディレクトリを作成出来なかった。chownをご確認下さい。パス:{$this->dataDir}"); return Result::Error('エラー:ユーザーデータの保存に失敗。'); } $assDir = $this->assDir.$user->username; // 以前は親のディレクトリ(user/)が無いと失敗した if (!is_dir($assDir) && !@mkdir($assDir, 0755, true)) { logger(LogType::Auth, "【{$where}】アセットディレクトリを作成出来なかった。chownをご確認下さい。パス:{$assDir}"); return Result::Error('エラー:ユーザーのアイコンディレクトリの作成に失敗。'); } // 同時に登録しても同じIDにならない様に $lock = @fopen($this->dataDir.'.lock', 'c'); if ($lock) flock($lock, LOCK_EX); try { $last = 0; foreach ($this->userFiles() as $f) $last = max($last, (int)strtok(basename($f), '.')); $user->id = $last + 1; $path = "{$this->dataDir}{$user->id}.{$user->username}.json"; $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (file_put_contents($path, $json, LOCK_EX) === false) { logger(LogType::Auth, "【{$where}】JSONファイルを作成出来なかった。パス:{$path}"); @rmdir($assDir); return Result::Error('エラー:ユーザーデータの保存に失敗。'); } } finally { if ($lock) { flock($lock, LOCK_UN); fclose($lock); } } return Result::Success('', ['path' => $path]); } //////////////////// private function verifyLogin(string $username, #[SensitiveParameter] string $password): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); $userData = $this->getUserData(); if ($username !== $userData->username || !password_verify($password, $userData->password)) { return Result::Error('エラー:ユーザー名又はパスワードが一致していません。'); } if (password_needs_rehash($userData->password, $this->algo)) { $userData->password = password_hash($password, $this->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) { logger(LogType::Auth, '【verifyLogin】JSONファイルを変更出来なかった。chownをご確認下さい。パス:'.$path); return Result::Error('エラー:ユーザーデータの保存に失敗。'); } } return Result::Success(); } private function purgeOldTokens(\stdClass $userData): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); // 有効期限切れたトークンの削除 $out = $userData; $out->tokens = array_values(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) { logger(LogType::Auth, '【purgeOldTokens】JSONファイルを変更出来なかった。chownをご確認下さい。パス:'.$path); return Result::Error('エラー:ユーザーデータの保存に失敗。'); } return Result::Success('', ['user' => $out]); } private function getUserId(?string $username = null, ?string $token = null): int { if (!AUTH_ENABLED) return 0; if (!$username && !$token) return 0; if (!$username && !preg_match('/^[0-9a-f]{128}$/', $token)) return 0; $file = @scandir($this->dataDir) ?: []; $now = time(); $hashed = $token ? hash('sha256', $token) : ''; foreach ($file as $f) { if (!preg_match('/^(\d+)\.[A-Za-z0-9]+\.json$/', $f, $m)) continue; $path = "{$this->dataDir}{$f}"; if (!is_file($path)) continue; $data = json_decode(file_get_contents($path)); if (!$data instanceof \stdClass) continue; if ($username) { if (isset($data->username) && $data->username === $username) return (int)$m[1]; continue; } foreach (($data->tokens ?? []) as $t) { if (!isset($t->token, $t->expDate)) continue; if ($t->expDate > $now && hash_equals((string)$t->token, $hashed)) return (int)$m[1]; } } return 0; } private function getUserData(): ?\stdClass { if (!AUTH_ENABLED) return new \stdClass; $file = @scandir($this->dataDir) ?: []; $userFile = ""; $matches = []; foreach ($file as $f) { if ($f === '.kara' || $f === '.' || $f === '..') continue; if (preg_match('/^'.$this->id.'\.(.*?)\.json$/', $f, $matches)) { $userFile = $matches[0]; break; } } // 以前はファイルが無くてもディレクトリ自体を読もうとした(ログインしていない時のgetUser()等) if ($userFile === '') return null; $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 getUserDataFromName(string $name): \stdClass|null { if (!AUTH_ENABLED) return new \stdClass; $file = @scandir($this->dataDir) ?: []; $userFile = ""; $matches = []; foreach ($file as $f) { if ($f === '.kara' || $f === '.' || $f === '..') continue; if (preg_match('/^(.*?)\.(.*?)\.json$/', $f, $matches)) { if (isset($matches[2]) && $matches[2] === $name) { $userFile = $matches[0]; break; } } } if (!$userFile) return null; $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 = null): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); $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, bool $checkMx = true): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); 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 ($checkMx && !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 (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); if (null === $username || '' === $username) return Result::Error('エラー:ユーザー名をご入力下さい。'); if (strlen($username) < 6) return Result::Error('エラー:ユーザー名は6文字以上をご入力下さい。'); $res = $this->isUserExist($username); if ($res->isSuccess) return Result::Error($res->message); // ブログのアクターと同じ名前は使えない(WebFingerで区別出来ない) if (defined('FEDIINFO') && strcasecmp($username, (string)(FEDIINFO['actor'] ?? '')) === 0) { return Result::Error("ユーザー「{$username}」は既に存在します。"); } $accepted = 'A-Za-z0-9'; if (preg_match("/[^{$accepted}]/", $username)) { return Result::Error('エラー:ユーザー名に不正な文字が含みます。'); } return Result::Success(); } private function verifyPassword(#[SensitiveParameter] ?string $password = null, #[SensitiveParameter] ?string $passwordVerify = null): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); 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(#[SensitiveParameter] ?string $password = null): Result { if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); if (strlen($password) < $this->minPassLen) return Result::Error("エラー:パスワードは{$this->minPassLen}以上をご入力下さい。"); 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 = \count_special_chars($password) < 2; if ($countUpper || $countLower || $countDigit || $countSymbol) { return Result::Error('エラー:パスワードは2つ以上の大文字、2つ以上の小文字、2つ以上の数字、及び2つ以上の特別文字を含む事が必須です。'); } return Result::Success(); } };