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();
}
};