diff --git a/docs/en/administration/jwt-configuration.md b/docs/en/administration/jwt-configuration.md new file mode 100644 index 0000000000..51add16688 --- /dev/null +++ b/docs/en/administration/jwt-configuration.md @@ -0,0 +1,26 @@ +--- +title: JWT Configuration +--- + +SCM-Manager uses [JWT](https://datatracker.ietf.org/doc/html/rfc7519) to authenticate its users. +The creation of JWTs can be controlled via Java system properties. + +## Endless JWT + +Usually a JWT contains the exp claim. This claim determines how long a JWT is valid by defining an expiration time. +If the JWT does not contain this claim, then the JWT is valid forever until the secret for the signature changes. +Per default the JWT created by the SCM-Manager contain the exp claim with a duration of one hour. + +If needed, it is possible to configure the SCM-Manager, so that the JWT get created without the exp claim. +Therefore, the user session would be endless. + +We advise **against** this behavior, because limited lifespans for JWT improve security. +But if you really need it, you can enable endless JWT by starting the SCM-Manager with this flag: + +``` +-Dscm.endlessJwt="true" +``` + +If you want to disable the feature, then restart the SCM-Manager without this flag. +If you want to invalidate already created endless JWT, then restarting the SCM-Manager, with the endless JWT feature disabled, is enough. +The SCM-Manager will automatically create new secrets for the JWT and therefore invalidate every already existing JWT. diff --git a/gradle/changelog/endless_user_sessions.yml b/gradle/changelog/endless_user_sessions.yml new file mode 100644 index 0000000000..a6d508c909 --- /dev/null +++ b/gradle/changelog/endless_user_sessions.yml @@ -0,0 +1,2 @@ +- type: added + description: User sessions can now be configured to be endless diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/jwt/JwtSettings.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/jwt/JwtSettings.java new file mode 100644 index 0000000000..4fa9ed85a2 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/jwt/JwtSettings.java @@ -0,0 +1,44 @@ +/* + * 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.lifecycle.jwt; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; + +@Data +@XmlRootElement(name = "jwtSettings") +@XmlAccessorType(XmlAccessType.FIELD) +@NoArgsConstructor +@AllArgsConstructor +public class JwtSettings { + + private boolean endlessJwtEnabledLastStartUp = false; + private long keysValidAfterTimestampInMs = 0; +} diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/jwt/JwtSettingsStartupAction.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/jwt/JwtSettingsStartupAction.java new file mode 100644 index 0000000000..44cc1f61c0 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/jwt/JwtSettingsStartupAction.java @@ -0,0 +1,87 @@ +/* + * 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.lifecycle.jwt; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.lifecycle.PrivilegedStartupAction; +import sonia.scm.plugin.Extension; +import sonia.scm.security.JwtSystemProperties; + +import javax.inject.Inject; +import java.time.Clock; +import java.time.Instant; + +@Extension +public class JwtSettingsStartupAction implements PrivilegedStartupAction { + + private static final Logger LOG = LoggerFactory.getLogger(JwtSettingsStartupAction.class); + private final JwtSettingsStore store; + private final Clock clock; + + @Inject + public JwtSettingsStartupAction(JwtSettingsStore store) { + this(store, Clock.systemDefaultZone()); + } + + public JwtSettingsStartupAction(JwtSettingsStore store, Clock clock) { + this.store = store; + this.clock = clock; + } + + @Override + public void run() { + LOG.debug("Checking JWT Settings"); + + JwtSettings settings = store.get(); + boolean isEndlessJwtEnabledNow = JwtSystemProperties.isEndlessJwtEnabled(); + + if(!areSettingsChanged(settings, isEndlessJwtEnabledNow)) { + LOG.debug("JWT Settings unchanged"); + return; + } + + JwtSettings updatedSettings = new JwtSettings(); + updatedSettings.setEndlessJwtEnabledLastStartUp(isEndlessJwtEnabledNow); + + if(areEndlessJwtNeedingInvalidation(settings, isEndlessJwtEnabledNow)) { + updatedSettings.setKeysValidAfterTimestampInMs(Instant.now(clock).toEpochMilli()); + } else { + updatedSettings.setKeysValidAfterTimestampInMs(settings.getKeysValidAfterTimestampInMs()); + } + + store.set(updatedSettings); + + LOG.debug("JWT Settings updated"); + } + + private boolean areSettingsChanged(JwtSettings settings, boolean isEndlessJwtEnabledNow) { + return settings.isEndlessJwtEnabledLastStartUp() != isEndlessJwtEnabledNow; + } + + private boolean areEndlessJwtNeedingInvalidation(JwtSettings settings, boolean isEndlessJwtEnabledNow) { + return settings.isEndlessJwtEnabledLastStartUp() && !isEndlessJwtEnabledNow; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/jwt/JwtSettingsStore.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/jwt/JwtSettingsStore.java new file mode 100644 index 0000000000..c0d43b8203 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/jwt/JwtSettingsStore.java @@ -0,0 +1,51 @@ +/* + * 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.lifecycle.jwt; + +import sonia.scm.store.ConfigurationStore; +import sonia.scm.store.ConfigurationStoreFactory; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class JwtSettingsStore { + + static final String STORE_NAME = "jwt-settings"; + private final ConfigurationStore store; + + @Inject + public JwtSettingsStore(ConfigurationStoreFactory storeFactory) { + store = storeFactory.withType(JwtSettings.class).withName(STORE_NAME).build(); + } + + public JwtSettings get() { + return store.getOptional().orElse(new JwtSettings()); + } + + public void set(JwtSettings settings) { + store.set(settings); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java index f099d342dd..52d276c60a 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java @@ -45,6 +45,8 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; +import static sonia.scm.security.JwtSystemProperties.ENDLESS_JWT; + /** * Jwt implementation of {@link AccessTokenBuilder}. * @@ -184,9 +186,11 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { Claims claims = Jwts.claims(customClaims) .setSubject(sub) .setId(id) - .setIssuedAt(Date.from(now)) - .setExpiration(new Date(now.toEpochMilli() + expiration)); + .setIssuedAt(Date.from(now)); + if(!JwtSystemProperties.isEndlessJwtEnabled()) { + claims.setExpiration(new Date(now.toEpochMilli() + expiration)); + } if (refreshableFor > 0) { long re = refreshableForUnit.toMillis(refreshableFor); diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtSystemProperties.java b/scm-webapp/src/main/java/sonia/scm/security/JwtSystemProperties.java new file mode 100644 index 0000000000..5200c23932 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtSystemProperties.java @@ -0,0 +1,34 @@ +/* + * 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.security; + +public class JwtSystemProperties { + + public static final String ENDLESS_JWT = "scm.endlessJwt"; + + public static boolean isEndlessJwtEnabled() { + return Boolean.parseBoolean(System.getProperty(ENDLESS_JWT)); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/SecureKeyResolver.java b/scm-webapp/src/main/java/sonia/scm/security/SecureKeyResolver.java index c2b7ef3578..cc00dbae05 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/SecureKeyResolver.java +++ b/scm-webapp/src/main/java/sonia/scm/security/SecureKeyResolver.java @@ -36,6 +36,8 @@ import io.jsonwebtoken.SigningKeyResolverAdapter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.lifecycle.jwt.JwtSettings; +import sonia.scm.lifecycle.jwt.JwtSettingsStore; import sonia.scm.store.ConfigurationEntryStore; import sonia.scm.store.ConfigurationEntryStoreFactory; @@ -82,16 +84,17 @@ public class SecureKeyResolver extends SigningKeyResolverAdapter */ @Inject @SuppressWarnings("unchecked") - public SecureKeyResolver(ConfigurationEntryStoreFactory storeFactory) { - this(storeFactory, new SecureRandom()); + public SecureKeyResolver(ConfigurationEntryStoreFactory storeFactory, JwtSettingsStore jwtSettingsStore) { + this(storeFactory, jwtSettingsStore, new SecureRandom()); } - SecureKeyResolver(ConfigurationEntryStoreFactory storeFactory, Random random) + SecureKeyResolver(ConfigurationEntryStoreFactory storeFactory, JwtSettingsStore jwtSettingsStore, Random random) { store = storeFactory .withType(SecureKey.class) .withName(STORE_NAME) .build(); + this.jwtSettingsStore = jwtSettingsStore; this.random = random; } @@ -109,13 +112,7 @@ public class SecureKeyResolver extends SigningKeyResolverAdapter checkArgument(!Strings.isNullOrEmpty(subject), "subject is required"); - SecureKey key = store.get(subject); - - if (key == null) { - return getSecureKey(subject).getBytes(); - } - - return key.getBytes(); + return getSecureKey(subject).getBytes(); } //~--- get methods ---------------------------------------------------------- @@ -132,7 +129,7 @@ public class SecureKeyResolver extends SigningKeyResolverAdapter { SecureKey key = store.get(subject); - if (key == null) + if (key == null || isKeyExpired(key)) { logger.trace("create new key for subject"); key = createNewKey(); @@ -142,6 +139,12 @@ public class SecureKeyResolver extends SigningKeyResolverAdapter return key; } + private boolean isKeyExpired(SecureKey key) { + JwtSettings settings = jwtSettingsStore.get(); + + return key.getCreationDate() < settings.getKeysValidAfterTimestampInMs(); + } + //~--- methods -------------------------------------------------------------- /** @@ -166,4 +169,6 @@ public class SecureKeyResolver extends SigningKeyResolverAdapter /** configuration entry store */ private final ConfigurationEntryStore store; + + private final JwtSettingsStore jwtSettingsStore; } diff --git a/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java b/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java index 8023ca6743..9ca422b6fa 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java +++ b/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java @@ -105,7 +105,7 @@ public class TokenRefreshFilter extends HttpFilter { LOG.trace("could not resolve token", e); return; } - if (accessToken instanceof JwtAccessToken) { + if (accessToken instanceof JwtAccessToken && !isEndlessToken((JwtAccessToken) accessToken)) { refresher.refresh((JwtAccessToken) accessToken) .ifPresent(jwtAccessToken -> refreshJwtToken(request, response, jwtAccessToken)); } @@ -116,4 +116,8 @@ public class TokenRefreshFilter extends HttpFilter { LOG.debug("refreshing JWT authentication token"); issuer.authenticate(request, response, jwtAccessToken); } + + private boolean isEndlessToken(JwtAccessToken token) { + return token.getExpiration() == null; + } } diff --git a/scm-webapp/src/test/java/sonia/scm/lifecycle/jwt/JwtSettingsStartupActionTest.java b/scm-webapp/src/test/java/sonia/scm/lifecycle/jwt/JwtSettingsStartupActionTest.java new file mode 100644 index 0000000000..66b6173196 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/lifecycle/jwt/JwtSettingsStartupActionTest.java @@ -0,0 +1,114 @@ +/* + * 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.lifecycle.jwt; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.security.JwtSystemProperties; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JwtSettingsStartupActionTest { + + private JwtSettingsStartupAction jwtSettingsAction; + + @Mock + private JwtSettingsStore jwtSettingsStore; + + private final Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()); + + @BeforeEach + void setupAction() { + jwtSettingsAction = new JwtSettingsStartupAction(jwtSettingsStore, clock); + } + + @BeforeEach + void clearSystemProperties() { + System.clearProperty(JwtSystemProperties.ENDLESS_JWT); + } + + @ParameterizedTest + @CsvSource({"true,true", "false,false"}) + void shouldNotChangeSettings(String isEndlessJwtNowEnabled, String isEndlessJwtEnabledLastStartUp) { + System.setProperty(JwtSystemProperties.ENDLESS_JWT, isEndlessJwtNowEnabled); + JwtSettings settings = new JwtSettings(Boolean.parseBoolean(isEndlessJwtEnabledLastStartUp), 0); + when(jwtSettingsStore.get()).thenReturn(settings); + + jwtSettingsAction.run(); + + assertThat(settings.isEndlessJwtEnabledLastStartUp()).isEqualTo(Boolean.parseBoolean(isEndlessJwtNowEnabled)); + assertThat(settings.getKeysValidAfterTimestampInMs()).isEqualTo(0); + + verify(jwtSettingsStore).get(); + verifyNoMoreInteractions(jwtSettingsStore); + } + + @Test + void shouldOnlyUpdateEndlessJwtEnabledLastStartup() { + System.setProperty(JwtSystemProperties.ENDLESS_JWT, "true"); + JwtSettings settings = new JwtSettings(false, 0); + when(jwtSettingsStore.get()).thenReturn(settings); + + jwtSettingsAction.run(); + + + verify(jwtSettingsStore).get(); + verify(jwtSettingsStore).set(argThat(actualSettings -> { + assertThat(actualSettings.isEndlessJwtEnabledLastStartUp()).isEqualTo(true); + assertThat(actualSettings.getKeysValidAfterTimestampInMs()).isEqualTo(0); + return true; + })); + } + + @Test + void shouldInvalidateKeys() { + System.setProperty(JwtSystemProperties.ENDLESS_JWT, "false"); + JwtSettings settings = new JwtSettings(true, 0); + when(jwtSettingsStore.get()).thenReturn(settings); + + jwtSettingsAction.run(); + + verify(jwtSettingsStore).get(); + verify(jwtSettingsStore).set(argThat(actualSettings -> { + assertThat(actualSettings.isEndlessJwtEnabledLastStartUp()).isEqualTo(false); + assertThat(actualSettings.getKeysValidAfterTimestampInMs()).isEqualTo(Instant.now(clock).toEpochMilli()); + return true; + })); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java index 92f175f9e8..a911ef3a3d 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java @@ -95,6 +95,11 @@ class JwtAccessTokenBuilderTest { factory = new JwtAccessTokenBuilderFactory(keyGenerator, secureKeyResolver, enrichers); } + @BeforeEach + void clearSystemProperties() { + System.clearProperty(JwtSystemProperties.ENDLESS_JWT); + } + @Nested class SimpleTests { @@ -261,5 +266,70 @@ class JwtAccessTokenBuilderTest { JwtAccessToken token = factory.create().subject("dent").build(); assertThat(token.getCustom("c")).get().isEqualTo("d"); } + + + } + + @Nested + class WithEndlessJwtFeature { + + @Test + void testBuildWithEndlessJwtEnabled() { + System.setProperty(JwtSystemProperties.ENDLESS_JWT, "true"); + + JwtAccessToken token = factory.create().subject("Red").issuer("https://scm-manager.org").build(); + + assertThat(token.getId()).isNotEmpty(); + assertThat(token.getIssuedAt()).isNotNull(); + assertThat(token.getExpiration()).isNull(); + assertThat(token.getSubject()).isEqualTo("Red"); + assertThat(token.getIssuer()).isNotEmpty(); + assertThat(token.getIssuer().get()).isEqualTo("https://scm-manager.org"); + } + + @Test + void testBuildWithEndlessJwtDisabled() { + System.setProperty(JwtSystemProperties.ENDLESS_JWT, "false"); + + JwtAccessToken token = factory.create().subject("Red").issuer("https://scm-manager.org").build(); + + assertThat(token.getId()).isNotEmpty(); + assertThat(token.getIssuedAt()).isNotNull(); + assertThat(token.getExpiration()).isNotNull(); + assertThat(token.getExpiration().getTime() > token.getIssuedAt().getTime()).isTrue(); + assertThat(token.getSubject()).isEqualTo("Red"); + assertThat(token.getIssuer()).isNotEmpty(); + assertThat(token.getIssuer().get()).isEqualTo("https://scm-manager.org"); + } + + @Test + void testBuildWithInvalidConfig() { + System.setProperty(JwtSystemProperties.ENDLESS_JWT, "invalidStuff"); + + JwtAccessToken token = factory.create().subject("Red").issuer("https://scm-manager.org").build(); + + assertThat(token.getId()).isNotEmpty(); + assertThat(token.getIssuedAt()).isNotNull(); + assertThat(token.getExpiration()).isNotNull(); + assertThat(token.getExpiration().getTime() > token.getIssuedAt().getTime()).isTrue(); + assertThat(token.getSubject()).isEqualTo("Red"); + assertThat(token.getIssuer()).isNotEmpty(); + assertThat(token.getIssuer().get()).isEqualTo("https://scm-manager.org"); + } + + @Test + void testBuildWithMissingConfig() { + System.clearProperty(JwtSystemProperties.ENDLESS_JWT); + + JwtAccessToken token = factory.create().subject("Red").issuer("https://scm-manager.org").build(); + + assertThat(token.getId()).isNotEmpty(); + assertThat(token.getIssuedAt()).isNotNull(); + assertThat(token.getExpiration()).isNotNull(); + assertThat(token.getExpiration().getTime() > token.getIssuedAt().getTime()).isTrue(); + assertThat(token.getSubject()).isEqualTo("Red"); + assertThat(token.getIssuer()).isNotEmpty(); + assertThat(token.getIssuer().get()).isEqualTo("https://scm-manager.org"); + } } } diff --git a/scm-webapp/src/test/java/sonia/scm/security/SecureKeyResolverTest.java b/scm-webapp/src/test/java/sonia/scm/security/SecureKeyResolverTest.java index 61fc00a59b..670539b577 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/SecureKeyResolverTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/SecureKeyResolverTest.java @@ -32,12 +32,16 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.lifecycle.jwt.JwtSettings; +import sonia.scm.lifecycle.jwt.JwtSettingsStore; import sonia.scm.store.ConfigurationEntryStore; import sonia.scm.store.ConfigurationEntryStoreFactory; +import java.util.Arrays; import java.util.Random; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.not; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; @@ -66,12 +70,30 @@ public class SecureKeyResolverTest assertNotNull(key); when(store.get("test")).thenReturn(key); + when(jwtSettingsStore.get()).thenReturn(settings); SecureKey sameKey = resolver.getSecureKey("test"); assertSame(key, sameKey); } + @Test + public void shouldReturnRegeneratedKey() { + when(jwtSettingsStore.get()).thenReturn(settings); + SecureKey expiredKey = new SecureKey("oldKey".getBytes(), 0); + when(store.get("test")).thenReturn(expiredKey); + + SecureKey regeneratedKey = resolver.getSecureKey("test"); + assertThat(Arrays.equals(regeneratedKey.getBytes(), expiredKey.getBytes())).isFalse(); + assertThat(regeneratedKey.getCreationDate() > settings.getKeysValidAfterTimestampInMs()).isTrue(); + + + when(store.get("test")).thenReturn(regeneratedKey); + SecureKey sameRegeneratedKey = resolver.getSecureKey("test"); + assertThat(Arrays.equals(sameRegeneratedKey.getBytes(), regeneratedKey.getBytes())).isTrue(); + assertThat(sameRegeneratedKey.getCreationDate()).isEqualTo(regeneratedKey.getCreationDate()); + } + /** * Method description * @@ -82,6 +104,7 @@ public class SecureKeyResolverTest SecureKey key = resolver.getSecureKey("test"); when(store.get("test")).thenReturn(key); + when(jwtSettingsStore.get()).thenReturn(settings); byte[] bytes = resolver.resolveSigningKeyBytes(null, Jwts.claims().setSubject("test")); @@ -129,7 +152,7 @@ public class SecureKeyResolverTest }))).thenReturn(store); Random random = mock(Random.class); doAnswer(invocation -> ((byte[]) invocation.getArguments()[0])[0] = 42).when(random).nextBytes(any()); - resolver = new SecureKeyResolver(factory, random); + resolver = new SecureKeyResolver(factory, jwtSettingsStore, random); } //~--- fields --------------------------------------------------------------- @@ -140,4 +163,9 @@ public class SecureKeyResolverTest /** Field description */ @Mock private ConfigurationEntryStore store; + + @Mock + private JwtSettingsStore jwtSettingsStore; + + private JwtSettings settings = new JwtSettings(false, 100); } diff --git a/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java b/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java index 140ac4b260..713982ba72 100644 --- a/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java +++ b/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java @@ -45,6 +45,7 @@ import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.Date; import java.util.Set; import static java.util.Collections.singleton; @@ -54,6 +55,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static sonia.scm.security.BearerToken.valueOf; @@ -129,6 +131,7 @@ class TokenRefreshFilterTest { when(tokenGenerator.createToken(request)).thenReturn(token); when(resolver.resolve(token)).thenReturn(jwtToken); when(refresher.refresh(jwtToken)).thenReturn(of(newJwtToken)); + when(jwtToken.getExpiration()).thenReturn(new Date()); filter.doFilter(request, response, filterChain); @@ -136,6 +139,21 @@ class TokenRefreshFilterTest { verify(filterChain).doFilter(request, response); } + @Test + void shouldNotRefreshEndlessToken() throws IOException, ServletException { + BearerToken token = createValidToken(); + when(tokenGenerator.createToken(request)).thenReturn(token); + + JwtAccessToken jwtToken = mock(JwtAccessToken.class); + when(resolver.resolve(token)).thenReturn(jwtToken); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verifyNoInteractions(refresher); + verifyNoInteractions(issuer); + } + @Test void shouldTrackMetricIfTokenWasRefreshed() throws IOException, ServletException { BearerToken token = createValidToken(); @@ -144,6 +162,7 @@ class TokenRefreshFilterTest { when(tokenGenerator.createToken(request)).thenReturn(token); when(resolver.resolve(token)).thenReturn(jwtToken); when(refresher.refresh(jwtToken)).thenReturn(of(newJwtToken)); + when(jwtToken.getExpiration()).thenReturn(new Date()); filter.doFilter(request, response, filterChain);