generated from jric11/baseProject
Codex's updates...
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
<?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']]);
|
||||
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–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): void {
|
||||
$ipAllowed = $this->allowAttempt('reset-ip', $_SERVER['REMOTE_ADDR'] ?? '', 10, 3600);
|
||||
$accountAllowed = $this->allowAttempt('reset-account', $identifier, 3, 3600);
|
||||
if (!$ipAllowed || !$accountAllowed)
|
||||
return;
|
||||
$user = $this->findUser($identifier);
|
||||
if (!$user || $user['user_status'] !== 'active')
|
||||
return;
|
||||
$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();
|
||||
$stmt = $db->prepare('INSERT INTO password_resets (token_hash, user_serial, expires_at) VALUES (:hash, :id, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 MINUTE))');
|
||||
$hash = hash('sha256', $token);
|
||||
$stmt->execute([':hash' => $hash, ':id' => $user['user_serial']]);
|
||||
$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.');
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
$db->commit();
|
||||
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();
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user