Files
Projectkiln/ProjectKiln/auth.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;
}
}