Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions migrations/040_users_logout_all_devices_at.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Migration: 040_users_logout_all_devices_at.sql
-- Description: Add `logout_all_devices_at` column for F3 "log out all devices" feature.
--
-- When a user triggers "log out all devices", their `logout_all_devices_at`
-- timestamp is set to the current Unix time. Any access token with an iat
-- (issued at) earlier than this timestamp is rejected in validateAccessToken,
-- effectively invalidating all existing sessions and tokens.
--
-- The column is NULL when no global logout has been performed (the normal case).
-- A NULL value means no epoch invalidation is applied.
--
-- Idempotent: re-running ADD COLUMN raises "Duplicate column name" error,
-- which the migration runner downgrades to a note (see MigrationRunner.php).

ALTER TABLE users
ADD COLUMN logout_all_devices_at INT UNSIGNED NULL DEFAULT NULL AFTER status;
27 changes: 27 additions & 0 deletions migrations/041_users_password_reset_fields.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Migration: 041_users_password_reset_fields.sql
-- Description: Add password reset token and must_change_password for S7+F1.
--
-- S7+F1 stops returning plaintext passwords on admin password reset and instead
-- issues a one-time reset token. The token is:
-- - Generated as a secure random string
-- - Stored as a HASH (NOT plaintext) for security
-- - Set to expire after a configurable window (default 15 minutes)
-- - Cleared once used (password is changed)
--
-- The `must_change_password` flag forces the user to set a new password on next
-- login before they can access any content. An admin can also set this flag
-- manually to force a password change.
--
-- Column order: added at the end of the users table.
--
-- Idempotent: re-running ADD COLUMN raises "Duplicate column name" error,
-- which the migration runner downgrades to a note (see MigrationRunner.php).

ALTER TABLE users
ADD COLUMN must_change_password TINYINT(1) NOT NULL DEFAULT 0 AFTER status;

ALTER TABLE users
ADD COLUMN password_reset_token VARCHAR(255) NULL DEFAULT NULL AFTER must_change_password;

ALTER TABLE users
ADD COLUMN password_reset_expires_at INT UNSIGNED NULL DEFAULT NULL AFTER password_reset_token;
26 changes: 18 additions & 8 deletions public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
use Phlix\Server\Http\Middleware\AdminMiddleware;
use Phlix\Server\Http\Middleware\CorsManager;
use Phlix\Server\Http\Request;
use Phlix\Server\Http\RequestAuthenticator;
use Phlix\Server\Http\Response;
use Phlix\Server\Http\Router;
use Phlix\Server\Http\Controllers\BookController;
Expand Down Expand Up @@ -87,16 +88,25 @@
$request = Request::fromGlobals();

/**
* Authenticate request if token provided
* C6/B4: Shared request authentication.
*
* Checks for Bearer token in Authorization header.
* If valid, sets userId on request for downstream handlers.
* C6/B4: Uses the shared RequestAuthenticator to authenticate requests,
* which handles Bearer token AND the phlix_session cookie fallback.
* This ensures the same auth behavior as the Workerman daemon.
*
* S6: After authentication, validates Origin/Referer for cookie-authenticated
* state-changing requests to prevent CSRF attacks.
*/
$token = $request->getBearerToken();
if ($token) {
$auth = $authManager->validateAccessToken($token);
if (is_array($auth) && is_string($auth['user_id'] ?? null)) {
$request->userId = $auth['user_id'];
$authenticator = new RequestAuthenticator($authManager);
$authenticator->authenticate($request);

// S6: CSRF validation for cookie-authenticated state-changing requests.
if ($authenticator->isCookieAuthenticated($request)) {
if (!$authenticator->validateCsrfOrigin($request)) {
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'CSRF validation failed', 'code' => 'csrf.invalid_origin']);
exit;
}
}

Expand Down
20 changes: 20 additions & 0 deletions src/Auth/AuthManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,16 @@ public function login(string $username, string $password, string $deviceId): arr
throw AccountInactiveException::forStatus($status);
}

// S7+F1: if the account has must_change_password set, block normal login
// and require the user to set a new password via the reset-token flow.
if ($this->userRepository->mustChangePassword($userId)) {
$this->auditLogger->logFailedAuth('password_change_required', [
'username' => $username,
'device_id' => $deviceId,
]);
throw new PasswordChangeRequiredException();
}

$this->clearRateLimit($clientIp);

// Update last login
Expand Down Expand Up @@ -694,6 +704,16 @@ public function refreshToken(string $refreshToken): array
throw new \InvalidArgumentException('Account is not active');
}

// S7+F1: if the account has must_change_password set, block token
// refresh and require the user to set a new password via the reset-token flow.
if ($this->userRepository->mustChangePassword($userId)) {
$this->auditLogger->logFailedAuth('password_change_required', [
'user_id' => $userId,
'context' => 'refresh',
]);
throw new \InvalidArgumentException('Password change required');
}

return $this->createAuthResponse($userId);
}

Expand Down
18 changes: 18 additions & 0 deletions src/Auth/Dto/UserRow.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,22 @@ public static function int(?array $row, string $key, int $default = 0): int
$value = $row[$key];
return is_numeric($value) ? (int) $value : $default;
}

/**
* Read an unsigned-int column that may be NULL.
*
* @param array<string, mixed>|null $row
* @return int|null Null when the value is absent, null, or non-numeric.
*/
public static function intOrNull(?array $row, string $key): ?int
{
if ($row === null || !array_key_exists($key, $row)) {
return null;
}
$value = $row[$key];
if ($value === null) {
return null;
}
return is_numeric($value) ? (int) $value : null;
}
}
35 changes: 35 additions & 0 deletions src/Auth/PasswordChangeRequiredException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Phlix\Auth;

