497 lines
19 KiB
PHP
497 lines
19 KiB
PHP
<?php
|
||
// src/controllers/AuthController.php
|
||
|
||
require_once __DIR__ . '/../../config/config.php';
|
||
require_once PROJECT_ROOT . '/src/models/AuthModel.php';
|
||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
|
||
|
||
use RobThree\Auth\Algorithm;
|
||
use RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider;
|
||
use Jumbojett\OpenIDConnectClient;
|
||
|
||
class AuthController
|
||
{
|
||
|
||
public function auth(): void
|
||
{
|
||
header('Content-Type: application/json');
|
||
set_exception_handler(function ($e) {
|
||
error_log("Unhandled exception: " . $e->getMessage());
|
||
http_response_code(500);
|
||
echo json_encode(['error' => 'Internal Server Error']);
|
||
exit();
|
||
});
|
||
|
||
// Decode any JSON payload
|
||
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
||
$username = trim($data['username'] ?? '');
|
||
$password = trim($data['password'] ?? '');
|
||
$totpCode = trim($data['totp_code'] ?? '');
|
||
$rememberMe = !empty($data['remember_me']);
|
||
|
||
//
|
||
// 1) TOTP‑only step: user already passed credentials and we asked for TOTP,
|
||
// now they POST just totp_code.
|
||
//
|
||
if ($totpCode && isset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret'])) {
|
||
$username = $_SESSION['pending_login_user'];
|
||
$secret = $_SESSION['pending_login_secret'];
|
||
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
|
||
$tfa = new TwoFactorAuth(new GoogleChartsQrCodeProvider(), 'FileRise', 6, 30, Algorithm::Sha1);
|
||
if (! $tfa->verifyCode($secret, $totpCode)) {
|
||
echo json_encode(['error' => 'Invalid TOTP code']);
|
||
exit();
|
||
}
|
||
// clear the pending markers
|
||
unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']);
|
||
// now finish login
|
||
$this->finalizeLogin($username, $rememberMe);
|
||
}
|
||
|
||
//
|
||
// 2) OIDC flow
|
||
//
|
||
$oidcAction = $_GET['oidc'] ?? null;
|
||
if (! $oidcAction && isset($_GET['code'])) {
|
||
$oidcAction = 'callback';
|
||
}
|
||
if ($oidcAction) {
|
||
$cfg = AdminModel::getConfig();
|
||
$clientId = $cfg['oidc']['clientId'] ?? null;
|
||
$clientSecret = $cfg['oidc']['clientSecret'] ?? null;
|
||
// When configured as a public client (no secret), pass null, not an empty string.
|
||
if ($clientSecret === '') { $clientSecret = null; }
|
||
|
||
$oidc = new OpenIDConnectClient(
|
||
$cfg['oidc']['providerUrl'],
|
||
$clientId ?: null,
|
||
$clientSecret
|
||
);
|
||
|
||
// Always send PKCE (S256). Required by Authelia for public clients, safe for confidential ones.
|
||
if (method_exists($oidc, 'setCodeChallengeMethod')) {
|
||
$oidc->setCodeChallengeMethod('S256');
|
||
}
|
||
// client_secret_post with Authelia using config.php
|
||
if (method_exists($oidc, 'setTokenEndpointAuthMethod') && OIDC_TOKEN_ENDPOINT_AUTH_METHOD) {
|
||
$oidc->setTokenEndpointAuthMethod(OIDC_TOKEN_ENDPOINT_AUTH_METHOD);
|
||
}
|
||
$oidc->setRedirectURL($cfg['oidc']['redirectUri']);
|
||
$oidc->addScope(['openid','profile','email']);
|
||
|
||
|
||
if ($oidcAction === 'callback') {
|
||
try {
|
||
$oidc->authenticate();
|
||
$username =
|
||
$oidc->requestUserInfo('preferred_username')
|
||
?: $oidc->requestUserInfo('email')
|
||
?: $oidc->requestUserInfo('sub');
|
||
|
||
// check if this user has a TOTP secret
|
||
$totp_secret = null;
|
||
$usersFile = USERS_DIR . USERS_FILE;
|
||
if (file_exists($usersFile)) {
|
||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||
$parts = explode(':', trim($line));
|
||
if (count($parts) >= 4 && $parts[0] === $username && $parts[3] !== '') {
|
||
$totp_secret = decryptData($parts[3], $GLOBALS['encryptionKey']);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if ($totp_secret) {
|
||
$_SESSION['pending_login_user'] = $username;
|
||
$_SESSION['pending_login_secret'] = $totp_secret;
|
||
header('Location: /index.html?totp_required=1');
|
||
exit();
|
||
}
|
||
|
||
// no TOTP → finish immediately
|
||
$this->finishBrowserLogin($username);
|
||
} catch (\Exception $e) {
|
||
error_log("OIDC auth error: " . $e->getMessage());
|
||
http_response_code(401);
|
||
echo json_encode(['error' => 'Authentication failed.']);
|
||
exit();
|
||
}
|
||
} else {
|
||
// initial OIDC redirect
|
||
try {
|
||
$oidc->authenticate();
|
||
exit();
|
||
} catch (\Exception $e) {
|
||
error_log("OIDC initiation error: " . $e->getMessage());
|
||
http_response_code(401);
|
||
echo json_encode(['error' => 'Authentication initiation failed.']);
|
||
exit();
|
||
}
|
||
}
|
||
}
|
||
|
||
//
|
||
// 3) Form‑based / AJAX login
|
||
//
|
||
if (! $username || ! $password) {
|
||
http_response_code(400);
|
||
echo json_encode(['error' => 'Username and password are required']);
|
||
exit();
|
||
}
|
||
if (! preg_match(REGEX_USER, $username)) {
|
||
http_response_code(400);
|
||
echo json_encode(['error' => 'Invalid username format']);
|
||
exit();
|
||
}
|
||
|
||
// rate‑limit
|
||
$ip = $_SERVER['REMOTE_ADDR'];
|
||
$attemptsFile = USERS_DIR . 'failed_logins.json';
|
||
$failed = AuthModel::loadFailedAttempts($attemptsFile);
|
||
if (
|
||
isset($failed[$ip]) &&
|
||
$failed[$ip]['count'] >= 5 &&
|
||
time() - $failed[$ip]['last_attempt'] < 30 * 60
|
||
) {
|
||
http_response_code(429);
|
||
echo json_encode(['error' => 'Too many failed login attempts. Please try again later.']);
|
||
exit();
|
||
}
|
||
|
||
$user = AuthModel::authenticate($username, $password);
|
||
if ($user === false) {
|
||
// record failure
|
||
$failed[$ip] = [
|
||
'count' => ($failed[$ip]['count'] ?? 0) + 1,
|
||
'last_attempt' => time()
|
||
];
|
||
AuthModel::saveFailedAttempts($attemptsFile, $failed);
|
||
http_response_code(401);
|
||
echo json_encode(['error' => 'Invalid credentials']);
|
||
exit();
|
||
}
|
||
|
||
// if this account has TOTP, ask for it
|
||
if (! empty($user['totp_secret'])) {
|
||
$_SESSION['pending_login_user'] = $username;
|
||
$_SESSION['pending_login_secret'] = $user['totp_secret'];
|
||
$_SESSION['pending_login_remember_me'] = $rememberMe;
|
||
echo json_encode(['totp_required' => true]);
|
||
exit();
|
||
}
|
||
|
||
// otherwise clear rate‑limit & finish
|
||
if (isset($failed[$ip])) {
|
||
unset($failed[$ip]);
|
||
AuthModel::saveFailedAttempts($attemptsFile, $failed);
|
||
}
|
||
$this->finalizeLogin($username, $rememberMe);
|
||
}
|
||
|
||
/**
|
||
* Finalize an AJAX‐style login (form/basic/TOTP) by
|
||
* issuing the session, remember‑me cookie, and JSON payload.
|
||
*/
|
||
protected function finalizeLogin(string $username, bool $rememberMe): void
|
||
{
|
||
session_regenerate_id(true);
|
||
$_SESSION['authenticated'] = true;
|
||
$_SESSION['username'] = $username;
|
||
$_SESSION['isAdmin'] = (AuthModel::getUserRole($username) === '1');
|
||
|
||
$perms = loadUserPermissions($username);
|
||
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
||
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
||
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
||
|
||
// remember‑me
|
||
if ($rememberMe) {
|
||
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
||
$token = bin2hex(random_bytes(32));
|
||
$expiry = time() + 30 * 24 * 60 * 60;
|
||
$all = [];
|
||
|
||
if (file_exists($tokFile)) {
|
||
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
||
$all = json_decode($dec, true) ?: [];
|
||
}
|
||
|
||
$all[$token] = [
|
||
'username' => $username,
|
||
'expiry' => $expiry,
|
||
'isAdmin' => $_SESSION['isAdmin']
|
||
];
|
||
|
||
file_put_contents(
|
||
$tokFile,
|
||
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
|
||
LOCK_EX
|
||
);
|
||
|
||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||
|
||
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
||
|
||
setcookie(
|
||
session_name(),
|
||
session_id(),
|
||
$expiry,
|
||
'/',
|
||
'',
|
||
$secure,
|
||
true
|
||
);
|
||
|
||
session_regenerate_id(true);
|
||
}
|
||
|
||
echo json_encode([
|
||
'status' => 'ok',
|
||
'success' => 'Login successful',
|
||
'isAdmin' => $_SESSION['isAdmin'],
|
||
'folderOnly' => $_SESSION['folderOnly'],
|
||
'readOnly' => $_SESSION['readOnly'],
|
||
'disableUpload' => $_SESSION['disableUpload'],
|
||
'username' => $username
|
||
]);
|
||
exit();
|
||
}
|
||
|
||
/**
|
||
* A version of finalizeLogin() that ends in a browser redirect
|
||
* (used for OIDC non‑AJAX flows).
|
||
*/
|
||
protected function finishBrowserLogin(string $username): void
|
||
{
|
||
session_regenerate_id(true);
|
||
$_SESSION['authenticated'] = true;
|
||
$_SESSION['username'] = $username;
|
||
$_SESSION['isAdmin'] = (AuthModel::getUserRole($username) === '1');
|
||
|
||
$perms = loadUserPermissions($username);
|
||
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
||
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
||
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
||
|
||
header('Location: /index.html');
|
||
exit();
|
||
}
|
||
|
||
public function checkAuth(): void
|
||
{
|
||
|
||
// 1) Remember-me re-login
|
||
if (empty($_SESSION['authenticated']) && !empty($_COOKIE['remember_me_token'])) {
|
||
$payload = AuthModel::validateRememberToken($_COOKIE['remember_me_token']);
|
||
if ($payload) {
|
||
$old = $_SESSION['csrf_token'] ?? bin2hex(random_bytes(32));
|
||
session_regenerate_id(true);
|
||
$_SESSION['csrf_token'] = $old;
|
||
$_SESSION['authenticated'] = true;
|
||
$_SESSION['username'] = $payload['username'];
|
||
$_SESSION['isAdmin'] = !empty($payload['isAdmin']);
|
||
$_SESSION['folderOnly'] = $payload['folderOnly'] ?? false;
|
||
$_SESSION['readOnly'] = $payload['readOnly'] ?? false;
|
||
$_SESSION['disableUpload'] = $payload['disableUpload'] ?? false;
|
||
// regenerate CSRF if you use one
|
||
|
||
|
||
// TOTP enabled? (same logic as below)
|
||
$usersFile = USERS_DIR . USERS_FILE;
|
||
$totp = false;
|
||
if (file_exists($usersFile)) {
|
||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||
$parts = explode(':', trim($line));
|
||
if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) {
|
||
$totp = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
echo json_encode([
|
||
'authenticated' => true,
|
||
'csrf_token' => $_SESSION['csrf_token'],
|
||
'isAdmin' => $_SESSION['isAdmin'],
|
||
'totp_enabled' => $totp,
|
||
'username' => $_SESSION['username'],
|
||
'folderOnly' => $_SESSION['folderOnly'],
|
||
'readOnly' => $_SESSION['readOnly'],
|
||
'disableUpload' => $_SESSION['disableUpload']
|
||
]);
|
||
exit();
|
||
}
|
||
}
|
||
|
||
$usersFile = USERS_DIR . USERS_FILE;
|
||
|
||
// 2) Setup mode?
|
||
if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') {
|
||
error_log("checkAuth: setup mode");
|
||
echo json_encode(['setup' => true]);
|
||
exit();
|
||
}
|
||
|
||
// 3) Session-based auth
|
||
if (empty($_SESSION['authenticated'])) {
|
||
echo json_encode(['authenticated' => false]);
|
||
exit();
|
||
}
|
||
|
||
// 4) TOTP enabled?
|
||
$totp = false;
|
||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||
$parts = explode(':', trim($line));
|
||
if ($parts[0] === ($_SESSION['username'] ?? '') && !empty($parts[3])) {
|
||
$totp = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 5) Final response
|
||
$resp = [
|
||
'authenticated' => true,
|
||
'isAdmin' => !empty($_SESSION['isAdmin']),
|
||
'totp_enabled' => $totp,
|
||
'username' => $_SESSION['username'],
|
||
'folderOnly' => $_SESSION['folderOnly'] ?? false,
|
||
'readOnly' => $_SESSION['readOnly'] ?? false,
|
||
'disableUpload' => $_SESSION['disableUpload'] ?? false
|
||
];
|
||
|
||
echo json_encode($resp);
|
||
exit();
|
||
}
|
||
|
||
public function getToken(): void
|
||
{
|
||
// 1) Ensure session and CSRF token exist
|
||
if (empty($_SESSION['csrf_token'])) {
|
||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||
}
|
||
|
||
// 2) Emit headers
|
||
header('Content-Type: application/json');
|
||
header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
|
||
|
||
// 3) Return JSON payload
|
||
echo json_encode([
|
||
'csrf_token' => $_SESSION['csrf_token'],
|
||
'share_url' => SHARE_URL
|
||
]);
|
||
exit;
|
||
}
|
||
|
||
public function loginBasic(): void
|
||
{
|
||
// Set header for plain-text or JSON as needed.
|
||
header('Content-Type: application/json');
|
||
|
||
// Check for HTTP Basic auth credentials.
|
||
if (!isset($_SERVER['PHP_AUTH_USER'])) {
|
||
header('WWW-Authenticate: Basic realm="FileRise Login"');
|
||
header('HTTP/1.0 401 Unauthorized');
|
||
echo 'Authorization Required';
|
||
exit;
|
||
}
|
||
|
||
$username = trim($_SERVER['PHP_AUTH_USER']);
|
||
$password = trim($_SERVER['PHP_AUTH_PW']);
|
||
|
||
// Validate username format.
|
||
if (!preg_match(REGEX_USER, $username)) {
|
||
header('WWW-Authenticate: Basic realm="FileRise Login"');
|
||
header('HTTP/1.0 401 Unauthorized');
|
||
echo 'Invalid username format';
|
||
exit;
|
||
}
|
||
|
||
// Attempt authentication.
|
||
$role = AuthModel::authenticate($username, $password);
|
||
if ($role !== false) {
|
||
// Check for TOTP secret.
|
||
$secret = AuthModel::getUserTOTPSecret($username);
|
||
if ($secret) {
|
||
// If TOTP is required, store pending values and redirect to prompt for TOTP.
|
||
$_SESSION['pending_login_user'] = $username;
|
||
$_SESSION['pending_login_secret'] = $secret;
|
||
header("Location: /index.html?totp_required=1");
|
||
exit;
|
||
}
|
||
// Finalize login.
|
||
session_regenerate_id(true);
|
||
$_SESSION["authenticated"] = true;
|
||
$_SESSION["username"] = $username;
|
||
$_SESSION["isAdmin"] = (AuthModel::getUserRole($username) === "1");
|
||
// load _all_ the permissions
|
||
$userPerms = loadUserPermissions($username);
|
||
$_SESSION["folderOnly"] = $userPerms["folderOnly"] ?? false;
|
||
$_SESSION["readOnly"] = $userPerms["readOnly"] ?? false;
|
||
$_SESSION["disableUpload"] = $userPerms["disableUpload"] ?? false;
|
||
|
||
header("Location: /index.html");
|
||
exit;
|
||
}
|
||
// Invalid credentials; prompt again.
|
||
header('WWW-Authenticate: Basic realm="FileRise Login"');
|
||
header('HTTP/1.0 401 Unauthorized');
|
||
echo 'Invalid credentials';
|
||
exit;
|
||
}
|
||
|
||
public function logout(): void
|
||
{
|
||
// Retrieve headers and check CSRF token.
|
||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
||
|
||
// Log mismatch but do not prevent logout.
|
||
if (isset($_SESSION['csrf_token']) && $receivedToken !== $_SESSION['csrf_token']) {
|
||
error_log("CSRF token mismatch on logout. Proceeding with logout.");
|
||
}
|
||
|
||
// Remove the "remember_me_token" from persistent tokens.
|
||
if (isset($_COOKIE['remember_me_token'])) {
|
||
$token = $_COOKIE['remember_me_token'];
|
||
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
|
||
if (file_exists($persistentTokensFile)) {
|
||
$encryptedContent = file_get_contents($persistentTokensFile);
|
||
$decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']);
|
||
$persistentTokens = json_decode($decryptedContent, true);
|
||
if (is_array($persistentTokens) && isset($persistentTokens[$token])) {
|
||
unset($persistentTokens[$token]);
|
||
$newEncryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']);
|
||
file_put_contents($persistentTokensFile, $newEncryptedContent, LOCK_EX);
|
||
}
|
||
}
|
||
// Clear the cookie.
|
||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
|
||
}
|
||
|
||
// Clear session data.
|
||
$_SESSION = [];
|
||
|
||
// Clear the session cookie.
|
||
if (ini_get("session.use_cookies")) {
|
||
$params = session_get_cookie_params();
|
||
setcookie(
|
||
session_name(),
|
||
'',
|
||
time() - 42000,
|
||
$params["path"],
|
||
$params["domain"],
|
||
$params["secure"],
|
||
$params["httponly"]
|
||
);
|
||
}
|
||
|
||
// Destroy the session.
|
||
session_destroy();
|
||
|
||
// Redirect the user to the login page (or index) with a logout flag.
|
||
header("Location: /index.html?logout=1");
|
||
exit;
|
||
}
|
||
}
|