Show session expired warning if the jwt token is expired

This commit is contained in:
Eduard Heimbuch
2020-08-07 08:49:02 +02:00
parent 9f8cde331b
commit 914f5eb4bb
9 changed files with 214 additions and 188 deletions

View File

@@ -38,6 +38,7 @@ import sonia.scm.SCMContext;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.AnonymousMode; import sonia.scm.security.AnonymousMode;
import sonia.scm.security.AnonymousToken; import sonia.scm.security.AnonymousToken;
import sonia.scm.security.BearerToken;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
import sonia.scm.util.Util; import sonia.scm.util.Util;
import sonia.scm.web.WebTokenGenerator; import sonia.scm.web.WebTokenGenerator;
@@ -49,7 +50,7 @@ import javax.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.util.Set; import java.util.Set;
//~--- JDK imports ------------------------------------------------------------ import static sonia.scm.web.filter.JwtValidator.isJwtTokenExpired;
/** /**
* Handles authentication, if a one of the {@link WebTokenGenerator} returns * Handles authentication, if a one of the {@link WebTokenGenerator} returns
@@ -61,23 +62,16 @@ import java.util.Set;
@Singleton @Singleton
public class AuthenticationFilter extends HttpFilter { public class AuthenticationFilter extends HttpFilter {
private static final Logger logger = LoggerFactory.getLogger(AuthenticationFilter.class);
/** /**
* marker for failed authentication * marker for failed authentication
*/ */
private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed"; private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed";
/**
* Field description
*/
private static final String HEADER_AUTHORIZATION = "Authorization"; private static final String HEADER_AUTHORIZATION = "Authorization";
/** private final Set<WebTokenGenerator> tokenGenerators;
* the logger for AuthenticationFilter protected ScmConfiguration configuration;
*/
private static final Logger logger =
LoggerFactory.getLogger(AuthenticationFilter.class);
//~--- constructors ---------------------------------------------------------
/** /**
* Constructs a new basic authenticaton filter. * Constructs a new basic authenticaton filter.
@@ -91,8 +85,6 @@ public class AuthenticationFilter extends HttpFilter {
this.tokenGenerators = tokenGenerators; this.tokenGenerators = tokenGenerators;
} }
//~--- methods --------------------------------------------------------------
/** /**
* Handles authentication, if a one of the {@link WebTokenGenerator} returns * Handles authentication, if a one of the {@link WebTokenGenerator} returns
* an {@link AuthenticationToken}. * an {@link AuthenticationToken}.
@@ -104,14 +96,15 @@ public class AuthenticationFilter extends HttpFilter {
* @throws ServletException * @throws ServletException
*/ */
@Override @Override
protected void doFilter(HttpServletRequest request, protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
HttpServletResponse response, FilterChain chain)
throws IOException, ServletException { throws IOException, ServletException {
Subject subject = SecurityUtils.getSubject(); Subject subject = SecurityUtils.getSubject();
AuthenticationToken token = createToken(request); 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( logger.trace(
"found authentication token on request, start authentication"); "found authentication token on request, start authentication");
handleAuthentication(request, response, chain, subject, token); handleAuthentication(request, response, chain, subject, token);
@@ -173,11 +166,8 @@ public class AuthenticationFilter extends HttpFilter {
* @param response http response * @param response http response
* @throws IOException * @throws IOException
*/ */
protected void sendUnauthorizedError(HttpServletRequest request, protected void sendUnauthorizedError(HttpServletRequest request, HttpServletResponse response) throws IOException {
HttpServletResponse response) HttpUtil.sendUnauthorized(request, response, configuration.getRealmDescription());
throws IOException {
HttpUtil.sendUnauthorized(request, response,
configuration.getRealmDescription());
} }
/** /**
@@ -260,8 +250,6 @@ public class AuthenticationFilter extends HttpFilter {
response); response);
} }
//~--- get methods ----------------------------------------------------------
/** /**
* Returns {@code true} if anonymous access is enabled. * Returns {@code true} if anonymous access is enabled.
* *
@@ -270,16 +258,4 @@ public class AuthenticationFilter extends HttpFilter {
private boolean isAnonymousAccessEnabled() { private boolean isAnonymousAccessEnabled() {
return (configuration != null) && configuration.getAnonymousMode() != AnonymousMode.OFF; return (configuration != null) && configuration.getAnonymousMode() != AnonymousMode.OFF;
} }
//~--- fields ---------------------------------------------------------------
/**
* set of web token generators
*/
private final Set<WebTokenGenerator> tokenGenerators;
/**
* scm main configuration
*/
protected ScmConfiguration configuration;
} }

View File

@@ -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;
}
}

