Skip to content

Commit 24f085e

Browse files
committed
feat: add security.password.enabled to allow SSO-only deployments
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.
1 parent 8081bb4 commit 24f085e

5 files changed

Lines changed: 88 additions & 14 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}

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)