AbstractSsoLoginSuccessHandler.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.entrystore.rest.springboot.security;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.entrystore.PrincipalManager;
import org.entrystore.User;
import org.entrystore.rest.springboot.service.auth.BasicVerifier;
import org.entrystore.rest.springboot.util.ErrorResponseWriter;
import org.entrystore.rest.springboot.util.HttpUtil;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import java.io.IOException;
import java.util.Set;
/**
* Shared SSO login success flow for CAS, SAML and OIDC: token-type guard, reserved-name check,
* auto-provisioning, disabled-user check, session lifetime, and the clear-session-then-redirect failure path.
* Subclasses contribute the protocol-specific parts through the hook methods, optionally
* carrying per-request state of type {@code C} (resolved once via
* {@link #resolveContext(HttpServletRequest, Authentication)}) through the flow. The token
* type {@code T} ties the {@link #tokenType()} guard to the typed token handed to
* {@code resolveContext} at compile time.
*/
@Slf4j
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
abstract class AbstractSsoLoginSuccessHandler<T extends Authentication, C>
extends SavedRequestAwareAuthenticationSuccessHandler {
private static final Set<String> RESERVED_USERNAMES = Set.of("admin", "guest");
protected final ESUserDetailsService userService;
protected final PrincipalManager principalManager;
protected final ErrorResponseWriter errorResponseWriter;
private final AuthTokenCookies authTokenCookies;
@Override
public final void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
try {
handleSsoAuthentication(request, response, authentication);
} catch (IOException | ServletException e) {
throw e;
} catch (Exception e) {
String user = authentication != null ? authentication.getName() : "<unknown>";
log.error("Unexpected {} during {} login for user '{}': {}",
e.getClass().getSimpleName(), authTypeLabel(), HttpUtil.sanitizeForLog(user), e.getMessage(), e);
// Undo the filter-persisted token before resolving the (subclass-supplied) failure URL: a
// throwing defaultFailureUrl() must not leave the rejected user authenticated behind the 500.
HttpUtil.clearAuthenticatedSession(request);
errorResponseWriter.redirectOrWriteUnauthorized(request, response, defaultFailureUrl(),
authTypeLabel() + " login failed");
}
}
private void handleSsoAuthentication(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
if (!tokenType().isInstance(authentication)) {
// Defense in depth: the protocol's filter only ever produces tokens of tokenType(),
// but if a different token type ever reaches this handler we must not treat
// that as success — the filter has already persisted it to the SecurityContext.
// ERROR because reaching this branch indicates a Spring Security wiring bug.
log.error("Unexpected authentication type '{}' in {} success handler",
authentication == null ? "null" : authentication.getClass().getName(), authTypeLabel());
redirectToLoginFailureUrl(request, response, defaultFailureUrl());
return;
}
String username = authentication.getName();
C context = resolveContext(request, tokenType().cast(authentication));
log.info("Successfully authenticated via {}, username: '{}'",
describeAuthSource(context), HttpUtil.sanitizeForLog(username));
if (isReservedUsername(username)) {
log.warn("Ignoring reserved username '{}' from {}", HttpUtil.sanitizeForLog(username), describeAuthSource(context));
redirectToLoginFailureUrl(request, response, resolveFailureUrl(context));
return;
}
User esUser = userService.loadUser(username);
if (esUser == null) {
if (!isAutoProvisioningEnabled(context)) {
// Cause-neutral: the protocol subclass logs the specific reason (e.g. unknown SAML IdP)
// where it differs, so this shared line must not assert "auto-provisioning is disabled".
log.warn("Login denied for {} user '{}': not found in EntryStore and not auto-provisioned",
describeAuthSource(context), HttpUtil.sanitizeForLog(username));
redirectToLoginFailureUrl(request, response, resolveFailureUrl(context));
return;
}
log.info("User '{}' not found in EntryStore. Creating new user (auto-provisioning enabled)",
HttpUtil.sanitizeForLog(username));
esUser = userService.createUser(username);
} else {
log.info("Existing EntryStore user '{}' logged in via {}", HttpUtil.sanitizeForLog(username), authTypeLabel());
}
if (BasicVerifier.isUserDisabled(principalManager, esUser)) {
log.warn("Login denied for {} user '{}': account is disabled",
describeAuthSource(context), HttpUtil.sanitizeForLog(username));
redirectToLoginFailureUrl(request, response, resolveFailureUrl(context));
return;
}
authTokenCookies.applySessionLifetime(request, null);
authTokenCookies.expireStale(request, response);
if (tryCustomSuccessRedirect(request, response, context)) {
return;
}
// Clear any saved request that might point back to SSO endpoints
new HttpSessionRequestCache().removeRequest(request, response);
// Proceeds with standard Spring behavior (redirects to defaultTargetUrl)
super.onAuthenticationSuccess(request, response, authentication);
}
private static boolean isReservedUsername(String username) {
// equalsIgnoreCase (simple per-char case folding) deliberately matches Turkish dotted/dotless-I
// variants such as "ADMİN" (U+0130) and "admın" (U+0131); a toLowerCase()-based set lookup uses
// full case mapping and would let them through — and downstream principal lookup lowercases with
// the default JVM locale, where "ADMİN" can resolve to the real admin principal.
return RESERVED_USERNAMES.stream().anyMatch(reserved -> reserved.equalsIgnoreCase(username));
}
private void redirectToLoginFailureUrl(HttpServletRequest request, HttpServletResponse response,
String failureUrl) throws IOException {
// The protocol's filter already persisted the token to the SecurityContext; undo that
// before redirecting so the rejected user doesn't remain authenticated.
HttpUtil.clearAuthenticatedSession(request);
errorResponseWriter.redirectOrWriteUnauthorized(request, response, failureUrl,
authTypeLabel() + " login failed");
}
@Override
protected final String determineTargetUrl(HttpServletRequest request, HttpServletResponse response) {
// ENTRYSTORE-996: any custom success URL must be resolved and whitelist-validated by the
// subclass (see tryCustomSuccessRedirect). On fall-through we must use only the trusted default
// target and never derive it from a request parameter (e.g. ?successurl=) — doing so would be an
// open redirect, since the SSO callback filters process their callbacks independently.
// Overriding the two-arg variant intercepts the parameter/referer logic in the Spring base class.
return getDefaultTargetUrl();
}
@Override
protected final String determineTargetUrl(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
// Spring's handle() calls this 3-arg variant; its default delegates to the 2-arg determineTargetUrl.
// Sealing it final stops a subclass from re-opening the ENTRYSTORE-996 open-redirect hole by
// overriding the variant the framework actually invokes.
return determineTargetUrl(request, response);
}
/** Protocol label used in log lines and the failure message, e.g. {@code "CAS"} or {@code "SAML"}. */
protected abstract String authTypeLabel();
/** The only {@link Authentication} type this handler accepts; anything else is rejected. */
protected abstract Class<T> tokenType();
/**
* Resolves the per-request protocol state carried through the flow. Called once, after the
* token-type guard. May return {@code null} if the protocol needs no state.
*/
protected abstract C resolveContext(HttpServletRequest request, T token);
/** Whether a user unknown to EntryStore may be auto-provisioned for this request. */
protected abstract boolean isAutoProvisioningEnabled(C context);
/**
* The failure redirect URL for paths where no context exists (token-guard and unexpected-exception
* paths), or {@code null} to write a 401 JSON error instead.
*/
protected abstract String defaultFailureUrl();
/**
* The failure redirect URL for in-flow denials; {@code context} is the non-null value returned by
* {@link #resolveContext(HttpServletRequest, Authentication)} (or {@code null} only if that hook
* returned {@code null}). Defaults to {@link #defaultFailureUrl()}.
*/
protected String resolveFailureUrl(C context) {
return defaultFailureUrl();
}
/** Description of the authentication source for log lines; defaults to {@link #authTypeLabel()}. */
protected String describeAuthSource(C context) {
return authTypeLabel();
}
/**
* Hook for a protocol-specific success redirect (e.g. a whitelist-validated custom URL).
* Returns {@code true} if the response was handled; {@code false} to proceed with the
* default target redirect.
*/
protected boolean tryCustomSuccessRedirect(HttpServletRequest request, HttpServletResponse response,
C context) throws IOException {
return false;
}
}