501 lines
11 KiB
PHP
501 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/db.php';
|
|
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
|
|
const REMEMBER_COOKIE_NAME = 'remember_login';
|
|
const API_TOKEN_COOKIE_NAME = 'api_token';
|
|
|
|
const REMEMBER_DAYS = 30;
|
|
const AUTH_TOKEN_HOURS = 12;
|
|
|
|
function isSecureRequest(): bool
|
|
{
|
|
return (
|
|
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
|
|| (($_SERVER['SERVER_PORT'] ?? null) === '443')
|
|
);
|
|
}
|
|
|
|
function hashPassword(string $password): string
|
|
{
|
|
return password_hash($password, PASSWORD_ARGON2ID);
|
|
}
|
|
|
|
function setApiTokenCookie(string $token): void
|
|
{
|
|
setcookie(API_TOKEN_COOKIE_NAME, $token, [
|
|
'expires' => time() + 60 * 60 * AUTH_TOKEN_HOURS,
|
|
'path' => '/',
|
|
'secure' => isSecureRequest(),
|
|
'httponly' => false,
|
|
'samesite' => 'Lax',
|
|
]);
|
|
|
|
$_COOKIE[API_TOKEN_COOKIE_NAME] = $token;
|
|
}
|
|
|
|
function deleteApiTokenCookie(): void
|
|
{
|
|
setcookie(API_TOKEN_COOKIE_NAME, '', [
|
|
'expires' => time() - 3600,
|
|
'path' => '/',
|
|
'secure' => isSecureRequest(),
|
|
'httponly' => false,
|
|
'samesite' => 'Lax',
|
|
]);
|
|
|
|
unset($_COOKIE[API_TOKEN_COOKIE_NAME]);
|
|
}
|
|
|
|
function login(string $email, string $password, bool $remember = false): ?string
|
|
{
|
|
$email = strtolower(trim($email));
|
|
|
|
$stmt = db()->prepare('SELECT * FROM users WHERE email = ? LIMIT 1');
|
|
$stmt->execute([$email]);
|
|
$user = $stmt->fetch();
|
|
|
|
if (!$user || !password_verify($password, $user['passwd'])) {
|
|
return null;
|
|
}
|
|
|
|
session_regenerate_id(true);
|
|
$_SESSION['user_id'] = (int)$user['id'];
|
|
|
|
if ($remember) {
|
|
createRememberToken((int)$user['id']);
|
|
}
|
|
|
|
$token = createAuthToken((int)$user['id']);
|
|
setApiTokenCookie($token);
|
|
|
|
return $token;
|
|
}
|
|
|
|
function logout(): void
|
|
{
|
|
$token = getBearerToken();
|
|
|
|
if ($token) {
|
|
revokeAuthToken($token);
|
|
}
|
|
|
|
if (isset($_SESSION['user_id'])) {
|
|
revokeUserAuthTokens((int)$_SESSION['user_id']);
|
|
}
|
|
|
|
deleteRememberCookie();
|
|
deleteApiTokenCookie();
|
|
|
|
$_SESSION = [];
|
|
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
session_destroy();
|
|
}
|
|
}
|
|
|
|
function currentUser(): ?array
|
|
{
|
|
if (!isset($_SESSION['user_id'])) {
|
|
tryRememberLogin();
|
|
}
|
|
|
|
if (isset($_SESSION['user_id'])) {
|
|
$stmt = db()->prepare(
|
|
'SELECT id, name, email, picture
|
|
FROM users
|
|
WHERE id = ?
|
|
LIMIT 1'
|
|
);
|
|
|
|
$stmt->execute([$_SESSION['user_id']]);
|
|
|
|
return $stmt->fetch() ?: null;
|
|
}
|
|
|
|
$token = getBearerToken();
|
|
|
|
if ($token) {
|
|
return authenticateApiToken($token);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function requireLogin(): array
|
|
{
|
|
$user = currentUser();
|
|
|
|
if (!$user) {
|
|
header('Location: ?page=login');
|
|
exit;
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
function requireGuest(): void
|
|
{
|
|
if (currentUser()) {
|
|
header('Location: ?page=home');
|
|
exit;
|
|
}
|
|
}
|
|
|
|
function createRememberToken(int $userId): void
|
|
{
|
|
$selector = bin2hex(random_bytes(16));
|
|
$token = bin2hex(random_bytes(32));
|
|
$tokenHash = hash('sha256', $token);
|
|
|
|
$expires = date('Y-m-d H:i:s', time() + 60 * 60 * 24 * REMEMBER_DAYS);
|
|
|
|
$stmt = db()->prepare(
|
|
'INSERT INTO remember_tokens (user_id, selector, token_hash, expires_at)
|
|
VALUES (?, ?, ?, ?)'
|
|
);
|
|
|
|
$stmt->execute([$userId, $selector, $tokenHash, $expires]);
|
|
|
|
setcookie(REMEMBER_COOKIE_NAME, $selector . ':' . $token, [
|
|
'expires' => time() + 60 * 60 * 24 * REMEMBER_DAYS,
|
|
'path' => '/',
|
|
'secure' => isSecureRequest(),
|
|
'httponly' => true,
|
|
'samesite' => 'Lax',
|
|
]);
|
|
}
|
|
|
|
function tryRememberLogin(): void
|
|
{
|
|
if (empty($_COOKIE[REMEMBER_COOKIE_NAME])) {
|
|
return;
|
|
}
|
|
|
|
$parts = explode(':', $_COOKIE[REMEMBER_COOKIE_NAME]);
|
|
|
|
if (count($parts) !== 2) {
|
|
deleteRememberCookie();
|
|
return;
|
|
}
|
|
|
|
[$selector, $token] = $parts;
|
|
$tokenHash = hash('sha256', $token);
|
|
|
|
$stmt = db()->prepare(
|
|
'SELECT *
|
|
FROM remember_tokens
|
|
WHERE selector = ?
|
|
AND expires_at > NOW()
|
|
LIMIT 1'
|
|
);
|
|
|
|
$stmt->execute([$selector]);
|
|
$row = $stmt->fetch();
|
|
|
|
if (!$row || !hash_equals($row['token_hash'], $tokenHash)) {
|
|
deleteRememberCookie();
|
|
return;
|
|
}
|
|
|
|
session_regenerate_id(true);
|
|
$_SESSION['user_id'] = (int)$row['user_id'];
|
|
|
|
db()->prepare('DELETE FROM remember_tokens WHERE id = ?')->execute([$row['id']]);
|
|
|
|
createRememberToken((int)$row['user_id']);
|
|
|
|
$apiToken = createAuthToken((int)$row['user_id']);
|
|
setApiTokenCookie($apiToken);
|
|
}
|
|
|
|
function deleteRememberCookie(): void
|
|
{
|
|
if (!empty($_COOKIE[REMEMBER_COOKIE_NAME])) {
|
|
$parts = explode(':', $_COOKIE[REMEMBER_COOKIE_NAME]);
|
|
|
|
if (count($parts) === 2) {
|
|
db()->prepare(
|
|
'DELETE FROM remember_tokens WHERE selector = ?'
|
|
)->execute([$parts[0]]);
|
|
}
|
|
}
|
|
|
|
setcookie(REMEMBER_COOKIE_NAME, '', [
|
|
'expires' => time() - 3600,
|
|
'path' => '/',
|
|
'secure' => isSecureRequest(),
|
|
'httponly' => true,
|
|
'samesite' => 'Lax',
|
|
]);
|
|
|
|
unset($_COOKIE[REMEMBER_COOKIE_NAME]);
|
|
}
|
|
|
|
function createAuthToken(int $userId): string
|
|
{
|
|
$token = bin2hex(random_bytes(32));
|
|
$tokenHash = hash('sha256', $token);
|
|
|
|
$expires = date('Y-m-d H:i:s', time() + 60 * 60 * AUTH_TOKEN_HOURS);
|
|
|
|
$stmt = db()->prepare(
|
|
'INSERT INTO auth_tokens (user_id, token_hash, expires_at)
|
|
VALUES (?, ?, ?)'
|
|
);
|
|
|
|
$stmt->execute([
|
|
$userId,
|
|
$tokenHash,
|
|
$expires
|
|
]);
|
|
|
|
return $token;
|
|
}
|
|
|
|
function authenticateApiToken(string $token): ?array
|
|
{
|
|
$token = trim($token);
|
|
|
|
if ($token === '') {
|
|
return null;
|
|
}
|
|
|
|
$tokenHash = hash('sha256', $token);
|
|
|
|
$stmt = db()->prepare(
|
|
'SELECT users.id, users.name, users.email, users.picture
|
|
FROM auth_tokens
|
|
JOIN users ON users.id = auth_tokens.user_id
|
|
WHERE auth_tokens.token_hash = ?
|
|
AND auth_tokens.expires_at > NOW()
|
|
AND auth_tokens.revoked_at IS NULL
|
|
LIMIT 1'
|
|
);
|
|
|
|
$stmt->execute([$tokenHash]);
|
|
$user = $stmt->fetch();
|
|
|
|
if (!$user) {
|
|
return null;
|
|
}
|
|
|
|
db()->prepare(
|
|
'UPDATE auth_tokens
|
|
SET last_used_at = NOW()
|
|
WHERE token_hash = ?'
|
|
)->execute([$tokenHash]);
|
|
|
|
return $user;
|
|
}
|
|
|
|
function getBearerToken(): ?string
|
|
{
|
|
$header = $_SERVER['HTTP_AUTHORIZATION']
|
|
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
|
?? '';
|
|
|
|
if (preg_match('/Bearer\s+(.+)/i', $header, $matches)) {
|
|
return trim($matches[1]);
|
|
}
|
|
|
|
if (!empty($_COOKIE[API_TOKEN_COOKIE_NAME])) {
|
|
return trim($_COOKIE[API_TOKEN_COOKIE_NAME]);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function jsonAuthError(string $message, int $status): void
|
|
{
|
|
http_response_code($status);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => $message
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
|
|
exit;
|
|
}
|
|
|
|
function userHasRight(int $userId, string $rightName): bool
|
|
{
|
|
try {
|
|
$stmt = db()->prepare(
|
|
'SELECT rights.id
|
|
FROM user_rights
|
|
INNER JOIN rights ON rights.id = user_rights.right_id
|
|
WHERE user_rights.user_id = ?
|
|
AND rights.name = ?
|
|
LIMIT 1'
|
|
);
|
|
$stmt->execute([$userId, $rightName]);
|
|
|
|
return (bool)$stmt->fetch();
|
|
} catch (Throwable $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function userIsAdmin(int $userId): bool
|
|
{
|
|
return userHasRight($userId, 'Admin');
|
|
}
|
|
|
|
function requireUserRight(int $userId, string $rightName): void
|
|
{
|
|
if (userIsAdmin($userId) || userHasRight($userId, $rightName)) {
|
|
return;
|
|
}
|
|
|
|
jsonAuthError("Missing right: {$rightName}.", 403);
|
|
}
|
|
|
|
function projectExists(string $projectId): bool
|
|
{
|
|
$stmt = db()->prepare('SELECT id FROM projects WHERE id = ? LIMIT 1');
|
|
$stmt->execute([$projectId]);
|
|
|
|
return (bool)$stmt->fetch();
|
|
}
|
|
|
|
function userCanAccessProject(int $userId, string $projectId): bool
|
|
{
|
|
if (userIsAdmin($userId)) {
|
|
return true;
|
|
}
|
|
|
|
$stmt = db()->prepare(
|
|
'SELECT projects.id
|
|
FROM projects
|
|
LEFT JOIN user_access
|
|
ON user_access.project_id = projects.id
|
|
AND user_access.user_id = ?
|
|
WHERE projects.id = ?
|
|
AND (projects.owner = ? OR user_access.id IS NOT NULL)
|
|
LIMIT 1'
|
|
);
|
|
$stmt->execute([$userId, $projectId, $userId]);
|
|
|
|
return (bool)$stmt->fetch();
|
|
}
|
|
|
|
function requireProjectAccessForUser(int $userId, string $projectId): void
|
|
{
|
|
if (!projectExists($projectId)) {
|
|
jsonAuthError('Project not found.', 404);
|
|
}
|
|
|
|
if (!userCanAccessProject($userId, $projectId)) {
|
|
jsonAuthError('No access to this project.', 403);
|
|
}
|
|
}
|
|
|
|
function requireProjectRight(int $userId, string $projectId, string $rightName): void
|
|
{
|
|
requireProjectAccessForUser($userId, $projectId);
|
|
requireUserRight($userId, $rightName);
|
|
}
|
|
|
|
function requireApiAuth(): array
|
|
{
|
|
$token = getBearerToken();
|
|
|
|
if (!$token) {
|
|
jsonAuthError('Missing API token.', 401);
|
|
}
|
|
|
|
$user = authenticateApiToken($token);
|
|
|
|
if (!$user) {
|
|
jsonAuthError('Invalid or expired API token.', 401);
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
function revokeAuthToken(string $token): void
|
|
{
|
|
$tokenHash = hash('sha256', $token);
|
|
|
|
db()->prepare(
|
|
'UPDATE auth_tokens
|
|
SET revoked_at = NOW()
|
|
WHERE token_hash = ?
|
|
AND revoked_at IS NULL'
|
|
)->execute([$tokenHash]);
|
|
}
|
|
|
|
function revokeUserAuthTokens(int $userId): void
|
|
{
|
|
db()->prepare(
|
|
'UPDATE auth_tokens
|
|
SET revoked_at = NOW()
|
|
WHERE user_id = ?
|
|
AND revoked_at IS NULL'
|
|
)->execute([$userId]);
|
|
}
|
|
|
|
function cleanupExpiredAuthData(): void
|
|
{
|
|
db()->exec('DELETE FROM remember_tokens WHERE expires_at < NOW()');
|
|
db()->exec('DELETE FROM auth_tokens WHERE expires_at < NOW() OR revoked_at IS NOT NULL');
|
|
}
|
|
|
|
function register(string $username, string $email, string $password): ?int
|
|
{
|
|
$username = trim($username);
|
|
$email = strtolower(trim($email));
|
|
|
|
if ($username === '') {
|
|
return null;
|
|
}
|
|
|
|
if ($email === '') {
|
|
return null;
|
|
}
|
|
|
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
return null;
|
|
}
|
|
|
|
if ($password === '') {
|
|
return null;
|
|
}
|
|
|
|
if (strlen($password) < 8) {
|
|
return null;
|
|
}
|
|
|
|
$passwordHash = password_hash($password, PASSWORD_ARGON2ID);
|
|
|
|
if ($passwordHash === false) {
|
|
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
|
|
}
|
|
|
|
try {
|
|
$stmt = db()->prepare(
|
|
'INSERT INTO users (name, email, passwd)
|
|
VALUES (?, ?, ?)'
|
|
);
|
|
|
|
$stmt->execute([
|
|
$username,
|
|
$email,
|
|
$passwordHash
|
|
]);
|
|
|
|
return (int)db()->lastInsertId();
|
|
|
|
} catch (PDOException $e) {
|
|
return null;
|
|
}
|
|
}
|