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 797799bfab..5d1f2159a7 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 @@ -38,6 +38,7 @@ import sonia.scm.SCMContext; import sonia.scm.config.ScmConfiguration; import sonia.scm.security.AnonymousMode; import sonia.scm.security.AnonymousToken; +import sonia.scm.security.BearerToken; import sonia.scm.util.HttpUtil; import sonia.scm.util.Util; import sonia.scm.web.WebTokenGenerator; @@ -49,7 +50,7 @@ import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Set; -//~--- JDK imports ------------------------------------------------------------ +import static sonia.scm.web.filter.JwtValidator.isJwtTokenExpired; /** * Handles authentication, if a one of the {@link WebTokenGenerator} returns @@ -61,23 +62,16 @@ import java.util.Set; @Singleton public class AuthenticationFilter extends HttpFilter { + private static final Logger logger = LoggerFactory.getLogger(AuthenticationFilter.class); + /** * marker for failed authentication */ private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed"; - - /** - * Field description - */ private static final String HEADER_AUTHORIZATION = "Authorization"; - /** - * the logger for AuthenticationFilter - */ - private static final Logger logger = - LoggerFactory.getLogger(AuthenticationFilter.class); - - //~--- constructors --------------------------------------------------------- + private final Set tokenGenerators; + protected ScmConfiguration configuration; /** * Constructs a new basic authenticaton filter. @@ -91,8 +85,6 @@ public class AuthenticationFilter extends HttpFilter { this.tokenGenerators = tokenGenerators; } - //~--- methods -------------------------------------------------------------- - /** * Handles authentication, if a one of the {@link WebTokenGenerator} returns * an {@link AuthenticationToken}. @@ -104,14 +96,15 @@ public class AuthenticationFilter extends HttpFilter { * @throws ServletException */ @Override - protected void doFilter(HttpServletRequest request, - HttpServletResponse response, FilterChain chain) + protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { Subject subject = SecurityUtils.getSubject(); AuthenticationToken token = createToken(request); - if (token != null) { + if (token instanceof BearerToken && isJwtTokenExpired(((BearerToken) token).getCredentials())) { + handleUnauthorized(request, response, chain); + } else if (token != null) { logger.trace( "found authentication token on request, start authentication"); handleAuthentication(request, response, chain, subject, token); @@ -173,11 +166,8 @@ public class AuthenticationFilter extends HttpFilter { * @param response http response * @throws IOException */ - protected void sendUnauthorizedError(HttpServletRequest request, - HttpServletResponse response) - throws IOException { - HttpUtil.sendUnauthorized(request, response, - configuration.getRealmDescription()); + protected void sendUnauthorizedError(HttpServletRequest request, HttpServletResponse response) throws IOException { + HttpUtil.sendUnauthorized(request, response, configuration.getRealmDescription()); } /** @@ -260,8 +250,6 @@ public class AuthenticationFilter extends HttpFilter { response); } - //~--- get methods ---------------------------------------------------------- - /** * Returns {@code true} if anonymous access is enabled. * @@ -270,16 +258,4 @@ public class AuthenticationFilter extends HttpFilter { private boolean isAnonymousAccessEnabled() { return (configuration != null) && configuration.getAnonymousMode() != AnonymousMode.OFF; } - - //~--- fields --------------------------------------------------------------- - - /** - * set of web token generators - */ - private final Set tokenGenerators; - - /** - * scm main configuration - */ - protected ScmConfiguration configuration; } diff --git a/scm-core/src/main/java/sonia/scm/web/filter/JwtValidator.java b/scm-core/src/main/java/sonia/scm/web/filter/JwtValidator.java new file mode 100644 index 0000000000..e7a1a8bf93 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/web/filter/JwtValidator.java @@ -0,0 +1,63 @@ +/* + * 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.web.filter; + +import java.time.Instant; +import java.util.Base64; + +public class JwtValidator { + + private JwtValidator() { + } + + /** + * Checks if the jwt token is expired. + * + * @return {@code true}if the token is expired + */ + public static boolean isJwtTokenExpired(String raw) { + + boolean expired = false; + + String[] parts = raw.split("\\."); + + if (parts.length > 1) { + Base64.Decoder decoder = Base64.getUrlDecoder(); + String payload = new String(decoder.decode(parts[1])); + String[] splitJwt = payload.split(","); + + for (String entry : splitJwt) { + if (entry.contains("\"exp\"")) { + long expirationTime = Long.parseLong(entry.replaceAll("[^\\d.]", "")); + + if (Instant.now().isAfter(Instant.ofEpochSecond(expirationTime))) { + expired = true; + } + } + } + } + return expired; + } +} diff --git a/scm-core/src/test/java/sonia/scm/web/filter/AuthenticationFilterTest.java b/scm-core/src/test/java/sonia/scm/web/filter/AuthenticationFilterTest.java index a9349c83c9..6adf973b43 100644 --- a/scm-core/src/test/java/sonia/scm/web/filter/AuthenticationFilterTest.java +++ b/scm-core/src/test/java/sonia/scm/web/filter/AuthenticationFilterTest.java @@ -21,10 +21,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - -package sonia.scm.web.filter; -//~--- non-JDK imports -------------------------------------------------------- +package sonia.scm.web.filter; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; @@ -38,6 +36,7 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.config.ScmConfiguration; +import sonia.scm.security.BearerToken; import sonia.scm.web.WebTokenGenerator; import javax.servlet.FilterChain; @@ -47,191 +46,98 @@ import javax.servlet.http.HttpServletResponse; import java.io.IOException; import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; -//~--- JDK imports ------------------------------------------------------------ - -/** - * - * @author Sebastian Sdorra - */ @RunWith(MockitoJUnitRunner.class) @SubjectAware(configuration = "classpath:sonia/scm/shiro.ini") -public class AuthenticationFilterTest -{ +public class AuthenticationFilterTest { + + @Rule + public ShiroRule shiro = new ShiroRule(); + + @Mock + private FilterChain chain; + @Mock + private HttpServletRequest request; + @Mock + private HttpServletResponse response; + + private ScmConfiguration configuration; - /** - * Method description - * - * - * @throws IOException - * @throws ServletException - */ @Test @SubjectAware(username = "trillian", password = "secret") - public void testDoFilterAuthenticated() throws IOException, ServletException - { + public void testDoFilterAuthenticated() throws IOException, ServletException { AuthenticationFilter filter = createAuthenticationFilter(); filter.doFilter(request, response, chain); - verify(chain).doFilter(any(HttpServletRequest.class), - any(HttpServletResponse.class)); + verify(chain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class)); } - /** - * Method description - * - * - * @throws IOException - * @throws ServletException - */ @Test - public void testDoFilterUnauthorized() throws IOException, ServletException - { + public void testDoFilterUnauthorized() throws IOException, ServletException { AuthenticationFilter filter = createAuthenticationFilter(); filter.doFilter(request, response, chain); - verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, - "Authorization Required"); + verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization Required"); } - /** - * Method description - * - * - * @throws IOException - * @throws ServletException - */ @Test - public void testDoFilterWithAuthenticationFailed() - throws IOException, ServletException - { - AuthenticationFilter filter = - createAuthenticationFilter(new DemoWebTokenGenerator("trillian", "sec")); + public void testDoFilterWithAuthenticationFailed() throws IOException, ServletException { + AuthenticationFilter filter = createAuthenticationFilter(new DemoWebTokenGenerator("trillian", "sec")); filter.doFilter(request, response, chain); - verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, - "Authorization Required"); + + verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization Required"); } - /** - * Method description - * - * - * @throws IOException - * @throws ServletException - */ @Test - public void testDoFilterWithAuthenticationSuccess() - throws IOException, ServletException - { - AuthenticationFilter filter = - createAuthenticationFilter(new DemoWebTokenGenerator("trillian", - "secret")); + public void testDoFilterWithAuthenticationSuccess() throws IOException, ServletException { + AuthenticationFilter filter = createAuthenticationFilter(); filter.doFilter(request, response, chain); - verify(chain).doFilter(any(HttpServletRequest.class), - any(HttpServletResponse.class)); + + verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization Required"); } - //~--- set methods ---------------------------------------------------------- + @Test + public void testExpiredBearerToken() throws IOException, ServletException { + WebTokenGenerator generator = mock(WebTokenGenerator.class); + when(generator.createToken(request)).thenReturn(BearerToken.create(null, + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzY21hZG1pbiIsImp0aSI6IjNqUzZ4TzMwUzEiLCJpYXQiOjE1OTY3ODA5Mjg" + + "sImV4cCI6MTU5Njc0NDUyOCwic2NtLW1hbmFnZXIucmVmcmVzaEV4cGlyYXRpb24iOjE1OTY4MjQxMjg2MDIsInNjbS1tYW5h" + + "Z2VyLnBhcmVudFRva2VuSWQiOiIzalM2eE8zMFMxIn0.utZLmzGZr-M6MP19yrd0dgLPkJ0u1xojwHKQi36_QAs")); + AuthenticationFilter filter = createAuthenticationFilter(generator); + + filter.doFilter(request, response, chain); + verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization Required"); + } - /** - * Method description - * - */ @Before - public void setUp() - { + public void setUp() { configuration = new ScmConfiguration(); } - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param generators - * - * @return - */ - private AuthenticationFilter createAuthenticationFilter( - WebTokenGenerator... generators) - { - return new AuthenticationFilter(configuration, - ImmutableSet.copyOf(generators)); + private AuthenticationFilter createAuthenticationFilter(WebTokenGenerator... generators) { + return new AuthenticationFilter(configuration, ImmutableSet.copyOf(generators)); } - //~--- inner classes -------------------------------------------------------- + private static class DemoWebTokenGenerator implements WebTokenGenerator { - /** - * Class description - * - * - * @version Enter version here..., 15/02/21 - * @author Enter your name here... - */ - private static class DemoWebTokenGenerator implements WebTokenGenerator - { + private final String username; + private final String password; - /** - * Constructs ... - * - * - * @param username - * @param password - */ - public DemoWebTokenGenerator(String username, String password) - { + public DemoWebTokenGenerator(String username, String password) { this.username = username; this.password = password; } - //~--- methods ------------------------------------------------------------ - - /** - * Method description - * - * - * @param request - * - * @return - */ @Override - public AuthenticationToken createToken(HttpServletRequest request) - { + public AuthenticationToken createToken(HttpServletRequest request) { return new UsernamePasswordToken(username, password); } - - //~--- fields ------------------------------------------------------------- - - /** Field description */ - private final String password; - - /** Field description */ - private final String username; } - //~--- fields --------------------------------------------------------------- - - /** Field description */ - @Rule - public ShiroRule shiro = new ShiroRule(); - - /** Field description */ - @Mock - private FilterChain chain; - - /** Field description */ - private ScmConfiguration configuration; - - /** Field description */ - @Mock - private HttpServletRequest request; - - /** Field description */ - @Mock - private HttpServletResponse response; } diff --git a/scm-core/src/test/java/sonia/scm/web/filter/JwtValidatorTest.java b/scm-core/src/test/java/sonia/scm/web/filter/JwtValidatorTest.java new file mode 100644 index 0000000000..d4ba075d0c --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/web/filter/JwtValidatorTest.java @@ -0,0 +1,65 @@ +/* + * 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.web.filter; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static sonia.scm.web.filter.JwtValidator.isJwtTokenExpired; + +class JwtValidatorTest { + + @Test + void shouldReturnFalseIfNotJwtToken() { + String raw = "scmadmin.scmadmin.scmadmin"; + + boolean result = isJwtTokenExpired(raw); + + assertThat(result).isFalse(); + } + + @Test + void shouldValidateExpiredJwtToken() { + String raw = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzY21hZG1pbiIsImp0aSI6IjNqUzZ4TzMwUzEiLCJpYXQiOjE1OTY3ODA5Mjgs" + + "ImV4cCI6MTU5Njc0NDUyOCwic2NtLW1hbmFnZXIucmVmcmVzaEV4cGlyYXRpb24iOjE1OTY4MjQxMjg2MDIsInNjbS1tYW5hZ2VyLnB" + + "hcmVudFRva2VuSWQiOiIzalM2eE8zMFMxIn0.utZLmzGZr-M6MP19yrd0dgLPkJ0u1xojwHKQi36_QAs"; + + boolean result = isJwtTokenExpired(raw); + + assertThat(result).isTrue(); + } + + @Test + void shouldValidateNotExpiredJwtToken() { + String raw = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzY21hZG1pbiIsImp0aSI6IjNqUzZ4TzMwUzEiLCJpYXQiOjE1OTY3ODA5Mjgs" + + "ImV4cCI6NTU5Njc0NDUyOCwic2NtLW1hbmFnZXIucmVmcmVzaEV4cGlyYXRpb24iOjE1OTY4MjQxMjg2MDIsInNjbS1tYW5hZ2VyLnB" + + "hcmVudFRva2VuSWQiOiIzalM2eE8zMFMxIn0.cvK4E58734T2PqtEqqhYCInnX_uryUkMhRNX-94riY0"; + + boolean result = isJwtTokenExpired(raw); + + assertThat(result).isFalse(); + } + +} diff --git a/scm-ui/ui-webapp/src/admin/roles/containers/RepositoryRoles.tsx b/scm-ui/ui-webapp/src/admin/roles/containers/RepositoryRoles.tsx index b7f0739fd4..d5755c4a5d 100644 --- a/scm-ui/ui-webapp/src/admin/roles/containers/RepositoryRoles.tsx +++ b/scm-ui/ui-webapp/src/admin/roles/containers/RepositoryRoles.tsx @@ -60,8 +60,8 @@ class RepositoryRoles extends React.Component { } componentDidUpdate = (prevProps: Props) => { - const { loading, list, page, rolesLink, location, fetchRolesByPage } = this.props; - if (list && page && !loading) { + const { loading, error, list, page, rolesLink, location, fetchRolesByPage } = this.props; + if (list && page && !loading && !error) { const statePage: number = list.page + 1; if (page !== statePage || prevProps.location.search !== location.search) { fetchRolesByPage(rolesLink, page); @@ -73,7 +73,7 @@ class RepositoryRoles extends React.Component { const { t, loading, error } = this.props; if (error) { - return ; + return ; } if (loading) { diff --git a/scm-ui/ui-webapp/src/containers/App.tsx b/scm-ui/ui-webapp/src/containers/App.tsx index 52026a44dc..bc63f68f43 100644 --- a/scm-ui/ui-webapp/src/containers/App.tsx +++ b/scm-ui/ui-webapp/src/containers/App.tsx @@ -73,7 +73,7 @@ class App extends Component { let content; const navigation = authenticated ? : ""; - if (!authenticated) { + if (!authenticated && !loading) { content = ; } else if (loading) { content = ; diff --git a/scm-ui/ui-webapp/src/groups/containers/Groups.tsx b/scm-ui/ui-webapp/src/groups/containers/Groups.tsx index f700fe5e94..31456b9301 100644 --- a/scm-ui/ui-webapp/src/groups/containers/Groups.tsx +++ b/scm-ui/ui-webapp/src/groups/containers/Groups.tsx @@ -25,7 +25,6 @@ import React from "react"; import { connect } from "react-redux"; import { WithTranslation, withTranslation } from "react-i18next"; import { RouteComponentProps } from "react-router-dom"; -import { History } from "history"; import { Group, PagedCollection } from "@scm-manager/ui-types"; import { CreateButton, @@ -34,7 +33,8 @@ import { OverviewPageActions, Page, PageActions, - urls + urls, + Loading } from "@scm-manager/ui-components"; import { getGroupsLink } from "../../modules/indexResource"; import { @@ -88,6 +88,11 @@ class Groups extends React.Component { render() { const { groups, loading, error, canAddGroups, t } = this.props; + + if (loading) { + return ; + } + return ( {this.renderGroupTable()} diff --git a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx index 66719b3fca..ae5d5f6246 100644 --- a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx @@ -33,7 +33,8 @@ import { OverviewPageActions, Page, PageActions, - urls + urls, + Loading } from "@scm-manager/ui-components"; import { getRepositoriesLink } from "../../modules/indexResource"; import { @@ -80,6 +81,11 @@ class Overview extends React.Component { render() { const { error, loading, showCreateButton, t } = this.props; + + if (loading) { + return ; + } + return ( {this.renderOverview()} diff --git a/scm-ui/ui-webapp/src/users/containers/Users.tsx b/scm-ui/ui-webapp/src/users/containers/Users.tsx index d358fc2a92..677b6c294b 100644 --- a/scm-ui/ui-webapp/src/users/containers/Users.tsx +++ b/scm-ui/ui-webapp/src/users/containers/Users.tsx @@ -25,7 +25,6 @@ import React from "react"; import { connect } from "react-redux"; import { WithTranslation, withTranslation } from "react-i18next"; import { RouteComponentProps } from "react-router-dom"; -import { History } from "history"; import { PagedCollection, User } from "@scm-manager/ui-types"; import { CreateButton, @@ -34,7 +33,8 @@ import { OverviewPageActions, Page, PageActions, - urls + urls, + Loading } from "@scm-manager/ui-components"; import { getUsersLink } from "../../modules/indexResource"; import { @@ -88,6 +88,11 @@ class Users extends React.Component { render() { const { users, loading, error, canAddUsers, t } = this.props; + + if (loading) { + return ; + } + return ( {this.renderUserTable()}