1105 lines
40 KiB
PHP
1105 lines
40 KiB
PHP
<?php
|
|
// UserController.php located in src/controllers/
|
|
|
|
require_once __DIR__ . '/../../config/config.php';
|
|
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
|
|
|
class UserController
|
|
{
|
|
/**
|
|
* @OA\Get(
|
|
* path="/api/getUsers.php",
|
|
* summary="Retrieve a list of users",
|
|
* description="Returns a JSON array of users. Only available to authenticated admin users.",
|
|
* operationId="getUsers",
|
|
* tags={"Users"},
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="Successful response with an array of users",
|
|
* @OA\JsonContent(
|
|
* type="array",
|
|
* @OA\Items(
|
|
* type="object",
|
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
|
* @OA\Property(property="role", type="string", example="admin")
|
|
* )
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=401,
|
|
* description="Unauthorized: the user is not authenticated or is not an admin"
|
|
* )
|
|
* )
|
|
*/
|
|
|
|
public function getUsers()
|
|
{
|
|
header('Content-Type: application/json');
|
|
|
|
// Check authentication and admin privileges.
|
|
if (
|
|
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
|
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
|
|
) {
|
|
http_response_code(401);
|
|
echo json_encode(["error" => "Unauthorized"]);
|
|
exit;
|
|
}
|
|
|
|
// Retrieve users using the model
|
|
$users = userModel::getAllUsers();
|
|
echo json_encode($users);
|
|
}
|
|
|
|
/**
|
|
* @OA\Post(
|
|
* path="/api/addUser.php",
|
|
* summary="Add a new user",
|
|
* description="Adds a new user to the system. In setup mode, the new user is automatically made admin.",
|
|
* operationId="addUser",
|
|
* tags={"Users"},
|
|
* @OA\RequestBody(
|
|
* required=true,
|
|
* @OA\JsonContent(
|
|
* required={"username", "password"},
|
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
|
* @OA\Property(property="password", type="string", example="securepassword"),
|
|
* @OA\Property(property="isAdmin", type="boolean", example=true)
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="User added successfully",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(property="success", type="string", example="User added successfully")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=400,
|
|
* description="Bad Request"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=401,
|
|
* description="Unauthorized"
|
|
* )
|
|
* )
|
|
*/
|
|
|
|
public function addUser()
|
|
{
|
|
// 1) Ensure JSON output and session
|
|
header('Content-Type: application/json');
|
|
|
|
// 1a) Initialize CSRF token if missing
|
|
if (empty($_SESSION['csrf_token'])) {
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
}
|
|
|
|
// 2) Determine setup mode (first-ever admin creation)
|
|
$usersFile = USERS_DIR . USERS_FILE;
|
|
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
|
|
$setupMode = false;
|
|
if (
|
|
$isSetup && (! file_exists($usersFile)
|
|
|| filesize($usersFile) === 0
|
|
|| trim(file_get_contents($usersFile)) === ''
|
|
)
|
|
) {
|
|
$setupMode = true;
|
|
} else {
|
|
// 3) In non-setup, enforce CSRF + auth checks
|
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
$receivedToken = trim($headersArr['x-csrf-token'] ?? '');
|
|
|
|
// 3a) Soft-fail CSRF: on mismatch, regenerate and return new token
|
|
if ($receivedToken !== $_SESSION['csrf_token']) {
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
|
|
echo json_encode([
|
|
'csrf_expired' => true,
|
|
'csrf_token' => $_SESSION['csrf_token']
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// 3b) Must be logged in as admin
|
|
if (
|
|
empty($_SESSION['authenticated'])
|
|
|| $_SESSION['authenticated'] !== true
|
|
|| empty($_SESSION['isAdmin'])
|
|
|| $_SESSION['isAdmin'] !== true
|
|
) {
|
|
echo json_encode(["error" => "Unauthorized"]);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// 4) Parse input
|
|
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$newUsername = trim($data['username'] ?? '');
|
|
$newPassword = trim($data['password'] ?? '');
|
|
|
|
// 5) Determine admin flag
|
|
if ($setupMode) {
|
|
$isAdmin = '1';
|
|
} else {
|
|
$isAdmin = !empty($data['isAdmin']) ? '1' : '0';
|
|
}
|
|
|
|
// 6) Validate fields
|
|
if ($newUsername === '' || $newPassword === '') {
|
|
echo json_encode(["error" => "Username and password required"]);
|
|
exit;
|
|
}
|
|
if (!preg_match(REGEX_USER, $newUsername)) {
|
|
echo json_encode([
|
|
"error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed."
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// 7) Delegate to model
|
|
$result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
|
|
|
|
// 8) Return model result
|
|
echo json_encode($result);
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* @OA\Delete(
|
|
* path="/api/removeUser.php",
|
|
* summary="Remove a user",
|
|
* description="Removes the specified user from the system. Cannot remove the currently logged-in user.",
|
|
* operationId="removeUser",
|
|
* tags={"Users"},
|
|
* @OA\RequestBody(
|
|
* required=true,
|
|
* @OA\JsonContent(
|
|
* required={"username"},
|
|
* @OA\Property(property="username", type="string", example="johndoe")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="User removed successfully",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(property="success", type="string", example="User removed successfully")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=400,
|
|
* description="Bad Request"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=401,
|
|
* description="Unauthorized"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=403,
|
|
* description="Invalid CSRF token"
|
|
* )
|
|
* )
|
|
*/
|
|
|
|
public function removeUser()
|
|
{
|
|
header('Content-Type: application/json');
|
|
|
|
// CSRF token check.
|
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
|
http_response_code(403);
|
|
echo json_encode(["error" => "Invalid CSRF token"]);
|
|
exit;
|
|
}
|
|
|
|
// Authentication and admin check.
|
|
if (
|
|
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
|
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
|
|
) {
|
|
http_response_code(401);
|
|
echo json_encode(["error" => "Unauthorized"]);
|
|
exit;
|
|
}
|
|
|
|
// Retrieve JSON data.
|
|
$data = json_decode(file_get_contents("php://input"), true);
|
|
$usernameToRemove = trim($data["username"] ?? "");
|
|
|
|
if (!$usernameToRemove) {
|
|
echo json_encode(["error" => "Username is required"]);
|
|
exit;
|
|
}
|
|
|
|
// Validate the username format.
|
|
if (!preg_match(REGEX_USER, $usernameToRemove)) {
|
|
echo json_encode(["error" => "Invalid username format"]);
|
|
exit;
|
|
}
|
|
|
|
// Prevent removal of the currently logged-in user.
|
|
if (isset($_SESSION['username']) && $_SESSION['username'] === $usernameToRemove) {
|
|
echo json_encode(["error" => "Cannot remove yourself"]);
|
|
exit;
|
|
}
|
|
|
|
// Delegate the removal logic to the model.
|
|
$result = userModel::removeUser($usernameToRemove);
|
|
echo json_encode($result);
|
|
}
|
|
|
|
/**
|
|
* @OA\Get(
|
|
* path="/api/getUserPermissions.php",
|
|
* summary="Retrieve user permissions",
|
|
* description="Returns the permissions for the current user, or all permissions if the user is an admin.",
|
|
* operationId="getUserPermissions",
|
|
* tags={"Users"},
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="Successful response with user permissions",
|
|
* @OA\JsonContent(type="object")
|
|
* ),
|
|
* @OA\Response(
|
|
* response=401,
|
|
* description="Unauthorized"
|
|
* )
|
|
* )
|
|
*/
|
|
|
|
public function getUserPermissions()
|
|
{
|
|
header('Content-Type: application/json');
|
|
|
|
// Check if the user is authenticated.
|
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
|
http_response_code(401);
|
|
echo json_encode(["error" => "Unauthorized"]);
|
|
exit;
|
|
}
|
|
|
|
// Delegate to the model.
|
|
$permissions = userModel::getUserPermissions();
|
|
echo json_encode($permissions);
|
|
}
|
|
|
|
/**
|
|
* @OA\Put(
|
|
* path="/api/updateUserPermissions.php",
|
|
* summary="Update user permissions",
|
|
* description="Updates permissions for users. Only available to authenticated admin users.",
|
|
* operationId="updateUserPermissions",
|
|
* tags={"Users"},
|
|
* @OA\RequestBody(
|
|
* required=true,
|
|
* @OA\JsonContent(
|
|
* required={"permissions"},
|
|
* @OA\Property(
|
|
* property="permissions",
|
|
* type="array",
|
|
* @OA\Items(
|
|
* type="object",
|
|
* @OA\Property(property="username", type="string", example="johndoe"),
|
|
* @OA\Property(property="folderOnly", type="boolean", example=true),
|
|
* @OA\Property(property="readOnly", type="boolean", example=false),
|
|
* @OA\Property(property="disableUpload", type="boolean", example=false)
|
|
* )
|
|
* )
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="User permissions updated successfully",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(property="success", type="string", example="User permissions updated successfully.")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=401,
|
|
* description="Unauthorized"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=403,
|
|
* description="Invalid CSRF token"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=400,
|
|
* description="Bad Request"
|
|
* )
|
|
* )
|
|
*/
|
|
|
|
public function updateUserPermissions()
|
|
{
|
|
header('Content-Type: application/json');
|
|
|
|
// Only admins can update permissions.
|
|
if (
|
|
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
|
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true
|
|
) {
|
|
http_response_code(401);
|
|
echo json_encode(["error" => "Unauthorized"]);
|
|
exit;
|
|
}
|
|
|
|
// Verify CSRF token from headers.
|
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
$csrfToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
if (!isset($_SESSION['csrf_token']) || $csrfToken !== $_SESSION['csrf_token']) {
|
|
http_response_code(403);
|
|
echo json_encode(["error" => "Invalid CSRF token"]);
|
|
exit;
|
|
}
|
|
|
|
// Get POST input.
|
|
$input = json_decode(file_get_contents("php://input"), true);
|
|
if (!isset($input['permissions']) || !is_array($input['permissions'])) {
|
|
echo json_encode(["error" => "Invalid input"]);
|
|
exit;
|
|
}
|
|
|
|
$permissions = $input['permissions'];
|
|
|
|
// Delegate to the model.
|
|
$result = userModel::updateUserPermissions($permissions);
|
|
echo json_encode($result);
|
|
}
|
|
|
|
/**
|
|
* @OA\Post(
|
|
* path="/api/changePassword.php",
|
|
* summary="Change user password",
|
|
* description="Allows an authenticated user to change their password by verifying the old password and updating to a new one.",
|
|
* operationId="changePassword",
|
|
* tags={"Users"},
|
|
* @OA\RequestBody(
|
|
* required=true,
|
|
* @OA\JsonContent(
|
|
* required={"oldPassword", "newPassword", "confirmPassword"},
|
|
* @OA\Property(property="oldPassword", type="string", example="oldpass123"),
|
|
* @OA\Property(property="newPassword", type="string", example="newpass456"),
|
|
* @OA\Property(property="confirmPassword", type="string", example="newpass456")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="Password updated successfully",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(property="success", type="string", example="Password updated successfully.")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=400,
|
|
* description="Bad Request"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=401,
|
|
* description="Unauthorized"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=403,
|
|
* description="Invalid CSRF token"
|
|
* )
|
|
* )
|
|
*/
|
|
|
|
public function changePassword()
|
|
{
|
|
header('Content-Type: application/json');
|
|
|
|
// Ensure user is authenticated.
|
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
|
http_response_code(401);
|
|
echo json_encode(["error" => "Unauthorized"]);
|
|
exit;
|
|
}
|
|
|
|
$username = $_SESSION['username'] ?? '';
|
|
if (!$username) {
|
|
echo json_encode(["error" => "No username in session"]);
|
|
exit;
|
|
}
|
|
|
|
// CSRF token check.
|
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
if ($receivedToken !== $_SESSION['csrf_token']) {
|
|
http_response_code(403);
|
|
echo json_encode(["error" => "Invalid CSRF token"]);
|
|
exit;
|
|
}
|
|
|
|
// Get POST data.
|
|
$data = json_decode(file_get_contents("php://input"), true);
|
|
$oldPassword = trim($data["oldPassword"] ?? "");
|
|
$newPassword = trim($data["newPassword"] ?? "");
|
|
$confirmPassword = trim($data["confirmPassword"] ?? "");
|
|
|
|
// Validate input.
|
|
if (!$oldPassword || !$newPassword || !$confirmPassword) {
|
|
echo json_encode(["error" => "All fields are required."]);
|
|
exit;
|
|
}
|
|
if ($newPassword !== $confirmPassword) {
|
|
echo json_encode(["error" => "New passwords do not match."]);
|
|
exit;
|
|
}
|
|
|
|
// Delegate password change logic to the model.
|
|
$result = userModel::changePassword($username, $oldPassword, $newPassword);
|
|
echo json_encode($result);
|
|
}
|
|
|
|
/**
|
|
* @OA\Put(
|
|
* path="/api/updateUserPanel.php",
|
|
* summary="Update user panel settings",
|
|
* description="Updates user panel settings by disabling TOTP when not enabled. Accessible to authenticated users.",
|
|
* operationId="updateUserPanel",
|
|
* tags={"Users"},
|
|
* @OA\RequestBody(
|
|
* required=true,
|
|
* @OA\JsonContent(
|
|
* required={"totp_enabled"},
|
|
* @OA\Property(property="totp_enabled", type="boolean", example=false)
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="User panel updated successfully",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(property="success", type="string", example="User panel updated: TOTP disabled")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=401,
|
|
* description="Unauthorized"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=403,
|
|
* description="Invalid CSRF token"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=400,
|
|
* description="Bad Request"
|
|
* )
|
|
* )
|
|
*/
|
|
|
|
public function updateUserPanel()
|
|
{
|
|
header('Content-Type: application/json');
|
|
|
|
// Check if the user is authenticated.
|
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
|
http_response_code(403);
|
|
echo json_encode(["error" => "Unauthorized"]);
|
|
exit;
|
|
}
|
|
|
|
// Verify the CSRF token.
|
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
$csrfToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
if (!isset($_SESSION['csrf_token']) || $csrfToken !== $_SESSION['csrf_token']) {
|
|
http_response_code(403);
|
|
echo json_encode(["error" => "Invalid CSRF token"]);
|
|
exit;
|
|
}
|
|
|
|
// Get the POST input.
|
|
$data = json_decode(file_get_contents("php://input"), true);
|
|
if (!is_array($data)) {
|
|
http_response_code(400);
|
|
echo json_encode(["error" => "Invalid input"]);
|
|
exit;
|
|
}
|
|
|
|
$username = $_SESSION['username'] ?? '';
|
|
if (!$username) {
|
|
http_response_code(400);
|
|
echo json_encode(["error" => "No username in session"]);
|
|
exit;
|
|
}
|
|
|
|
// Extract totp_enabled, converting it to boolean.
|
|
$totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false;
|
|
|
|
// Delegate to the model.
|
|
$result = userModel::updateUserPanel($username, $totp_enabled);
|
|
echo json_encode($result);
|
|
}
|
|
|
|
/**
|
|
* @OA\Put(
|
|
* path="/api/totp_disable.php",
|
|
* summary="Disable TOTP for the authenticated user",
|
|
* description="Clears the TOTP secret from the users file for the current user.",
|
|
* operationId="disableTOTP",
|
|
* tags={"TOTP"},
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="TOTP disabled successfully",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(property="success", type="boolean", example=true),
|
|
* @OA\Property(property="message", type="string", example="TOTP disabled successfully.")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=403,
|
|
* description="Not authenticated or invalid CSRF token"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=500,
|
|
* description="Failed to disable TOTP"
|
|
* )
|
|
* )
|
|
*/
|
|
|
|
public function disableTOTP()
|
|
{
|
|
header('Content-Type: application/json');
|
|
|
|
// Authentication check.
|
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
|
http_response_code(403);
|
|
echo json_encode(["error" => "Not authenticated"]);
|
|
exit;
|
|
}
|
|
|
|
$username = $_SESSION['username'] ?? '';
|
|
if (empty($username)) {
|
|
http_response_code(400);
|
|
echo json_encode(["error" => "Username not found in session"]);
|
|
exit;
|
|
}
|
|
|
|
// CSRF token check.
|
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
$csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
|
http_response_code(403);
|
|
echo json_encode(["error" => "Invalid CSRF token"]);
|
|
exit;
|
|
}
|
|
|
|
// Delegate the TOTP disabling logic to the model.
|
|
$result = userModel::disableTOTPSecret($username);
|
|
|
|
if ($result) {
|
|
echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]);
|
|
} else {
|
|
http_response_code(500);
|
|
echo json_encode(["error" => "Failed to disable TOTP."]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @OA\Post(
|
|
* path="/api/totp_recover.php",
|
|
* summary="Recover TOTP",
|
|
* description="Verifies a recovery code to disable TOTP and finalize login.",
|
|
* operationId="recoverTOTP",
|
|
* tags={"TOTP"},
|
|
* @OA\RequestBody(
|
|
* required=true,
|
|
* @OA\JsonContent(
|
|
* required={"recovery_code"},
|
|
* @OA\Property(property="recovery_code", type="string", example="ABC123DEF456")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="Recovery successful",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(property="status", type="string", example="ok")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=400,
|
|
* description="Invalid input or recovery code"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=403,
|
|
* description="Invalid CSRF token"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=405,
|
|
* description="Method not allowed"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=429,
|
|
* description="Too many attempts"
|
|
* )
|
|
* )
|
|
*/
|
|
|
|
public function recoverTOTP()
|
|
{
|
|
header('Content-Type: application/json');
|
|
|
|
// 1) Only allow POST.
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
http_response_code(405);
|
|
exit(json_encode(['status' => 'error', 'message' => 'Method not allowed']));
|
|
}
|
|
|
|
// 2) CSRF check.
|
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
$csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
|
http_response_code(403);
|
|
exit(json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']));
|
|
}
|
|
|
|
// 3) Identify the user.
|
|
$userId = $_SESSION['username'] ?? $_SESSION['pending_login_user'] ?? null;
|
|
if (!$userId) {
|
|
http_response_code(401);
|
|
exit(json_encode(['status' => 'error', 'message' => 'Unauthorized']));
|
|
}
|
|
|
|
// 4) Validate userId format.
|
|
if (!preg_match(REGEX_USER, $userId)) {
|
|
http_response_code(400);
|
|
exit(json_encode(['status' => 'error', 'message' => 'Invalid user identifier']));
|
|
}
|
|
|
|
// 5) Get the recovery code from input.
|
|
$inputData = json_decode(file_get_contents("php://input"), true);
|
|
$recoveryCode = $inputData['recovery_code'] ?? '';
|
|
|
|
// 6) Delegate to the model.
|
|
$result = userModel::recoverTOTP($userId, $recoveryCode);
|
|
|
|
if ($result['status'] === 'ok') {
|
|
// 7) Finalize login.
|
|
session_regenerate_id(true);
|
|
$_SESSION['authenticated'] = true;
|
|
$_SESSION['username'] = $userId;
|
|
unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']);
|
|
echo json_encode(['status' => 'ok']);
|
|
} else {
|
|
// Set appropriate HTTP code for errors.
|
|
if ($result['message'] === 'Too many attempts. Try again later.') {
|
|
http_response_code(429);
|
|
} else {
|
|
http_response_code(400);
|
|
}
|
|
echo json_encode($result);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @OA\Post(
|
|
* path="/api/totp_saveCode.php",
|
|
* summary="Generate and save a new TOTP recovery code",
|
|
* description="Generates a new TOTP recovery code for the authenticated user, stores its hash, and returns the plain text recovery code.",
|
|
* operationId="totpSaveCode",
|
|
* tags={"TOTP"},
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="Recovery code generated successfully",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(property="status", type="string", example="ok"),
|
|
* @OA\Property(property="recoveryCode", type="string", example="ABC123DEF456")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=400,
|
|
* description="Bad Request"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=403,
|
|
* description="Invalid CSRF token or unauthorized"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=405,
|
|
* description="Method not allowed"
|
|
* )
|
|
* )
|
|
*/
|
|
|
|
public function saveTOTPRecoveryCode()
|
|
{
|
|
header('Content-Type: application/json');
|
|
|
|
// 1) Only allow POST requests.
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
http_response_code(405);
|
|
error_log("totp_saveCode: invalid method {$_SERVER['REQUEST_METHOD']}");
|
|
exit(json_encode(['status' => 'error', 'message' => 'Method not allowed']));
|
|
}
|
|
|
|
// 2) CSRF token check.
|
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
$csrfHeader = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
|
http_response_code(403);
|
|
exit(json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']));
|
|
}
|
|
|
|
// 3) Ensure the user is authenticated.
|
|
if (empty($_SESSION['username'])) {
|
|
http_response_code(401);
|
|
error_log("totp_saveCode: unauthorized attempt from IP {$_SERVER['REMOTE_ADDR']}");
|
|
exit(json_encode(['status' => 'error', 'message' => 'Unauthorized']));
|
|
}
|
|
|
|
// 4) Validate the username format.
|
|
$userId = $_SESSION['username'];
|
|
if (!preg_match(REGEX_USER, $userId)) {
|
|
http_response_code(400);
|
|
error_log("totp_saveCode: invalid username format: {$userId}");
|
|
exit(json_encode(['status' => 'error', 'message' => 'Invalid user identifier']));
|
|
}
|
|
|
|
// 5) Delegate to the model.
|
|
$result = userModel::saveTOTPRecoveryCode($userId);
|
|
if ($result['status'] === 'ok') {
|
|
echo json_encode($result);
|
|
} else {
|
|
http_response_code(500);
|
|
echo json_encode($result);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @OA\Get(
|
|
* path="/api/totp_setup.php",
|
|
* summary="Set up TOTP and generate a QR code",
|
|
* description="Generates (or retrieves) the TOTP secret for the user and builds a QR code image for scanning.",
|
|
* operationId="setupTOTP",
|
|
* tags={"TOTP"},
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="QR code image for TOTP setup",
|
|
* @OA\MediaType(
|
|
* mediaType="image/png"
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=403,
|
|
* description="Unauthorized or invalid CSRF token"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=500,
|
|
* description="Server error"
|
|
* )
|
|
* )
|
|
*/
|
|
|
|
public function setupTOTP()
|
|
{
|
|
// Allow access if the user is authenticated or pending TOTP.
|
|
if (!((isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']))) {
|
|
http_response_code(403);
|
|
exit(json_encode(["error" => "Not authorized to access TOTP setup"]));
|
|
}
|
|
|
|
// Verify CSRF token from headers.
|
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
|
|
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
|
|
http_response_code(403);
|
|
echo json_encode(["error" => "Invalid CSRF token"]);
|
|
exit;
|
|
}
|
|
|
|
$username = $_SESSION['username'] ?? '';
|
|
if (!$username) {
|
|
http_response_code(400);
|
|
exit;
|
|
}
|
|
|
|
// Set header for PNG output.
|
|
header("Content-Type: image/png");
|
|
|
|
// Delegate the TOTP setup work to the model.
|
|
$result = userModel::setupTOTP($username);
|
|
if (isset($result['error'])) {
|
|
http_response_code(500);
|
|
echo json_encode(["error" => $result['error']]);
|
|
exit;
|
|
}
|
|
|
|
// Output the QR code image.
|
|
echo $result['imageData'];
|
|
}
|
|
|
|
/**
|
|
* @OA\Post(
|
|
* path="/api/totp_verify.php",
|
|
* summary="Verify TOTP code",
|
|
* description="Verifies a TOTP code and completes login for pending users or validates TOTP for setup verification.",
|
|
* operationId="verifyTOTP",
|
|
* tags={"TOTP"},
|
|
* @OA\RequestBody(
|
|
* required=true,
|
|
* @OA\JsonContent(
|
|
* required={"totp_code"},
|
|
* @OA\Property(property="totp_code", type="string", example="123456")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="TOTP successfully verified",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(property="status", type="string", example="ok"),
|
|
* @OA\Property(property="message", type="string", example="Login successful")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=400,
|
|
* description="Bad Request (e.g., invalid input)"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=403,
|
|
* description="Not authenticated or invalid CSRF token"
|
|
* ),
|
|
* @OA\Response(
|
|
* response=429,
|
|
* description="Too many attempts. Try again later."
|
|
* )
|
|
* )
|
|
*/
|
|
|
|
public function verifyTOTP()
|
|
{
|
|
header('Content-Type: application/json');
|
|
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
|
|
|
// Rate-limit
|
|
if (!isset($_SESSION['totp_failures'])) {
|
|
$_SESSION['totp_failures'] = 0;
|
|
}
|
|
if ($_SESSION['totp_failures'] >= 5) {
|
|
http_response_code(429);
|
|
echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
|
|
exit;
|
|
}
|
|
|
|
// Must be authenticated OR pending login
|
|
if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) {
|
|
http_response_code(403);
|
|
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
|
|
exit;
|
|
}
|
|
|
|
// CSRF check
|
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
|
$csrfHeader = $headersArr['x-csrf-token'] ?? '';
|
|
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
|
http_response_code(403);
|
|
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
|
|
exit;
|
|
}
|
|
|
|
// Parse & validate input
|
|
$inputData = json_decode(file_get_contents("php://input"), true);
|
|
$code = trim($inputData['totp_code'] ?? '');
|
|
if (!preg_match('/^\d{6}$/', $code)) {
|
|
http_response_code(400);
|
|
echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
|
|
exit;
|
|
}
|
|
|
|
// TFA helper
|
|
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
|
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
|
|
'FileRise',
|
|
6,
|
|
30,
|
|
\RobThree\Auth\Algorithm::Sha1
|
|
);
|
|
|
|
// === Pending-login flow (we just came from auth and need to finish login) ===
|
|
if (isset($_SESSION['pending_login_user'])) {
|
|
$username = $_SESSION['pending_login_user'];
|
|
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
|
|
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
|
|
|
|
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
|
|
$_SESSION['totp_failures']++;
|
|
http_response_code(400);
|
|
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
|
|
exit;
|
|
}
|
|
|
|
// Issue “remember me” token if requested
|
|
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' => ((int)userModel::getUserRole($username) === 1),
|
|
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false,
|
|
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false,
|
|
'disableUpload' => loadUserPermissions($username)['disableUpload'] ?? false
|
|
];
|
|
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);
|
|
}
|
|
|
|
// === Finalize login into session exactly as finalizeLogin() would ===
|
|
session_regenerate_id(true);
|
|
$_SESSION['authenticated'] = true;
|
|
$_SESSION['username'] = $username;
|
|
$_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1);
|
|
$perms = loadUserPermissions($username);
|
|
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
|
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
|
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
|
|
|
// Clean up pending markers
|
|
unset(
|
|
$_SESSION['pending_login_user'],
|
|
$_SESSION['pending_login_secret'],
|
|
$_SESSION['pending_login_remember_me'],
|
|
$_SESSION['totp_failures']
|
|
);
|
|
|
|
// Send back full login payload
|
|
echo json_encode([
|
|
'status' => 'ok',
|
|
'success' => 'Login successful',
|
|
'isAdmin' => $_SESSION['isAdmin'],
|
|
'folderOnly' => $_SESSION['folderOnly'],
|
|
'readOnly' => $_SESSION['readOnly'],
|
|
'disableUpload' => $_SESSION['disableUpload'],
|
|
'username' => $_SESSION['username']
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Setup/verification flow (not pending)
|
|
$username = $_SESSION['username'] ?? '';
|
|
if (!$username) {
|
|
http_response_code(400);
|
|
echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
|
|
exit;
|
|
}
|
|
|
|
$totpSecret = userModel::getTOTPSecret($username);
|
|
if (!$totpSecret) {
|
|
http_response_code(500);
|
|
echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
|
|
exit;
|
|
}
|
|
|
|
if (!$tfa->verifyCode($totpSecret, $code)) {
|
|
$_SESSION['totp_failures']++;
|
|
http_response_code(400);
|
|
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
|
|
exit;
|
|
}
|
|
|
|
// Successful setup/verification
|
|
unset($_SESSION['totp_failures']);
|
|
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
|
|
}
|
|
|
|
public function uploadPicture()
|
|
{
|
|
header('Content-Type: application/json');
|
|
|
|
// 1) Auth check
|
|
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
|
http_response_code(401);
|
|
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
|
|
exit;
|
|
}
|
|
|
|
// 2) CSRF check
|
|
$headers = function_exists('getallheaders')
|
|
? array_change_key_case(getallheaders(), CASE_LOWER)
|
|
: [];
|
|
$csrf = $headers['x-csrf-token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
|
if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
|
|
http_response_code(403);
|
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
|
exit;
|
|
}
|
|
|
|
// 3) File presence
|
|
if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
|
|
exit;
|
|
}
|
|
$file = $_FILES['profile_picture'];
|
|
|
|
// 4) Validate MIME & size
|
|
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
|
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
$mime = finfo_file($finfo, $file['tmp_name']);
|
|
finfo_close($finfo);
|
|
if (!isset($allowed[$mime])) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'Invalid file type']);
|
|
exit;
|
|
}
|
|
if ($file['size'] > 2 * 1024 * 1024) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'File too large']);
|
|
exit;
|
|
}
|
|
|
|
// 5) Destination under public/uploads/profile_pics
|
|
$uploadDir = UPLOAD_DIR . '/profile_pics';
|
|
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
|
|
exit;
|
|
}
|
|
|
|
// 6) Move file
|
|
$ext = $allowed[$mime];
|
|
$user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username']);
|
|
$filename = $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
|
$dest = "$uploadDir/$filename";
|
|
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
|
|
exit;
|
|
}
|
|
|
|
// 7) Build public URL
|
|
$url = '/uploads/profile_pics/' . $filename;
|
|
|
|
// ─── THIS IS WHERE WE PERSIST INTO users.txt ───
|
|
$result = UserModel::setProfilePicture($_SESSION['username'], $url);
|
|
if (!$result['success']) {
|
|
// on failure, remove the file we just wrote
|
|
@unlink($dest);
|
|
http_response_code(500);
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Failed to save profile picture setting'
|
|
]);
|
|
exit;
|
|
}
|
|
// ─────────────────────────────────────────────────
|
|
|
|
// 8) Return success
|
|
echo json_encode(['success' => true, 'url' => $url]);
|
|
exit;
|
|
}
|
|
}
|