Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -66,7 +74,8 @@ public SetupStatusResponse getStatus() {
hasOrgInfo,
hasConnections,
hasLlmConfig,
googleEnabled
googleEnabled,
passwordLoginEnabled
);
}

Expand Down Expand Up @@ -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) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +27,7 @@

@Service
@RequiredArgsConstructor
@Slf4j
public class PasswordlessAuthService {
private static final SecureRandom RANDOM = new SecureRandom();

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

/**
* Whether email + password sign-in is accepted at all.
*
* <p>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.
*
* <p>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;

Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions backend/src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ void setUp() {
ReflectionTestUtils.setField(service, "maxIpStarts", 20);
ReflectionTestUtils.setField(service, "maxPasswordFailures", 10);
ReflectionTestUtils.setField(service, "adminMfaEnabled", false);
// @Value is not processed by @InjectMocks, so a boolean field defaults to
// false. Unlike rateLimitEnabled (checked as `if (enabled)`, so absence
// merely skips it), password login is checked as `if (!enabled)` — leaving
// it unset would reject every login below.
ReflectionTestUtils.setField(service, "passwordLoginEnabled", true);

when(authLoginChallengeRepository.save(any(AuthLoginChallenge.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
Expand Down
3 changes: 2 additions & 1 deletion src/lib/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -3682,7 +3682,8 @@ export const setupAPI = {

/**
* Returns current setup state: setupComplete, hasOrganizationInfo,
* hasConnections, hasLlmConfig. Public endpoint — no auth required.
* hasConnections, hasLlmConfig, googleEnabled, passwordLoginEnabled.
* Public endpoint — no auth required.
*/
getStatus: async () => {
const response = await apiClient.get("/api/setup/status");
Expand Down
37 changes: 25 additions & 12 deletions src/pages/Login.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
<form onSubmit={handlePasswordLogin} className="space-y-5">
<div>
<label htmlFor="email" className="text-xs font-semibold text-gray-500 uppercase tracking-wider block mb-1.5">
Expand Down Expand Up @@ -148,6 +155,7 @@ export default function Login() {
{loading ? 'Signing in…' : 'Sign In'}
</button>
</form>
)}

{/*
Only rendered when the server reports security.google.enabled. The login
Expand All @@ -159,21 +167,24 @@ export default function Login() {
so this is a full-page navigation and must not submit the form above.
*/}
{setupStatus?.googleEnabled && (
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-3 text-xs font-medium uppercase tracking-wider text-gray-400">
or
</span>
<div className={passwordLoginEnabled ? 'mt-6' : ''}>
{/* The divider only separates two things — drop it when SSO stands alone. */}
{passwordLoginEnabled && (
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-3 text-xs font-medium uppercase tracking-wider text-gray-400">
or
</span>
</div>
</div>
</div>
)}

<a
href={authAPI.getGoogleStartUrl()}
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]"
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]`}
>
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<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" />
Expand Down Expand Up @@ -250,7 +261,9 @@ export default function Login() {
const stepTitle = step === STEP_OTP ? 'Verify your sign-in' : 'Secure sign-in'
const stepSubtitle = step === STEP_OTP
? 'Complete the extra email verification step for this workspace.'
: 'Sign in with your email and password to access DeepSQL.'
: passwordLoginEnabled
? 'Sign in with your email and password to access DeepSQL.'
: 'Sign in with your work Google account to access DeepSQL.'

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