mirror of
https://github.com/error311/FileRise.git
synced 2026-05-16 08:59:15 -05:00
release(v2.11.2): fix PocketID OIDC token auth + harden login/WebDAV (closes #77)
This commit is contained in:
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 12/24/2025 (v2.11.2)
|
||||
|
||||
`release(v2.11.2): fix PocketID OIDC token auth + harden login/WebDAV (closes #77)`
|
||||
|
||||
**Fixed**
|
||||
|
||||
- **OIDC / PocketID compatibility:** token endpoint auth now defaults to **`client_secret_basic`** when a client secret exists, and **never attempts `client_secret_*`** when the secret is missing/blank (public client mode). *(Closes #77.)*
|
||||
- **WebDAV uploads:** stop buffering entire uploads into memory; uploads now stream to a temp file and then replace the target file.
|
||||
- **WebDAV path safety:** improved uploads path prefix/boundary checks (prevents edge cases like `/uploads` matching `/uploads2`).
|
||||
- **WebDAV metadata:** uploader no longer defaults to `Unknown` when the WebDAV user is not set.
|
||||
|
||||
**Security / Hardening**
|
||||
|
||||
- **Login rate limiting:** rate-limit tracking is now keyed by **IP + username** (instead of only IP) and stale counters are reset after the lockout window.
|
||||
- **Trusted reverse proxy support:** client IP can be derived from a configured header (e.g. `X-Forwarded-For`) when `REMOTE_ADDR` is a trusted proxy.
|
||||
- **Fail2ban-friendly logging:** failed logins are written to `users/fail2ban.log` with basic rotation.
|
||||
|
||||
**UI**
|
||||
|
||||
- Login screen now shows a clearer tip for definitive failures (e.g., “attempts used” and lockout messaging).
|
||||
|
||||
**Configuration**
|
||||
|
||||
- New optional env/config knobs:
|
||||
- `FR_TRUSTED_PROXIES` — comma-separated IPs/CIDRs to treat as trusted proxies
|
||||
- `FR_IP_HEADER` — header to trust for the real client IP (default: `X-Forwarded-For`)
|
||||
- `FR_WEBDAV_MAX_UPLOAD_BYTES` — WebDAV upload size limit in bytes (`0` = unlimited)
|
||||
|
||||
**Misc**
|
||||
|
||||
- Updated sponsor list in Admin Panel.
|
||||
|
||||
---
|
||||
|
||||
## Changes 12/22/2025 (v2.11.1)
|
||||
|
||||
`release(v2.11.1): scope dotfile blocking to allow WebDAV dotpaths + add/revise OpenAPI annotations`
|
||||
|
||||
@@ -47,12 +47,8 @@ Full list of features: [Full Feature Wiki](https://github.com/error311/FileRise/
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/87b06f1a-1400-4df1-bf1d-aaae88fcdfbd
|
||||
|
||||
|
||||
|
||||
> 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
|
||||
> Check out [filerise.net](https://filerise.net) – FileRise Core stays fully open-source (MIT).
|
||||
|
||||
@@ -167,6 +163,8 @@ docker compose up -d
|
||||
| `PUID` | Optional | `99` | If running as root, remap `www-data` user to this UID (e.g. Unraid’s 99). |
|
||||
| `PGID` | Optional | `100` | If running as root, remap `www-data` group to this GID (e.g. Unraid’s 100). |
|
||||
| `FR_PUBLISHED_URL` | Optional | `https://example.com/files` | Public URL when behind proxies/subpaths (share links, portals, redirects). |
|
||||
| `FR_TRUSTED_PROXIES` | Optional | `127.0.0.1,10.0.0.0/8` | Comma-separated IPs/CIDRs for trusted proxies; only these can supply the client IP header. |
|
||||
| `FR_IP_HEADER` | Optional | `X-Forwarded-For` | Header to trust for the real client IP when the proxy is trusted. |
|
||||
|
||||
> Full list of common env variables: [Common Environment variables](https://github.com/error311/FileRise/wiki/Common-Env-Variables)
|
||||
>
|
||||
@@ -223,6 +221,12 @@ Quick write test:
|
||||
sudo -u www-data touch /var/www/users/.write_test && echo OK
|
||||
```
|
||||
|
||||
### Login security notes
|
||||
|
||||
- Failed login attempts are throttled per **IP + username** and stored in `/var/www/users/failed_logins.json` by default.
|
||||
- Failed logins are also written to `/var/www/users/fail2ban.log` (rotate at 50MB, keep 5 files).
|
||||
- If you’re behind a reverse proxy, set `FR_TRUSTED_PROXIES` and `FR_IP_HEADER` so rate limiting and logs use the real client IP.
|
||||
|
||||
### Install from release ZIP (recommended)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -101,6 +101,27 @@ if (!defined('FR_OIDC_DEBUG')) {
|
||||
define('FR_OIDC_DEBUG', false);
|
||||
}
|
||||
}
|
||||
// Optional: trusted proxy IP resolution for rate limiting/logging
|
||||
// Set FR_TRUSTED_PROXIES to a comma-separated list of IPs/CIDRs (e.g. "127.0.0.1,10.0.0.0/8").
|
||||
if (!defined('FR_TRUSTED_PROXIES')) {
|
||||
$envVal = getenv('FR_TRUSTED_PROXIES');
|
||||
define('FR_TRUSTED_PROXIES', ($envVal !== false && $envVal !== '') ? $envVal : '');
|
||||
}
|
||||
// Which header to trust when REMOTE_ADDR matches FR_TRUSTED_PROXIES.
|
||||
if (!defined('FR_IP_HEADER')) {
|
||||
$envVal = getenv('FR_IP_HEADER');
|
||||
define('FR_IP_HEADER', ($envVal !== false && $envVal !== '') ? $envVal : 'X-Forwarded-For');
|
||||
}
|
||||
// Optional: WebDAV max upload size in bytes (0 = unlimited)
|
||||
if (!defined('FR_WEBDAV_MAX_UPLOAD_BYTES')) {
|
||||
$envVal = getenv('FR_WEBDAV_MAX_UPLOAD_BYTES');
|
||||
if ($envVal !== false && $envVal !== '') {
|
||||
$val = (int)$envVal;
|
||||
define('FR_WEBDAV_MAX_UPLOAD_BYTES', $val > 0 ? $val : 0);
|
||||
} else {
|
||||
define('FR_WEBDAV_MAX_UPLOAD_BYTES', 0);
|
||||
}
|
||||
}
|
||||
// Antivirus / ClamAV (optional)
|
||||
// If VIRUS_SCAN_ENABLED is set in the environment, it overrides the admin setting.
|
||||
// If it is not set, we don't define the constant and the admin checkbox controls scanning.
|
||||
|
||||
@@ -61,8 +61,10 @@ try {
|
||||
if (defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD') && OIDC_TOKEN_ENDPOINT_AUTH_METHOD) {
|
||||
$tokenAuthMethod = OIDC_TOKEN_ENDPOINT_AUTH_METHOD;
|
||||
}
|
||||
if (!$tokenAuthMethod) {
|
||||
$tokenAuthMethod = $publicClient ? 'none' : 'client_secret_basic';
|
||||
if ($publicClient || $clientSecret === null || $clientSecret === '') {
|
||||
$tokenAuthMethod = 'none';
|
||||
} elseif (!$tokenAuthMethod) {
|
||||
$tokenAuthMethod = 'client_secret_basic';
|
||||
}
|
||||
|
||||
$loginOptions = is_array($cfg['loginOptions'] ?? null) ? $cfg['loginOptions'] : [];
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="Unauthorized due to missing credentials or invalid credentials."
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=429,
|
||||
* description="Too many failed login attempts."
|
||||
* )
|
||||
* )
|
||||
*
|
||||
@@ -31,4 +35,4 @@ require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
$authController = new AuthController();
|
||||
$authController->loginBasic();
|
||||
$authController->loginBasic();
|
||||
|
||||
@@ -24,7 +24,10 @@ const DEFAULT_SUPPORTERS = [
|
||||
'Matthias S',
|
||||
'Emerson Beltrán',
|
||||
'shucking',
|
||||
'Sascha A.'
|
||||
'Sascha A.',
|
||||
'JBR0XN',
|
||||
'Blaž Pivk',
|
||||
'Rob Parker'
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+98
-33
@@ -602,6 +602,25 @@ async function syncPermissionsToLocalStorage() {
|
||||
// ——— main ———
|
||||
let __loginInFlight = false;
|
||||
|
||||
function isDefinitiveLoginError(message) {
|
||||
return /invalid credentials|invalid username format|too many failed login attempts/i.test(String(message || ''));
|
||||
}
|
||||
|
||||
function handleLoginFailureTip(message) {
|
||||
const msg = String(message || '');
|
||||
if (/too many failed login attempts/i.test(msg)) {
|
||||
if (typeof window.__frShowLoginLockoutTip === 'function') {
|
||||
window.__frShowLoginLockoutTip();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (/invalid credentials/i.test(msg)) {
|
||||
if (typeof window.__frRecordLoginFailure === 'function') {
|
||||
window.__frRecordLoginFailure();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLogin(data) {
|
||||
if (__loginInFlight) return;
|
||||
__loginInFlight = true;
|
||||
@@ -629,6 +648,9 @@ async function submitLogin(data) {
|
||||
|
||||
// TOTP requested?
|
||||
if (await sniffTOTP(res, body)) {
|
||||
if (typeof window.__frResetLoginFailure === 'function') {
|
||||
window.__frResetLoginFailure();
|
||||
}
|
||||
try { await primeCsrfStrict(); } catch (e) { }
|
||||
window.pendingTOTP = true;
|
||||
try {
|
||||
@@ -640,6 +662,9 @@ async function submitLogin(data) {
|
||||
|
||||
// Full success (no TOTP)
|
||||
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
|
||||
if (typeof window.__frResetLoginFailure === 'function') {
|
||||
window.__frResetLoginFailure();
|
||||
}
|
||||
|
||||
await syncPermissionsToLocalStorage();
|
||||
return afterLogin();
|
||||
@@ -647,46 +672,19 @@ async function submitLogin(data) {
|
||||
|
||||
// Cookie set but non-JSON body — double check session
|
||||
if (!body && await isAuthedNow()) {
|
||||
if (typeof window.__frResetLoginFailure === 'function') {
|
||||
window.__frResetLoginFailure();
|
||||
}
|
||||
|
||||
await syncPermissionsToLocalStorage();
|
||||
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
// Attempt #2 — form fallback
|
||||
res = await fetchWithCsrf('/api/auth/auth.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
|
||||
body: toFormBody(payload)
|
||||
});
|
||||
body = await safeJson(res);
|
||||
|
||||
if (await sniffTOTP(res, body)) {
|
||||
try { await primeCsrfStrict(); } catch (e) { }
|
||||
window.pendingTOTP = true;
|
||||
try {
|
||||
const auth = await import(withBase('/js/auth.js?v={{APP_QVER}}'));
|
||||
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
|
||||
await syncPermissionsToLocalStorage();
|
||||
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
if (!body && await isAuthedNow()) {
|
||||
|
||||
await syncPermissionsToLocalStorage();
|
||||
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
// Rate limit still respected
|
||||
if (body?.error && /Too many failed login attempts/i.test(body.error)) {
|
||||
if (typeof window.__frShowLoginLockoutTip === 'function') {
|
||||
window.__frShowLoginLockoutTip();
|
||||
}
|
||||
showToast(body.error);
|
||||
const btn = document.querySelector("#authForm button[type='submit']");
|
||||
if (btn) {
|
||||
@@ -699,6 +697,73 @@ async function submitLogin(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (body?.error && isDefinitiveLoginError(body.error)) {
|
||||
handleLoginFailureTip(body.error);
|
||||
showToast('Login failed' + (body?.error ? `: ${body.error}` : ''));
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt #2 — form fallback
|
||||
res = await fetchWithCsrf('/api/auth/auth.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
|
||||
body: toFormBody(payload)
|
||||
});
|
||||
body = await safeJson(res);
|
||||
|
||||
if (await sniffTOTP(res, body)) {
|
||||
if (typeof window.__frResetLoginFailure === 'function') {
|
||||
window.__frResetLoginFailure();
|
||||
}
|
||||
try { await primeCsrfStrict(); } catch (e) { }
|
||||
window.pendingTOTP = true;
|
||||
try {
|
||||
const auth = await import(withBase('/js/auth.js?v={{APP_QVER}}'));
|
||||
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
|
||||
if (typeof window.__frResetLoginFailure === 'function') {
|
||||
window.__frResetLoginFailure();
|
||||
}
|
||||
await syncPermissionsToLocalStorage();
|
||||
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
if (!body && await isAuthedNow()) {
|
||||
if (typeof window.__frResetLoginFailure === 'function') {
|
||||
window.__frResetLoginFailure();
|
||||
}
|
||||
|
||||
await syncPermissionsToLocalStorage();
|
||||
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
// Rate limit still respected
|
||||
if (body?.error && /Too many failed login attempts/i.test(body.error)) {
|
||||
if (typeof window.__frShowLoginLockoutTip === 'function') {
|
||||
window.__frShowLoginLockoutTip();
|
||||
}
|
||||
showToast(body.error);
|
||||
const btn = document.querySelector("#authForm button[type='submit']");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
showToast("You can now try logging in again.");
|
||||
}, 30 * 60 * 1000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (body?.error) {
|
||||
handleLoginFailureTip(body.error);
|
||||
}
|
||||
showToast('Login failed' + (body?.error ? `: ${body.error}` : ''));
|
||||
|
||||
} catch (e) {
|
||||
|
||||
+126
-4
@@ -106,6 +106,64 @@ function showLoginTip(message) {
|
||||
tip.style.display = 'block';
|
||||
}
|
||||
|
||||
const LOGIN_FAIL_STORAGE_KEY = 'fr_login_fail_state';
|
||||
const LOGIN_FAIL_WINDOW_MS = 30 * 60 * 1000;
|
||||
const LOGIN_FAIL_MAX = 5;
|
||||
|
||||
function readLoginFailState(now = Date.now()) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(LOGIN_FAIL_STORAGE_KEY);
|
||||
if (!raw) return { count: 0, last: 0 };
|
||||
const parsed = JSON.parse(raw);
|
||||
const count = Number(parsed.count) || 0;
|
||||
const last = Number(parsed.last) || 0;
|
||||
if (!last || (now - last) > LOGIN_FAIL_WINDOW_MS) {
|
||||
return { count: 0, last: 0 };
|
||||
}
|
||||
return { count, last };
|
||||
} catch (e) {
|
||||
return { count: 0, last: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function writeLoginFailState(state) {
|
||||
try {
|
||||
sessionStorage.setItem(LOGIN_FAIL_STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
function resetLoginFailState() {
|
||||
try {
|
||||
sessionStorage.removeItem(LOGIN_FAIL_STORAGE_KEY);
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
function showLoginFailTip(count) {
|
||||
if (count >= LOGIN_FAIL_MAX) {
|
||||
showLoginTip('Too many failed login attempts. Please try again later.');
|
||||
return;
|
||||
}
|
||||
showLoginTip(`Failed to log in: ${count} of ${LOGIN_FAIL_MAX} attempts used.`);
|
||||
}
|
||||
|
||||
function recordLoginFailure() {
|
||||
const now = Date.now();
|
||||
const state = readLoginFailState(now);
|
||||
const count = Math.min((state.count || 0) + 1, LOGIN_FAIL_MAX);
|
||||
writeLoginFailState({ count, last: now });
|
||||
showLoginFailTip(count);
|
||||
return count;
|
||||
}
|
||||
|
||||
function showLoginLockoutTip() {
|
||||
showLoginTip('Too many failed login attempts. Please try again later.');
|
||||
}
|
||||
|
||||
window.showLoginTip = showLoginTip;
|
||||
window.__frRecordLoginFailure = recordLoginFailure;
|
||||
window.__frResetLoginFailure = resetLoginFailState;
|
||||
window.__frShowLoginLockoutTip = showLoginLockoutTip;
|
||||
|
||||
async function hideOverlaySmoothly(overlay) {
|
||||
if (!overlay) return;
|
||||
try { await document.fonts?.ready; } catch (e) { }
|
||||
@@ -522,6 +580,21 @@ function bindDarkMode() {
|
||||
} catch (e) {
|
||||
window.__FR_PRO_SEARCH_CFG__ = { enabled: false, defaultLimit: 50, lockedByEnv: false };
|
||||
}
|
||||
try {
|
||||
const audit = (cfg && cfg.proAudit && typeof cfg.proAudit === 'object') ? cfg.proAudit : {};
|
||||
const isPro = window.__FR_IS_PRO === true;
|
||||
const levelRaw = (typeof audit.level === 'string') ? audit.level : 'standard';
|
||||
const level = (levelRaw === 'standard' || levelRaw === 'verbose') ? levelRaw : 'standard';
|
||||
window.__FR_PRO_AUDIT_CFG__ = {
|
||||
enabled: isPro && !!audit.enabled,
|
||||
level,
|
||||
maxFileMb: Number(audit.maxFileMb || 200),
|
||||
maxFiles: Number(audit.maxFiles || 10),
|
||||
available: !!audit.available,
|
||||
};
|
||||
} catch (e) {
|
||||
window.__FR_PRO_AUDIT_CFG__ = { enabled: false, level: 'standard', maxFileMb: 200, maxFiles: 10, available: false };
|
||||
}
|
||||
|
||||
// Expose a simple boolean for ClamAV scanning
|
||||
if (cfg && cfg.clamav && typeof cfg.clamav.scanUploads !== 'undefined') {
|
||||
@@ -951,6 +1024,23 @@ function bindDarkMode() {
|
||||
} catch (e) { }
|
||||
return false;
|
||||
}
|
||||
function isDefinitiveLoginError(message) {
|
||||
return /invalid credentials|invalid username format|too many failed login attempts/i.test(String(message || ''));
|
||||
}
|
||||
function handleLoginFailureTip(message) {
|
||||
const msg = String(message || '');
|
||||
if (/too many failed login attempts/i.test(msg)) {
|
||||
if (typeof window.__frShowLoginLockoutTip === 'function') {
|
||||
window.__frShowLoginLockoutTip();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (/invalid credentials/i.test(msg)) {
|
||||
if (typeof window.__frRecordLoginFailure === 'function') {
|
||||
window.__frRecordLoginFailure();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openTotpNow() {
|
||||
// refresh CSRF for the upcoming /totp_verify call
|
||||
@@ -1011,9 +1101,26 @@ function bindDarkMode() {
|
||||
const j = await r.clone().json().catch(() => ({}));
|
||||
|
||||
// TOTP step-up?
|
||||
if (looksLikeTOTP(r, j)) { await openTotpNow(); return; }
|
||||
if (looksLikeTOTP(r, j)) {
|
||||
if (typeof window.__frResetLoginFailure === 'function') {
|
||||
window.__frResetLoginFailure();
|
||||
}
|
||||
await openTotpNow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (j && (j.authenticated || j.success || j.status === 'ok' || j.result === 'ok')) return afterLogin();
|
||||
if (j && (j.authenticated || j.success || j.status === 'ok' || j.result === 'ok')) {
|
||||
if (typeof window.__frResetLoginFailure === 'function') {
|
||||
window.__frResetLoginFailure();
|
||||
}
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
if (j && j.error && isDefinitiveLoginError(j.error)) {
|
||||
handleLoginFailureTip(j.error);
|
||||
alert('Login failed');
|
||||
return;
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
// fallback form
|
||||
@@ -1028,9 +1135,24 @@ function bindDarkMode() {
|
||||
const j2 = await r2.clone().json().catch(() => ({}));
|
||||
|
||||
// TOTP step-up on fallback too
|
||||
if (looksLikeTOTP(r2, j2)) { await openTotpNow(); return; }
|
||||
if (looksLikeTOTP(r2, j2)) {
|
||||
if (typeof window.__frResetLoginFailure === 'function') {
|
||||
window.__frResetLoginFailure();
|
||||
}
|
||||
await openTotpNow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (j2 && (j2.authenticated || j2.success || j2.status === 'ok' || j2.result === 'ok')) return afterLogin();
|
||||
if (j2 && (j2.authenticated || j2.success || j2.status === 'ok' || j2.result === 'ok')) {
|
||||
if (typeof window.__frResetLoginFailure === 'function') {
|
||||
window.__frResetLoginFailure();
|
||||
}
|
||||
return afterLogin();
|
||||
}
|
||||
|
||||
if (j2 && j2.error) {
|
||||
handleLoginFailureTip(j2.error);
|
||||
}
|
||||
} catch (e) { }
|
||||
alert('Login failed');
|
||||
});
|
||||
|
||||
@@ -145,9 +145,11 @@ class AuthController
|
||||
if (defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD') && OIDC_TOKEN_ENDPOINT_AUTH_METHOD) {
|
||||
$tokenAuthMethod = OIDC_TOKEN_ENDPOINT_AUTH_METHOD;
|
||||
}
|
||||
// Default token endpoint auth: none for public clients, basic for confidential.
|
||||
if (!$tokenAuthMethod) {
|
||||
$tokenAuthMethod = $clientSecret ? 'client_secret_basic' : 'none';
|
||||
// Never send client_secret_* when no secret is available (public client or empty secret).
|
||||
if ($clientSecret === null) {
|
||||
$tokenAuthMethod = 'none';
|
||||
} elseif (!$tokenAuthMethod) {
|
||||
$tokenAuthMethod = 'client_secret_basic';
|
||||
}
|
||||
|
||||
$this->logOidcDebug('Building OIDC client', [
|
||||
@@ -170,7 +172,7 @@ class AuthController
|
||||
$oidc->setCodeChallengeMethod('S256');
|
||||
}
|
||||
|
||||
// client_secret_post with Authelia using config.php
|
||||
// Apply explicit token endpoint auth method when configured.
|
||||
if (method_exists($oidc, 'setTokenEndpointAuthMethod') && $tokenAuthMethod) {
|
||||
$oidc->setTokenEndpointAuthMethod($tokenAuthMethod);
|
||||
}
|
||||
@@ -391,14 +393,24 @@ class AuthController
|
||||
}
|
||||
|
||||
// rate-limit
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
$ip = AuthModel::getClientIp($_SERVER);
|
||||
$key = AuthModel::getFailedLoginKey($ip, $username);
|
||||
$attemptsFile = USERS_DIR . 'failed_logins.json';
|
||||
$failed = AuthModel::loadFailedAttempts($attemptsFile);
|
||||
$now = time();
|
||||
if (isset($failed[$key])) {
|
||||
$lastAttempt = (int)($failed[$key]['last_attempt'] ?? 0);
|
||||
if ($lastAttempt <= 0 || ($now - $lastAttempt) >= 30 * 60) {
|
||||
$failed[$key] = ['count' => 0, 'last_attempt' => 0];
|
||||
}
|
||||
}
|
||||
if (
|
||||
isset($failed[$ip]) &&
|
||||
$failed[$ip]['count'] >= 5 &&
|
||||
time() - $failed[$ip]['last_attempt'] < 30 * 60
|
||||
isset($failed[$key]) &&
|
||||
($failed[$key]['count'] ?? 0) >= 5 &&
|
||||
($failed[$key]['last_attempt'] ?? 0) > 0 &&
|
||||
$now - $failed[$key]['last_attempt'] < 30 * 60
|
||||
) {
|
||||
AuthModel::logFailedLogin($ip, $username, 'lockout', $_SERVER['HTTP_USER_AGENT'] ?? '');
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'Too many failed login attempts. Please try again later.']);
|
||||
exit();
|
||||
@@ -407,11 +419,12 @@ class AuthController
|
||||
$user = AuthModel::authenticate($username, $password);
|
||||
if ($user === false) {
|
||||
// record failure
|
||||
$failed[$ip] = [
|
||||
'count' => ($failed[$ip]['count'] ?? 0) + 1,
|
||||
'last_attempt' => time()
|
||||
$failed[$key] = [
|
||||
'count' => ($failed[$key]['count'] ?? 0) + 1,
|
||||
'last_attempt' => $now
|
||||
];
|
||||
AuthModel::saveFailedAttempts($attemptsFile, $failed);
|
||||
AuthModel::logFailedLogin($ip, $username, 'invalid_credentials', $_SERVER['HTTP_USER_AGENT'] ?? '');
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Invalid credentials']);
|
||||
exit();
|
||||
@@ -427,8 +440,8 @@ class AuthController
|
||||
}
|
||||
|
||||
// otherwise clear rate-limit & finish
|
||||
if (isset($failed[$ip])) {
|
||||
unset($failed[$ip]);
|
||||
if (isset($failed[$key])) {
|
||||
unset($failed[$key]);
|
||||
AuthModel::saveFailedAttempts($attemptsFile, $failed);
|
||||
}
|
||||
$this->finalizeLogin($username, $rememberMe);
|
||||
@@ -644,6 +657,30 @@ class AuthController
|
||||
exit();
|
||||
}
|
||||
|
||||
// rate-limit
|
||||
$ip = AuthModel::getClientIp($_SERVER);
|
||||
$key = AuthModel::getFailedLoginKey($ip, $username);
|
||||
$attemptsFile = USERS_DIR . 'failed_logins.json';
|
||||
$failed = AuthModel::loadFailedAttempts($attemptsFile);
|
||||
$now = time();
|
||||
if (isset($failed[$key])) {
|
||||
$lastAttempt = (int)($failed[$key]['last_attempt'] ?? 0);
|
||||
if ($lastAttempt <= 0 || ($now - $lastAttempt) >= 30 * 60) {
|
||||
$failed[$key] = ['count' => 0, 'last_attempt' => 0];
|
||||
}
|
||||
}
|
||||
if (
|
||||
isset($failed[$key]) &&
|
||||
($failed[$key]['count'] ?? 0) >= 5 &&
|
||||
($failed[$key]['last_attempt'] ?? 0) > 0 &&
|
||||
$now - $failed[$key]['last_attempt'] < 30 * 60
|
||||
) {
|
||||
AuthModel::logFailedLogin($ip, $username, 'lockout', $_SERVER['HTTP_USER_AGENT'] ?? '');
|
||||
header('HTTP/1.1 429 Too Many Requests');
|
||||
echo json_encode(['error' => 'Too many failed login attempts. Please try again later.']);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Attempt authentication.
|
||||
$role = AuthModel::authenticate($username, $password);
|
||||
if ($role !== false) {
|
||||
@@ -653,14 +690,28 @@ class AuthController
|
||||
// If TOTP is required, store pending values and redirect to prompt for TOTP.
|
||||
$_SESSION['pending_login_user'] = $username;
|
||||
$_SESSION['pending_login_secret'] = $secret;
|
||||
if (isset($failed[$key])) {
|
||||
unset($failed[$key]);
|
||||
AuthModel::saveFailedAttempts($attemptsFile, $failed);
|
||||
}
|
||||
header("Location: " . fr_with_base_path("/index.html?totp_required=1"));
|
||||
exit();
|
||||
}
|
||||
|
||||
if (isset($failed[$key])) {
|
||||
unset($failed[$key]);
|
||||
AuthModel::saveFailedAttempts($attemptsFile, $failed);
|
||||
}
|
||||
$this->finishBrowserLogin($username);
|
||||
}
|
||||
|
||||
// Invalid credentials; prompt again.
|
||||
$failed[$key] = [
|
||||
'count' => ($failed[$key]['count'] ?? 0) + 1,
|
||||
'last_attempt' => $now
|
||||
];
|
||||
AuthModel::saveFailedAttempts($attemptsFile, $failed);
|
||||
AuthModel::logFailedLogin($ip, $username, 'invalid_credentials', $_SERVER['HTTP_USER_AGENT'] ?? '');
|
||||
header('WWW-Authenticate: Basic realm="FileRise Login"');
|
||||
header('HTTP/1.0 401 Unauthorized');
|
||||
echo 'Invalid credentials';
|
||||
|
||||
@@ -314,7 +314,7 @@ class AdminModel
|
||||
|
||||
foreach ($required as $k) {
|
||||
if (empty($oidc[$k]) || !is_string($oidc[$k])) {
|
||||
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, redirectUri" . ($publicClient ? '' : ', clientSecret') . ")."];
|
||||
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, redirectUri" . ($publicClient ? '' : ', clientSecret') . "). If you want a blank secret, enable Public Client or disable OIDC login."];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ require_once PROJECT_ROOT . '/src/models/AdminModel.php';
|
||||
|
||||
class AuthModel
|
||||
{
|
||||
private const FAIL2BAN_LOG_MAX_BYTES = 50 * 1024 * 1024;
|
||||
private const FAIL2BAN_LOG_MAX_FILES = 5;
|
||||
|
||||
public static function isOidcDemoteAllowed(): bool
|
||||
{
|
||||
@@ -297,6 +299,218 @@ class AuthModel
|
||||
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
}
|
||||
|
||||
public static function getFailedLoginKey(string $ip, string $username): string
|
||||
{
|
||||
$ip = trim($ip);
|
||||
$user = trim($username);
|
||||
if ($user !== '') {
|
||||
$user = strtolower($user);
|
||||
$ipKey = $ip !== '' ? $ip : 'unknown';
|
||||
return $ipKey . '|' . $user;
|
||||
}
|
||||
return $ip !== '' ? $ip : 'unknown';
|
||||
}
|
||||
|
||||
public static function getClientIp(array $server = null): string
|
||||
{
|
||||
$server = $server ?? $_SERVER;
|
||||
$remote = trim((string)($server['REMOTE_ADDR'] ?? ''));
|
||||
|
||||
$trusted = self::getTrustedProxies();
|
||||
if ($remote !== '' && $trusted && self::isTrustedProxy($remote, $trusted)) {
|
||||
$headerName = defined('FR_IP_HEADER') ? (string)FR_IP_HEADER : 'X-Forwarded-For';
|
||||
$headerKey = self::normalizeHeaderKey($headerName);
|
||||
$headerVal = trim((string)($server[$headerKey] ?? ''));
|
||||
if ($headerVal !== '') {
|
||||
$candidate = $headerVal;
|
||||
if (strpos($candidate, ',') !== false) {
|
||||
$parts = explode(',', $candidate);
|
||||
$candidate = trim($parts[0]);
|
||||
}
|
||||
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($remote !== '' && filter_var($remote, FILTER_VALIDATE_IP)) {
|
||||
return $remote;
|
||||
}
|
||||
|
||||
return $remote !== '' ? $remote : 'unknown';
|
||||
}
|
||||
|
||||
protected static function sanitizeLogValue(string $value, int $maxLen = 120): string
|
||||
{
|
||||
$value = str_replace(["\r", "\n", "\t"], ' ', $value);
|
||||
$value = str_replace('"', "'", $value);
|
||||
$value = trim($value);
|
||||
if ($maxLen > 0 && strlen($value) > $maxLen) {
|
||||
$value = substr($value, 0, $maxLen);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function logFailedLogin(string $ip, string $username, string $reason, string $userAgent = ''): void
|
||||
{
|
||||
$logFile = USERS_DIR . 'fail2ban.log';
|
||||
$ts = date('Y-m-d H:i:s');
|
||||
|
||||
$ip = self::sanitizeLogValue($ip, 64);
|
||||
$username = self::sanitizeLogValue($username, 80);
|
||||
$reason = self::sanitizeLogValue($reason, 60);
|
||||
$ua = $userAgent !== '' ? $userAgent : ($_SERVER['HTTP_USER_AGENT'] ?? '');
|
||||
$ua = self::sanitizeLogValue($ua, 200);
|
||||
|
||||
$line = $ts
|
||||
. ' filerise_auth failed_login ip=' . ($ip !== '' ? $ip : 'unknown')
|
||||
. ' user=' . ($username !== '' ? $username : 'unknown')
|
||||
. ' reason=' . ($reason !== '' ? $reason : 'unknown');
|
||||
if ($ua !== '') {
|
||||
$line .= ' ua="' . $ua . '"';
|
||||
}
|
||||
$line .= "\n";
|
||||
|
||||
self::rotateFail2banLog($logFile);
|
||||
@file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
protected static function rotateFail2banLog(string $logFile): void
|
||||
{
|
||||
$maxBytes = self::FAIL2BAN_LOG_MAX_BYTES;
|
||||
$maxFiles = self::FAIL2BAN_LOG_MAX_FILES;
|
||||
if ($maxBytes <= 0 || $maxFiles <= 1) {
|
||||
return;
|
||||
}
|
||||
if (!file_exists($logFile)) {
|
||||
return;
|
||||
}
|
||||
$size = @filesize($logFile);
|
||||
if ($size === false || $size < $maxBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
$maxRotated = $maxFiles - 1;
|
||||
for ($i = $maxRotated; $i >= 1; $i--) {
|
||||
$src = $logFile . '.' . $i;
|
||||
if ($i === $maxRotated) {
|
||||
if (file_exists($src)) {
|
||||
@unlink($src);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$dst = $logFile . '.' . ($i + 1);
|
||||
if (file_exists($src)) {
|
||||
@rename($src, $dst);
|
||||
}
|
||||
}
|
||||
@rename($logFile, $logFile . '.1');
|
||||
}
|
||||
|
||||
protected static function getTrustedProxies(): array
|
||||
{
|
||||
$raw = '';
|
||||
if (defined('FR_TRUSTED_PROXIES')) {
|
||||
$raw = FR_TRUSTED_PROXIES;
|
||||
} else {
|
||||
$env = getenv('FR_TRUSTED_PROXIES');
|
||||
if ($env !== false && $env !== '') {
|
||||
$raw = $env;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
if (!is_string($raw) || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parts = array_map('trim', explode(',', $raw));
|
||||
return array_values(array_filter($parts, fn($part) => $part !== ''));
|
||||
}
|
||||
|
||||
protected static function normalizeHeaderKey(string $header): string
|
||||
{
|
||||
$key = strtoupper(str_replace('-', '_', trim($header)));
|
||||
if ($key === 'REMOTE_ADDR') {
|
||||
return $key;
|
||||
}
|
||||
if (strpos($key, 'HTTP_') !== 0) {
|
||||
$key = 'HTTP_' . $key;
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
protected static function isTrustedProxy(string $ip, array $trusted): bool
|
||||
{
|
||||
foreach ($trusted as $entry) {
|
||||
$entry = trim((string)$entry);
|
||||
if ($entry === '') {
|
||||
continue;
|
||||
}
|
||||
if (strpos($entry, '/') === false) {
|
||||
if ($ip === $entry) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (self::ipInCidr($ip, $entry)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static function ipInCidr(string $ip, string $cidr): bool
|
||||
{
|
||||
$cidr = trim($cidr);
|
||||
if ($cidr === '' || strpos($cidr, '/') === false) {
|
||||
return false;
|
||||
}
|
||||
[$subnet, $maskRaw] = explode('/', $cidr, 2);
|
||||
$subnet = trim($subnet);
|
||||
$mask = (int)trim($maskRaw);
|
||||
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP) || !filter_var($subnet, FILTER_VALIDATE_IP)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strpos($ip, ':') !== false || strpos($subnet, ':') !== false) {
|
||||
if ($mask < 0 || $mask > 128) {
|
||||
return false;
|
||||
}
|
||||
$ipBin = inet_pton($ip);
|
||||
$netBin = inet_pton($subnet);
|
||||
if ($ipBin === false || $netBin === false) {
|
||||
return false;
|
||||
}
|
||||
$bytes = intdiv($mask, 8);
|
||||
$bits = $mask % 8;
|
||||
if ($bytes > 0 && substr($ipBin, 0, $bytes) !== substr($netBin, 0, $bytes)) {
|
||||
return false;
|
||||
}
|
||||
if ($bits > 0) {
|
||||
$maskByte = (~((1 << (8 - $bits)) - 1)) & 0xFF;
|
||||
if ((ord($ipBin[$bytes]) & $maskByte) !== (ord($netBin[$bytes]) & $maskByte)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($mask < 0 || $mask > 32) {
|
||||
return false;
|
||||
}
|
||||
$ipLong = ip2long($ip);
|
||||
$netLong = ip2long($subnet);
|
||||
if ($ipLong === false || $netLong === false) {
|
||||
return false;
|
||||
}
|
||||
$maskLong = $mask === 0 ? 0 : (-1 << (32 - $mask));
|
||||
return (($ipLong & $maskLong) === ($netLong & $maskLong));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a user's TOTP secret from the users file.
|
||||
*
|
||||
|
||||
@@ -6,11 +6,11 @@ namespace FileRise\WebDAV;
|
||||
* Singleton holder for the current WebDAV username.
|
||||
*/
|
||||
class CurrentUser {
|
||||
private static string $user = 'Unknown';
|
||||
private static string $user = '';
|
||||
public static function set(string $u): void {
|
||||
self::$user = $u;
|
||||
}
|
||||
public static function get(): string {
|
||||
return self::$user;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,11 +181,10 @@ class FileRiseDirectory implements ICollection, INode {
|
||||
|
||||
// Write directly to FS, then ensure metadata via FileRiseFile::put()
|
||||
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||
$content = is_resource($data) ? stream_get_contents($data) : (string)$data;
|
||||
|
||||
// Let FileRiseFile handle metadata & overwrite semantics
|
||||
$fileNode = new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
|
||||
$fileNode->put($content);
|
||||
$fileNode->put($data);
|
||||
|
||||
return $fileNode;
|
||||
}
|
||||
@@ -229,6 +228,10 @@ class FileRiseDirectory implements ICollection, INode {
|
||||
$real = realpath($absPath) ?: $absPath;
|
||||
|
||||
if (stripos($real, $realBase) !== 0) return 'root';
|
||||
if (strlen($real) > strlen($realBase)) {
|
||||
$next = $real[strlen($realBase)] ?? '';
|
||||
if ($next !== '/' && $next !== '\\') return 'root';
|
||||
}
|
||||
$rel = ltrim(str_replace('\\','/', substr($real, strlen($realBase))), '/');
|
||||
return ($rel === '' ? 'root' : $rel);
|
||||
}
|
||||
|
||||
@@ -122,10 +122,64 @@ class FileRiseFile implements IFile, INode {
|
||||
}
|
||||
|
||||
// write + metadata (unchanged)
|
||||
file_put_contents(
|
||||
$this->path,
|
||||
is_resource($data) ? stream_get_contents($data) : (string)$data
|
||||
);
|
||||
$maxBytes = defined('FR_WEBDAV_MAX_UPLOAD_BYTES') ? (int)FR_WEBDAV_MAX_UPLOAD_BYTES : 0;
|
||||
if ($maxBytes < 0) {
|
||||
$maxBytes = 0;
|
||||
}
|
||||
|
||||
if (is_resource($data)) {
|
||||
$dir = dirname($this->path);
|
||||
$tmp = $dir . DIRECTORY_SEPARATOR . '.' . basename($this->path) . '.tmp-' . bin2hex(random_bytes(8));
|
||||
$out = @fopen($tmp, 'wb');
|
||||
if ($out === false) {
|
||||
throw new Forbidden('Unable to write file');
|
||||
}
|
||||
$written = 0;
|
||||
while (!feof($data)) {
|
||||
$chunk = fread($data, 1024 * 1024);
|
||||
if ($chunk === false) {
|
||||
fclose($out);
|
||||
@unlink($tmp);
|
||||
throw new Forbidden('Unable to write file');
|
||||
}
|
||||
if ($chunk === '') {
|
||||
continue;
|
||||
}
|
||||
$len = strlen($chunk);
|
||||
if ($maxBytes > 0 && ($written + $len) > $maxBytes) {
|
||||
fclose($out);
|
||||
@unlink($tmp);
|
||||
throw new Forbidden('Upload exceeds maximum size');
|
||||
}
|
||||
$offset = 0;
|
||||
while ($offset < $len) {
|
||||
$wrote = fwrite($out, substr($chunk, $offset));
|
||||
if ($wrote === false || $wrote === 0) {
|
||||
fclose($out);
|
||||
@unlink($tmp);
|
||||
throw new Forbidden('Unable to write file');
|
||||
}
|
||||
$offset += $wrote;
|
||||
}
|
||||
$written += $len;
|
||||
}
|
||||
fclose($out);
|
||||
if (!@rename($tmp, $this->path)) {
|
||||
if ($exists) {
|
||||
@unlink($this->path);
|
||||
}
|
||||
if (!@rename($tmp, $this->path)) {
|
||||
@unlink($tmp);
|
||||
throw new Forbidden('Unable to write file');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$payload = (string)$data;
|
||||
if ($maxBytes > 0 && strlen($payload) > $maxBytes) {
|
||||
throw new Forbidden('Upload exceeds maximum size');
|
||||
}
|
||||
file_put_contents($this->path, $payload);
|
||||
}
|
||||
$this->updateMetadata($folderKey, $fileName);
|
||||
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
|
||||
AuditHook::log($exists ? 'file.edit' : 'file.upload', [
|
||||
|
||||
Reference in New Issue
Block a user