View File

@@ -24,8 +24,6 @@
package sonia.scm.web.filter; package sonia.scm.web.filter;
//~--- non-JDK imports --------------------------------------------------------
import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware; import com.github.sdorra.shiro.SubjectAware;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
@@ -38,6 +36,7 @@ import org.junit.runner.RunWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner; import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.BearerToken;
import sonia.scm.web.WebTokenGenerator; import sonia.scm.web.WebTokenGenerator;
import javax.servlet.FilterChain; import javax.servlet.FilterChain;
@@ -47,191 +46,98 @@ import javax.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import static org.mockito.Mockito.any; import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
//~--- JDK imports ------------------------------------------------------------
/**
*
* @author Sebastian Sdorra
*/
@RunWith(MockitoJUnitRunner.class) @RunWith(MockitoJUnitRunner.class)
@SubjectAware(configuration = "classpath:sonia/scm/shiro.ini") @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 @Test
@SubjectAware(username = "trillian", password = "secret") @SubjectAware(username = "trillian", password = "secret")
public void testDoFilterAuthenticated() throws IOException, ServletException public void testDoFilterAuthenticated() throws IOException, ServletException {
{
AuthenticationFilter filter = createAuthenticationFilter(); AuthenticationFilter filter = createAuthenticationFilter();
filter.doFilter(request, response, chain); filter.doFilter(request, response, chain);
verify(chain).doFilter(any(HttpServletRequest.class), verify(chain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class));
any(HttpServletResponse.class));
} }
/**
* Method description
*
*
* @throws IOException
* @throws ServletException
*/
@Test @Test
public void testDoFilterUnauthorized() throws IOException, ServletException public void testDoFilterUnauthorized() throws IOException, ServletException {
{
AuthenticationFilter filter = createAuthenticationFilter(); AuthenticationFilter filter = createAuthenticationFilter();
filter.doFilter(request, response, chain); filter.doFilter(request, response, chain);
verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization Required");
"Authorization Required");
} }
/**
* Method description
*
*
* @throws IOException
* @throws ServletException
*/
@Test @Test
public void testDoFilterWithAuthenticationFailed() public void testDoFilterWithAuthenticationFailed() throws IOException, ServletException {
throws IOException, ServletException AuthenticationFilter filter = createAuthenticationFilter(new DemoWebTokenGenerator("trillian", "sec"));
{
AuthenticationFilter filter =
createAuthenticationFilter(new DemoWebTokenGenerator("trillian", "sec"));
filter.doFilter(request, response, chain); 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 @Test
public void testDoFilterWithAuthenticationSuccess() public void testDoFilterWithAuthenticationSuccess() throws IOException, ServletException {
throws IOException, ServletException AuthenticationFilter filter = createAuthenticationFilter();
{
AuthenticationFilter filter =
createAuthenticationFilter(new DemoWebTokenGenerator("trillian",
"secret"));
filter.doFilter(request, response, chain); 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 @Before
public void setUp() public void setUp() {
{
configuration = new ScmConfiguration(); configuration = new ScmConfiguration();
} }
//~--- methods -------------------------------------------------------------- private AuthenticationFilter createAuthenticationFilter(WebTokenGenerator... generators) {
return new AuthenticationFilter(configuration, ImmutableSet.copyOf(generators));
/**
* Method description
*
*
* @param generators
*
* @return
*/
private AuthenticationFilter createAuthenticationFilter(
WebTokenGenerator... generators)
{
return new AuthenticationFilter(configuration,
ImmutableSet.copyOf(generators));
} }
//~--- inner classes -------------------------------------------------------- private static class DemoWebTokenGenerator implements WebTokenGenerator {
/** private final String username;
* Class description private final String password;
*
*
* @version Enter version here..., 15/02/21
* @author Enter your name here...
*/
private static class DemoWebTokenGenerator implements WebTokenGenerator
{
/** public DemoWebTokenGenerator(String username, String password) {
* Constructs ...
*
*
* @param username
* @param password
*/
public DemoWebTokenGenerator(String username, String password)
{
this.username = username; this.username = username;
this.password = password; this.password = password;
} }
//~--- methods ------------------------------------------------------------
/**
* Method description
*
*
* @param request
*
* @return
*/
@Override @Override
public AuthenticationToken createToken(HttpServletRequest request) public AuthenticationToken createToken(HttpServletRequest request) {
{
return new UsernamePasswordToken(username, password); 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;
} }

View File

@@ -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();
}
}

View File

@@ -60,8 +60,8 @@ class RepositoryRoles extends React.Component<Props> {
} }
componentDidUpdate = (prevProps: Props) => { componentDidUpdate = (prevProps: Props) => {
const { loading, list, page, rolesLink, location, fetchRolesByPage } = this.props; const { loading, error, list, page, rolesLink, location, fetchRolesByPage } = this.props;
if (list && page && !loading) { if (list && page && !loading && !error) {
const statePage: number = list.page + 1; const statePage: number = list.page + 1;
if (page !== statePage || prevProps.location.search !== location.search) { if (page !== statePage || prevProps.location.search !== location.search) {
fetchRolesByPage(rolesLink, page); fetchRolesByPage(rolesLink, page);
@@ -73,7 +73,7 @@ class RepositoryRoles extends React.Component<Props> {
const { t, loading, error } = this.props; const { t, loading, error } = this.props;
if (error) { if (error) {
return <ErrorNotification />; return <ErrorNotification error={error}/>;
} }
if (loading) { if (loading) {

View File

@@ -73,7 +73,7 @@ class App extends Component<Props> {
let content; let content;
const navigation = authenticated ? <PrimaryNavigation links={links} /> : ""; const navigation = authenticated ? <PrimaryNavigation links={links} /> : "";
if (!authenticated) { if (!authenticated && !loading) {
content = <Login />; content = <Login />;
} else if (loading) { } else if (loading) {
content = <Loading />; content = <Loading />;

View File

@@ -25,7 +25,6 @@ import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { RouteComponentProps } from "react-router-dom"; import { RouteComponentProps } from "react-router-dom";
import { History } from "history";
import { Group, PagedCollection } from "@scm-manager/ui-types"; import { Group, PagedCollection } from "@scm-manager/ui-types";
import { import {
CreateButton, CreateButton,
@@ -34,7 +33,8 @@ import {
OverviewPageActions, OverviewPageActions,
Page, Page,
PageActions, PageActions,
urls urls,
Loading
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { getGroupsLink } from "../../modules/indexResource"; import { getGroupsLink } from "../../modules/indexResource";
import { import {
@@ -88,6 +88,11 @@ class Groups extends React.Component<Props> {
render() { render() {
const { groups, loading, error, canAddGroups, t } = this.props; const { groups, loading, error, canAddGroups, t } = this.props;
if (loading) {
return <Loading />;
}
return ( return (
<Page title={t("groups.title")} subtitle={t("groups.subtitle")} loading={loading || !groups} error={error}> <Page title={t("groups.title")} subtitle={t("groups.subtitle")} loading={loading || !groups} error={error}>
{this.renderGroupTable()} {this.renderGroupTable()}

View File

@@ -33,7 +33,8 @@ import {
OverviewPageActions, OverviewPageActions,
Page, Page,
PageActions, PageActions,
urls urls,
Loading
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { getRepositoriesLink } from "../../modules/indexResource"; import { getRepositoriesLink } from "../../modules/indexResource";
import { import {
@@ -80,6 +81,11 @@ class Overview extends React.Component<Props> {
render() { render() {
const { error, loading, showCreateButton, t } = this.props; const { error, loading, showCreateButton, t } = this.props;
if (loading) {
return <Loading />;
}
return ( return (
<Page title={t("overview.title")} subtitle={t("overview.subtitle")} loading={loading} error={error}> <Page title={t("overview.title")} subtitle={t("overview.subtitle")} loading={loading} error={error}>
{this.renderOverview()} {this.renderOverview()}

View File

@@ -25,7 +25,6 @@ import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { RouteComponentProps } from "react-router-dom"; import { RouteComponentProps } from "react-router-dom";
import { History } from "history";
import { PagedCollection, User } from "@scm-manager/ui-types"; import { PagedCollection, User } from "@scm-manager/ui-types";
import { import {
CreateButton, CreateButton,
@@ -34,7 +33,8 @@ import {
OverviewPageActions, OverviewPageActions,
Page, Page,
PageActions, PageActions,
urls urls,
Loading
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { getUsersLink } from "../../modules/indexResource"; import { getUsersLink } from "../../modules/indexResource";
import { import {
@@ -88,6 +88,11 @@ class Users extends React.Component<Props> {
render() { render() {
const { users, loading, error, canAddUsers, t } = this.props; const { users, loading, error, canAddUsers, t } = this.props;
if (loading) {
return <Loading />;
}
return ( return (
<Page title={t("users.title")} subtitle={t("users.subtitle")} loading={loading || !users} error={error}> <Page title={t("users.title")} subtitle={t("users.subtitle")} loading={loading || !users} error={error}>
{this.renderUserTable()} {this.renderUserTable()}