feat(webui): honor security overrides in auth, captcha, login, and proxy
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAuthenticationEnabled } from "@/lib/auth";
|
||||
import { createCaptcha } from "@/lib/server/captcha";
|
||||
import { effectiveSecurity } from "@/lib/server/systemConfig";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
if (!isAuthenticationEnabled()) {
|
||||
if (!effectiveSecurity().authEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "WebUI authentication is disabled." },
|
||||
{ status: 404, headers: { "Cache-Control": "no-store" } }
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
isAuthenticationEnabled,
|
||||
isSafeReturnPath,
|
||||
rememberTtlSeconds,
|
||||
sessionTtlSeconds,
|
||||
} from "@/lib/auth";
|
||||
import {
|
||||
AuthConfigurationError,
|
||||
@@ -13,6 +11,7 @@ import {
|
||||
verifyCredentials,
|
||||
} from "@/lib/server/auth";
|
||||
import { verifyCaptcha } from "@/lib/server/captcha";
|
||||
import { effectiveSecurity } from "@/lib/server/systemConfig";
|
||||
import {
|
||||
clearFailures,
|
||||
clientIp,
|
||||
@@ -26,7 +25,7 @@ export const dynamic = "force-dynamic";
|
||||
const NO_STORE = { "Cache-Control": "no-store" };
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!isAuthenticationEnabled()) {
|
||||
if (!effectiveSecurity().authEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "WebUI authentication is disabled." },
|
||||
{ status: 404, headers: NO_STORE }
|
||||
@@ -87,7 +86,9 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
clearFailures(ip);
|
||||
const rememberMe = body?.rememberMe === true;
|
||||
const ttlSeconds = rememberMe ? rememberTtlSeconds() : sessionTtlSeconds();
|
||||
const ttlSeconds = rememberMe
|
||||
? rememberTtlSeconds()
|
||||
: effectiveSecurity().sessionTtlSeconds;
|
||||
const { token, payload } = createSession(user.username, user.role, ttlSeconds);
|
||||
await recordLogin(payload);
|
||||
const response = NextResponse.json(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { AUTH_COOKIE_NAME, isAuthenticationEnabled } from "@/lib/auth";
|
||||
import { AUTH_COOKIE_NAME } from "@/lib/auth";
|
||||
import { decodeSessionPayload, roleForUsername } from "@/lib/server/auth";
|
||||
import { effectiveSecurity } from "@/lib/server/systemConfig";
|
||||
import { ensureBootstrapAdmin } from "@/lib/server/userStore";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -9,7 +10,7 @@ export const dynamic = "force-dynamic";
|
||||
const NO_STORE = { "Cache-Control": "no-store" };
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
if (!isAuthenticationEnabled()) {
|
||||
if (!effectiveSecurity().authEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "WebUI authentication is disabled." },
|
||||
{ status: 404, headers: NO_STORE }
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
AUTH_COOKIE_NAME,
|
||||
isAuthenticationEnabled,
|
||||
} from "@/lib/auth";
|
||||
import { AUTH_COOKIE_NAME } from "@/lib/auth";
|
||||
import {
|
||||
AuthConfigurationError,
|
||||
authCookieOptions,
|
||||
@@ -13,6 +10,7 @@ import {
|
||||
updateAuthPassword,
|
||||
verifyCredentials,
|
||||
} from "@/lib/server/auth";
|
||||
import { effectiveSecurity } from "@/lib/server/systemConfig";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -28,7 +26,9 @@ function hasControlCharacter(value: string): boolean {
|
||||
}
|
||||
|
||||
export function passwordValidationError(password: string): string | null {
|
||||
if (password.length < 8) return "New password must contain at least 8 characters.";
|
||||
const minLength = effectiveSecurity().passwordMinLength;
|
||||
if (password.length < minLength)
|
||||
return `New password must contain at least ${minLength} characters.`;
|
||||
if (password.length > 256) return "New password is too long.";
|
||||
if (hasControlCharacter(password)) {
|
||||
return "New password contains unsupported control characters.";
|
||||
@@ -37,7 +37,7 @@ export function passwordValidationError(password: string): string | null {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!isAuthenticationEnabled()) {
|
||||
if (!effectiveSecurity().authEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "WebUI authentication is disabled." },
|
||||
{ status: 404, headers: NO_STORE }
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAuthenticationEnabled } from "@/lib/auth";
|
||||
import { effectiveSecurity } from "@/lib/server/systemConfig";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{ enabled: isAuthenticationEnabled() },
|
||||
{ enabled: effectiveSecurity().authEnabled },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import "server-only";
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { AUTH_COOKIE_NAME, isAuthenticationEnabled } from "@/lib/auth";
|
||||
import { AUTH_COOKIE_NAME } from "@/lib/auth";
|
||||
import { decodeSessionPayload, roleForUsername } from "./auth";
|
||||
import { effectiveSecurity } from "./systemConfig";
|
||||
import { ensureBootstrapAdmin, type UserRole } from "./userStore";
|
||||
|
||||
export interface Actor {
|
||||
@@ -26,7 +27,7 @@ export class ActorError extends Error {
|
||||
* callers that need it.
|
||||
*/
|
||||
export function requireActor(request: NextRequest): Actor {
|
||||
if (!isAuthenticationEnabled()) {
|
||||
if (!effectiveSecurity().authEnabled) {
|
||||
return { sub: "local-admin", role: "admin" };
|
||||
}
|
||||
const session = decodeSessionPayload(
|
||||
|
||||
@@ -5,9 +5,9 @@ import { join } from "path";
|
||||
import {
|
||||
AUTH_COOKIE_NAME,
|
||||
authSecret,
|
||||
isAuthenticationEnabled,
|
||||
} from "@/lib/auth";
|
||||
import { webuiDataDir } from "./dataDir";
|
||||
import { effectiveSecurity } from "./systemConfig";
|
||||
import {
|
||||
ensureBootstrapAdmin,
|
||||
getUser,
|
||||
@@ -34,7 +34,7 @@ interface LoginAudit {
|
||||
export class AuthConfigurationError extends Error {}
|
||||
|
||||
function requireSecret(): string {
|
||||
if (!isAuthenticationEnabled()) {
|
||||
if (!effectiveSecurity().authEnabled) {
|
||||
throw new AuthConfigurationError("WebUI authentication is disabled.");
|
||||
}
|
||||
const secret = authSecret();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
import {
|
||||
clearAllFailuresForTests,
|
||||
clearFailures,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { effectiveSecurity } from "./systemConfig";
|
||||
|
||||
export const FAILURE_THRESHOLD = 3;
|
||||
const ENTRY_TTL_MS = 15 * 60 * 1000;
|
||||
@@ -26,13 +27,15 @@ export function recordFailure(ip: string): void {
|
||||
}
|
||||
|
||||
export function shouldRequireCaptcha(ip: string): boolean {
|
||||
const { captchaEnabled, captchaThreshold } = effectiveSecurity();
|
||||
if (!captchaEnabled) return false;
|
||||
const entry = entries.get(ip);
|
||||
if (!entry) return false;
|
||||
if (Date.now() - entry.touchedAt > ENTRY_TTL_MS) {
|
||||
entries.delete(ip);
|
||||
return false;
|
||||
}
|
||||
return entry.count >= FAILURE_THRESHOLD;
|
||||
return entry.count >= captchaThreshold;
|
||||
}
|
||||
|
||||
export function clearFailures(ip: string): void {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-sec-override-"));
|
||||
const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR;
|
||||
process.env.EVOSCIENTIST_DATA_DIR = dataDir;
|
||||
|
||||
const {
|
||||
getSystemConfig,
|
||||
saveSystemConfig,
|
||||
resetSystemConfigCacheForTests,
|
||||
} = await import("./systemConfig");
|
||||
const loginFailures = await import("./loginFailures");
|
||||
|
||||
describe("security overrides", () => {
|
||||
beforeEach(() => {
|
||||
const config = getSystemConfig();
|
||||
config.security = {
|
||||
authEnabled: null,
|
||||
sessionTtlHours: null,
|
||||
passwordMinLength: null,
|
||||
captchaEnabled: null,
|
||||
captchaThreshold: null,
|
||||
};
|
||||
saveSystemConfig(config);
|
||||
resetSystemConfigCacheForTests();
|
||||
loginFailures.clearAllFailuresForTests();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR;
|
||||
else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("requires captcha after 3 failures by default", () => {
|
||||
loginFailures.recordFailure("1.1.1.1");
|
||||
loginFailures.recordFailure("1.1.1.1");
|
||||
expect(loginFailures.shouldRequireCaptcha("1.1.1.1")).toBe(false);
|
||||
loginFailures.recordFailure("1.1.1.1");
|
||||
expect(loginFailures.shouldRequireCaptcha("1.1.1.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("honors a captcha threshold override", () => {
|
||||
const config = getSystemConfig();
|
||||
config.security.captchaThreshold = 1;
|
||||
saveSystemConfig(config);
|
||||
resetSystemConfigCacheForTests();
|
||||
loginFailures.recordFailure("2.2.2.2");
|
||||
expect(loginFailures.shouldRequireCaptcha("2.2.2.2")).toBe(true);
|
||||
});
|
||||
|
||||
it("never requires captcha when disabled", () => {
|
||||
const config = getSystemConfig();
|
||||
config.security.captchaEnabled = false;
|
||||
saveSystemConfig(config);
|
||||
resetSystemConfigCacheForTests();
|
||||
for (let i = 0; i < 10; i += 1) loginFailures.recordFailure("3.3.3.3");
|
||||
expect(loginFailures.shouldRequireCaptcha("3.3.3.3")).toBe(false);
|
||||
});
|
||||
});
|
||||
+4
-2
@@ -3,8 +3,8 @@ import {
|
||||
AUTH_COOKIE_NAME,
|
||||
AUTH_LOGIN_PATH,
|
||||
authSecret,
|
||||
isAuthenticationEnabled,
|
||||
} from "@/lib/auth";
|
||||
import { effectiveSecurity } from "@/lib/server/systemConfig";
|
||||
|
||||
interface SessionPayload {
|
||||
username: string;
|
||||
@@ -81,6 +81,8 @@ function isPublicPath(pathname: string): boolean {
|
||||
pathname === "/api/usage/events" ||
|
||||
pathname === "/api/usage/sources/heartbeat" ||
|
||||
pathname === "/api/usage/capabilities" ||
|
||||
pathname === "/api/system/config/public" ||
|
||||
pathname.startsWith("/api/system/branding/asset/") ||
|
||||
pathname.startsWith("/api/workspace/render/") ||
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname === "/favicon.ico" ||
|
||||
@@ -101,7 +103,7 @@ function configurationError(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
if (!isAuthenticationEnabled() || isPublicPath(request.nextUrl.pathname)) {
|
||||
if (!effectiveSecurity().authEnabled || isPublicPath(request.nextUrl.pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
if (!authSecret() || authSecret()!.length < 32) {
|
||||
|
||||
Reference in New Issue
Block a user