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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ Controls the scopes returned by the server: `openid,profile,email`

Controls the link HTML snippet displayed on the Login page for this provider. Location of the link text can optionally be customised by modifying `Login.vm`.

### openid.`providerId`.autoLogin

When set to `true`, an unauthenticated visitor to the XNAT login page is automatically sent into this provider's OpenID flow using OIDC `prompt=none`, skipping the "Sign in with …" button. If the visitor already has a session at the provider they are logged straight into XNAT; if not, the provider reports that interaction is required and XNAT quietly shows the normal login page. Defaults to `false`.

The automatic attempt is guarded by a short-lived cookie (about two minutes) so a signed-out visitor is not caught in a redirect loop; once it expires a later visit will try again — which also means a visitor who has since signed in to the provider gets picked up. Only anonymous visitors are redirected, and the provider must support `prompt=none`.

Multiple OpenID providers can be configured on the same XNAT alongside this feature; their normal "Sign in with …" links still appear whenever the automatic attempt does not sign the visitor straight in. Only one provider may enable `autoLogin`, though — if more than one does, the first is used and a warning is logged. Because a visitor who already has a session at the auto-login provider is signed in through it before the login page is shown, `autoLogin` is best suited to a deployment with a single primary identity provider.

### openid.`providerId`.shouldFilterEmailDomains

Controls whether domains of the email should be compared against the whitelist: `allowedEmailDomains`.
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/au/edu/qcif/xnat/auth/openid/OpenIdAuthPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import java.util.Set;
import java.util.stream.Collectors;

import static au.edu.qcif.xnat.auth.openid.etc.OpenIdAuthConstant.AUTO_LOGIN;
import static au.edu.qcif.xnat.auth.openid.etc.OpenIdAuthConstant.DEFAULT_REDIR_URI;
import static au.edu.qcif.xnat.auth.openid.etc.OpenIdAuthConstant.KEY_REDIR_URI;
import static au.edu.qcif.xnat.auth.openid.etc.OpenIdAuthConstant.PKCE_ENABLED;
Expand Down Expand Up @@ -150,6 +151,28 @@ public List<String> getEnabledProviders() {
return _openIdProviders;
}

/**
* Returns the id of the enabled provider that opts into auto-login on the login page
* ({@code openid.<providerId>.autoLogin=true}), or {@code null} if none do. Auto-login can only
* target a single identity provider, so if more than one opts in the first enabled one is used and a
* warning is logged.
*
* @return the auto-login provider id, or {@code null} if the feature is not enabled for any provider.
*/
public String getAutoLoginProviderId() {
final List<String> autoLoginProviders = _openIdProviders.stream()
.filter(providerId -> Boolean.parseBoolean(getProperty(providerId, AUTO_LOGIN)))
.collect(Collectors.toList());
if (autoLoginProviders.isEmpty()) {
return null;
}
if (autoLoginProviders.size() > 1) {
log.warn("More than one provider has {} enabled ({}); using '{}' for auto-login.",
AUTO_LOGIN, autoLoginProviders, autoLoginProviders.get(0));
}
return autoLoginProviders.get(0);
}

