feat(auth): add OIDC password-login disabler when OIDC is active (#10889)

Validado no worktree combinado: typecheck:core, changelog-integrity, complexity, cognitive-complexity, file-size, lint e testes focados (auth-login-route, login-bootstrap-route, feature-flags-settings — corrigi EXPECTED_FEATURE_FLAG_COUNT 51→52 fix-in-place, novo flag adicionado sem atualizar a própria contagem) todos verdes. CI vermelho é o base-red já rastreado em #9985. Obrigado!
This commit is contained in:
Reza Rezaei
2026-08-21 09:18:55 +02:00
committed by GitHub
parent 4fa204de68
commit e4a24173af
10 changed files with 208 additions and 91 deletions

View File

@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { classifyIpScope } from "@/lib/ipUtils";
import { getCachedSettings } from "@/lib/localDb";
import { getCachedSettings } from "@/lib/db/settings";
import { SignJWT } from "jose";
import { cookies } from "next/headers";
import {
@@ -9,6 +9,7 @@ import {
getStoredManagementPassword,
verifyManagementPassword,
} from "@/lib/auth/managementPassword";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { loginSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { checkLoginGuard, clearLoginAttempts, recordLoginFailure } from "@/server/auth/loginGuard";
@@ -74,8 +75,32 @@ export async function POST(request) {
return NextResponse.json({ error: "Invalid password payload" }, { status: 400 });
}
const settings = await getCachedSettings();
const bruteForceEnabled = settings.bruteForceProtection !== false;
const clientIp = auditContext.ipAddress || null;
const oidcDisabledPassword =
settings.oidcEnabled === true &&
(settings.oidcDisablePasswordLogin === true ||
isFeatureFlagEnabled("OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN") ||
process.env.OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN === "true" ||
process.env.OIDC_DISABLE_PASSWORD_LOGIN === "true");
if (oidcDisabledPassword) {
logAuditEvent({
action: "auth.login.password_disabled_by_oidc",
actor: "anonymous",
target: "dashboard-auth",
resourceType: "auth_session",
status: "failed",
ipAddress: clientIp || undefined,
requestId: auditContext.requestId,
metadata: { reason: "password_login_disabled_when_oidc_active" },
});
return NextResponse.json(
{ error: "Password login is disabled when OIDC is active. Please sign in with OIDC." },
{ status: 403 }
);
}
const bruteForceEnabled = settings.bruteForceProtection !== false;
const guardCheck = checkLoginGuard(clientIp, { enabled: bruteForceEnabled });
if (!guardCheck.allowed) {

View File

@@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
import { getSettings, updateSettings } from "@/lib/localDb";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { getSettings, updateSettings } from "@/lib/db/settings";
import {
hasManagementPasswordConfigured,
hashManagementPassword,
@@ -52,12 +53,19 @@ export async function GET() {
const hasPassword = hasManagementPasswordConfigured(settings);
const setupComplete = !!settings.setupComplete;
const oidcEnabled = !!settings.oidcEnabled;
const oidcDisablePasswordLogin =
oidcEnabled &&
(settings.oidcDisablePasswordLogin === true ||
isFeatureFlagEnabled("OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN") ||
process.env.OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN === "true" ||
process.env.OIDC_DISABLE_PASSWORD_LOGIN === "true");
return NextResponse.json({
authenticated,
requireLogin,
hasPassword,
setupComplete,
oidcEnabled,
oidcDisablePasswordLogin,
...nodeInfo,
});
} catch (error) {
@@ -69,6 +77,7 @@ export async function GET() {
hasPassword: true,
setupComplete: true,
oidcEnabled: false,
oidcDisablePasswordLogin: false,
...nodeInfo,
},
{ status: 200 }

View File

@@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSettings, getSettingsRevision, updateSettings } from "@/lib/localDb";
import { SettingsRevisionConflictError } from "@/lib/db/settings";
import {
getSettings,
getSettingsRevision,
updateSettings,
SettingsRevisionConflictError,
} from "@/lib/db/settings";
import { getRuntimePorts } from "@/lib/runtime/ports";
import { updateSettingsSchema } from "@/shared/validation/settingsSchemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -118,6 +122,7 @@ const SECURITY_IMPACTING_KEYS = [
"requireLogin",
"newPassword",
"oidcEnabled",
"oidcDisablePasswordLogin",
"oidcClientSecret",
] as const;

View File

@@ -11,9 +11,10 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [hasPassword, setHasPassword] = useState(null);
const [setupComplete, setSetupComplete] = useState(null);
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
const [setupComplete, setSetupComplete] = useState<boolean | null>(null);
const [oidcEnabled, setOidcEnabled] = useState<boolean | null>(null);
const [oidcDisablePasswordLogin, setOidcDisablePasswordLogin] = useState<boolean | null>(null);
const [mounted, setMounted] = useState(false);
const [nodeVersion, setNodeVersion] = useState(null);
const [nodeCompatible, setNodeCompatible] = useState(true);
@@ -44,16 +45,19 @@ export default function LoginPage() {
setHasPassword(!!data.hasPassword);
setSetupComplete(!!data.setupComplete);
setOidcEnabled(!!data.oidcEnabled);
setOidcDisablePasswordLogin(!!data.oidcDisablePasswordLogin);
} else {
setHasPassword(true);
setSetupComplete(true);
setOidcEnabled(false);
setOidcDisablePasswordLogin(false);
}
} catch (err) {
clearTimeout(timeoutId);
setHasPassword(true);
setSetupComplete(true);
setOidcEnabled(false);
setOidcDisablePasswordLogin(false);
}
}
checkAuth();
@@ -122,7 +126,12 @@ export default function LoginPage() {
</div>
</div>
) : null;
if (hasPassword === null || setupComplete === null || oidcEnabled === null) {
if (
hasPassword === null ||
setupComplete === null ||
oidcEnabled === null ||
(oidcEnabled && oidcDisablePasswordLogin === null)
) {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
{nodeWarningBanner}
@@ -235,60 +244,84 @@ export default function LoginPage() {
</span>
</div>
<h1 className="text-2xl font-bold text-text-main tracking-tight">{t("signIn")}</h1>
<p className="text-text-muted mt-1.5">{t("enterPassword")}</p>
<p className="text-text-muted mt-1.5">
{oidcEnabled && oidcDisablePasswordLogin
? t("continueWithOidc")
: t("enterPassword")}
</p>
</div>
<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-text-main">{t("password")}</label>
<Input
type="password"
placeholder={t("enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="h-11"
/>
{error && (
<p className="text-sm text-red-500 flex items-center gap-1.5 pt-1">
<span className="material-symbols-outlined text-base">error</span>
{error}
</p>
)}
<p className="text-xs text-text-muted/60 pt-0.5">{t("defaultPasswordHint")}</p>
</div>
<Button
type="submit"
variant="primary"
className="w-full h-11 text-sm font-medium"
loading={loading}
>
{t("continue")}
</Button>
</form>
{oidcEnabled && (
<div className="mt-4">
{oidcEnabled && oidcDisablePasswordLogin ? (
<div className="space-y-4">
<Button
type="button"
variant="secondary"
className="w-full h-11 text-sm font-medium"
variant="primary"
className="w-full h-11 text-sm font-medium flex items-center justify-center gap-2"
onClick={() => (window.location.href = "/api/auth/oidc/login")}
>
<span className="material-symbols-outlined text-lg">login</span>
{t("continueWithOidc")}
</Button>
</div>
) : (
<>
<form onSubmit={handleLogin} className="space-y-5 w-full">
<div className="space-y-2">
<label className="text-sm font-medium text-text-main">{t("password")}</label>
<Input
type="password"
placeholder={t("enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="h-11"
/>
{error && (
<p className="text-sm text-red-500 flex items-center gap-1.5 pt-1">
<span className="material-symbols-outlined text-base">error</span>
{error}
</p>
)}
<p className="text-xs text-text-muted/60 pt-0.5">{t("defaultPasswordHint")}</p>
</div>
<Button
type="submit"
variant="primary"
className="w-full h-11 text-sm font-medium"
loading={loading}
>
{t("continue")}
</Button>
</form>
{oidcEnabled && (
<div className="mt-4">
<Button
type="button"
variant="secondary"
className="w-full h-11 text-sm font-medium flex items-center justify-center gap-2"
onClick={() => (window.location.href = "/api/auth/oidc/login")}
>
<span className="material-symbols-outlined text-lg">login</span>
{t("continueWithOidc")}
</Button>
</div>
)}
</>
)}
<div className="mt-6 pt-6 border-t border-border">
<a
href="/forgot-password"
className="text-sm text-text-muted hover:text-primary transition-colors"
>
{t("forgotPassword")}
</a>
</div>
{!oidcEnabled && (
<div className="mt-6 pt-6 border-t border-border">
<a
href="/forgot-password"
className="text-sm text-text-muted hover:text-primary transition-colors"
>
{t("forgotPassword")}
</a>
</div>
)}
</div>
</div>

View File

@@ -162,6 +162,7 @@ export async function getSettings() {
antigravitySignatureCacheMode: "enabled",
requireLogin: true,
oidcEnabled: false,
oidcDisablePasswordLogin: false,
oidcIssuer: "",
oidcClientId: "",
oidcClientSecret: "",

View File

@@ -109,9 +109,9 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
key: "AUTH_LOG_INCLUDE_ACCOUNT_ID",
label: "Log Account IDs",
description:
"Include the account ID prefix in AUTH log lines (e.g. \"Using <provider> account: abc12345...\"). " +
"Disabled by default so the account identifier is redacted in shared/multi-tenant process logs. " +
"Independent of Debug Mode flipping Debug Mode on does not reveal this.",
'Include account prefix in AUTH log lines (e.g. "Using <provider> account: abc12345..."). ' +
"Disabled by default so account identifiers are redacted from shared/multi-tenant process logs. " +
"Independent from Debug Mode; flipping Debug Mode does not reveal this.",
descriptionI18nKey: "featureFlagAuthLogIncludeAccountIdDescription",
category: "security",
defaultValue: "false",
@@ -119,6 +119,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
requiresRestart: false,
warningLevel: "info",
},
{
key: "OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN",
label: "Disable Password Login With OIDC",
description:
"When OIDC is enabled, disable password login so users can only authenticate via OIDC Single Sign-On. When disabled (default), both password login and OIDC are available.",
descriptionI18nKey: "featureFlagOidcDisablePasswordLoginDescription",
category: "security",
defaultValue: "false",
type: "boolean",
requiresRestart: false,
warningLevel: "info",
},
// ──────────────── Network (7) ────────────────
{
key: "ENABLE_TLS_FINGERPRINT",

View File

@@ -108,6 +108,7 @@ export const updateSettingsSchema = z.object({
language: z.string().max(10).optional(),
requireLogin: z.boolean().optional(),
oidcEnabled: z.boolean().optional(),
oidcDisablePasswordLogin: z.boolean().optional(),
oidcIssuer: z.string().max(500).optional(),
oidcClientId: z.string().max(200).optional(),
oidcClientSecret: z.string().max(500).optional(),