Files
huntingclubroster/classes/AuthService.php
T

253 lines
15 KiB
PHP

<?php
/** Shared account storage and security rules; no HTML or request dispatch here. */
class AuthService extends Base {
public const IDLE_TIMEOUT = 1800;
public const ABSOLUTE_TIMEOUT = 28800;
public function findUser(string $identifier): ?array {
$stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_email = :email OR user_name = :name LIMIT 1');
$stmt->execute([':email' => trim($identifier), ':name' => trim($identifier)]);
return $stmt->fetch() ?: null;
}
public static function passwordError(string $password, string $confirmation): string {
if ($password !== $confirmation)
return 'Passwords must match.';
// PASSWORD_DEFAULT is bcrypt on supported PHP versions; avoid silent truncation.
if (strlen($password) < 12 || strlen($password) > 72 || str_contains($password, "\0")) {
return 'Use a password between 12 and 72 bytes long.';
}
return '';
}
/** Shared database counters survive session/cookie changes. Requires the auth migration. */
public function allowAttempt(string $scope, string $identifier, int $limit, int $seconds): bool {
$db = $this->connect();
$key = hash('sha256', $scope . ':' . strtolower(trim($identifier)));
$stmt = $db->prepare('INSERT INTO auth_attempts (attempt_key, attempts, window_started) VALUES (:key, 1, UTC_TIMESTAMP())
ON DUPLICATE KEY UPDATE attempts = IF(window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :seconds SECOND), 1, attempts + 1),
window_started = IF(window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :window SECOND), UTC_TIMESTAMP(), window_started)');
$stmt->execute([':key' => $key, ':seconds' => $seconds, ':window' => $seconds]);
$stmt = $db->prepare('SELECT attempts FROM auth_attempts WHERE attempt_key = :key');
$stmt->execute([':key' => $key]);
return (int) $stmt->fetchColumn() <= $limit;
}
public function login(string $identifier, string $password): bool {
$ipAllowed = $this->allowAttempt('login-ip', $_SERVER['REMOTE_ADDR'] ?? '', 50, 900);
$accountAllowed = $this->allowAttempt('login-account', $identifier, 10, 900);
if (!$ipAllowed || !$accountAllowed)
return false;
$user = $this->findUser($identifier);
// A real hash avoids an immediate return for unknown accounts.
$hash = $user['user_password_hash'] ?? '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.';
$verified = password_verify($password, $hash);
if (!$user || !$verified || $user['user_status'] !== 'active')
return false;
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$stmt = $this->connect()->prepare('UPDATE users SET user_password_hash = :hash WHERE user_serial = :id AND user_password_hash = :old_hash');
$stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['user_serial'], ':old_hash' => $hash]);
}
session_regenerate_id(true);
unset($_SESSION['mfa_user'],$_SESSION['recent_auth'],$_SESSION['mfa_pending'],$_SESSION['mfa_replacement']);
$_SESSION['authenticated_at']=time();
$_SESSION[self::USER_AUTHENTICATED] = true;
$_SESSION[self::USER_SERIAL] = (int) $user['user_serial'];
$_SESSION[self::USER_NAME] = $user['user_name'];
$_SESSION[self::USER_FULL_NAME] = $user['user_full_name'];
$_SESSION[self::USER_EMAIL] = $user['user_email'];
$_SESSION[self::USER_ROLE] = $user['user_role'];
$_SESSION['auth_version'] = (int) $user['user_auth_version'];
$_SESSION['last_activity'] = time();
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$stmt = $this->connect()->prepare('UPDATE users SET user_last_login = :now WHERE user_serial = :id');
$stmt->execute([':now' => $this->getTimestamp(), ':id' => $user['user_serial']]);
Audit::record($this->connect(),(int)$user['user_serial'],'logged in ('.$user['user_role'].')');
return true;
}
public function currentUser(): ?array {
if (empty($_SESSION[self::USER_AUTHENTICATED]))
return null;
if (time() - ($_SESSION['last_activity'] ?? 0) > self::IDLE_TIMEOUT || time() - ($_SESSION['authenticated_at'] ?? 0) >= self::ABSOLUTE_TIMEOUT)
return null;
$stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_serial = :id');
$stmt->execute([':id' => $_SESSION[self::USER_SERIAL] ?? 0]);
$user = $stmt->fetch();
if (!$user || $user['user_status'] !== 'active' || (int) $user['user_auth_version'] !== ($_SESSION['auth_version'] ?? -1))
return null;
$_SESSION['last_activity'] = time();
$_SESSION[self::USER_ROLE] = $user['user_role'];
$_SESSION[self::USER_NAME] = $user['user_name'];
$_SESSION[self::USER_FULL_NAME] = $user['user_full_name'];
$_SESSION[self::USER_EMAIL] = $user['user_email'];
return $user;
}
public function register(array $input): void {
$first = trim($input['user_first_name'] ?? '');
$last = trim($input['user_last_name'] ?? '');
$email = trim($input['user_email'] ?? '');
$name = trim($input['user_name'] ?? '');
if (!$first || !$last || strlen($first) > 128 || strlen($last) > 128)
throw new InvalidArgumentException('Enter your first and last name (up to 128 bytes each).');
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100)
throw new InvalidArgumentException('Enter a valid email address (up to 100 bytes).');
if (!preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name))
throw new InvalidArgumentException('Use 3 to 64 letters, numbers, dots, underscores or hyphens for your username.');
$error = self::passwordError($input['user_password'] ?? '', $input['user_password_confirm'] ?? '');
if ($error)
throw new InvalidArgumentException($error);
$db = $this->connect();
// Cross-field uniqueness keeps email-or-username login unambiguous.
$stmt = $db->prepare('SELECT user_serial FROM users WHERE user_name IN (:name, :email) OR user_email IN (:email2, :name2) LIMIT 1');
$stmt->execute([':name' => $name, ':email' => $email, ':email2' => $email, ':name2' => $name]);
if ($stmt->fetch())
throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.');
$stmt = $db->prepare("INSERT INTO users (user_name, user_first_name, user_last_name, user_email, user_password_hash, user_role, user_status, user_creator) VALUES (:name, :first, :last, :email, :hash, 'User', 'active', 'Self registration')");
$kind=$input['registration_kind']??'account';
if(!in_array($kind,['account','new_club','invited'],true))throw new InvalidArgumentException('Choose a valid signup option.');
$clubName=trim($input['club_name']??'');
if($kind==='new_club' && ($clubName===''||strlen($clubName)>128))throw new InvalidArgumentException('Enter your new club name (up to 128 bytes).');
try {
DatabaseTransaction::run($db,function(PDO $db)use($stmt,$name,$first,$last,$email,$input,$kind,$clubName){
if($kind==='invited'){
$invites=$db->prepare("SELECT i.club_serial,i.invited_by,i.membership_role FROM club_invitations i JOIN clubs c ON c.club_serial=i.club_serial WHERE i.invite_email=? AND i.invitation_status='pending' AND i.expires_at>UTC_TIMESTAMP() AND c.club_status='active'");$invites->execute([$email]);$valid=false;$access=new TenantAccess();
foreach($invites->fetchAll() as $invite)if($access->canManage((int)$invite['club_serial'],$db,(int)$invite['invited_by'])&&($invite['membership_role']==='member'||$access->isSuper($db,(int)$invite['invited_by']))){$valid=true;break;}
if(!$valid)throw new InvalidArgumentException('Ask your club administrator for a current invitation to this email address.');
}
$stmt->execute([':name'=>$name,':first'=>$first,':last'=>$last,':email'=>$email,':hash'=>password_hash($input['user_password'],PASSWORD_DEFAULT)]);
$user=(int)$db->lastInsertId();
if($kind==='new_club'){
$club=$db->prepare("INSERT INTO clubs(club_name,club_status) VALUES(?,'active')");$club->execute([$clubName]);$clubId=(int)$db->lastInsertId();
$membership=$db->prepare("INSERT INTO club_memberships(club_serial,user_serial,membership_role,membership_status,joined_on) VALUES(?,?,'administrator','active',?)");$membership->execute([$clubId,$user,date('Y-m-d')]);
Audit::required($db,$user,'registered new club '.json_encode(['club'=>$clubId,'name'=>$clubName]));
}
});
} catch (PDOException $e) {
if ($e->getCode() === '23000')
throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.');
throw $e;
}
}
/** Never modify the password when somebody merely requests an email. */
public function requestReset(string $identifier): bool {
$ipAllowed = $this->allowAttempt('reset-ip', $_SERVER['REMOTE_ADDR'] ?? '', 10, 3600);
$accountAllowed = $this->allowAttempt('reset-account', $identifier, 3, 3600);
if (!$ipAllowed || !$accountAllowed)
return false;
$user = $this->findUser($identifier);
if (!$user || $user['user_status'] !== 'active')
return false;
$baseUrl = rtrim((string) $this->getSettings()->ApplicationUrl, '/');
if (!filter_var($baseUrl, FILTER_VALIDATE_URL) || parse_url($baseUrl, PHP_URL_SCHEME) !== 'https'
|| parse_url($baseUrl, PHP_URL_QUERY) !== null || parse_url($baseUrl, PHP_URL_FRAGMENT) !== null
|| parse_url($baseUrl, PHP_URL_USER) !== null || parse_url($baseUrl, PHP_URL_PASS) !== null) {
throw new RuntimeException('Configure an HTTPS ApplicationUrl before sending reset emails.');
}
$token = bin2hex(random_bytes(32));
$db = $this->connect();
$hash = hash('sha256', $token);
$db->beginTransaction();
try {
// Share the user-row lock with account edits and password changes. A
// request resolved before such a change must not mint a token afterward.
$stmt=$db->prepare('SELECT user_email,user_status,user_auth_version FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$user['user_serial']]);$current=$stmt->fetch();
if(!$current || $current['user_status']!=='active' || $current['user_email']!==$user['user_email'] || (int)$current['user_auth_version']!==(int)$user['user_auth_version']){
$db->rollBack();return false;
}
$stmt=$db->prepare('INSERT INTO password_resets (token_hash,user_serial,expires_at) VALUES (?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 30 MINUTE))');
$stmt->execute([$hash,$user['user_serial']]);$db->commit();
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
$url = $baseUrl . '/main.php?Xaction=resetpassword&token=' . rawurlencode($token);
if (!$this->deliverResetEmail($user, $url)) {
$stmt = $db->prepare('DELETE FROM password_resets WHERE token_hash = :hash');
$stmt->execute([':hash' => $hash]);
throw new RuntimeException('Password reset email delivery failed.');
}
Audit::record($db,(int)$user['user_serial'],'requested password reset');
return true;
}
protected function deliverResetEmail(array $user, string $url): bool {
return (new Email())->sendPasswordReset($user, $url);
}
public function resetPassword(string $token, string $password, string $confirmation): bool {
$error = self::passwordError($password, $confirmation);
if ($error)
throw new InvalidArgumentException($error);
if (!preg_match('/\A[a-f0-9]{64}\z/', $token))
return false;
$db = $this->connect();
$db->beginTransaction();
try {
// Serialize resets and password changes on the user row.
$stmt = $db->prepare('SELECT user_serial FROM password_resets WHERE token_hash = :hash');
$stmt->execute([':hash' => hash('sha256', $token)]);
$id = $stmt->fetchColumn();
if (!$id) {
$db->rollBack();
return false;
}
$stmt = $db->prepare("SELECT user_serial FROM users WHERE user_serial = :id AND user_status = 'active' FOR UPDATE");
$stmt->execute([':id' => $id]);
if (!$stmt->fetch()) {
$db->rollBack();
return false;
}
$stmt = $db->prepare('SELECT token_hash FROM password_resets WHERE token_hash = :hash AND expires_at > UTC_TIMESTAMP() FOR UPDATE');
$stmt->execute([':hash' => hash('sha256', $token)]);
if (!$stmt->fetch()) {
$db->rollBack();
return false;
}
$this->storePassword($db, (int) $id, $password);
$stmt=$db->prepare('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_serial=?');$stmt->execute([$id]);
$db->commit();
Audit::record($db,(int)$id,'reset password');
return true;
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();
throw $e;
}
}
public function changePassword(int $id, string $current, string $password, string $confirmation): bool {
$error = self::passwordError($password, $confirmation);
if ($error)
throw new InvalidArgumentException($error);
$db = $this->connect();
$db->beginTransaction();
try {
$stmt = $db->prepare("SELECT user_password_hash, user_auth_version FROM users WHERE user_serial = :id AND user_status = 'active' FOR UPDATE");
$stmt->execute([':id' => $id]);
$user = $stmt->fetch();
if (!$user || (int) $user['user_auth_version'] !== ($_SESSION['auth_version'] ?? -1) || !password_verify($current, $user['user_password_hash'])) {
$db->rollBack();
return false;
}
$this->storePassword($db, $id, $password);
$db->commit();
Audit::record($db,$id,'changed password');
return true;
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();
throw $e;
}
}
private function storePassword(PDO $db, int $id, string $password): void {
$stmt = $db->prepare('UPDATE users SET user_password_hash = :hash, user_change_password = 0, user_auth_version = user_auth_version + 1 WHERE user_serial = :id');
$stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT), ':id' => $id]);
$stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial = :id');
$stmt->execute([':id' => $id]);
}
}