@Bean
public AccessTokenProvider accessTokenProvider() {
return new AccessTokenProviderChain(Arrays.<AccessTokenProvider>asList(new PkceAuthorizationCodeAccessTokenProvider(stateKeyLength),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ public class OpenIdConnectFilter extends AbstractAuthenticationProcessingFilter
*/
public static final String OPENID_ERROR_MESSAGE = "openIdErrorMessage";

/**
* Cookie set by the login-screen extension when an auto-login (prompt=none) redirect is issued. Acts as a
* short-lived, one-shot guard: while it is present the login page renders normally instead of re-issuing
* the auto-login redirect, so a signed-out visitor is not caught in a redirect loop. It is a cookie rather
* than an {@code HttpSession} attribute because the guard must survive the
* {@code Login.vm -> /openid-login -> provider -> callback} redirect chain, across which the session does
* not reliably persist (the Turbine login page and the Spring filter can observe different sessions).
*/
public static final String AUTO_LOGIN_ATTEMPTED_COOKIE = "OPENID_AUTOLOGIN_TRIED";

private final OpenIdAuthPlugin _plugin;
private final AuthenticationEventPublisher _eventPublisher;
private final KeystoreService _keystoreService;
Expand Down Expand Up @@ -145,6 +155,14 @@ public void setOAuth2RestTemplate(final OAuth2RestTemplate restTemplate) {
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
log.debug("Executed attemptAuthentication...");

// An OAuth error returned to the redirect URI (e.g. ?error=login_required) carries no code to
// exchange; handle it up front so a failed auto-login (prompt=none) attempt falls back to the login
// page quietly instead of being run through — and mangled by — the token-exchange path.
final String authorizationError = request.getParameter("error");
if (StringUtils.isNotBlank(authorizationError)) {
return handleAuthorizationError(response, authorizationError);
}

HttpSession session = request.getSession(false);
if (session != null) {
String requestProviderId = request.getParameter("providerId");
Expand Down Expand Up @@ -308,6 +326,36 @@ private static String getUserMessage(final AuthenticationException failed) {
return userMessage;
}

/**
* Handles an OAuth error returned to the redirect URI. An auto-login (prompt=none) attempt that finds no
* session to reuse comes back with an "interaction required" error (login_required and friends); that is an
* expected negative result, so we quietly return to the login page. The login-screen extension's short-lived
* {@link #AUTO_LOGIN_ATTEMPTED_COOKIE} guard, set when the redirect was issued, stops the page from
* immediately re-issuing the auto-login redirect and looping. Any other error is treated as a genuine failure.
*
* <p>The decision keys off the OIDC error code alone: the interaction-required codes are returned only in
* response to a {@code prompt=none} request, which only the auto-login flow issues. It deliberately does not
* consult server-side session state, which does not reliably survive the redirect chain (see
* {@link #AUTO_LOGIN_ATTEMPTED_COOKIE}).</p>
*/
private Authentication handleAuthorizationError(final HttpServletResponse response, final String error) throws IOException {
if (isInteractionRequiredError(error)) {
log.debug("OpenID auto-login returned '{}'; showing the login page.", error);
response.sendRedirect(TurbineUtils.GetFullServerPath() + "/app/template/Login.vm");
return null;
}
log.info("OpenID provider returned an authorization error: {}", error);
throw new BadCredentialsException("OpenID provider returned error: " + error);
}

/** The OIDC error codes that mean the user would have had to interact, i.e. there was no existing session to reuse. */
static boolean isInteractionRequiredError(final String error) {
return "login_required".equals(error)
|| "interaction_required".equals(error)
|| "consent_required".equals(error)
|| "account_selection_required".equals(error);
}

private TokenContext parseIdToken(final String idToken, final String providerId)
throws JOSEException, ParseException, NotFoundException {
final SignedJWT signedJWT;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ public class OpenIdAuthConstant {
public static final byte[] CHUNK_SEPARATOR = {'\r', '\n'};
public static final String ISSUER = "issuer";
public static final String JWKS_URI = "jwksUri";
public static final String AUTO_LOGIN = "autoLogin";
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ private UserRedirectRequiredException getRedirectForAuthorization(final Authoriz
requestParameters.put("scope", String.join(" ", Optional.ofNullable(resource.getScope()).orElseGet(Collections::emptyList)));
}

// Forward an OIDC 'prompt' hint when the caller supplied one (e.g. prompt=none for auto-login);
// a normal login request has no prompt parameter and so is unaffected.
final String prompt = request.getFirst("prompt");
if (StringUtils.isNotBlank(prompt)) {
requestParameters.put("prompt", prompt);
}

final UserRedirectRequiredException redirectException = new UserRedirectRequiredException(resource.getUserAuthorizationUri(), requestParameters);

final String stateKey = stateKeyGenerator.generateKey(resource);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,160 @@
package org.nrg.xnat.extensions.screens.Login;

import static au.edu.qcif.xnat.auth.openid.OpenIdConnectFilter.AUTO_LOGIN_ATTEMPTED_COOKIE;
import static au.edu.qcif.xnat.auth.openid.OpenIdConnectFilter.OPENID_ERROR_MESSAGE;

import au.edu.qcif.xnat.auth.openid.OpenIdAuthPlugin;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.turbine.util.RunData;
import org.nrg.framework.utilities.Reflection;
import org.nrg.xdat.XDAT;
import org.nrg.xdat.turbine.utils.TurbineUtils;
import org.nrg.xft.security.UserI;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;

/**
* Login screen extension that displays custom OpenID authentication error messages.
* This extension is automatically loaded by XNAT's dynamic variable loading mechanism
* when classes implementing {@link Reflection.InjectableI} are found in the
* org.nrg.xnat.extensions.screens.Login package.
* Login screen extension for the OpenID plugin. This extension is automatically loaded by XNAT's dynamic
* variable loading mechanism when classes implementing {@link Reflection.InjectableI} are found in the
* {@code org.nrg.xnat.extensions.screens.Login} package, and runs while the {@code Login.vm} screen is being
* built (before the response is committed).
*
* <p>It does two things:</p>
* <ol>
* <li>Displays a pending OpenID authentication error message on the login page, if there is one.</li>
* <li>Otherwise, when a provider opts into it ({@code openid.<providerId>.autoLogin=true}), automatically
* redirects an unauthenticated visitor into the OpenID flow with {@code prompt=none}. If the user already
* has a session at the provider they are logged straight in; if not, the provider returns an error and the
* filter brings them back to this page — which then renders normally.</li>
* </ol>
*/
@Slf4j
public class OpenIdLoginExtension implements Reflection.InjectableI {

/**
* Lifetime of the one-shot auto-login guard cookie, in seconds. Long enough to break the
* {@code Login.vm -> provider -> callback -> Login.vm} redirect chain, short enough that a later visit
* retries — so a visitor who signs in at the provider in the meantime is still picked up automatically.
*/
private static final int AUTO_LOGIN_COOKIE_MAX_AGE_SECONDS = 120;

@Override
public void execute(Map<String, Object> params) {
final RunData data = (RunData) params.get("data");
if (data == null) {
return;
}

try {
final String errorMessage = (String) data.getSession().getAttribute(OPENID_ERROR_MESSAGE);
if (StringUtils.isNotBlank(errorMessage)) {
log.debug("Setting OpenID error message on login page: {}", errorMessage);
data.setMessage(errorMessage);
// Remove the attribute so it doesn't persist across page loads
data.getSession().removeAttribute(OPENID_ERROR_MESSAGE);
}
handle(data, resolveCurrentUser(), resolvePlugin());
} catch (Exception e) {
// Never let a problem here break login-page rendering.
log.error("Error processing OpenID login extension", e);
}
}

/** The current request's user; isolated here so tests can override it without the static XNAT dependency. */
UserI resolveCurrentUser() {
return XDAT.getUserDetails();
}

/** The OpenID plugin bean; isolated here so tests can override it without the static XNAT dependency. */
OpenIdAuthPlugin resolvePlugin() {
return XDAT.getContextService().getBeanSafely(OpenIdAuthPlugin.class);
}

/**
* Core logic, split out from {@link #execute} so it can be unit-tested without the static XNAT lookups.
*
* @param data the Turbine request data for the login page.
* @param currentUser the current user (guest/anonymous if not logged in), or {@code null} if unknown.
* @param plugin the OpenID plugin, or {@code null} if it could not be resolved.
*/
void handle(final RunData data, final UserI currentUser, final OpenIdAuthPlugin plugin) throws IOException {
final HttpSession session = data.getSession();

// A pending error message means a previous login attempt failed; show it and let the user decide,
// rather than redirecting and potentially masking the problem.
final String errorMessage = (String) session.getAttribute(OPENID_ERROR_MESSAGE);
if (StringUtils.isNotBlank(errorMessage)) {
log.debug("Setting OpenID error message on login page: {}", errorMessage);
data.setMessage(errorMessage);
// Remove the attribute so it doesn't persist across page loads.
session.removeAttribute(OPENID_ERROR_MESSAGE);
return;
}

maybeAutoLogin(data, currentUser, plugin);
}

private void maybeAutoLogin(final RunData data, final UserI currentUser, final OpenIdAuthPlugin plugin)
throws IOException {
final HttpServletResponse response = data.getResponse();
// The Login screen redirects an already-authenticated user to the dashboard and commits the response
// before this runs; never attempt a second redirect.
if (response == null || response.isCommitted()) {
return;
}
// Only anonymous (guest) visitors should be auto-redirected.
if (currentUser != null && !currentUser.isGuest()) {
return;
}
// One-shot guard: a short-lived cookie set when the last auto-login was issued. It is a cookie rather
// than a session attribute because it must survive the Login.vm -> /openid-login -> provider ->
// callback redirect chain, across which the HttpSession does not reliably persist.
if (hasAutoLoginAttemptCookie(data)) {
return;
}
final String providerId = plugin == null ? null : plugin.getAutoLoginProviderId();
if (StringUtils.isBlank(providerId)) {
return;
}

setAutoLoginAttemptCookie(data);
final String target = fullServerPath() + "/openid-login?providerId="
+ URLEncoder.encode(providerId, StandardCharsets.UTF_8.name()) + "&prompt=none";
log.debug("Attempting OpenID auto-login via provider '{}'", providerId);
response.sendRedirect(target);
}

/** True if the one-shot auto-login guard cookie is present on the request. */
private static boolean hasAutoLoginAttemptCookie(final RunData data) {
return hasNamedCookie(data, AUTO_LOGIN_ATTEMPTED_COOKIE);
}

private static boolean hasNamedCookie(final RunData data, final String name) {
final HttpServletRequest request = data.getRequest();
if (request == null || request.getCookies() == null) {
return false;
}
for (final Cookie cookie : request.getCookies()) {
if (name.equals(cookie.getName())) {
return true;
}
}
return false;
}

/** Sets the short-lived, one-shot guard cookie marking that an auto-login has just been attempted. */
private void setAutoLoginAttemptCookie(final RunData data) {
final HttpServletRequest request = data.getRequest();
final Cookie cookie = new Cookie(AUTO_LOGIN_ATTEMPTED_COOKIE, "1");
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setSecure(request != null && request.isSecure());
cookie.setMaxAge(AUTO_LOGIN_COOKIE_MAX_AGE_SECONDS);
data.getResponse().addCookie(cookie);
}

/** The site's full server path; isolated here so tests can override it without the static XNAT dependency. */
String fullServerPath() {
return TurbineUtils.GetFullServerPath();
}
}
5 changes: 5 additions & 0 deletions src/main/resources/openid-provider-sample-Keycloak.properties
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ openid.keycloak.userInfoUri=http://localhost:8082/realms/master/protocol/openid-
openid.keycloak.scopes=openid,profile,email
openid.keycloak.link=<p>To sign-in using your keycloak credentials, please click on the button below.</p></p><p><a href="/openid-login?providerId=keycloak"> <img src="/images/btn_keycloak_signin_dark_normal_web.png" /> </a></p>

# When true, an unauthenticated visitor to the login page is automatically redirected into this provider
# using OIDC prompt=none: if they already have a session at the provider they are logged straight in,
# otherwise the normal login page is shown. Attempted once per browser session. Off by default.
openid.keycloak.autoLogin=false

# Flag that sets if we should be checking email domains
openid.keycloak.shouldFilterEmailDomains=false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.core.AuthenticationException;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.Map;

import static org.junit.Assert.*;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

/**
Expand Down Expand Up @@ -109,4 +112,29 @@ public void threePartTokenIsNotEncrypted() throws Exception {
// A signed JWT (JWS) has three dot-separated parts.
assertFalse(isIdTokenEncrypted(filter, "header.payload.signature"));
}

// ---- auto-login error handling -------------------------------------------------------------

@Test
public void interactionRequiredErrorsAreRecognised() {
assertTrue(OpenIdConnectFilter.isInteractionRequiredError("login_required"));
assertTrue(OpenIdConnectFilter.isInteractionRequiredError("interaction_required"));
assertTrue(OpenIdConnectFilter.isInteractionRequiredError("consent_required"));
assertTrue(OpenIdConnectFilter.isInteractionRequiredError("account_selection_required"));
assertFalse(OpenIdConnectFilter.isInteractionRequiredError("access_denied"));
assertFalse(OpenIdConnectFilter.isInteractionRequiredError(null));
assertFalse(OpenIdConnectFilter.isInteractionRequiredError(""));
}

@Test(expected = BadCredentialsException.class)
public void nonInteractionRequiredErrorIsTreatedAsFailure() throws Exception {
final OpenIdConnectFilter filter = filterWith(Collections.emptyMap());
final HttpServletRequest request = mock(HttpServletRequest.class);
final HttpServletResponse response = mock(HttpServletResponse.class);
when(request.getParameter("error")).thenReturn("access_denied");

// A non-interaction-required error (e.g. access_denied) is a genuine credential failure, not an
// expected auto-login fallback, so it surfaces as an exception.
filter.attemptAuthentication(request, response);
}
}
Loading