diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java index 7d1d2cb288..327ee926a1 100644 --- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.config; @@ -312,10 +312,23 @@ public class ScmConfiguration implements Configuration { return realmDescription; } + /** + * since 2.4.0 + * @return anonymousMode + */ public AnonymousMode getAnonymousMode() { return anonymousMode; } + /** + * @deprecated since 2.4.0 + * @use {@link ScmConfiguration#getAnonymousMode} instead + */ + @Deprecated + public boolean isAnonymousAccessEnabled() { + return anonymousMode != AnonymousMode.OFF; + } + public boolean isDisableGroupingGrid() { return disableGroupingGrid; } @@ -361,6 +374,23 @@ public class ScmConfiguration implements Configuration { return skipFailedAuthenticators; } + /** + * @deprecated since 2.4.0 + * @use {@link ScmConfiguration#setAnonymousMode(AnonymousMode)} instead + */ + @Deprecated + public void setAnonymousAccessEnabled(boolean anonymousAccessEnabled) { + if (anonymousAccessEnabled) { + this.anonymousMode = AnonymousMode.PROTOCOL_ONLY; + } else { + this.anonymousMode = AnonymousMode.OFF; + } + } + + /** + * since 2.4.0 + * @param mode + */ public void setAnonymousMode(AnonymousMode mode) { this.anonymousMode = mode; } diff --git a/scm-core/src/main/java/sonia/scm/security/AnonymousMode.java b/scm-core/src/main/java/sonia/scm/security/AnonymousMode.java index d08b65eb77..0ca7683d32 100644 --- a/scm-core/src/main/java/sonia/scm/security/AnonymousMode.java +++ b/scm-core/src/main/java/sonia/scm/security/AnonymousMode.java @@ -24,6 +24,10 @@ package sonia.scm.security; +/** + * Available modes for anonymous access + * @since 2.4.0 + */ public enum AnonymousMode { FULL, PROTOCOL_ONLY, OFF } diff --git a/scm-core/src/main/java/sonia/scm/web/filter/JwtValidator.java b/scm-core/src/main/java/sonia/scm/security/TokenExpiredException.java similarity index 57% rename from scm-core/src/main/java/sonia/scm/web/filter/JwtValidator.java rename to scm-core/src/main/java/sonia/scm/security/TokenExpiredException.java index e7a1a8bf93..a4da835c0a 100644 --- a/scm-core/src/main/java/sonia/scm/web/filter/JwtValidator.java +++ b/scm-core/src/main/java/sonia/scm/security/TokenExpiredException.java @@ -22,42 +22,34 @@ * SOFTWARE. */ -package sonia.scm.web.filter; +package sonia.scm.security; -import java.time.Instant; -import java.util.Base64; +import org.apache.shiro.authc.AuthenticationException; -public class JwtValidator { +/** + * This exception is thrown if the session token is expired + * @since 2.4.0 + */ +@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here +public class TokenExpiredException extends AuthenticationException { - private JwtValidator() { + /** + * Constructs a new SessionExpiredException. + * + * @param message the reason for the exception + */ + public TokenExpiredException(String message) { + super(message); } /** - * Checks if the jwt token is expired. + * Constructs a new SessionExpiredException. * - * @return {@code true}if the token is expired + * @param message the reason for the exception + * @param cause the underlying Throwable that caused this exception to be thrown. */ - 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; + public TokenExpiredException(String message, Throwable cause) { + super(message, cause); } + } 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 5d1f2159a7..04ffe12e21 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,7 +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.security.TokenExpiredException; import sonia.scm.util.HttpUtil; import sonia.scm.util.Util; import sonia.scm.web.WebTokenGenerator; @@ -50,8 +50,6 @@ import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Set; -import static sonia.scm.web.filter.JwtValidator.isJwtTokenExpired; - /** * Handles authentication, if a one of the {@link WebTokenGenerator} returns * an {@link AuthenticationToken}. @@ -62,13 +60,12 @@ import static sonia.scm.web.filter.JwtValidator.isJwtTokenExpired; @Singleton public class AuthenticationFilter extends HttpFilter { - private static final Logger logger = LoggerFactory.getLogger(AuthenticationFilter.class); + private static final Logger logger = LoggerFactory.getLogger(AuthenticationFilter.class); /** * marker for failed authentication */ private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed"; - private static final String HEADER_AUTHORIZATION = "Authorization"; private final Set tokenGenerators; protected ScmConfiguration configuration; @@ -102,9 +99,7 @@ public class AuthenticationFilter extends HttpFilter { AuthenticationToken token = createToken(request); - if (token instanceof BearerToken && isJwtTokenExpired(((BearerToken) token).getCredentials())) { - handleUnauthorized(request, response, chain); - } else if (token != null) { + if (token != null) { logger.trace( "found authentication token on request, start authentication"); handleAuthentication(request, response, chain, subject, token); @@ -213,6 +208,13 @@ public class AuthenticationFilter extends HttpFilter { try { 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); } catch (AuthenticationException ex) { logger.warn("authentication failed", ex); handleUnauthorized(request, response, chain); 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 deleted file mode 100644 index d4ba075d0c..0000000000 --- a/scm-core/src/test/java/sonia/scm/web/filter/JwtValidatorTest.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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/e2e-tests/cypress.json b/scm-ui/e2e-tests/cypress.json index 0967ef424b..03e8546581 100644 --- a/scm-ui/e2e-tests/cypress.json +++ b/scm-ui/e2e-tests/cypress.json @@ -1 +1,3 @@ -{} +{ + "baseUrl": "http://localhost:8081/scm" +} diff --git a/scm-ui/e2e-tests/cypress/fixtures/example.json b/scm-ui/e2e-tests/cypress/fixtures/example.json deleted file mode 100644 index da18d9352a..0000000000 --- a/scm-ui/e2e-tests/cypress/fixtures/example.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Using fixtures to represent data", - "email": "hello@cypress.io", - "body": "Fixtures are a great way to mock data for responses to routes" -} \ No newline at end of file diff --git a/scm-ui/e2e-tests/cypress/integration/anonymousAccess.spec.js b/scm-ui/e2e-tests/cypress/integration/anonymousAccess.spec.js index 01d8029913..608e8bda27 100644 --- a/scm-ui/e2e-tests/cypress/integration/anonymousAccess.spec.js +++ b/scm-ui/e2e-tests/cypress/integration/anonymousAccess.spec.js @@ -23,145 +23,115 @@ */ describe("With Anonymous mode disabled", () => { - it("Should show login page without primary navigation", () => { - loginUser("scmadmin", "scmadmin"); + before("Disable anonymous access", () => { + cy.login("scmadmin", "scmadmin"); setAnonymousMode("OFF"); + cy.byTestId("primary-navigation-logout").click(); + }); - cy.get("li") - .contains("Logout") - .click(); - cy.contains("Please login to proceed"); - cy.get("div").not("Login"); - cy.get("div").not("Repositories"); + it("Should show login page without primary navigation", () => { + cy.byTestId("login-button"); + cy.containsNotByTestId("div", "primary-navigation-login"); + cy.containsNotByTestId("div", "primary-navigation-repositories"); }); it("Should redirect after login", () => { - loginUser("scmadmin", "scmadmin"); + cy.login("scmadmin", "scmadmin"); - cy.visit("http://localhost:8081/scm/me"); - cy.contains("Profile"); - cy.get("li") - .contains("Logout") - .click(); + cy.visit("/me"); + cy.byTestId("footer-user-profile"); + cy.byTestId("primary-navigation-logout").click(); }); }); describe("With Anonymous mode protocol only enabled", () => { - it("Should show login page without primary navigation", () => { - loginUser("scmadmin", "scmadmin"); + before("Set anonymous mode to protocol only", () => { + cy.login("scmadmin", "scmadmin"); setAnonymousMode("PROTOCOL_ONLY"); + cy.byTestId("primary-navigation-logout").click(); + }); - // Give anonymous user permissions - cy.get("li") - .contains("Users") - .click(); - cy.get("td") - .contains("_anonymous") - .click(); - cy.get("a") - .contains("Settings") - .click(); - cy.get("li") - .contains("Permissions") - .click(); - cy.get("label") - .contains("Read all repositories") - .click(); - cy.get("button") - .contains("Set permissions") - .click(); + it("Should show login page without primary navigation", () => { + cy.visit("/repos/"); + cy.byTestId("login-button"); + cy.containsNotByTestId("div", "primary-navigation-login"); + cy.containsNotByTestId("div", "primary-navigation-repositories"); + }); - cy.get("li") - .contains("Logout") - .click(); - cy.visit("http://localhost:8081/scm/repos/"); - cy.contains("Please login to proceed"); - cy.get("div").not("Login"); - cy.get("div").not("Repositories"); + after("Disable anonymous access", () => { + cy.login("scmadmin", "scmadmin"); + setAnonymousMode("OFF"); + cy.byTestId("primary-navigation-logout").click(); }); }); describe("With Anonymous mode fully enabled", () => { - it("Should show repositories overview with Login button in primary navigation", () => { - loginUser("scmadmin", "scmadmin"); + before("Set anonymous mode to full", () => { + cy.login("scmadmin", "scmadmin"); setAnonymousMode("FULL"); - cy.get("li") - .contains("Logout") - .click(); - cy.visit("http://localhost:8081/scm/repos/"); - cy.contains("Overview of available repositories"); - cy.contains("SCM Anonymous"); - cy.get("ul").contains("Login"); + // Give anonymous user permissions + cy.byTestId("primary-navigation-users").click(); + cy.byTestId("_anonymous").click(); + cy.byTestId("user-settings-link").click(); + cy.byTestId("user-permissions-link").click(); + cy.byTestId("read-all-repositories").click(); + cy.byTestId("set-permissions-button").click(); + + cy.byTestId("primary-navigation-logout").click(); + }); + + it("Should show repositories overview with Login button in primary navigation", () => { + cy.visit("/repos/"); + cy.byTestId("repository-overview-filter"); + cy.byTestId("SCM-Anonymous"); + cy.byTestId("primary-navigation-login"); }); it("Should show login page on url", () => { - cy.visit("http://localhost:8081/scm/login/"); + cy.visit("/login/"); + cy.byTestId("login-button"); }); it("Should show login page on link click", () => { - cy.visit("http://localhost:8081/scm/repos/"); - cy.contains("Overview of available repositories"); - cy.contains("Login").click(); - cy.contains("Please login to proceed"); + cy.visit("/repos/"); + cy.byTestId("repository-overview-filter"); + cy.byTestId("primary-navigation-login").click(); + cy.byTestId("login-button"); }); it("Should login and direct to repositories overview", () => { - loginUser("scmadmin", "scmadmin"); + cy.login("scmadmin", "scmadmin"); - cy.visit("http://localhost:8081/scm/login"); - cy.contains("SCM Administrator"); - cy.get("li") - .contains("Logout") - .click(); + cy.visit("/login"); + cy.byTestId("SCM-Administrator"); + cy.byTestId("primary-navigation-logout").click(); }); it("Should logout and direct to login page", () => { - loginUser("scmadmin", "scmadmin"); + cy.login("scmadmin", "scmadmin"); - cy.visit("http://localhost:8081/scm/repos/"); - cy.contains("Overview of available repositories"); - cy.contains("SCM Administrator"); - cy.contains("Logout").click(); - cy.contains("Please login to proceed"); + cy.visit("/repos/"); + cy.byTestId("repository-overview-filter"); + cy.byTestId("SCM-Administrator"); + cy.byTestId("primary-navigation-logout").click(); + cy.byTestId("login-button"); }); it("Anonymous user should not be able to change password", () => { - cy.visit("http://localhost:8081/scm/repos/"); - cy.contains("Profile").click(); - cy.contains("scm-anonymous@scm-manager.org"); - cy.get("ul").not("Settings"); + cy.visit("/repos/"); + cy.byTestId("footer-user-profile").click(); + cy.byTestId("SCM-Anonymous"); + cy.containsNotByTestId("ul", "user-settings-link"); cy.get("section").not("Change password"); }); -}); -describe("Disable anonymous mode after tests", () => { - it("Disable anonymous mode after tests", () => { - loginUser("scmadmin", "scmadmin"); + after("Disable anonymous access", () => { + cy.login("scmadmin", "scmadmin"); setAnonymousMode("OFF"); - - cy.get("li") - .contains("Logout") - .click(); + cy.byTestId("primary-navigation-logout").click(); }); }); const setAnonymousMode = anonymousMode => { - cy.get("li") - .contains("Administration") - .click(); - cy.get("li") - .contains("Settings") - .click(); - cy.get("select") - .contains("Disabled") - .parent() + cy.byTestId("primary-navigation-admin").click(); + cy.byTestId("admin-settings-link").click(); + cy.byTestId("anonymous-mode-select") .select(anonymousMode) .should("have.value", anonymousMode); - cy.get("button") - .contains("Submit") - .click(); -}; - -const loginUser = (username, password) => { - cy.visit("http://localhost:8081/scm/login"); - cy.get("div.field.username > div > input").type(username); - cy.get("div.field.password > div > input").type(password); - cy.get("button") - .contains("Login") - .click(); + cy.byTestId("submit-button").click(); }; diff --git a/scm-ui/e2e-tests/cypress/support/commands.js b/scm-ui/e2e-tests/cypress/support/commands.js index cb9cdc5ecc..e0a4923e7d 100644 --- a/scm-ui/e2e-tests/cypress/support/commands.js +++ b/scm-ui/e2e-tests/cypress/support/commands.js @@ -46,3 +46,16 @@ // // -- This will overwrite an existing command -- // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) + + +const login = (username, password) => { + cy.visit( "/login"); + cy.byTestId("username-input").type(username); + cy.byTestId("password-input").type(password); + cy.byTestId("login-button").click(); +}; + +Cypress.Commands.add("login", login); +Cypress.Commands.add("byTestId", (testId) => cy.get("[data-testid=" + testId + "]")); +Cypress.Commands.add("containsNotByTestId", (container, testId) => cy.get(container).not("[data-testid=" + testId + "]")); + diff --git a/scm-ui/ui-components/src/Icon.tsx b/scm-ui/ui-components/src/Icon.tsx index 91edfbfd10..8e87871f83 100644 --- a/scm-ui/ui-components/src/Icon.tsx +++ b/scm-ui/ui-components/src/Icon.tsx @@ -23,6 +23,7 @@ */ import React from "react"; import classNames from "classnames"; +import { createAttributesForTesting } from "./devBuild"; type Props = { title?: string; @@ -31,6 +32,7 @@ type Props = { color: string; className?: string; onClick?: () => void; + testId?: string; }; export default class Icon extends React.Component { @@ -40,12 +42,23 @@ export default class Icon extends React.Component { }; render() { - const { title, iconStyle, name, color, className, onClick } = this.props; + const { title, iconStyle, name, color, className, onClick, testId } = this.props; if (title) { return ( - + ); } - return ; + return ( + + ); } } diff --git a/scm-ui/ui-components/src/OverviewPageActions.tsx b/scm-ui/ui-components/src/OverviewPageActions.tsx index 03df29b869..a5c90960f0 100644 --- a/scm-ui/ui-components/src/OverviewPageActions.tsx +++ b/scm-ui/ui-components/src/OverviewPageActions.tsx @@ -32,11 +32,12 @@ type Props = RouteComponentProps & { showCreateButton: boolean; link: string; label?: string; + testId?: string; }; class OverviewPageActions extends React.Component { render() { - const { history, location, link } = this.props; + const { history, location, link, testId } = this.props; return ( <> { filter={filter => { history.push(`/${link}/?q=${filter}`); }} + testId={testId + "-filter"} /> {this.renderCreateButton()} diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index 92c6869962..2ce63602df 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -39093,15 +39093,6 @@ exports[`Storyshots Layout|Footer Default 1`] = ` footer.user.profile -
  • - - profile.changePasswordNavLink - -
  • - - trillian - - Trillian McMillian +
    + + trillian + + Trillian McMillian +
    -
  • - - profile.changePasswordNavLink - -
  • { icon, fullWidth, reducedMobile, - children + children, + testId } = this.props; const loadingClass = loading ? "is-loading" : ""; const fullWidthClass = fullWidth ? "is-fullwidth" : ""; @@ -86,6 +89,7 @@ class Button extends React.Component { disabled={disabled} onClick={this.onClick} className={classNames("button", "is-" + color, loadingClass, fullWidthClass, reducedMobileClass, className)} + {...createAttributesForTesting(testId)} > @@ -104,6 +108,7 @@ class Button extends React.Component { disabled={disabled} onClick={this.onClick} className={classNames("button", "is-" + color, loadingClass, fullWidthClass, className)} + {...createAttributesForTesting(testId)} > {label} {children} diff --git a/scm-ui/ui-components/src/buttons/SubmitButton.tsx b/scm-ui/ui-components/src/buttons/SubmitButton.tsx index a7ccd87acd..d03a15772f 100644 --- a/scm-ui/ui-components/src/buttons/SubmitButton.tsx +++ b/scm-ui/ui-components/src/buttons/SubmitButton.tsx @@ -26,6 +26,7 @@ import Button, { ButtonProps } from "./Button"; type SubmitButtonProps = ButtonProps & { scrollToTop: boolean; + testId?: string; }; class SubmitButton extends React.Component { @@ -34,7 +35,7 @@ class SubmitButton extends React.Component { }; render() { - const { action, scrollToTop } = this.props; + const { action, scrollToTop, testId } = this.props; return (