From 44edb48771a76e1c817035e6ed05434b23a4739c Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Thu, 20 Aug 2020 17:44:36 +0200 Subject: [PATCH] initial implementation --- .../scm/web/filter/AuthenticationFilter.java | 11 +-- scm-ui/ui-components/src/apiclient.ts | 7 +- .../lifecycle/modules/ScmSecurityModule.java | 9 ++ .../DefaultAccessTokenCookieIssuer.java | 11 +-- .../ScmAtLeastOneSuccessfulStrategy.java | 91 +++++++++++++++++++ .../scm/security/TokenExpiredFilter.java | 90 ++++++++++++++++++ 6 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/security/ScmAtLeastOneSuccessfulStrategy.java create mode 100644 scm-webapp/src/main/java/sonia/scm/security/TokenExpiredFilter.java diff --git a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java index 04ffe12e21..a6514465fd 100644 --- a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java +++ b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java @@ -67,6 +67,7 @@ public class AuthenticationFilter extends HttpFilter { */ private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed"; + private final Set tokenGenerators; protected ScmConfiguration configuration; @@ -117,7 +118,7 @@ public class AuthenticationFilter extends HttpFilter { } /** - * Sends status code 403 back to client, if the authentication has failed. + * Sends status code 401 back to client, if the authentication has failed. * In all other cases the method will send status code 403 back to client. * * @param request servlet request @@ -209,12 +210,8 @@ public class AuthenticationFilter extends HttpFilter { subject.login(token); processChain(request, response, chain, subject); } catch (TokenExpiredException ex) { - if (logger.isTraceEnabled()) { - logger.trace("{} expired", token.getClass(), ex); - } else { - logger.debug("{} expired", token.getClass()); - } - handleUnauthorized(request, response, chain); + // Rethrow to be caught by TokenExpiredFilter + throw ex; } catch (AuthenticationException ex) { logger.warn("authentication failed", ex); handleUnauthorized(request, response, chain); diff --git a/scm-ui/ui-components/src/apiclient.ts b/scm-ui/ui-components/src/apiclient.ts index 346b02845d..daabeeb122 100644 --- a/scm-ui/ui-components/src/apiclient.ts +++ b/scm-ui/ui-components/src/apiclient.ts @@ -120,7 +120,12 @@ function handleFailure(response: Response) { if (!response.ok) { if (isBackendError(response)) { return response.json().then((content: BackendErrorContent) => { - throw createBackendError(content, response.status); + if (content.errorCode === "DDS8D8unr1") { + window.location.replace(`${contextPath}/login`); + throw new UnauthorizedError("Unauthorized", 401); + } else { + throw createBackendError(content, response.status); + } }); } else { if (response.status === 401) { diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmSecurityModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmSecurityModule.java index 36fd0bc363..cfa5b10f6e 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmSecurityModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmSecurityModule.java @@ -28,8 +28,11 @@ package sonia.scm.lifecycle.modules; import com.google.inject.name.Names; +import org.apache.shiro.authc.Authenticator; import org.apache.shiro.authc.credential.DefaultPasswordService; import org.apache.shiro.authc.credential.PasswordService; +import org.apache.shiro.authc.pam.AuthenticationStrategy; +import org.apache.shiro.authc.pam.ModularRealmAuthenticator; import org.apache.shiro.crypto.hash.DefaultHashService; import org.apache.shiro.guice.web.ShiroWebModule; import org.apache.shiro.realm.Realm; @@ -44,6 +47,7 @@ import sonia.scm.plugin.ExtensionProcessor; import javax.servlet.ServletContext; import org.apache.shiro.mgt.RememberMeManager; import sonia.scm.security.DisabledRememberMeManager; +import sonia.scm.security.ScmAtLeastOneSuccessfulStrategy; /** * @@ -94,6 +98,11 @@ public class ScmSecurityModule extends ShiroWebModule // disable remember me cookie generation bind(RememberMeManager.class).to(DisabledRememberMeManager.class); + // bind authentication strategy + bind(ModularRealmAuthenticator.class); + bind(Authenticator.class).to(ModularRealmAuthenticator.class); + bind(AuthenticationStrategy.class).to(ScmAtLeastOneSuccessfulStrategy.class); + // bind realm for (Class realm : extensionProcessor.byExtensionPoint(Realm.class)) { diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultAccessTokenCookieIssuer.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultAccessTokenCookieIssuer.java index cea0770c01..73d9e7f3a7 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultAccessTokenCookieIssuer.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultAccessTokenCookieIssuer.java @@ -51,6 +51,10 @@ public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIs * the logger for DefaultAccessTokenCookieIssuer */ private static final Logger LOG = LoggerFactory.getLogger(DefaultAccessTokenCookieIssuer.class); + + private static final int DEFAULT_COOKIE_EXPIRATION_AMOUNT = 24; + private static final TimeUnit DEFAULT_COOKIE_EXPIRATION_UNIT = TimeUnit.HOURS; + private static final int DEFAULT_COOKIE_EXPIRATION = (int) TimeUnit.SECONDS.convert(DEFAULT_COOKIE_EXPIRATION_AMOUNT, DEFAULT_COOKIE_EXPIRATION_UNIT); private final ScmConfiguration configuration; @@ -75,7 +79,7 @@ public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIs LOG.trace("create and attach cookie for access token {}", accessToken.getId()); Cookie c = new Cookie(HttpUtil.COOKIE_BEARER_AUTHENTICATION, accessToken.compact()); c.setPath(contextPath(request)); - c.setMaxAge(getMaxAge(accessToken)); + c.setMaxAge(DEFAULT_COOKIE_EXPIRATION); c.setHttpOnly(isHttpOnly()); c.setSecure(isSecure(request)); @@ -111,11 +115,6 @@ public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIs return contextPath; } - private int getMaxAge(AccessToken accessToken){ - long maxAgeMs = accessToken.getExpiration().getTime() - new Date().getTime(); - return (int) TimeUnit.MILLISECONDS.toSeconds(maxAgeMs); - } - private boolean isSecure(HttpServletRequest request){ boolean secure = request.isSecure(); if (!secure) { diff --git a/scm-webapp/src/main/java/sonia/scm/security/ScmAtLeastOneSuccessfulStrategy.java b/scm-webapp/src/main/java/sonia/scm/security/ScmAtLeastOneSuccessfulStrategy.java new file mode 100644 index 0000000000..9a0931dfd2 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ScmAtLeastOneSuccessfulStrategy.java @@ -0,0 +1,91 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.security; + +import org.apache.shiro.authc.AuthenticationException; +import org.apache.shiro.authc.AuthenticationInfo; +import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.authc.pam.AbstractAuthenticationStrategy; +import org.apache.shiro.realm.Realm; +import org.apache.shiro.subject.PrincipalCollection; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public class ScmAtLeastOneSuccessfulStrategy extends AbstractAuthenticationStrategy { + + private final ThreadLocal> threadLocal = new ThreadLocal<>(); + + @Override + public AuthenticationInfo beforeAllAttempts(Collection realms, AuthenticationToken token) throws AuthenticationException { + this.threadLocal.set(new ArrayList<>()); + return super.beforeAllAttempts(realms, token); + } + + @Override + public AuthenticationInfo afterAttempt(Realm realm, AuthenticationToken token, AuthenticationInfo singleRealmInfo, AuthenticationInfo aggregateInfo, Throwable t) throws AuthenticationException { + if (t != null) { + this.threadLocal.get().add(t); + } + return super.afterAttempt(realm, token, singleRealmInfo, aggregateInfo, t); + } + + @Override + public AuthenticationInfo afterAllAttempts(AuthenticationToken token, AuthenticationInfo aggregate) throws AuthenticationException { + final List throwables = threadLocal.get(); + threadLocal.remove(); + if (isAuthenticationSuccessful(aggregate)) { + return aggregate; + } + Optional tokenExpiredException = findTokenExpiredException(throwables); + + if (tokenExpiredException.isPresent()) { + throw tokenExpiredException.get(); + } else { + throw createAuthenticationException(token); + } + } + + private static boolean isAuthenticationSuccessful(AuthenticationInfo aggregate) { + return aggregate != null && isNotEmpty(aggregate.getPrincipals()); + } + + private static boolean isNotEmpty(PrincipalCollection pc) { + return pc != null && !pc.isEmpty(); + } + + private static Optional findTokenExpiredException(List throwables) { + return throwables.stream().filter(t -> t instanceof TokenExpiredException).findFirst().map(t -> (TokenExpiredException) t); + } + + private static AuthenticationException createAuthenticationException(AuthenticationToken token) { + return new AuthenticationException("Authentication token of type [" + token.getClass() + "] " + + "could not be authenticated by any configured realms. Please ensure that at least one realm can " + + "authenticate these tokens."); + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/TokenExpiredFilter.java b/scm-webapp/src/main/java/sonia/scm/security/TokenExpiredFilter.java new file mode 100644 index 0000000000..759ba97e0f --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/TokenExpiredFilter.java @@ -0,0 +1,90 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import sonia.scm.Priority; +import sonia.scm.api.v2.resources.ErrorDto; +import sonia.scm.filter.Filters; +import sonia.scm.filter.WebElement; +import sonia.scm.web.VndMediaType; +import sonia.scm.web.filter.HttpFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@WebElement("/*") +@Priority(Filters.PRIORITY_PRE_AUTHENTICATION) +@Singleton +public class TokenExpiredFilter extends HttpFilter { + private static final String TOKEN_EXPIRED_ERROR_CODE = "DDS8D8unr1"; + private static final Logger LOG = LoggerFactory.getLogger(TokenExpiredFilter.class); + + private final AccessTokenCookieIssuer accessTokenCookieIssuer; + private final ObjectMapper objectMapper; + + @Inject + public TokenExpiredFilter(AccessTokenCookieIssuer accessTokenCookieIssuer, ObjectMapper objectMapper) { + this.accessTokenCookieIssuer = accessTokenCookieIssuer; + this.objectMapper = objectMapper; + } + + @Override + protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + try { + chain.doFilter(request, response); + } catch (TokenExpiredException ex) { + if (LOG.isTraceEnabled()) { + LOG.trace("Token expired", ex); + } else { + LOG.debug("Token expired"); + } + handleTokenExpired(request, response); + } + } + + protected void handleTokenExpired(HttpServletRequest request, + HttpServletResponse response) throws IOException { + accessTokenCookieIssuer.invalidate(request, response); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(VndMediaType.ERROR_TYPE); + final ErrorDto errorDto = new ErrorDto(); + errorDto.setMessage("Token Expired"); + errorDto.setErrorCode(TOKEN_EXPIRED_ERROR_CODE); + errorDto.setTransactionId(MDC.get("transaction_id")); + try (ServletOutputStream stream = response.getOutputStream()) { + objectMapper.writeValue(stream, errorDto); + } + } +}