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.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<WebTokenGenerator> 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<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

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

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) => {
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<Props> {
const { t, loading, error } = this.props;
if (error) {
return <ErrorNotification />;
return <ErrorNotification error={error}/>;
}
if (loading) {

View File

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

View File

@@ -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<Props> {
render() {
const { groups, loading, error, canAddGroups, t } = this.props;
if (loading) {
return <Loading />;
}
return (
<Page title={t("groups.title")} subtitle={t("groups.subtitle")} loading={loading || !groups} error={error}>
{this.renderGroupTable()}

View File

@@ -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<Props> {
render() {
const { error, loading, showCreateButton, t } = this.props;
if (loading) {
return <Loading />;
}
return (
<Page title={t("overview.title")} subtitle={t("overview.subtitle")} loading={loading} error={error}>
{this.renderOverview()}

View File

@@ -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<Props> {
render() {
const { users, loading, error, canAddUsers, t } = this.props;
if (loading) {
return <Loading />;
}
return (
<Page title={t("users.title")} subtitle={t("users.subtitle")} loading={loading || !users} error={error}>
{this.renderUserTable()}