generated from jric11/baseProject
233 lines
13 KiB
PHP
233 lines
13 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 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);
|
|
$_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)
|
|
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')");
|
|
try {
|
|
$stmt->execute([':name' => $name, ':first' => $first, ':last' => $last, ':email' => $email, ':hash' => password_hash($input['user_password'], PASSWORD_DEFAULT)]);
|
|
} 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]);
|
|
}
|
|
}
|