Skip to content

Commit 7b8ea65

Browse files
authored
feat: add security.password.enabled for SSO-only deployments (#84)
Follow-up to #83, which noted this gap but did not close it. ## Problem Enabling Google SSO does not disable password sign-in, and there is no way to make it exclusive. - `PasswordlessAuthService.loginWithPassword()` never consults `security.google.enabled` — no branch, no guard - `/auth/login` is `permitAll` in `SecurityConfig` unconditionally - no `security.password.enabled` (or equivalent) exists anywhere So a deployment that puts every human behind Google Workspace — domain-allowlisted, `hd`-claim verified — still leaves `/auth/login` open to every local account. Anyone reasoning "we turned on SSO, so password login is closed" is wrong, and nothing in the codebase says otherwise. This is most acute right after enabling SSO on an install that has seed or demo accounts: those credentials keep working, internet-facing, with SSO fully configured. ## Change Adds `security.password.enabled`, **defaulting to `true`** so existing installs behave exactly as before. **`PasswordlessAuthService`** - Rejects **before** the rate limiter and before any credential comparison. Nothing needs rate-limiting when the path is closed, and rejecting early avoids leaking whether an account exists. - Emits `PASSWORD_LOGIN_FAILURE` with `reason=password_login_disabled`, so refusals are auditable instead of 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 discovered at the login screen. - The class needed `@Slf4j` — it had no logger. **`SetupController`** — exposes `passwordLoginEnabled` on the public `/setup/status`, alongside the `googleEnabled` added in #83. **`Login.jsx`** — hides the password form, drops the now-meaningless "or" divider, and rewords the subtitle to "Sign in with your work Google account." The frontend guard is `passwordLoginEnabled !== false`, deliberately **not** a truthiness test: `setupStatus` is `null` on first paint and the field is `undefined` on installs predating this flag. Both must render the form. Inverting it would strand users on a login page with no way in whenever the status call was slow or failed. ## Testing Verified on a live self-host install with `SECURITY_PASSWORD_ENABLED=false`: ``` /api/setup/status -> "googleEnabled":true,"passwordLoginEnabled":false POST /api/auth/login -> {"message":"Password sign-in is disabled. Please sign in with Google."} (real ADMIN account, account_status=ACTIVE — a valid credential is refused, not just a bad one) /api/auth/google/start -> 302 (unaffected) startup log -> Password sign-in is DISABLED (security.password.enabled=false); Google SSO only. security_event -> PASSWORD_LOGIN_FAILURE | {"reason": "password_login_disabled"} ``` Login page renders the Google button alone — no password fields, no orphaned divider. With the flag unset, the page and the login behaviour are unchanged.
1 parent 8081bb4 commit 7b8ea65

7 files changed

Lines changed: 95 additions & 15 deletions

File tree

backend/src/main/java/com/dbaagent/controller/SetupController.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ public class SetupController {
4646
@Value("${security.google.enabled:false}")
4747
private boolean googleEnabled;
4848

49+
/**
50+
* Mirrors {@code security.password.enabled}. Lets the login page hide the
51+
* email/password form on SSO-only installs instead of rendering a form that
52+
* always fails. Same non-final reasoning as above.
53+
*/
54+
@Value("${security.password.enabled:true}")
55+
private boolean passwordLoginEnabled;
56+
4957
// ── GET /setup/status ─────────────────────────────────────────────────────
5058

5159
/** Returns setup completion state. Public endpoint — no auth required. */
@@ -66,7 +74,8 @@ public SetupStatusResponse getStatus() {
6674
hasOrgInfo,
6775
hasConnections,
6876
hasLlmConfig,
69-
googleEnabled
77+
googleEnabled,
78+
passwordLoginEnabled
7079
);
7180
}
7281

@@ -293,7 +302,9 @@ public record SetupStatusResponse(
293302
boolean hasConnections,
294303
boolean hasLlmConfig,
295304
/** Whether Google Workspace SSO is configured; drives the login page's SSO button. */
296-
boolean googleEnabled
305+
boolean googleEnabled,
306+
/** Whether email+password sign-in is accepted; false hides the password form. */
307+
boolean passwordLoginEnabled
297308
) {}
298309

299310
public record InitializeRequest(String orgName, String adminUsername, String adminEmail, String adminPassword) {}

backend/src/main/java/com/dbaagent/service/PasswordlessAuthService.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import com.dbaagent.repository.*;
55
import com.dbaagent.security.EncryptionService;
66
import com.dbaagent.util.SecurityHashUtil;
7+
import jakarta.annotation.PostConstruct;
78
import lombok.Builder;
89
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
911
import org.springframework.beans.factory.annotation.Value;
1012
import org.springframework.http.HttpStatus;
1113
import org.springframework.http.MediaType;
@@ -25,6 +27,7 @@
2527

2628
@Service
2729
@RequiredArgsConstructor
30+
@Slf4j
2831
public class PasswordlessAuthService {
2932
private static final SecureRandom RANDOM = new SecureRandom();
3033

@@ -72,6 +75,32 @@ public class PasswordlessAuthService {
7275
@Value("${security.google.enabled:false}")
7376
private boolean googleEnabled;
7477

78+
/**
79+
* Whether email + password sign-in is accepted at all.
80+
*
81+
* <p>Defaults to {@code true} so existing installs are unaffected. Set it to
82+
* false on deployments that front DeepSQL with Google Workspace SSO: enabling
83+
* SSO does NOT by itself close the password path, so without this flag
84+
* {@code /auth/login} stays open to every local account even when every human
85+
* signs in through Google.
86+
*
87+
* <p>Turning this off while {@code security.google.enabled} is also off leaves
88+
* no way to sign in — {@link #warnIfNoAuthMethodEnabled()} shouts about that at
89+
* startup rather than letting it be discovered at the login screen.
90+
*/
91+
@Value("${security.password.enabled:true}")
92+
private boolean passwordLoginEnabled;
93+
94+
@PostConstruct
95+
void warnIfNoAuthMethodEnabled() {
96+
if (!passwordLoginEnabled && !googleEnabled) {
97+
log.error("security.password.enabled=false AND security.google.enabled=false — "
98+
+ "no sign-in method is available and nobody can log in. Enable one of them.");
99+
} else if (!passwordLoginEnabled) {
100+
log.info("Password sign-in is DISABLED (security.password.enabled=false); Google SSO only.");
101+
}
102+
}
103+
75104
@Value("${security.google.client-id:}")
76105
private String googleClientId;
77106

@@ -87,6 +116,23 @@ public class PasswordlessAuthService {
87116
@Transactional
88117
public AuthFlowResult loginWithPassword(String email, String password, String clientIp, String userAgent, String requestId) {
89118
String normalizedEmail = normalizeEmail(email);
119+
120+
// Checked before the rate limiter and before any credential comparison:
121+
// when the password path is closed there is nothing to rate-limit and no
122+
// secret to compare, and we must not leak whether the account exists.
123+
if (!passwordLoginEnabled) {
124+
securityEventService.log(SecurityEventService.EventRequest.builder()
125+
.eventType(SecurityEventType.PASSWORD_LOGIN_FAILURE)
126+
.outcome(SecurityEventOutcome.FAILURE)
127+
.email(normalizedEmail)
128+
.clientIp(clientIp)
129+
.userAgent(userAgent)
130+
.requestId(requestId)
131+
.metadata(Map.of("reason", "password_login_disabled"))
132+
.build());
133+
return AuthFlowResult.invalid("Password sign-in is disabled. Please sign in with Google.");
134+
}
135+
90136
if (rateLimitEnabled) enforcePasswordRateLimit(normalizedEmail, clientIp);
91137

92138
User user = normalizedEmail == null ? null : userRepository.findByEmailIgnoreCase(normalizedEmail).orElse(null);

backend/src/main/resources/application-prod.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ spring.threads.virtual.enabled=true
77

88
# Security Configuration - set to false to bypass authentication
99
security.auth.enabled=true
10+
security.password.enabled=${SECURITY_PASSWORD_ENABLED:true}
1011
security.jwt.secret=${SECURITY_JWT_SECRET:}
1112
# First-user bootstrap. Off by default: the endpoint creates an ADMIN without
1213
# authenticating, so it stays shut unless someone is deliberately installing.

backend/src/main/resources/application.properties

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ spring.threads.virtual.enabled=true
1616
# matches production behavior. Override with SECURITY_AUTH_ENABLED=false only
1717
# for explicit single-user bypass scenarios.
1818
security.auth.enabled=${SECURITY_AUTH_ENABLED:true}
19+
# Email+password sign-in. Defaults true so existing installs are unaffected.
20+
# Set false on SSO-only deployments: enabling Google does NOT close /auth/login.
21+
security.password.enabled=${SECURITY_PASSWORD_ENABLED:true}
1922
security.admin-mfa.enabled=${SECURITY_ADMIN_MFA_ENABLED:false}
2023
security.jwt.secret=${SECURITY_JWT_SECRET:}
2124
security.admin.bootstrap.enabled=${SECURITY_ADMIN_BOOTSTRAP_ENABLED:false}

backend/src/test/java/com/dbaagent/service/PasswordlessAuthServiceTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ void setUp() {
5757
ReflectionTestUtils.setField(service, "maxIpStarts", 20);
5858
ReflectionTestUtils.setField(service, "maxPasswordFailures", 10);
5959
ReflectionTestUtils.setField(service, "adminMfaEnabled", false);
60+
// @Value is not processed by @InjectMocks, so a boolean field defaults to
61+
// false. Unlike rateLimitEnabled (checked as `if (enabled)`, so absence
62+
// merely skips it), password login is checked as `if (!enabled)` — leaving
63+
// it unset would reject every login below.
64+
ReflectionTestUtils.setField(service, "passwordLoginEnabled", true);
6065

6166
when(authLoginChallengeRepository.save(any(AuthLoginChallenge.class)))
6267
.thenAnswer(invocation -> invocation.getArgument(0));

src/lib/api/client.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3682,7 +3682,8 @@ export const setupAPI = {
36823682

36833683
/**
36843684
* Returns current setup state: setupComplete, hasOrganizationInfo,
3685-
* hasConnections, hasLlmConfig. Public endpoint — no auth required.
3685+
* hasConnections, hasLlmConfig, googleEnabled, passwordLoginEnabled.
3686+
* Public endpoint — no auth required.
36863687
*/
36873688
getStatus: async () => {
36883689
const response = await apiClient.get("/api/setup/status");

src/pages/Login.jsx

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,15 @@ export default function Login() {
9898
}
9999

100100

101+
// Default to showing the password form: setupStatus is null on first paint and
102+
// while the request is in flight, and an install that never set the flag gets
103+
// `undefined`. Both must render the form, or a slow/failed status call would
104+
// strand everyone on a login page with no way in.
105+
const passwordLoginEnabled = setupStatus?.passwordLoginEnabled !== false
106+
101107
const renderLoginStep = () => (
102108
<>
109+
{passwordLoginEnabled && (
103110
<form onSubmit={handlePasswordLogin} className="space-y-5">
104111
<div>
105112
<label htmlFor="email" className="text-xs font-semibold text-gray-500 uppercase tracking-wider block mb-1.5">
@@ -148,6 +155,7 @@ export default function Login() {
148155
{loading ? 'Signing in…' : 'Sign In'}
149156
</button>
150157
</form>
158+
)}
151159

152160
{/*
153161
Only rendered when the server reports security.google.enabled. The login
@@ -159,21 +167,24 @@ export default function Login() {
159167
so this is a full-page navigation and must not submit the form above.
160168
*/}
161169
{setupStatus?.googleEnabled && (
162-
<div className="mt-6">
163-
<div className="relative">
164-
<div className="absolute inset-0 flex items-center" aria-hidden="true">
165-
<div className="w-full border-t border-gray-200" />
166-
</div>
167-
<div className="relative flex justify-center">
168-
<span className="bg-white px-3 text-xs font-medium uppercase tracking-wider text-gray-400">
169-
or
170-
</span>
170+
<div className={passwordLoginEnabled ? 'mt-6' : ''}>
171+
{/* The divider only separates two things — drop it when SSO stands alone. */}
172+
{passwordLoginEnabled && (
173+
<div className="relative">
174+
<div className="absolute inset-0 flex items-center" aria-hidden="true">
175+
<div className="w-full border-t border-gray-200" />
176+
</div>
177+
<div className="relative flex justify-center">
178+
<span className="bg-white px-3 text-xs font-medium uppercase tracking-wider text-gray-400">
179+
or
180+
</span>
181+
</div>
171182
</div>
172-
</div>
183+
)}
173184

174185
<a
175186
href={authAPI.getGoogleStartUrl()}
176-
className="mt-6 w-full min-h-[48px] flex items-center justify-center gap-3 bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 font-semibold py-3 rounded-full shadow-sm transition-all active:scale-[0.98]"
187+
className={`${passwordLoginEnabled ? 'mt-6' : ''} w-full min-h-[48px] flex items-center justify-center gap-3 bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 font-semibold py-3 rounded-full shadow-sm transition-all active:scale-[0.98]`}
177188
>
178189
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
179190
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.76h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
@@ -250,7 +261,9 @@ export default function Login() {
250261
const stepTitle = step === STEP_OTP ? 'Verify your sign-in' : 'Secure sign-in'
251262
const stepSubtitle = step === STEP_OTP
252263
? 'Complete the extra email verification step for this workspace.'
253-
: 'Sign in with your email and password to access DeepSQL.'
264+
: passwordLoginEnabled
265+
? 'Sign in with your email and password to access DeepSQL.'
266+
: 'Sign in with your work Google account to access DeepSQL.'
254267

255268
return (
256269
<div className="flex min-h-screen w-full bg-white text-gray-900 overflow-x-hidden">

0 commit comments

Comments
 (0)