use RuntimeException;

/**
* Thrown by {@see AuthManager::login()} and {@see AuthManager::refreshToken()}
* when a user authenticates with correct credentials but has the
* `must_change_password` flag set on their account.
*
* Carries a stable error code so the HTTP boundary can return a distinct
* 403 with `{error: "...", code: "auth.password_change_required"}` that
* the client UI can interpret as "show the password change form".
*
* @package Phlix\Auth
* @since S7+F1
*/
final class PasswordChangeRequiredException extends RuntimeException
{
public const ERROR_CODE = 'auth.password_change_required';

/** @var string Stable error code for the HTTP boundary. */
public string $errorCode;

public function __construct()
{
$this->errorCode = self::ERROR_CODE;
parent::__construct(
'Your password must be changed before you can access the system. Please use the password reset link.'
);
}
}
111 changes: 111 additions & 0 deletions src/Auth/UserRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,117 @@ public function updateProviderData(string $userId, array $data): void
);
}

/**
* Set or clear the must_change_password flag on a user account.
*
* S7+F1: When an admin resets a password, the user is forced to set a new
* password on next login before they can access any content.
*
* @param string $userId Local user UUID.
* @param bool $mustChange Whether the user must change their password.
*
* @return void
*
* @since S7+F1
*/
public function setMustChangePassword(string $userId, bool $mustChange): void
{
$this->db->query(
"UPDATE users SET must_change_password = ? WHERE id = ?",
[$mustChange ? 1 : 0, $userId],
);
}

/**
* Check if a user must change their password before accessing content.
*
* S7+F1: Gates login/refresh to require a password change when the flag is set.
*
* @param string $userId Local user UUID.
*
* @return bool True when must_change_password = 1, false otherwise.
*
* @since S7+F1
*/
public function mustChangePassword(string $userId): bool
{
$result = $this->db->query(
"SELECT must_change_password FROM users WHERE id = ?",
[$userId],
);
$row = UserRow::firstFromMixed($result);
return UserRow::int($row, 'must_change_password', 0) === 1;
}

/**
* Store a one-time password reset token (hashed at rest).
*
* S7+F1: The token is hashed with password_hash() before storage so raw
* tokens never appear in the database. It expires after a configurable
* window (default 15 minutes / 900 seconds).
*
* @param string $userId Local user UUID.
* @param string $hashedToken The hashed reset token (from password_hash).
* @param int $expiresAt Unix timestamp when this token expires.
*
* @return void
*
* @since S7+F1
*/
public function setPasswordResetToken(string $userId, string $hashedToken, int $expiresAt): void
{
$this->db->query(
"UPDATE users SET password_reset_token = ?, password_reset_expires_at = ? WHERE id = ?",
[$hashedToken, $expiresAt, $userId],
);
}

/**
* Get the stored password reset token hash and expiry for a user.
*
* @param string $userId Local user UUID.
*
* @return array{token: string|null, expires_at: int|null}|null The stored
* hashed token and expiry timestamp, or null if none set.
*
* @since S7+F1
*/
public function getPasswordResetData(string $userId): ?array
{
$result = $this->db->query(
"SELECT password_reset_token, password_reset_expires_at FROM users WHERE id = ?",
[$userId],
);
$row = UserRow::firstFromMixed($result);
if ($row === null) {
return null;
}
return [
'token' => UserRow::string($row, 'password_reset_token'),
'expires_at' => UserRow::intOrNull($row, 'password_reset_expires_at'),
];
}

/**
* Clear the stored password reset token and expiry.
*
* Called after a successful password change that was triggered by a
* reset token, or when the token expires.
*
* @param string $userId Local user UUID.
*
* @return void
*
* @since S7+F1
*/
public function clearPasswordResetToken(string $userId): void
{
$this->db->query(
"UPDATE users SET password_reset_token = NULL, password_reset_expires_at = NULL WHERE id = ?",
[$userId],
);
}

/**
* Generate a UUID v4 string.
*
Expand Down
41 changes: 41 additions & 0 deletions src/Media/Library/ScanJobRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,47 @@ public function getHistoryForLibrary(string $libraryId, int $limit = 20): array
return $out;
}

/**
* Return statistics about currently-running scan jobs.
*
* F6: Back the /admin/health/jobs endpoint alongside
* {@see \Phlix\Media\Transcoding\TranscodeManager::getTranscodeJobStats()}.
*
* @return array{running: int, oldest_age_seconds: int|null, oldest_started_at: string|null}
* running: Number of jobs currently in `running` state.
* oldest_age_seconds: Seconds since the oldest running job started, or null if none.
* oldest_started_at: ISO-8601 timestamp of the oldest running job, or null.
*
* @since F6
*/
public function getRunningJobStats(): array
{
$result = $this->db->query(
"SELECT id, started_at FROM library_scan_jobs WHERE status = 'running' ORDER BY started_at ASC"
);

if (!is_array($result) || count($result) === 0) {
return [
'running' => 0,
'oldest_age_seconds' => null,
'oldest_started_at' => null,
];
}

$oldestRow = is_array($result[0]) ? $result[0] : [];
$startedAt = is_string($oldestRow['started_at'] ?? null)
? strtotime((string) $oldestRow['started_at'])
: false;

return [
'running' => count($result),
'oldest_age_seconds' => $startedAt !== false ? time() - $startedAt : null,
'oldest_started_at' => $startedAt !== false
? date('c', $startedAt)
: null,
];
}

/**
* Defensively decode a raw DB row into a typed associative array.
*
Expand Down
Loading
Loading