From 25f9b1249a3c315b07e37614d12f6497610758ff Mon Sep 17 00:00:00 2001 From: Marco Herglotz <22731069+herglotzmarco@users.noreply.github.com> Date: Wed, 8 Jan 2025 19:20:48 +0100 Subject: [PATCH] Make the logout button actually log out (#24) * Make the logout button actually log out - Nexus has automatic session creation turned off and solely relies on the login dialog POSTing to SessionServlet for session creation - We never have a login dialog, so this does not happen, thus we turn on automatic session creation when our token factory is used - Oauth2 proxy has a "skip_jwt_bearer_tokens" flag that makes it accept, validate and process a sent jwt token instead of requesting its own - It still adds all necessary headers, but also forwards the Auth header which made our token factoy refuse the operation before - By doing this, the logout button actually works, destroying the user session and triggering a logout event we can listen for - When this event triggers, we perform backchannel logout via oauth proxy, which can then backchannel logout from the IDP - This logout requires page reload to not leave Nexus in an invalid state, however the logout call refuses to follow any kind of redirect so a kinda hacky solution in the frontend is necessary - We use javascript document observers to heuristically guess when logout occured and thus a page refresh should be triggered * better error handling + configurable logout url * adjust documentation to hint towards the new capability --------- Co-authored-by: Marco Herglotz --- docs/README.md | 5 +- src/frontend/src/index.js | 57 ++++++++ .../OAuth2ProxyHeaderAuthTokenFactory.java | 64 +++++--- .../github/tumbl3w33d/OAuth2ProxyRealm.java | 30 ++-- .../logout/OAuth2ProxyLogoutCapability.java | 47 ++++++ ...th2ProxyLogoutCapabilityConfiguration.java | 50 +++++++ ...oxyLogoutCapabilityConfigurationState.java | 36 +++++ ...OAuth2ProxyLogoutCapabilityDescriptor.java | 61 ++++++++ .../logout/OAuth2ProxyLogoutHandler.java | 138 ++++++++++++++++++ ...OAuth2ProxyHeaderAuthTokenFactoryTest.java | 39 +++-- .../tumbl3w33d/OAuth2ProxyRealmTest.java | 41 +++--- .../users/OAuth2ProxyUserManagerTest.java | 1 + 12 files changed, 488 insertions(+), 81 deletions(-) create mode 100644 src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapability.java create mode 100644 src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityConfiguration.java create mode 100644 src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityConfigurationState.java create mode 100644 src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityDescriptor.java create mode 100644 src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutHandler.java diff --git a/docs/README.md b/docs/README.md index 42cfe81..18ec05d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,8 +26,10 @@ It is important to highlight that this plugin is provided on an 'as-is' basis, w * automatic expiry of API tokens * there is a configurable task that lets API tokens expire, so another login by the user is necessary to renew it * as long as the user keeps showing up regularly, their token will not expire +* backchannel logout in IDP via oauth2 proxy (if supported) when the logout is performed in Nexus + * make sure to enable the OAuth2 Proxy: Logout capability in Nexus to make this work -**Note**: After authenticating with this realm, the logout button is non-operative, which is a common limitation with header-based authentication methods. To force a logout, you need to logout from your identity provider and/or delete the OAuth2 Proxy cookie if you must logout for some reason. +**Note**: If the OAuth2 Proxy: Logout capability is not enabled, the logout button is non-operative, which is a common limitation with header-based authentication methods. To force a logout, you need to logout from your identity provider and/or delete the OAuth2 Proxy cookie if you must logout for some reason. ## Supported Nexus version @@ -158,6 +160,7 @@ code_challenge_method = "S256" # PKCE, if your idp supports that client_id = "get the client id from your identity provider" client_secret = "get the secret from your identity provider" cookie_secret = "generate an individual cookie secret" +backend_logout_url = "https://idm.example.com/consult/your/idp-documentation/for/logout-url?id_token_hint={id_token}" # we don't need to wait for people to press the button, just redirect skip_provider_button = true diff --git a/src/frontend/src/index.js b/src/frontend/src/index.js index 583710d..f934000 100644 --- a/src/frontend/src/index.js +++ b/src/frontend/src/index.js @@ -14,4 +14,61 @@ window.plugins.push({ requiresUser: true } }] +}); + +const toolbarXPath = '//div[contains(@class, "x-toolbar") and contains(@role, "group")]' +const loginButtonXPath = '//a[contains(@id, "signin")]' +const logoutButtonXPath = '//a[contains(@id, "signout")]' + +function findFirstElementByXPath(xPath, searchRoot) { + return document.evaluate(xPath, searchRoot, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; +} + +function waitForElement(xPath, searchRoot, elementNameForLogging) { + elementNameForLogging = (typeof elementNameForLogging === 'undefined') ? xPath : elementNameForLogging; + return new Promise(resolve => { + let initElement = findFirstElementByXPath(xPath, searchRoot); + if(initElement && initElement.checkVisibility()) { + return resolve(initElement); + } + + console.log(elementNameForLogging + " not yet visible."); + const observer = new MutationObserver(mutations => { + let obsElement = findFirstElementByXPath(xPath, searchRoot); + if(obsElement && obsElement.checkVisibility()) { + console.log("Observer found " + elementNameForLogging + ". Unregistering observer and resolving promise"); + observer.disconnect(); + resolve(obsElement) + } + }); + observer.observe(searchRoot, {attributes: false, childList:true, subtree: true}) + console.log("Waiting for " + elementNameForLogging + " to become visible..."); + }); +} + +function triggerPageReloadWhenLogoutButtonIsReplacedWithLoginButton(toolbar) { + const toolbarObserver = new MutationObserver(mutations => { + let loginButton = findFirstElementByXPath(loginButtonXPath, toolbar); + if(loginButton && loginButton.checkVisibility()) { + console.log("Observer found login button. Either this is on page load or logout was done. Re-Checking in 1 second to make sure..."); + window.setTimeout(() => { + let stillLoginButton = findFirstElementByXPath(loginButtonXPath, toolbar); + if(stillLoginButton && stillLoginButton.checkVisibility()) { + console.log("Login button still present. Assuming logout was done. Reloading page to retrigger oauth login..."); + toolbarObserver.disconnect(); + location.reload(); + } else { + console.log("Login button is gone now. Assuming page was still loading. Skipping page reload..."); + } + }, 1000); + } + }); + toolbarObserver.observe(toolbar, {attributes: true, childList:true, subtree: true}) + console.log("Waiting for login button to become visible..."); +} + +waitForElement(toolbarXPath, document, "toolbar").then(toolbar => { + waitForElement(logoutButtonXPath, toolbar, "logout button").then(logoutButton => { + triggerPageReloadWhenLogoutButtonIsReplacedWithLoginButton(toolbar); + }); }); \ No newline at end of file diff --git a/src/main/java/com/github/tumbl3w33d/OAuth2ProxyHeaderAuthTokenFactory.java b/src/main/java/com/github/tumbl3w33d/OAuth2ProxyHeaderAuthTokenFactory.java index 6979560..9930331 100644 --- a/src/main/java/com/github/tumbl3w33d/OAuth2ProxyHeaderAuthTokenFactory.java +++ b/src/main/java/com/github/tumbl3w33d/OAuth2ProxyHeaderAuthTokenFactory.java @@ -11,6 +11,7 @@ import javax.servlet.http.HttpServletRequest; import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.subject.support.DefaultSubjectContext; import org.apache.shiro.web.util.WebUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,9 +30,8 @@ public class OAuth2ProxyHeaderAuthTokenFactory extends HttpHeaderAuthenticationT static final String X_FORWARDED_ACCESS_TOKEN = "X-Forwarded-Access-Token"; static final String X_FORWARDED_GROUPS = "X-Forwarded-Groups"; - static final List OAUTH2_PROXY_HEADERS = Collections - .unmodifiableList(Arrays.asList(X_FORWARDED_USER, X_FORWARDED_PREFERRED_USERNAME, - X_FORWARDED_EMAIL, X_FORWARDED_ACCESS_TOKEN, X_FORWARDED_GROUPS)); + static final List OAUTH2_PROXY_HEADERS = Collections.unmodifiableList(Arrays.asList(X_FORWARDED_USER, + X_FORWARDED_PREFERRED_USERNAME, X_FORWARDED_EMAIL, X_FORWARDED_ACCESS_TOKEN, X_FORWARDED_GROUPS)); @Override public AuthenticationToken createToken(ServletRequest request, ServletResponse response) { @@ -41,27 +41,27 @@ public AuthenticationToken createToken(ServletRequest request, ServletResponse r String xForwardedEmailHeader = httpRequest.getHeader(X_FORWARDED_EMAIL); String xForwardedPrefUsernameHeader = httpRequest.getHeader(X_FORWARDED_PREFERRED_USERNAME); - if (httpRequest.getHeader("Authorization") != null) { - logger.debug("not handling requests with Authorization header"); - return null; - } else { - if (xForwardedUserHeader == null || xForwardedEmailHeader == null - || xForwardedPrefUsernameHeader == null) { - logger.debug("required OAuth2 proxy headers incomplete - {}: {} - {}: {} - {}: {}", - X_FORWARDED_USER, xForwardedUserHeader, - X_FORWARDED_EMAIL, xForwardedEmailHeader, - X_FORWARDED_PREFERRED_USERNAME, xForwardedPrefUsernameHeader); + if (xForwardedUserHeader == null || xForwardedEmailHeader == null || xForwardedPrefUsernameHeader == null) { + // any proxy header is missing... + if (httpRequest.getHeader("Authorization") == null) { + // ...and Auth header is missing too -> probably UI based access + logger.debug("required OAuth2 proxy headers incomplete - {}: {} - {}: {} - {}: {}", X_FORWARDED_USER, + xForwardedUserHeader, X_FORWARDED_EMAIL, xForwardedEmailHeader, X_FORWARDED_PREFERRED_USERNAME, + xForwardedPrefUsernameHeader); + return null; + } else { + // ...and Auth header is present -> some access bypassing oauth2 proxy, so we are not responsible + logger.debug("not handling requests with Authorization header, but without any oauth2 proxy headers"); return null; } } OAuth2ProxyHeaderAuthToken token = new OAuth2ProxyHeaderAuthToken(); - token.user = new HttpHeaderAuthenticationToken(X_FORWARDED_USER, - xForwardedUserHeader, request.getRemoteHost()); + token.user = new HttpHeaderAuthenticationToken(X_FORWARDED_USER, xForwardedUserHeader, request.getRemoteHost()); - token.email = new HttpHeaderAuthenticationToken(X_FORWARDED_EMAIL, - xForwardedEmailHeader, request.getRemoteHost()); + token.email = new HttpHeaderAuthenticationToken(X_FORWARDED_EMAIL, xForwardedEmailHeader, + request.getRemoteHost()); token.preferred_username = new HttpHeaderAuthenticationToken(X_FORWARDED_PREFERRED_USERNAME, xForwardedPrefUsernameHeader, request.getRemoteHost()); @@ -69,26 +69,42 @@ public AuthenticationToken createToken(ServletRequest request, ServletResponse r // (unused) depending on oauth2 proxy config, this might be missing String accessToken = httpRequest.getHeader(X_FORWARDED_ACCESS_TOKEN); if (accessToken != null && !accessToken.isEmpty()) { - token.accessToken = new HttpHeaderAuthenticationToken(X_FORWARDED_ACCESS_TOKEN, - accessToken, request.getRemoteHost()); + token.accessToken = new HttpHeaderAuthenticationToken(X_FORWARDED_ACCESS_TOKEN, accessToken, + request.getRemoteHost()); } // depending on oauth2 proxy claims, this might be missing String groups = httpRequest.getHeader(X_FORWARDED_GROUPS); if (groups != null && !groups.isEmpty()) { - token.groups = new HttpHeaderAuthenticationToken(X_FORWARDED_GROUPS, - groups, - request.getRemoteHost()); + token.groups = new HttpHeaderAuthenticationToken(X_FORWARDED_GROUPS, groups, request.getRemoteHost()); } token.host = request.getRemoteHost(); logger.debug( "created token from oauth2 proxy headers: user: {} - preferred_username: {} - email: {} - access token: {} - groups: {}", - token.user.getHeaderValue(), token.preferred_username.getHeaderValue(), - token.email.getHeaderValue(), + token.user.getHeaderValue(), token.preferred_username.getHeaderValue(), token.email.getHeaderValue(), token.accessToken != null ? "" : null, token.groups != null ? token.groups.getHeaderValue() : null); + + if (httpRequest.getHeader("Authorization") == null) { + // "normal" oauth2 login, probably via UI -> create a user session + + // NexusBasicHttpAuthenticationFilter which is for reasons Sonatype itself does not know the root of the + // inheritance hierarchy of nexus auth filters turns off shiro session creation by setting this flag to false + + // Nexus solely relies on the fact that the session is manually created by POSTing to SessionServlet as part of the login dialog + // As we never get a login dialog, this does not trigger, which means there is no user session ever created. + + // While that does not hurt normal login as we continuously login with the oauth proxy anyways, + // it causes the logout button to throw exceptions instead of triggering a LogoutEvent we can listen to for redirecting + // logout to the oauth proxy + request.setAttribute(DefaultSubjectContext.SESSION_CREATION_ENABLED, Boolean.TRUE); + } else { + // most likely programmatic access making use of skip_jwt_bearer_tokens option in oauth2 proxy, meaning an already existing + // jwt token not created (but validated!) by oauth2 proxy is used for login. In this case we don't need a session, so nothing else to do + } + return token; } diff --git a/src/main/java/com/github/tumbl3w33d/OAuth2ProxyRealm.java b/src/main/java/com/github/tumbl3w33d/OAuth2ProxyRealm.java index 63f5976..904f20e 100644 --- a/src/main/java/com/github/tumbl3w33d/OAuth2ProxyRealm.java +++ b/src/main/java/com/github/tumbl3w33d/OAuth2ProxyRealm.java @@ -32,12 +32,14 @@ import org.eclipse.sisu.Description; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.sonatype.nexus.common.event.EventManager; import org.sonatype.nexus.security.role.RoleIdentifier; import org.sonatype.nexus.security.user.User; import org.sonatype.nexus.security.user.UserNotFoundException; import com.github.tumbl3w33d.h2.OAuth2ProxyLoginRecordStore; import com.github.tumbl3w33d.h2.OAuth2ProxyRoleStore; +import com.github.tumbl3w33d.logout.OAuth2ProxyLogoutHandler; import com.github.tumbl3w33d.users.OAuth2ProxyUserManager; import com.github.tumbl3w33d.users.OAuth2ProxyUserManager.UserWithPrincipals; import com.github.tumbl3w33d.users.db.OAuth2ProxyLoginRecord; @@ -65,10 +67,9 @@ public class OAuth2ProxyRealm extends AuthorizingRealm { private PasswordService passwordService; @Inject - public OAuth2ProxyRealm( - @Named OAuth2ProxyUserManager userManager, @Named OAuth2ProxyRoleStore roleStore, - @Named OAuth2ProxyLoginRecordStore loginRecordStore, - @Named PasswordService passwordService) { + public OAuth2ProxyRealm(@Named OAuth2ProxyUserManager userManager, @Named OAuth2ProxyRoleStore roleStore, + @Named OAuth2ProxyLoginRecordStore loginRecordStore, @Named PasswordService passwordService, + EventManager eventManager, OAuth2ProxyLogoutHandler logoutHandler) { this.userManager = checkNotNull(userManager); this.roleStore = roleStore; this.loginRecordStore = loginRecordStore; @@ -81,6 +82,8 @@ public OAuth2ProxyRealm( // authentication is provided by oauth2 proxy headers with every request setAuthenticationCachingEnabled(false); setAuthorizationCachingEnabled(false); + eventManager.register(logoutHandler); + logger.debug("Registered oauth2 proxy logout handler"); } private boolean isApiTokenMatching(AuthenticationToken token) { @@ -164,8 +167,7 @@ protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) String userId = userWithPrincipals.getUser().getUserId(); logger.trace("user {} (source {}) has roles {} before sync", userId, - userWithPrincipals.getUser().getSource(), - userWithPrincipals.getUser().getRoles()); + userWithPrincipals.getUser().getSource(), userWithPrincipals.getUser().getRoles()); if (oauth2Token.groups != null) { logger.trace("user {} has identity provider groups {}", userId, oauth2Token.groups); @@ -176,8 +178,7 @@ protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) if (userWithPrincipals.hasPrincipals()) { logger.debug("found principals for OAuth2 proxy user '{}': '{}' from realms '{}'", oauth2proxyUserId, - userWithPrincipals.getPrincipals(), - userWithPrincipals.getPrincipals().getRealmNames()); + userWithPrincipals.getPrincipals(), userWithPrincipals.getPrincipals().getRealmNames()); recordLogin(oauth2proxyUserId); @@ -230,26 +231,23 @@ void syncExternalRolesForGroups(User user, String groupsString) { for (RoleIdentifier role : user.getRoles()) { if (!role.getSource().equals(OAuth2ProxyUserManager.SOURCE)) { // not touching roles assigned outside of this realm logic - logger.debug("group sync leaving {}'s role {} untouched", user.getUserId(), - role.getRoleId(), role.getSource()); + logger.debug("group sync leaving {}'s role {} untouched", user.getUserId(), role.getRoleId(), + role.getSource()); continue; } if (!idpGroups.stream().anyMatch(idpGroup -> idpGroup.getRoleId().equals(role.getRoleId()))) { - logger.warn("marking role {} of user {} for deletion", role.getRoleId(), - user.getUserId()); + logger.warn("marking role {} of user {} for deletion", role.getRoleId(), user.getUserId()); rolesToDelete.add(role); } else { - logger.trace("user {} still has group for role {} in identity provider", - user.getUserId(), + logger.trace("user {} still has group for role {} in identity provider", user.getUserId(), role.getRoleId()); } } for (RoleIdentifier role : rolesToDelete) { user.removeRole(role); - logger.warn("deleted role {} from user {}", role.getRoleId(), - user.getUserId()); + logger.warn("deleted role {} from user {}", role.getRoleId(), user.getUserId()); } try { diff --git a/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapability.java b/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapability.java new file mode 100644 index 0000000..b6394e5 --- /dev/null +++ b/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapability.java @@ -0,0 +1,47 @@ +package com.github.tumbl3w33d.logout; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; + +import org.sonatype.nexus.capability.CapabilitySupport; + +@Named(OAuth2ProxyLogoutCapabilityDescriptor.TYPE_ID) +public class OAuth2ProxyLogoutCapability extends CapabilitySupport { + + private final OAuth2ProxyLogoutCapabilityConfigurationState state; + + @Inject + public OAuth2ProxyLogoutCapability(OAuth2ProxyLogoutCapabilityConfigurationState state) { + this.state = checkNotNull(state); + } + + @Override + protected OAuth2ProxyLogoutCapabilityConfiguration createConfig(Map properties) { + return new OAuth2ProxyLogoutCapabilityConfiguration(properties); + } + + @Override + protected void onActivate(OAuth2ProxyLogoutCapabilityConfiguration config) throws Exception { + state.set(config); + } + + @Override + protected void onUpdate(OAuth2ProxyLogoutCapabilityConfiguration config) throws Exception { + state.set(config); + } + + @Override + protected void onPassivate(OAuth2ProxyLogoutCapabilityConfiguration config) throws Exception { + state.reset(); + } + + @Override + protected void onRemove(OAuth2ProxyLogoutCapabilityConfiguration config) throws Exception { + state.reset(); + } + +} diff --git a/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityConfiguration.java b/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityConfiguration.java new file mode 100644 index 0000000..ee8d6f2 --- /dev/null +++ b/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityConfiguration.java @@ -0,0 +1,50 @@ +package com.github.tumbl3w33d.logout; + +import java.util.Map; +import java.util.Objects; + +public class OAuth2ProxyLogoutCapabilityConfiguration { + + public static final String LOGOUT_URL_ID = "oauth2-proxy-logout-url"; + public static final String LOGOUT_URL_LABEL = "OAuth2 Proxy logout url"; + public static final String LOGOUT_URL_HELP = "URL to be called for backchannel logout in OAuth2 Proxy when the Nexus logout button is pressed. Defaults to '{nexus-base-url}/oauth2/sign_out' if not specified"; + + private String logoutUrl; + + public OAuth2ProxyLogoutCapabilityConfiguration(Map properties) { + if (properties != null) { + logoutUrl = properties.get(LOGOUT_URL_ID); + } + } + + public String getLogoutUrl() { + return logoutUrl; + } + + public void setLogoutUrl(String logoutUrl) { + this.logoutUrl = logoutUrl; + } + + @Override + public int hashCode() { + return Objects.hash(logoutUrl); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + OAuth2ProxyLogoutCapabilityConfiguration other = (OAuth2ProxyLogoutCapabilityConfiguration) obj; + return Objects.equals(logoutUrl, other.logoutUrl); + } + + @Override + public String toString() { + return "OAuth2ProxyLogoutCapabilityConfiguration [logoutUrl=" + logoutUrl + "]"; + } + +} diff --git a/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityConfigurationState.java b/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityConfigurationState.java new file mode 100644 index 0000000..0ad58eb --- /dev/null +++ b/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityConfigurationState.java @@ -0,0 +1,36 @@ +package com.github.tumbl3w33d.logout; + +import java.util.Collections; +import java.util.Map; + +import javax.inject.Named; +import javax.inject.Singleton; + +import org.sonatype.nexus.rapture.StateContributor; + +@Named +@Singleton +public class OAuth2ProxyLogoutCapabilityConfigurationState implements StateContributor { + + private OAuth2ProxyLogoutCapabilityConfiguration config; + + @Override + public Map getState() { + if (config != null) { + return Collections.singletonMap("oauth2-proxy-logout", config); + } + return null; + } + + public OAuth2ProxyLogoutCapabilityConfiguration getConfig() { + return config; + } + + public void set(OAuth2ProxyLogoutCapabilityConfiguration config) { + this.config = config; + } + + public void reset() { + this.config = null; + } +} diff --git a/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityDescriptor.java b/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityDescriptor.java new file mode 100644 index 0000000..00b5e67 --- /dev/null +++ b/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityDescriptor.java @@ -0,0 +1,61 @@ +package com.github.tumbl3w33d.logout; + +import java.util.List; +import java.util.Map; + +import javax.inject.Named; +import javax.inject.Singleton; + +import org.sonatype.nexus.capability.CapabilityDescriptorSupport; +import org.sonatype.nexus.capability.CapabilityType; +import org.sonatype.nexus.common.upgrade.AvailabilityVersion; +import org.sonatype.nexus.formfields.FormField; +import org.sonatype.nexus.formfields.StringTextFormField; + +import com.google.common.collect.Lists; + +@AvailabilityVersion(from = "1.0") +@Named(OAuth2ProxyLogoutCapabilityDescriptor.TYPE_ID) +@Singleton +public class OAuth2ProxyLogoutCapabilityDescriptor + extends CapabilityDescriptorSupport { + + public static final String TYPE_ID = "oauth2-proxy.logout"; + public static final CapabilityType TYPE = CapabilityType.capabilityType(TYPE_ID); + + @SuppressWarnings("rawtypes") + private final List formFields; + + public OAuth2ProxyLogoutCapabilityDescriptor() { + formFields = Lists.newArrayList(new StringTextFormField(OAuth2ProxyLogoutCapabilityConfiguration.LOGOUT_URL_ID, + OAuth2ProxyLogoutCapabilityConfiguration.LOGOUT_URL_LABEL, + OAuth2ProxyLogoutCapabilityConfiguration.LOGOUT_URL_HELP, FormField.OPTIONAL)); + } + + @Override + public CapabilityType type() { + return TYPE; + } + + @Override + public String name() { + return "OAuth2 Proxy: Logout"; + } + + @SuppressWarnings("rawtypes") + @Override + public List formFields() { + return formFields; + } + + @Override + protected OAuth2ProxyLogoutCapabilityConfiguration createConfig(Map properties) { + return new OAuth2ProxyLogoutCapabilityConfiguration(properties); + } + + @Override + protected String renderAbout() throws Exception { + return "Specify settings regarding logout of OAuth2 Proxy triggered by Nexus. If this capability is disabled, no OAuth2 Proxy logout will be performed, effectively rendering the Nexus logout button ineffective"; + } + +} diff --git a/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutHandler.java b/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutHandler.java new file mode 100644 index 0000000..fdd05cf --- /dev/null +++ b/src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutHandler.java @@ -0,0 +1,138 @@ +package com.github.tumbl3w33d.logout; + +import java.io.IOException; +import java.net.URI; +import java.util.Arrays; +import java.util.stream.Collectors; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.protocol.RequestAddCookies; +import org.apache.http.impl.client.BasicCookieStore; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.cookie.BasicClientCookie; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.web.util.WebUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.sonatype.nexus.common.app.BaseUrlHolder; +import org.sonatype.nexus.security.authc.LogoutEvent; + +import com.github.tumbl3w33d.OAuth2ProxyRealm; +import com.google.common.eventbus.AllowConcurrentEvents; +import com.google.common.eventbus.Subscribe; + +@Singleton +@Named +public class OAuth2ProxyLogoutHandler { + + private final Logger logger = LoggerFactory.getLogger(OAuth2ProxyLogoutHandler.class); + + private OAuth2ProxyLogoutCapabilityConfigurationState configState; + + @Inject + public OAuth2ProxyLogoutHandler(OAuth2ProxyLogoutCapabilityConfigurationState configState) { + this.configState = configState; + } + + @AllowConcurrentEvents + @Subscribe + public void handle(final LogoutEvent event) { + if (OAuth2ProxyRealm.NAME.equals(event.getRealm())) { + if (configState.getConfig() != null) { + logger.debug("Triggering OAuth2 proxy logout for user " + event.getPrincipal()); + URI logoutUrl = URI.create(determineLogoutUrl(configState.getConfig())); + BasicCookieStore cookieStore = prepareCookieStore(logoutUrl); + try (CloseableHttpClient client = constructClient(cookieStore)) { + performOAuth2ProxyLogout(client, logoutUrl); + logger.info("User " + event.getPrincipal() + " was logged out from oauth2 proxy successfully"); + } catch (IOException e) { + logger.error("Failed to logout from oauth2 proxy: {}", + e.getMessage() == null ? e.getClass() : e.getMessage()); + logger.debug("Failed to logout from oauth2 proxy", e); + } + } else { + logger.debug( + "Logout from OAuth2 Proxy is disabled: enable the 'OAuth2 Proxy: Logout' capability to change this"); + } + } + } + + private String determineLogoutUrl(OAuth2ProxyLogoutCapabilityConfiguration config) { + if (config.getLogoutUrl() != null && !config.getLogoutUrl().trim().isEmpty()) { + return config.getLogoutUrl(); + } else { + String result = joinUri(BaseUrlHolder.get(), "oauth2/sign_out"); + logger.debug("Logout URL configured in logout capability is empty: Using default: {}", result); + return result; + } + } + + private String joinUri(String... parts) { + return Arrays.stream(parts).map(this::stripLeadingAndTrailingSlashes).collect(Collectors.joining("/")); + } + + private String stripLeadingAndTrailingSlashes(String s) { + if (s.startsWith("/")) { + s = s.substring(1); + } + if (s.endsWith("/")) { + s = s.substring(0, s.length() - 1); + } + return s; + } + + private BasicCookieStore prepareCookieStore(URI logoutUrl) { + BasicCookieStore cookieStore = new BasicCookieStore(); + HttpServletRequest request = WebUtils.getHttpRequest(SecurityUtils.getSubject()); + Arrays.stream(request.getCookies()).map(c -> toHttpCookie(logoutUrl, c)).forEach(c -> cookieStore.addCookie(c)); + return cookieStore; + } + + private BasicClientCookie toHttpCookie(URI logoutUrl, Cookie cookie) { + BasicClientCookie result = new BasicClientCookie(cookie.getName(), cookie.getValue()); + result.setDomain(logoutUrl.getHost()); + result.setPath("/"); + return result; + } + + private CloseableHttpClient constructClient(BasicCookieStore cookieStore) { + return HttpClients.custom()// + .disableCookieManagement() // disable response cookie processing as we don't care + .setDefaultCookieStore(cookieStore) // set cookie store containing the servlet requests cookies + .addInterceptorLast(new RequestAddCookies()) // disable also disabled request cookie processing, re-enable it manually + .build(); + } + + private void performOAuth2ProxyLogout(CloseableHttpClient client, URI logoutUrl) throws IOException { + HttpGet req = new HttpGet(logoutUrl); + req.setConfig(constructRequestConfig()); + + // oauth2 proxy will respond with 302, which means success + try (CloseableHttpResponse resp = client.execute(req)) { + if (resp.getStatusLine().getStatusCode() == 302) { + // pass the Set-Cookie header(s) to the frontend caller so the client session is invalidated as well + HttpServletResponse response = WebUtils.getHttpResponse(SecurityUtils.getSubject()); + Arrays.stream(resp.getHeaders("Set-Cookie")) + .forEach(h -> response.addHeader("Set-Cookie", h.getValue())); + } + } + } + + private RequestConfig constructRequestConfig() { + return RequestConfig.copy(RequestConfig.DEFAULT)// + .setRedirectsEnabled(false) // don't follow the redirect oauth2 proxy will respond with + .setConnectTimeout(5000) // very limited waiting time, might be that the URL is not configured + .build(); + } + +} diff --git a/src/test/java/com/github/tumbl3w33d/OAuth2ProxyHeaderAuthTokenFactoryTest.java b/src/test/java/com/github/tumbl3w33d/OAuth2ProxyHeaderAuthTokenFactoryTest.java index 3586f9e..56b9608 100644 --- a/src/test/java/com/github/tumbl3w33d/OAuth2ProxyHeaderAuthTokenFactoryTest.java +++ b/src/test/java/com/github/tumbl3w33d/OAuth2ProxyHeaderAuthTokenFactoryTest.java @@ -28,15 +28,32 @@ public class OAuth2ProxyHeaderAuthTokenFactoryTest { @Test void testCreateToken() { OAuth2ProxyHeaderAuthTokenFactory factory = new OAuth2ProxyHeaderAuthTokenFactory(); - HttpServletRequest request = createMockRequestViaProxy(false); - ServletResponse response = Mockito.mock(ServletResponse.class); AuthenticationToken token = factory.createToken(request, response); assertNotNull(token); assertDoesNotThrow(() -> (OAuth2ProxyHeaderAuthToken) token); OAuth2ProxyHeaderAuthToken proxyToken = (OAuth2ProxyHeaderAuthToken) token; + assertToken(proxyToken); + + // programmatic access - accept requests with Authorization header AND complete oauth2 proxy headers + // this allows oauth2 proxy's --skip-jwt-bearer-tokens option to be used for realising alternative oidc login flows + // e.g. obtaining an access token via device flow, then using it to login to nexus without having to deal with redirects + // in this case oauth2 proxy validates the token, populates all headers, but also forwards the Authorization header + Mockito.when(request.getHeader("Authorization")).thenReturn("Basic foo123=="); + assertNotNull(token); + assertDoesNotThrow(() -> (OAuth2ProxyHeaderAuthToken) token); + proxyToken = (OAuth2ProxyHeaderAuthToken) token; + assertToken(proxyToken); + + // programmatic access - reject requests with Authorization header but with incomplete oauth2 proxy headers + Mockito.when(request.getHeader("Authorization")).thenReturn("Basic foo123=="); + Mockito.when(request.getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_USER)).thenReturn(null); + assertNull(factory.createToken(request, response)); + } + + private static void assertToken(OAuth2ProxyHeaderAuthToken proxyToken) { assertEquals(username, proxyToken.getPrincipal()); assertNull(proxyToken.getCredentials()); assertEquals(userUuid, proxyToken.user.getHeaderValue()); @@ -45,27 +62,17 @@ void testCreateToken() { assertEquals(groups, proxyToken.groups.getHeaderValue()); assertEquals(host, proxyToken.getHost()); assertNull(proxyToken.accessToken); - - // programmatic access - reject requests with Authorization header - - Mockito.when(request.getHeader("Authorization")).thenReturn("Basic foo123=="); - - assertNull(factory.createToken(request, response)); } static HttpServletRequest createMockRequestViaProxy(boolean fakeMissingHeader) { HttpServletRequest request = Mockito.mock(HttpServletRequest.class); - Mockito.when(request.getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_USER)) - .thenReturn(userUuid); + Mockito.when(request.getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_USER)).thenReturn(userUuid); if (!fakeMissingHeader) { - Mockito.when(request - .getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_PREFERRED_USERNAME)) + Mockito.when(request.getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_PREFERRED_USERNAME)) .thenReturn(username); } - Mockito.when(request.getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_EMAIL)) - .thenReturn(mail); - Mockito.when(request.getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_GROUPS)) - .thenReturn(groups); + Mockito.when(request.getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_EMAIL)).thenReturn(mail); + Mockito.when(request.getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_GROUPS)).thenReturn(groups); Mockito.when(request.getRemoteHost()).thenReturn(host); return request; } diff --git a/src/test/java/com/github/tumbl3w33d/OAuth2ProxyRealmTest.java b/src/test/java/com/github/tumbl3w33d/OAuth2ProxyRealmTest.java index afa4516..5084280 100644 --- a/src/test/java/com/github/tumbl3w33d/OAuth2ProxyRealmTest.java +++ b/src/test/java/com/github/tumbl3w33d/OAuth2ProxyRealmTest.java @@ -38,6 +38,7 @@ import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.Logger; +import org.sonatype.nexus.common.event.EventManager; import org.sonatype.nexus.datastore.api.DataSession; import org.sonatype.nexus.security.authc.HttpHeaderAuthenticationToken; import org.sonatype.nexus.security.internal.DefaultSecurityPasswordService; @@ -52,6 +53,7 @@ import com.github.tumbl3w33d.h2.OAuth2ProxyRoleDAO; import com.github.tumbl3w33d.h2.OAuth2ProxyRoleStore; import com.github.tumbl3w33d.h2.OAuth2ProxyUserDAO; +import com.github.tumbl3w33d.logout.OAuth2ProxyLogoutHandler; import com.github.tumbl3w33d.users.OAuth2ProxyUserManager; import com.github.tumbl3w33d.users.OAuth2ProxyUserManager.UserWithPrincipals; import com.github.tumbl3w33d.users.db.OAuth2ProxyLoginRecord; @@ -138,8 +140,8 @@ void testDoGetAuthenticationInfo() { } /* - * Make sure there was a call to user creation in case no user - * for the provided proxy headers exists yet. + * Make sure there was a call to user creation in case no user for the provided + * proxy headers exists yet. */ private void testInteractiveAccessNewUser() { OAuth2ProxyUserManager userManager = Mockito.mock(OAuth2ProxyUserManager.class); @@ -170,8 +172,7 @@ private void testInteractiveAccessExistingUser() { OAuth2ProxyHeaderAuthToken token = createTestOAuth2ProxyHeaderAuthToken(); - User existingUser = OAuth2ProxyUserManager.createUserObject("test.user", - "test.user@example.com"); + User existingUser = OAuth2ProxyUserManager.createUserObject("test.user", "test.user@example.com"); try { Mockito.when(userManager.getUser("test.user")).thenReturn(existingUser); @@ -203,8 +204,7 @@ private void testProgrammaticAccess() { String hashedPassword = "$shiro1$SHA-512$1024$tz9GiwuH8w6FVj0kz+tEEQ==$DocY8XBn+cySKW6u3ZXy6fKnjpYJpFoTeqe9W8VFYmzdR0y6oFZu40faVDe6Clnb+vrpElRQhXDoVmnESLNa2A=="; Mockito.when(userManager.getApiToken(anyString())).thenReturn(Optional.of(hashedPassword)); - UsernamePasswordToken upToken = new UsernamePasswordToken("test.user", - "secret123"); + UsernamePasswordToken upToken = new UsernamePasswordToken("test.user", "secret123"); AuthenticationInfo authInfo = oauth2ProxyRealm.doGetAuthenticationInfo(upToken); String primaryPrincipal = (String) authInfo.getPrincipals().getPrimaryPrincipal(); assertNotNull(authInfo); @@ -273,12 +273,10 @@ void testSyncExternalRolesForGroups() throws UserNotFoundException { OAuth2ProxyUserManager userManager = Mockito.mock(OAuth2ProxyUserManager.class); oauth2ProxyRealm = getTestRealm(userManager, roleStore); - User user = OAuth2ProxyUserManager.createUserObject("test.user", - "test.user@example.com"); + User user = OAuth2ProxyUserManager.createUserObject("test.user", "test.user@example.com"); // user had another idp group before - user.addRole(new RoleIdentifier(OAuth2ProxyUserManager.SOURCE, - "other@idm.example.com")); + user.addRole(new RoleIdentifier(OAuth2ProxyUserManager.SOURCE, "other@idm.example.com")); // and was assigned a group from default source user.addRole(new RoleIdentifier(UserManager.DEFAULT_SOURCE, "nx-big-boss")); @@ -295,24 +293,18 @@ void testSyncExternalRolesForGroups() throws UserNotFoundException { verify(roleStore).addRolesIfMissing(roleCaptor.capture()); Set capturedRoles = roleCaptor.getValue(); Set testGroups = Stream.of(groups.split(",")) - .map(group -> new RoleIdentifier(OAuth2ProxyUserManager.SOURCE, - group)) - .collect(Collectors.toSet()); + .map(group -> new RoleIdentifier(OAuth2ProxyUserManager.SOURCE, group)).collect(Collectors.toSet()); assertEquals(testGroups, capturedRoles); - assertTrue(user.getRoles().stream().anyMatch( - role -> role.getRoleId().equals("administrators@idm.example.com"))); - assertTrue(user.getRoles().stream().anyMatch( - role -> role.getRoleId().equals("devs@idm.example.com"))); - assertTrue(user.getRoles().stream().anyMatch( - role -> role.getRoleId().equals("nx-big-boss")), + assertTrue( + user.getRoles().stream().anyMatch(role -> role.getRoleId().equals("administrators@idm.example.com"))); + assertTrue(user.getRoles().stream().anyMatch(role -> role.getRoleId().equals("devs@idm.example.com"))); + assertTrue(user.getRoles().stream().anyMatch(role -> role.getRoleId().equals("nx-big-boss")), "expected group sync to leave non-idp groups untouched"); - assertFalse(user.getRoles().stream().anyMatch( - role -> role.getRoleId().equals("other@idm.example.com"))); + assertFalse(user.getRoles().stream().anyMatch(role -> role.getRoleId().equals("other@idm.example.com"))); } - private OAuth2ProxyRealm getTestRealm(OAuth2ProxyUserManager userManager, - OAuth2ProxyRoleStore roleStore) { + private OAuth2ProxyRealm getTestRealm(OAuth2ProxyUserManager userManager, OAuth2ProxyRoleStore roleStore) { PasswordService passwordService = new DefaultSecurityPasswordService(Mockito.mock(PasswordService.class)); if (userManager == null) { @@ -321,7 +313,8 @@ private OAuth2ProxyRealm getTestRealm(OAuth2ProxyUserManager userManager, if (roleStore == null) { roleStore = Mockito.mock(OAuth2ProxyRoleStore.class); } - OAuth2ProxyRealm realm = new OAuth2ProxyRealm(userManager, roleStore, loginRecordStore, passwordService); + OAuth2ProxyRealm realm = new OAuth2ProxyRealm(userManager, roleStore, loginRecordStore, passwordService, + Mockito.mock(EventManager.class), Mockito.mock(OAuth2ProxyLogoutHandler.class)); return realm; } } diff --git a/src/test/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManagerTest.java b/src/test/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManagerTest.java index dd74a76..de84563 100644 --- a/src/test/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManagerTest.java +++ b/src/test/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManagerTest.java @@ -211,6 +211,7 @@ void testSearchUsers() { } @Test + @SuppressWarnings("deprecation") void testUpdateUser() { OAuth2ProxyUserStore userStore = Mockito.mock(OAuth2ProxyUserStore.class); User exampleUser = OAuth2ProxyUserManager.createUserObject("test.user",