From 42ab81cf50538da05f5be1ca02d06eb7abb1911d Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 13 Nov 2019 14:03:48 +0100 Subject: [PATCH 01/15] implement ui client session id This changeset introduces a client side session id, which is generated once by the client (ui: apiClient) and is send with each request to server. The server makes the session id available by the PrincipalCollection of the subject. --- pom.xml | 2 +- .../java/sonia/scm/security/BearerToken.java | 48 +++++--- .../sonia/scm/security/DAORealmHelper.java | 55 ++++----- .../java/sonia/scm/security/SessionID.java | 41 +++++++ .../main/java/sonia/scm/util/HttpUtil.java | 12 +- .../scm/security/DAORealmHelperTest.java | 18 ++- .../java/sonia/scm/util/HttpUtilTest.java | 25 +++++ scm-ui/ui-components/src/apiclient.ts | 10 +- .../java/sonia/scm/security/BearerRealm.java | 3 +- .../scm/web/BearerWebTokenGenerator.java | 7 +- .../web/CookieBearerWebTokenGenerator.java | 7 +- .../DefaultAdministrationContext.java | 35 ++---- .../sonia/scm/security/BearerRealmTest.java | 7 +- .../scm/web/BearerWebTokenGeneratorTest.java | 86 +++++++++----- .../CookieBearerWebTokenGeneratorTest.java | 105 +++++++++--------- .../DefaultAdministrationContextTest.java | 72 ++++++++++++ 16 files changed, 367 insertions(+), 166 deletions(-) create mode 100644 scm-core/src/main/java/sonia/scm/security/SessionID.java create mode 100644 scm-webapp/src/test/java/sonia/scm/web/security/DefaultAdministrationContextTest.java diff --git a/pom.xml b/pom.xml index 910287abf0..7a8e16b869 100644 --- a/pom.xml +++ b/pom.xml @@ -839,7 +839,7 @@ 2.3.0 - 1.5.1 + 1.6.0 9.4.22.v20191022 diff --git a/scm-core/src/main/java/sonia/scm/security/BearerToken.java b/scm-core/src/main/java/sonia/scm/security/BearerToken.java index 4dd60a09c7..43a225b5b6 100644 --- a/scm-core/src/main/java/sonia/scm/security/BearerToken.java +++ b/scm-core/src/main/java/sonia/scm/security/BearerToken.java @@ -37,6 +37,8 @@ import com.google.common.base.Preconditions; import com.google.common.base.Strings; import org.apache.shiro.authc.AuthenticationToken; +import javax.annotation.Nullable; + /** * Token used for authentication with bearer tokens. * @@ -45,20 +47,23 @@ import org.apache.shiro.authc.AuthenticationToken; */ public final class BearerToken implements AuthenticationToken { + private final SessionId sessionId; private final String raw; /** * Constructs a new instance. - * + * + * @param sessionId session id of the client * @param raw raw bearer token */ - private BearerToken(String raw) { + private BearerToken(SessionId sessionId, String raw) { + this.sessionId = sessionId; this.raw = raw; } - + /** * Returns the wrapped raw format of the token. - * + * * @return raw format */ @Override @@ -67,24 +72,41 @@ public final class BearerToken implements AuthenticationToken { } /** - * Returns always {@code null}. - * - * @return {@code null} + * Returns the session id or {@code null}. + * + * @return session id or {@code null} */ @Override - public Object getPrincipal() { - return null; + public SessionId getPrincipal() { + return sessionId; } - + /** * Creates a new {@link BearerToken} from raw string representation. - * + * * @param raw string representation - * + * * @return new bearer token */ public static BearerToken valueOf(String raw){ Preconditions.checkArgument(!Strings.isNullOrEmpty(raw), "raw token is required"); - return new BearerToken(raw); + return new BearerToken(null, raw); + } + + /** + * Creates a new {@link BearerToken} from raw string representation for the given ui session id. + * + * @param sessionId session id of the client + * @param rawToken bearer token string representation + * + * @return new bearer token + */ + public static BearerToken create(@Nullable String sessionId, String rawToken) { + Preconditions.checkArgument(!Strings.isNullOrEmpty(rawToken), "raw token is required"); + SessionId session = null; + if (!Strings.isNullOrEmpty(sessionId)) { + session = SessionId.valueOf(sessionId); + } + return new BearerToken(session, rawToken); } } diff --git a/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java b/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java index 6ec64a67de..6987fab0e1 100644 --- a/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java +++ b/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java @@ -67,13 +67,13 @@ public final class DAORealmHelper { private static final Logger LOG = LoggerFactory.getLogger(DAORealmHelper.class); private final LoginAttemptHandler loginAttemptHandler; - + private final UserDAO userDAO; - + private final String realm; - + //~--- constructors --------------------------------------------------------- - + /** * Constructs a new instance. Consider to use {@link DAORealmHelperFactory} which * handles dependency injection. @@ -92,9 +92,9 @@ public final class DAORealmHelper { /** * Wraps credentials matcher and applies login attempt policies. - * + * * @param credentialsMatcher credentials matcher to wrap - * + * * @return wrapped credentials matcher */ public CredentialsMatcher wrapCredentialsMatcher(CredentialsMatcher credentialsMatcher) { @@ -115,7 +115,7 @@ public final class DAORealmHelper { UsernamePasswordToken upt = (UsernamePasswordToken) token; String principal = upt.getUsername(); - return getAuthenticationInfo(principal, null, null); + return getAuthenticationInfo(principal, null, null, null); } /** @@ -129,8 +129,9 @@ public final class DAORealmHelper { return new AuthenticationInfoBuilder(principal); } - - private AuthenticationInfo getAuthenticationInfo(String principal, String credentials, Scope scope) { + private SimpleAuthenticationInfo getAuthenticationInfo( + String principal, String credentials, Scope scope, SessionId sessionId + ) { checkArgument(!Strings.isNullOrEmpty(principal), "username is required"); LOG.debug("try to authenticate {}", principal); @@ -150,6 +151,10 @@ public final class DAORealmHelper { collection.add(user, realm); collection.add(MoreObjects.firstNonNull(scope, Scope.empty()), realm); + if (sessionId != null) { + collection.add(sessionId, realm); + } + String creds = credentials; if (credentials == null) { @@ -170,7 +175,7 @@ public final class DAORealmHelper { private String credentials; private Scope scope; - private Iterable groups = Collections.emptySet(); + private SessionId sessionId; private AuthenticationInfoBuilder(String principal) { this.principal = principal; @@ -201,17 +206,17 @@ public final class DAORealmHelper { return this; } -// /** -// * With groups adds extra groups, besides those which come from the {@link GroupDAO}, to the authentication info. -// * -// * @param groups extra groups -// * -// * @return {@code this} -// */ -// public AuthenticationInfoBuilder withGroups(Iterable groups) { -// this.groups = groups; -// return this; -// } + /** + * With the session id. + * + * @param sessionId session id + * + * @return {@code this} + */ + public AuthenticationInfoBuilder withSessionId(SessionId sessionId) { + this.sessionId = sessionId; + return this; + } /** * Build creates the authentication info from the given information. @@ -219,7 +224,7 @@ public final class DAORealmHelper { * @return authentication info */ public AuthenticationInfo build() { - return getAuthenticationInfo(principal, credentials, scope); + return getAuthenticationInfo(principal, credentials, scope, sessionId); } } @@ -233,7 +238,7 @@ public final class DAORealmHelper { this.loginAttemptHandler = loginAttemptHandler; this.credentialsMatcher = credentialsMatcher; } - + @Override public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { loginAttemptHandler.beforeAuthentication(token); @@ -245,7 +250,7 @@ public final class DAORealmHelper { } return result; } - + } - + } diff --git a/scm-core/src/main/java/sonia/scm/security/SessionID.java b/scm-core/src/main/java/sonia/scm/security/SessionID.java new file mode 100644 index 0000000000..3c4fb126ff --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/security/SessionID.java @@ -0,0 +1,41 @@ +package sonia.scm.security; + +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; + +import java.util.Objects; + +/** + * Client side session id. + */ +public final class SessionId { + + private final String value; + + private SessionId(String value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SessionId sessionID = (SessionId) o; + return Objects.equals(value, sessionID.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return value; + } + + public static SessionId valueOf(String value) { + Preconditions.checkArgument(!Strings.isNullOrEmpty(value), "session id could not be empty or null"); + return new SessionId(value); + } +} diff --git a/scm-core/src/main/java/sonia/scm/util/HttpUtil.java b/scm-core/src/main/java/sonia/scm/util/HttpUtil.java index 2744addc62..8edc0c8e14 100644 --- a/scm-core/src/main/java/sonia/scm/util/HttpUtil.java +++ b/scm-core/src/main/java/sonia/scm/util/HttpUtil.java @@ -116,6 +116,12 @@ public final class HttpUtil */ public static final String HEADER_SCM_CLIENT = "X-SCM-Client"; + /** + * header for identifying the scm-manager client session + * @since 2.0.0 + */ + public static final String HEADER_SCM_SESSION = "X-SCM-Session-ID"; + /** Field description */ public static final String HEADER_USERAGENT = "User-Agent"; @@ -698,8 +704,10 @@ public final class HttpUtil String defaultValue) { String value = request.getHeader(header); - - return MoreObjects.firstNonNull(value, defaultValue); + if (value == null) { + value = defaultValue; + } + return value; } /** diff --git a/scm-core/src/test/java/sonia/scm/security/DAORealmHelperTest.java b/scm-core/src/test/java/sonia/scm/security/DAORealmHelperTest.java index 0fbcc20ac0..cc6d4020c1 100644 --- a/scm-core/src/test/java/sonia/scm/security/DAORealmHelperTest.java +++ b/scm-core/src/test/java/sonia/scm/security/DAORealmHelperTest.java @@ -27,9 +27,6 @@ class DAORealmHelperTest { @Mock private UserDAO userDAO; - @Mock - private GroupDAO groupDAO; - private DAORealmHelper helper; @BeforeEach @@ -87,6 +84,21 @@ class DAORealmHelperTest { assertThat(principals.oneByType(Scope.class)).isSameAs(scope); } + @Test + void shouldReturnAuthenticationInfoWithSessionId() { + User user = new User("trillian"); + when(userDAO.get("trillian")).thenReturn(user); + + SessionId session = SessionId.valueOf("abc123"); + + AuthenticationInfo authenticationInfo = helper.authenticationInfoBuilder("trillian") + .withSessionId(session) + .build(); + + PrincipalCollection principals = authenticationInfo.getPrincipals(); + assertThat(principals.oneByType(SessionId.class)).isSameAs(session); + } + @Test void shouldReturnAuthenticationInfoWithCredentials() { User user = new User("trillian"); diff --git a/scm-core/src/test/java/sonia/scm/util/HttpUtilTest.java b/scm-core/src/test/java/sonia/scm/util/HttpUtilTest.java index 5e6fa3e10e..c827764a92 100644 --- a/scm-core/src/test/java/sonia/scm/util/HttpUtilTest.java +++ b/scm-core/src/test/java/sonia/scm/util/HttpUtilTest.java @@ -54,6 +54,31 @@ import javax.servlet.http.HttpServletRequest; public class HttpUtilTest { + @Test + public void testGetHeader() { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader("Test")).thenReturn("Value-One"); + + String value = HttpUtil.getHeader(request, "Test", "Fallback"); + assertEquals("Value-One", value); + } + + @Test + public void testGetHeaderWithDefaultValue() { + HttpServletRequest request = mock(HttpServletRequest.class); + + String value = HttpUtil.getHeader(request, "Test", "Fallback"); + assertEquals("Fallback", value); + } + + @Test + public void testGetHeaderWithNullAsDefaultValue() { + HttpServletRequest request = mock(HttpServletRequest.class); + + String value = HttpUtil.getHeader(request, "Test", null); + assertNull(value); + } + @Test public void concatenateTest() { assertEquals( diff --git a/scm-ui/ui-components/src/apiclient.ts b/scm-ui/ui-components/src/apiclient.ts index 396200f1c1..60789ff5dd 100644 --- a/scm-ui/ui-components/src/apiclient.ts +++ b/scm-ui/ui-components/src/apiclient.ts @@ -2,12 +2,20 @@ import { contextPath } from "./urls"; import { createBackendError, ForbiddenError, isBackendError, UnauthorizedError } from "./errors"; import { BackendErrorContent } from "./errors"; +const sessionId = ( + Date.now().toString(36) + + Math.random() + .toString(36) + .substr(2, 5) +).toUpperCase(); + const applyFetchOptions: (p: RequestInit) => RequestInit = o => { o.credentials = "same-origin"; o.headers = { Cache: "no-cache", // identify the request as ajax request - "X-Requested-With": "XMLHttpRequest" + "X-Requested-With": "XMLHttpRequest", + "X-SCM-Session-ID": sessionId }; return o; }; diff --git a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java index 324dfe9082..a2064575db 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java @@ -56,7 +56,7 @@ import static com.google.common.base.Preconditions.checkArgument; @Extension public class BearerRealm extends AuthenticatingRealm { - + /** realm name */ @VisibleForTesting static final String REALM = "BearerRealm"; @@ -104,6 +104,7 @@ public class BearerRealm extends AuthenticatingRealm return helper.authenticationInfoBuilder(accessToken.getSubject()) .withCredentials(bt.getCredentials()) .withScope(Scopes.fromClaims(accessToken.getClaims())) + .withSessionId(bt.getPrincipal()) .build(); } diff --git a/scm-webapp/src/main/java/sonia/scm/web/BearerWebTokenGenerator.java b/scm-webapp/src/main/java/sonia/scm/web/BearerWebTokenGenerator.java index 57f83b4c35..4bb79e7b9a 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/BearerWebTokenGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/web/BearerWebTokenGenerator.java @@ -44,7 +44,7 @@ import javax.servlet.http.HttpServletRequest; /** * Creates a {@link BearerToken} from an authorization header with * bearer authorization. - * + * * @author Sebastian Sdorra * @since 2.0.0 */ @@ -53,7 +53,7 @@ public class BearerWebTokenGenerator extends SchemeBasedWebTokenGenerator { /** - * Creates a {@link BearerToken} from an authorization header + * Creates a {@link BearerToken} from an authorization header * with bearer authorization. * * @param request http servlet request @@ -70,7 +70,8 @@ public class BearerWebTokenGenerator extends SchemeBasedWebTokenGenerator if (HttpUtil.AUTHORIZATION_SCHEME_BEARER.equalsIgnoreCase(scheme)) { - token = BearerToken.valueOf(authorization); + String sessionId = request.getHeader(HttpUtil.HEADER_SCM_SESSION); + token = BearerToken.create(sessionId, authorization); } return token; diff --git a/scm-webapp/src/main/java/sonia/scm/web/CookieBearerWebTokenGenerator.java b/scm-webapp/src/main/java/sonia/scm/web/CookieBearerWebTokenGenerator.java index 27d82f5a41..2a0a97c07e 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/CookieBearerWebTokenGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/web/CookieBearerWebTokenGenerator.java @@ -43,7 +43,7 @@ import javax.servlet.http.HttpServletRequest; import sonia.scm.util.HttpUtil; /** - * Creates an {@link BearerToken} from the {@link #COOKIE_NAME} + * Creates an {@link BearerToken} from the {@link HttpUtil#COOKIE_BEARER_AUTHENTICATION} * cookie. * * @author Sebastian Sdorra @@ -54,7 +54,7 @@ public class CookieBearerWebTokenGenerator implements WebTokenGenerator { /** - * Creates an {@link BearerToken} from the {@link #COOKIE_NAME} + * Creates an {@link BearerToken} from the {@link HttpUtil#COOKIE_BEARER_AUTHENTICATION} * cookie. * * @param request http servlet request @@ -73,7 +73,8 @@ public class CookieBearerWebTokenGenerator implements WebTokenGenerator { if (HttpUtil.COOKIE_BEARER_AUTHENTICATION.equals(cookie.getName())) { - token = BearerToken.valueOf(cookie.getValue()); + String sessionId = HttpUtil.getHeader(request, HttpUtil.HEADER_SCM_SESSION, null); + token = BearerToken.create(sessionId, cookie.getValue()); break; } diff --git a/scm-webapp/src/main/java/sonia/scm/web/security/DefaultAdministrationContext.java b/scm-webapp/src/main/java/sonia/scm/web/security/DefaultAdministrationContext.java index b62b6c63f3..43238e3c3f 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/security/DefaultAdministrationContext.java +++ b/scm-webapp/src/main/java/sonia/scm/web/security/DefaultAdministrationContext.java @@ -177,45 +177,26 @@ public class DefaultAdministrationContext implements AdministrationContext //J+ } - /** - * Method description - * - * - * @param action - */ - private void doRunAsInNonWebSessionContext(PrivilegedAction action) - { - if (logger.isTraceEnabled()) - { - logger.trace("bind shiro security manager to current thread"); - } + private void doRunAsInNonWebSessionContext(PrivilegedAction action) { + logger.trace("bind shiro security manager to current thread"); - try - { + try { SecurityUtils.setSecurityManager(securityManager); Subject subject = createAdminSubject(); ThreadState state = new SubjectThreadState(subject); state.bind(); - try { - if (logger.isInfoEnabled()) - { - logger.info("execute action {} in administration context", - action.getClass().getName()); - } + logger.info("execute action {} in administration context", action.getClass().getName()); action.run(); + } finally { + logger.trace("restore current thread state"); + state.restore(); } - finally - { - state.clear(); - } - } - finally - { + } finally { SecurityUtils.setSecurityManager(null); } } diff --git a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java index d458f9c72c..897b251cfa 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java @@ -52,7 +52,7 @@ import static org.mockito.Mockito.when; /** * Unit tests for {@link BearerRealm}. - * + * * @author Sebastian Sdorra */ @ExtendWith(MockitoExtension.class) @@ -84,11 +84,9 @@ class BearerRealmTest { @Test void shouldDoGetAuthentication() { - BearerToken bearerToken = BearerToken.valueOf("__bearer__"); + BearerToken bearerToken = BearerToken.create("__session__", "__bearer__"); AccessToken accessToken = mock(AccessToken.class); - Set groups = ImmutableSet.of("HeartOfGold", "Puzzle42"); - when(accessToken.getSubject()).thenReturn("trillian"); when(accessToken.getClaims()).thenReturn(new HashMap<>()); when(accessTokenResolver.resolve(bearerToken)).thenReturn(accessToken); @@ -96,6 +94,7 @@ class BearerRealmTest { when(realmHelper.authenticationInfoBuilder("trillian")).thenReturn(builder); when(builder.withCredentials("__bearer__")).thenReturn(builder); when(builder.withScope(any(Scope.class))).thenReturn(builder); + when(builder.withSessionId(any(SessionId.class))).thenReturn(builder); when(builder.build()).thenReturn(authenticationInfo); AuthenticationInfo result = realm.doGetAuthenticationInfo(bearerToken); diff --git a/scm-webapp/src/test/java/sonia/scm/web/BearerWebTokenGeneratorTest.java b/scm-webapp/src/test/java/sonia/scm/web/BearerWebTokenGeneratorTest.java index 71195b7f48..1c054111b7 100644 --- a/scm-webapp/src/test/java/sonia/scm/web/BearerWebTokenGeneratorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/web/BearerWebTokenGeneratorTest.java @@ -1,10 +1,10 @@ /** * Copyright (c) 2014, Sebastian Sdorra * All rights reserved. - * + * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + * * 1. Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, @@ -13,7 +13,7 @@ * 3. Neither the name of SCM-Manager; nor the names of its * contributors may be used to endorse or promote products derived from this * software without specific prior written permission. - * + * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE @@ -24,56 +24,82 @@ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * + * * http://bitbucket.org/sdorra/scm-manager - * + * */ package sonia.scm.web; import javax.servlet.http.HttpServletRequest; import org.apache.shiro.authc.AuthenticationToken; -import org.junit.Test; -import static org.junit.Assert.*; -import static org.hamcrest.Matchers.*; -import org.junit.runner.RunWith; + + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; + +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; -import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.security.BearerToken; +import sonia.scm.security.SessionId; +import sonia.scm.util.HttpUtil; /** * * @author Sebastian Sdorra */ -@RunWith(MockitoJUnitRunner.class) -public class BearerWebTokenGeneratorTest { +@ExtendWith(MockitoExtension.class) +class BearerWebTokenGeneratorTest { + + private final BearerWebTokenGenerator tokenGenerator = new BearerWebTokenGenerator(); @Mock private HttpServletRequest request; - - private final BearerWebTokenGenerator tokenGenerator = new BearerWebTokenGenerator(); @Test - public void testCreateTokenWithWrongScheme() - { + void shouldNotCreateTokenWithWrongScheme() { when(request.getHeader("Authorization")).thenReturn("BASIC ASD"); - assertNull(tokenGenerator.createToken(request)); - } - - @Test - public void testCreateTokenWithoutAuthorizationHeader(){ - assertNull(tokenGenerator.createToken(request)); - } - - @Test - public void testCreateToken(){ - when(request.getHeader("Authorization")).thenReturn("Bearer asd"); + AuthenticationToken token = tokenGenerator.createToken(request); - assertNotNull(token); - assertThat(token, instanceOf(BearerToken.class)); + + assertThat(token).isNull(); + } + + @Test + void shouldNotCreateTokenWithoutAuthorizationHeader(){ + AuthenticationToken token = tokenGenerator.createToken(request); + + assertThat(token).isNull(); + } + + @Test + void shouldCreateToken(){ + when(request.getHeader("Authorization")).thenReturn("Bearer asd"); + + AuthenticationToken token = tokenGenerator.createToken(request); + assertThat(token) + .isNotNull() + .isInstanceOf(BearerToken.class); + BearerToken bt = (BearerToken) token; - assertThat(bt.getCredentials(), equalTo("asd")); + assertThat(bt.getCredentials()).isEqualTo("asd"); + } + + @Test + void shouldCreateTokenWithSessionId(){ + doReturn("Bearer asd").when(request).getHeader("Authorization"); + doReturn("bcd123").when(request).getHeader(HttpUtil.HEADER_SCM_SESSION); + + AuthenticationToken token = tokenGenerator.createToken(request); + assertThat(token) + .isNotNull() + .isInstanceOf(BearerToken.class); + + BearerToken bt = (BearerToken) token; + assertThat(bt.getPrincipal()).isEqualTo(SessionId.valueOf("bcd123")); + assertThat(bt.getCredentials()).isEqualTo("asd"); } } diff --git a/scm-webapp/src/test/java/sonia/scm/web/CookieBearerWebTokenGeneratorTest.java b/scm-webapp/src/test/java/sonia/scm/web/CookieBearerWebTokenGeneratorTest.java index 115b52fa36..45d2c46b83 100644 --- a/scm-webapp/src/test/java/sonia/scm/web/CookieBearerWebTokenGeneratorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/web/CookieBearerWebTokenGeneratorTest.java @@ -35,82 +35,81 @@ package sonia.scm.web; //~--- non-JDK imports -------------------------------------------------------- -import org.junit.Test; -import org.junit.runner.RunWith; - +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; - +import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.security.BearerToken; - -import static org.junit.Assert.*; - -import static org.mockito.Mockito.*; - -//~--- JDK imports ------------------------------------------------------------ +import sonia.scm.security.SessionId; +import sonia.scm.util.HttpUtil; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; -import sonia.scm.util.HttpUtil; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +//~--- JDK imports ------------------------------------------------------------ /** * * @author Sebastian Sdorra */ -@RunWith(MockitoJUnitRunner.class) -public class CookieBearerWebTokenGeneratorTest -{ +@ExtendWith(MockitoExtension.class) +class CookieBearerWebTokenGeneratorTest { + + private final CookieBearerWebTokenGenerator tokenGenerator = new CookieBearerWebTokenGenerator(); + + @Mock + private HttpServletRequest request; - /** - * Method description - * - */ @Test - public void testCreateToken() - { - Cookie c = mock(Cookie.class); - - when(c.getName()).thenReturn(HttpUtil.COOKIE_BEARER_AUTHENTICATION); - when(c.getValue()).thenReturn("value"); - when(request.getCookies()).thenReturn(new Cookie[] { c }); + void shouldCreateToken() { + assignBearerCookie("value"); BearerToken token = tokenGenerator.createToken(request); - assertNotNull(token); - assertEquals("value", token.getCredentials()); + assertThat(token).isNotNull(); + assertThat(token.getPrincipal()).isNull(); + assertThat(token.getCredentials()).isEqualTo("value"); } - /** - * Method description - * - */ @Test - public void testCreateTokenWithWrongCookie() - { + void shouldCreateTokenWithSessionId() { + when(request.getHeader(HttpUtil.HEADER_SCM_SESSION)).thenReturn("abc123"); + + assignBearerCookie("authc"); + + BearerToken token = tokenGenerator.createToken(request); + + assertThat(token).isNotNull(); + assertThat(token.getPrincipal()).isEqualTo(SessionId.valueOf("abc123")); + assertThat(token.getCredentials()).isEqualTo("authc"); + } + + private void assignBearerCookie(String value) { + assignCookie(HttpUtil.COOKIE_BEARER_AUTHENTICATION, value); + } + + private void assignCookie(String name, String value) { Cookie c = mock(Cookie.class); - when(c.getName()).thenReturn("other-cookie"); - when(request.getCookies()).thenReturn(new Cookie[] { c }); - assertNull(tokenGenerator.createToken(request)); + when(c.getName()).thenReturn(name); + lenient().when(c.getValue()).thenReturn(value); + when(request.getCookies()).thenReturn(new Cookie[]{c}); } - /** - * Method description - * - */ @Test - public void testCreateTokenWithoutCookies() - { - assertNull(tokenGenerator.createToken(request)); + void shouldNotCreateTokenForWrongCookie() { + assignCookie("other-cookie", "with-some-value"); + + BearerToken token = tokenGenerator.createToken(request); + assertThat(token).isNull(); } - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private final CookieBearerWebTokenGenerator tokenGenerator = - new CookieBearerWebTokenGenerator(); - - /** Field description */ - @Mock - private HttpServletRequest request; + @Test + void shouldNotCreateTokenWithoutCookies() { + BearerToken token = tokenGenerator.createToken(request); + assertThat(token).isNull(); + } } diff --git a/scm-webapp/src/test/java/sonia/scm/web/security/DefaultAdministrationContextTest.java b/scm-webapp/src/test/java/sonia/scm/web/security/DefaultAdministrationContextTest.java new file mode 100644 index 0000000000..7771559809 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/web/security/DefaultAdministrationContextTest.java @@ -0,0 +1,72 @@ +package sonia.scm.web.security; + +import com.google.inject.Guice; +import com.google.inject.Injector; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.mgt.DefaultSecurityManager; +import org.apache.shiro.mgt.SecurityManager; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultAdministrationContextTest { + + private DefaultAdministrationContext context; + + @Mock + private Subject subject; + + @BeforeEach + void create() { + Injector injector = Guice.createInjector(); + SecurityManager securityManager = new DefaultSecurityManager(); + + context = new DefaultAdministrationContext(injector, securityManager); + } + + @Test + void shouldBindSubject() { + context.runAsAdmin(() -> { + Subject adminSubject = SecurityUtils.getSubject(); + assertThat(adminSubject.getPrincipal()).isEqualTo("scmsystem"); + }); + } + + @Test + void shouldBindSubjectEvenIfAlreadyBound() { + ThreadContext.bind(subject); + try { + + context.runAsAdmin(() -> { + Subject adminSubject = SecurityUtils.getSubject(); + assertThat(adminSubject.getPrincipal()).isEqualTo("scmsystem"); + }); + + } finally { + ThreadContext.unbindSubject(); + } + } + + @Test + void shouldRestoreCurrentSubject() { + when(subject.getPrincipal()).thenReturn("tricia"); + ThreadContext.bind(subject); + try { + context.runAsAdmin(() -> {}); + Subject currentSubject = SecurityUtils.getSubject(); + assertThat(currentSubject.getPrincipal()).isEqualTo("tricia"); + } finally { + ThreadContext.unbindSubject(); + } + } + +} From dd3b02bbffb25a3cc8831305702e194587ffba60 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 11 Dec 2019 10:11:40 +0100 Subject: [PATCH 02/15] added subscription api to apiClient --- .../{SessionID.java => SessionId.java} | 0 scm-ui/ui-components/package.json | 1 + scm-ui/ui-components/src/apiclient.ts | 99 +++++++++++++++---- yarn.lock | 5 + 4 files changed, 87 insertions(+), 18 deletions(-) rename scm-core/src/main/java/sonia/scm/security/{SessionID.java => SessionId.java} (100%) diff --git a/scm-core/src/main/java/sonia/scm/security/SessionID.java b/scm-core/src/main/java/sonia/scm/security/SessionId.java similarity index 100% rename from scm-core/src/main/java/sonia/scm/security/SessionID.java rename to scm-core/src/main/java/sonia/scm/security/SessionId.java diff --git a/scm-ui/ui-components/package.json b/scm-ui/ui-components/package.json index 946416ca96..93a08a75f2 100644 --- a/scm-ui/ui-components/package.json +++ b/scm-ui/ui-components/package.json @@ -49,6 +49,7 @@ "@scm-manager/ui-types": "^2.0.0-SNAPSHOT", "classnames": "^2.2.6", "date-fns": "^2.4.1", + "event-source-polyfill": "^1.0.9", "query-string": "5", "react": "^16.8.6", "react-diff-view": "^1.8.1", diff --git a/scm-ui/ui-components/src/apiclient.ts b/scm-ui/ui-components/src/apiclient.ts index bd7f4eb6ab..da2287f72b 100644 --- a/scm-ui/ui-components/src/apiclient.ts +++ b/scm-ui/ui-components/src/apiclient.ts @@ -1,6 +1,36 @@ import { contextPath } from "./urls"; -import { createBackendError, ForbiddenError, isBackendError, UnauthorizedError } from "./errors"; -import { BackendErrorContent } from "./errors"; +// @ts-ignore we have not types for event-source-polyfill +import { EventSourcePolyfill } from "event-source-polyfill"; +import { createBackendError, ForbiddenError, isBackendError, UnauthorizedError, BackendErrorContent } from "./errors"; + +type SubscriptionEvent = { + type: string; +}; + +type OpenEvent = SubscriptionEvent; + +type ErrorEvent = SubscriptionEvent & { + error: Error; +}; + +type MessageEvent = SubscriptionEvent & { + data: string; + lastEventId?: string; +}; + +type MessageListeners = { + [eventType: string]: (event: MessageEvent) => void; +}; + +type SubscriptionContext = { + onOpen?: OpenEvent; + onMessage: MessageListeners; + onError?: ErrorEvent; +}; + +type SubscriptionArgument = MessageListeners | SubscriptionContext; + +type Cancel = () => void; const sessionId = ( Date.now().toString(36) + @@ -33,28 +63,34 @@ const extractXsrfToken = () => { return extractXsrfTokenFromCookie(document.cookie); }; -const applyFetchOptions: (p: RequestInit) => RequestInit = o => { - if (!o.headers) { - o.headers = {}; - } - - // @ts-ignore We are sure that here we only get headers of type Record - const headers: Record = o.headers; - headers["Cache"] = "no-cache"; - // identify the request as ajax request - headers["X-Requested-With"] = "XMLHttpRequest"; - // identify the web interface - headers["X-SCM-Client"] = "WUI"; - // identify the window session - headers["X-SCM-Session-ID"] = sessionId +const createRequestHeaders = () => { + const headers: { [key: string]: string } = { + // disable caching for now + Cache: "no-cache", + // identify the request as ajax request + "X-Requested-With": "XMLHttpRequest", + // identify the web interface + "X-SCM-Client": "WUI", + // identify the window session + "X-SCM-Session-ID": sessionId + }; const xsrf = extractXsrfToken(); if (xsrf) { headers["X-XSRF-Token"] = xsrf; } + return headers; +}; +const applyFetchOptions: (p: RequestInit) => RequestInit = o => { + if (o.headers) { + o.headers = { + ...createRequestHeaders() + }; + } else { + o.headers = createRequestHeaders(); + } o.credentials = "same-origin"; - o.headers = headers; return o; }; @@ -174,12 +210,39 @@ class ApiClient { if (!options.headers) { options.headers = {}; } - // @ts-ignore We are sure that here we only get headers of type Record + // @ts-ignore We are sure that here we only get headers of type {[name:string]: string} options.headers["Content-Type"] = contentType; } return fetch(createUrl(url), options).then(handleFailure); } + + subscribe(url: string, argument: SubscriptionArgument): Cancel { + const es = new EventSourcePolyfill(createUrl(url), { + withCredentials: true, + headers: createRequestHeaders() + }); + + let listeners: MessageListeners; + // type guard, to identify that argument is of type SubscriptionContext + if ("onMessage" in argument) { + listeners = (argument as SubscriptionContext).onMessage; + if (argument.onError) { + es.onerror = argument.onError; + } + if (argument.onOpen) { + es.onopen = argument.onOpen; + } + } else { + listeners = argument; + } + + for (const type in listeners) { + es.addEventListener(type, listeners[type]); + } + + return es.close; + } } export const apiClient = new ApiClient(); diff --git a/yarn.lock b/yarn.lock index 35cfe2e580..58c39be6ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6539,6 +6539,11 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +event-source-polyfill@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/event-source-polyfill/-/event-source-polyfill-1.0.9.tgz#1fe3ebf8e3faddafd4fc237424f5e5ab2706b6d0" + integrity sha512-+x0BMKTYwZcmGmlkHK0GsXkX1+otfEwqu3QitN0wmWuHaZniw3HeIx1k5OjWX3JUHQHlPS4yONol6eokS1ZAWg== + eventemitter3@^3.1.0: version "3.1.2" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7" From 3c615bc7baafa431823d93b7e2ef19a787b9c18f Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 11 Dec 2019 15:44:09 +0100 Subject: [PATCH 03/15] added toast component to display information in an asynchronous manner --- .../.storybook/preview-body.html | 2 + scm-ui/ui-components/src/index.ts | 1 + scm-ui/ui-components/src/toast/Toast.tsx | 61 +++++++++++++++++++ .../ui-components/src/toast/ToastButton.tsx | 37 +++++++++++ .../ui-components/src/toast/ToastButtons.tsx | 20 ++++++ .../ui-components/src/toast/index.stories.tsx | 42 +++++++++++++ scm-ui/ui-components/src/toast/index.ts | 3 + scm-ui/ui-components/src/toast/themes.ts | 53 ++++++++++++++++ scm-ui/ui-webapp/public/index.mustache | 1 + 9 files changed, 220 insertions(+) create mode 100644 scm-ui/ui-components/.storybook/preview-body.html create mode 100644 scm-ui/ui-components/src/toast/Toast.tsx create mode 100644 scm-ui/ui-components/src/toast/ToastButton.tsx create mode 100644 scm-ui/ui-components/src/toast/ToastButtons.tsx create mode 100644 scm-ui/ui-components/src/toast/index.stories.tsx create mode 100644 scm-ui/ui-components/src/toast/index.ts create mode 100644 scm-ui/ui-components/src/toast/themes.ts diff --git a/scm-ui/ui-components/.storybook/preview-body.html b/scm-ui/ui-components/.storybook/preview-body.html new file mode 100644 index 0000000000..d2084fbba6 --- /dev/null +++ b/scm-ui/ui-components/.storybook/preview-body.html @@ -0,0 +1,2 @@ +
+
diff --git a/scm-ui/ui-components/src/index.ts b/scm-ui/ui-components/src/index.ts index e039411842..46ccbb48ec 100644 --- a/scm-ui/ui-components/src/index.ts +++ b/scm-ui/ui-components/src/index.ts @@ -66,6 +66,7 @@ export * from "./modals"; export * from "./navigation"; export * from "./repos"; export * from "./table"; +export * from "./toast"; export { File, diff --git a/scm-ui/ui-components/src/toast/Toast.tsx b/scm-ui/ui-components/src/toast/Toast.tsx new file mode 100644 index 0000000000..cbbb2c58b6 --- /dev/null +++ b/scm-ui/ui-components/src/toast/Toast.tsx @@ -0,0 +1,61 @@ +import React, { FC } from "react"; +import { createPortal } from "react-dom"; +import styled from "styled-components"; +import { getTheme, Themeable, ToastThemeContext, Type } from "./themes"; + +type Props = { + type: Type; + title: string; +}; + +const rootElement = document.getElementById("toastRoot"); + +const Container = styled.div` + z-index: 99999; + position: fixed; + padding: 1.5rem; + right: 1.5rem; + bottom: 1.5rem; + color: ${props => props.theme.primary}; + background-color: ${props => props.theme.secondary}; + max-width: 18rem; + font-size: 0.75rem; + border-radius: 5px; + animation: 0.5s slide-up; + + & > p { + margin-bottom: 0.5rem; + } + + @keyframes slide-up { + from { + bottom: -10rem; + } + to { + bottom: 1.5rem; + } + } +`; + +const Title = styled.h1` + margin-bottom: 0.25rem; + font-weight: bold; +`; + +const Toast: FC = ({ children, title, type }) => { + if (!rootElement) { + throw new Error("could not find toast container #toastRoot"); + } + + const theme = getTheme(type); + const content = ( + + {title} + {children} + + ); + + return createPortal(content, rootElement); +}; + +export default Toast; diff --git a/scm-ui/ui-components/src/toast/ToastButton.tsx b/scm-ui/ui-components/src/toast/ToastButton.tsx new file mode 100644 index 0000000000..76ae63cd73 --- /dev/null +++ b/scm-ui/ui-components/src/toast/ToastButton.tsx @@ -0,0 +1,37 @@ +import React, { FC, useContext } from "react"; +import { ToastThemeContext, Themeable } from "./themes"; +import styled from "styled-components"; + +type Props = { + icon?: string; +}; + +const ThemedButton = styled.div.attrs(props => ({ + className: "button" +}))` + color: ${props => props.theme.primary}; + border-color: ${props => props.theme.primary}; + background-color: ${props => props.theme.secondary}; + font-size: 0.75rem; + + &:hover { + color: ${props => props.theme.primary}; + border-color: ${props => props.theme.tertiary}; + background-color: ${props => props.theme.tertiary}; + } +`; + +const ToastButtonIcon = styled.i` + margin-right: 0.25rem; +`; + +const ToastButton: FC = ({ icon, children }) => { + const theme = useContext(ToastThemeContext); + return ( + + {icon && } {children} + + ); +}; + +export default ToastButton; diff --git a/scm-ui/ui-components/src/toast/ToastButtons.tsx b/scm-ui/ui-components/src/toast/ToastButtons.tsx new file mode 100644 index 0000000000..4444a2e14a --- /dev/null +++ b/scm-ui/ui-components/src/toast/ToastButtons.tsx @@ -0,0 +1,20 @@ +import React, { FC } from "react"; +import styled from "styled-components"; + +const Buttons = styled.div` + display: flex; + padding-top: 0.5rem; + width: 100%; + + & > * { + flex-grow: 1; + } + + & > *:not(:last-child) { + margin-right: 0.5rem; + } +`; + +const ToastButtons: FC = ({ children }) => {children}; + +export default ToastButtons; diff --git a/scm-ui/ui-components/src/toast/index.stories.tsx b/scm-ui/ui-components/src/toast/index.stories.tsx new file mode 100644 index 0000000000..af17964d2e --- /dev/null +++ b/scm-ui/ui-components/src/toast/index.stories.tsx @@ -0,0 +1,42 @@ +import React, { useState } from "react"; +import { storiesOf } from "@storybook/react"; +import Toast from "./Toast"; +import ToastButtons from "./ToastButtons"; +import ToastButton from "./ToastButton"; +import { types } from "./themes"; + +const toastStories = storiesOf("Toast", module); + +const AnimatedToast = () => ( + + Awesome animated Toast + +); + +const Animator = () => { + const [display, setDisplay] = useState(false); + + return ( +
+ {display && } + +
+ ); +}; + +toastStories.add("Open/Close", () => ); + +types.forEach(type => { + toastStories.add(type.charAt(0).toUpperCase() + type.slice(1), () => ( + +

The underlying Pull-Request has changed. Press reload to see the changes.

+

Warning: Non saved modification will be lost.

+ + Reload + Ignore + +
+ )); +}); diff --git a/scm-ui/ui-components/src/toast/index.ts b/scm-ui/ui-components/src/toast/index.ts new file mode 100644 index 0000000000..f7b618a3ac --- /dev/null +++ b/scm-ui/ui-components/src/toast/index.ts @@ -0,0 +1,3 @@ +export { default as Toast } from "./Toast"; +export { default as ToastButton } from "./ToastButton"; +export { default as ToastButtons } from "./ToastButtons"; diff --git a/scm-ui/ui-components/src/toast/themes.ts b/scm-ui/ui-components/src/toast/themes.ts new file mode 100644 index 0000000000..1b2744870c --- /dev/null +++ b/scm-ui/ui-components/src/toast/themes.ts @@ -0,0 +1,53 @@ +import * as React from "react"; + +export type ToastTheme = { + primary: string; + secondary: string; + tertiary: string; +}; + +export type Themeable = { + theme: ToastTheme; +}; + +export type Type = "info" | "primary" | "success" | "warning" | "danger"; + +export const types: Type[] = ["info", "primary", "success", "warning", "danger"]; + +const themes: { [name in Type]: ToastTheme } = { + info: { + primary: "#363636", + secondary: "#99d8f3", + tertiary: "white" + }, + primary: { + primary: "#363636", + secondary: "#7fe8ef", + tertiary: "white" + }, + success: { + primary: "#363636", + secondary: "#7fe3cd", + tertiary: "white" + }, + warning: { + primary: "#905515", + secondary: "#ffeeab", + tertiary: "white" + }, + danger: { + primary: "#363636", + secondary: "#ff9baf", + tertiary: "white" + } +}; + +export const getTheme = (name: Type) => { + const theme = themes[name]; + if (!theme) { + throw new Error(`could not find theme with name ${name}`); + } + return theme; +}; + +export const ToastThemeContext = React.createContext(themes.warning); diff --git a/scm-ui/ui-webapp/public/index.mustache b/scm-ui/ui-webapp/public/index.mustache index d94bc552ee..86193387ce 100644 --- a/scm-ui/ui-webapp/public/index.mustache +++ b/scm-ui/ui-webapp/public/index.mustache @@ -34,6 +34,7 @@
+