From 24f085ea9a294df3184212a89085db4852fe8037 Mon Sep 17 00:00:00 2001 From: Sasank Talasila Date: Mon, 24 Aug 2026 20:17:09 +0000 Subject: [PATCH 1/2] feat: add security.password.enabled to allow SSO-only deployments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling Google SSO does not close the password path. loginWithPassword() never consulted security.google.enabled, /auth/login is permitAll unconditionally, and no toggle existed — so a deployment that puts every human behind Google Workspace still left /auth/login open to every local account. Anyone treating SSO as an exclusive gate was wrong about it. Adds security.password.enabled, defaulting to true so existing installs are unaffected. - PasswordlessAuthService: reject before the rate limiter and before any credential comparison. There is nothing to rate-limit when the path is closed, and rejecting early avoids leaking whether an account exists. Emits PASSWORD_LOGIN_FAILURE with reason=password_login_disabled so the refusal is auditable rather than silent. - @PostConstruct guard: logs an ERROR when password AND google are both disabled — that combination leaves nobody able to sign in, and is otherwise only discoverable at the login screen. - SetupController: expose passwordLoginEnabled on the public status response, alongside googleEnabled. - Login.jsx: hide the password form, drop the now-meaningless "or" divider, and reword the subtitle. Guarded as `!== false` rather than on truthiness: setupStatus is null on first paint and undefined on installs predating the flag, and both must render the form — inverting that would strand users on a login page with no way in if the status call were slow or failed. --- .../dbaagent/controller/SetupController.java | 15 +++++- .../service/PasswordlessAuthService.java | 46 +++++++++++++++++++ .../resources/application-prod.properties | 1 + .../src/main/resources/application.properties | 3 ++ src/pages/Login.jsx | 37 ++++++++++----- 5 files changed, 88 insertions(+), 14 deletions(-) diff --git a/backend/src/main/java/com/dbaagent/controller/SetupController.java b/backend/src/main/java/com/dbaagent/controller/SetupController.java index fbceab9..1c6f1dc 100644 --- a/backend/src/main/java/com/dbaagent/controller/SetupController.java +++ b/backend/src/main/java/com/dbaagent/controller/SetupController.java @@ -46,6 +46,14 @@ public class SetupController { @Value("${security.google.enabled:false}") private boolean googleEnabled; + /** + * Mirrors {@code security.password.enabled}. Lets the login page hide the + * email/password form on SSO-only installs instead of rendering a form that + * always fails. Same non-final reasoning as above. + */ + @Value("${security.password.enabled:true}") + private boolean passwordLoginEnabled; + // ── GET /setup/status ───────────────────────────────────────────────────── /** Returns setup completion state. Public endpoint — no auth required. */ @@ -66,7 +74,8 @@ public SetupStatusResponse getStatus() { hasOrgInfo, hasConnections, hasLlmConfig, - googleEnabled + googleEnabled, + passwordLoginEnabled ); } @@ -293,7 +302,9 @@ public record SetupStatusResponse( boolean hasConnections, boolean hasLlmConfig, /** Whether Google Workspace SSO is configured; drives the login page's SSO button. */ - boolean googleEnabled + boolean googleEnabled, + /** Whether email+password sign-in is accepted; false hides the password form. */ + boolean passwordLoginEnabled ) {} public record InitializeRequest(String orgName, String adminUsername, String adminEmail, String adminPassword) {} diff --git a/backend/src/main/java/com/dbaagent/service/PasswordlessAuthService.java b/backend/src/main/java/com/dbaagent/service/PasswordlessAuthService.java index 9a836b3..75b2822 100644 --- a/backend/src/main/java/com/dbaagent/service/PasswordlessAuthService.java +++ b/backend/src/main/java/com/dbaagent/service/PasswordlessAuthService.java @@ -4,8 +4,10 @@ import com.dbaagent.repository.*; import com.dbaagent.security.EncryptionService; import com.dbaagent.util.SecurityHashUtil; +import jakarta.annotation.PostConstruct; import lombok.Builder; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -25,6 +27,7 @@ @Service @RequiredArgsConstructor +@Slf4j public class PasswordlessAuthService { private static final SecureRandom RANDOM = new SecureRandom(); @@ -72,6 +75,32 @@ public class PasswordlessAuthService { @Value("${security.google.enabled:false}") private boolean googleEnabled; + /** + * Whether email + password sign-in is accepted at all. + * + *

Defaults to {@code true} so existing installs are unaffected. Set it to + * false on deployments that front DeepSQL with Google Workspace SSO: enabling + * SSO does NOT by itself close the password path, so without this flag + * {@code /auth/login} stays open to every local account even when every human + * signs in through Google. + * + *

Turning this off while {@code security.google.enabled} is also off leaves + * no way to sign in — {@link #warnIfNoAuthMethodEnabled()} shouts about that at + * startup rather than letting it be discovered at the login screen. + */ + @Value("${security.password.enabled:true}") + private boolean passwordLoginEnabled; + + @PostConstruct + void warnIfNoAuthMethodEnabled() { + if (!passwordLoginEnabled && !googleEnabled) { + log.error("security.password.enabled=false AND security.google.enabled=false — " + + "no sign-in method is available and nobody can log in. Enable one of them."); + } else if (!passwordLoginEnabled) { + log.info("Password sign-in is DISABLED (security.password.enabled=false); Google SSO only."); + } + } + @Value("${security.google.client-id:}") private String googleClientId; @@ -87,6 +116,23 @@ public class PasswordlessAuthService { @Transactional public AuthFlowResult loginWithPassword(String email, String password, String clientIp, String userAgent, String requestId) { String normalizedEmail = normalizeEmail(email); + + // Checked before the rate limiter and before any credential comparison: + // when the password path is closed there is nothing to rate-limit and no + // secret to compare, and we must not leak whether the account exists. + if (!passwordLoginEnabled) { + securityEventService.log(SecurityEventService.EventRequest.builder() + .eventType(SecurityEventType.PASSWORD_LOGIN_FAILURE) + .outcome(SecurityEventOutcome.FAILURE) + .email(normalizedEmail) + .clientIp(clientIp) + .userAgent(userAgent) + .requestId(requestId) + .metadata(Map.of("reason", "password_login_disabled")) + .build()); + return AuthFlowResult.invalid("Password sign-in is disabled. Please sign in with Google."); + } + if (rateLimitEnabled) enforcePasswordRateLimit(normalizedEmail, clientIp); User user = normalizedEmail == null ? null : userRepository.findByEmailIgnoreCase(normalizedEmail).orElse(null); diff --git a/backend/src/main/resources/application-prod.properties b/backend/src/main/resources/application-prod.properties index 065d3c4..8cf8390 100644 --- a/backend/src/main/resources/application-prod.properties +++ b/backend/src/main/resources/application-prod.properties @@ -7,6 +7,7 @@ spring.threads.virtual.enabled=true # Security Configuration - set to false to bypass authentication security.auth.enabled=true +security.password.enabled=${SECURITY_PASSWORD_ENABLED:true} security.jwt.secret=${SECURITY_JWT_SECRET:} # First-user bootstrap. Off by default: the endpoint creates an ADMIN without # authenticating, so it stays shut unless someone is deliberately installing. diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 02057fb..befdb2a 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -16,6 +16,9 @@ spring.threads.virtual.enabled=true # matches production behavior. Override with SECURITY_AUTH_ENABLED=false only # for explicit single-user bypass scenarios. security.auth.enabled=${SECURITY_AUTH_ENABLED:true} +# Email+password sign-in. Defaults true so existing installs are unaffected. +# Set false on SSO-only deployments: enabling Google does NOT close /auth/login. +security.password.enabled=${SECURITY_PASSWORD_ENABLED:true} security.admin-mfa.enabled=${SECURITY_ADMIN_MFA_ENABLED:false} security.jwt.secret=${SECURITY_JWT_SECRET:} security.admin.bootstrap.enabled=${SECURITY_ADMIN_BOOTSTRAP_ENABLED:false} diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index 308ec5f..b8929e3 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -98,8 +98,15 @@ export default function Login() { } + // Default to showing the password form: setupStatus is null on first paint and + // while the request is in flight, and an install that never set the flag gets + // `undefined`. Both must render the form, or a slow/failed status call would + // strand everyone on a login page with no way in. + const passwordLoginEnabled = setupStatus?.passwordLoginEnabled !== false + const renderLoginStep = () => ( <> + {passwordLoginEnabled && (