Add Configuration to JWT lifetime length

This commit is contained in:
Viktor Egorov
2025-02-25 13:16:27 +01:00
parent bc3967a614
commit 19930804a0
30 changed files with 400 additions and 72 deletions

View File

@@ -38,6 +38,12 @@ So können über das Plugin-Center besondere cloudogu platform-Plugins bezogen w
Eine bestehende Verbindung zwischen dem SCM-Manager und der cloudogu platform kann hier aufgehoben werden.
![Einstellungen, Plugin-Center mit der cloudogu platform verbunden, Button zum Lösen der Verbindung](assets/administration-settings-connected.png)
### JWT Einstellungen
Benutzer erhalten einen JWT als Authentifizierungstoken nach einem erfolgreichen login.
Administratoren können die Lebensdauer dieser JWTs konfigurieren.
Falls die Lebensdauer verringert wird, wird jeder bisher ausgestellter JWT ungültig.
Sollte in der `config.yml` des Servers die Option "endless JWT" aktiviert sein, dann wird diese Einstellung ignoriert.
#### Anonyme Zugriff
Der SCM-Manager 2 hat das Konzept für anonyme Zugriffe über einen "_anonymous"-Benutzer realisiert. Beim Aktivieren des anonymen Zugriffs wird ein neuer Benutzer erstellt mit dem Namen "_anonymous". Dieser Nutzer kann wie ein gewöhnlicher Benutzer für unterschiedliche Aktionen berechtigt werden. Bei einem Zugriff auf den SCM-Manager ohne Zugangsdaten wird dieser anonyme Benutzer verwendet.
Ist der anonyme Zugriff nur für Protokoll aktiviert, können die REST API und die VCS Protokolle anonym genutzt werden. Wurde der anonyme Zugriff vollständig aktiviert, ist auch ein Zugriff über den Webclient anonym möglich.

View File

@@ -37,6 +37,12 @@ Plugin Center Authentication URL: https://plugin-center-api.scm-manager.org/api/
An existing connection between a SCM-Manager and the cloudogu platform may be severed here.
![Plugin center settings, button sever connection to the cloudogu platform](assets/administration-settings-connected.png)
#### JWT settings
Users receive a JWT as an authentication token, after a successful login.
Administrators can configure the amount of hours until a JWT expires.
If the amount of hours get reduced, each created JWT will be invalidated.
This setting will be ignored, if the endless JWT option is set to true in the server `config.yml`.
#### Anonymous Access
In SCM-Manager 2 the access for anonymous access is realized by using an "_anonymous" user. When the feature is activated, a new user with the name "_anonymous" is created. This user can be authorized just like any other user. This user is used for access to SCM-Manager without login credentials.
If the anonymous mode is protocol only you may access the SCM-Manager via the REST API and VCS protocols. With fully enabled anonymous access you can also use the webclient without credentials.

View File

@@ -0,0 +1,2 @@
- type: added
description: JWT expiration time in general settings

View File

@@ -229,6 +229,22 @@ public class ScmConfiguration implements Configuration {
@XmlElement(name = "mail-domain-name")
private String mailDomainName = DEFAULT_MAIL_DOMAIN_NAME;
/**
* Time in hours for jwt expiration.
*
* @since 3.8.0
*/
@XmlElement(name = "jwt-expiration-time")
private int jwtExpirationInH = 1;
/**
* Enables endless jwt.
*
* @since 3.8.0
*/
@XmlElement(name = "jwt-expiration-endless")
private boolean enabledJwtEndless = false;
/**
* List of users that will be notified of administrative incidents.
*
@@ -278,6 +294,8 @@ public class ScmConfiguration implements Configuration {
this.emergencyContacts = other.emergencyContacts;
this.enabledUserConverter = other.enabledUserConverter;
this.enabledApiKeys = other.enabledApiKeys;
this.jwtExpirationInH = other.jwtExpirationInH;
this.enabledJwtEndless = other.enabledJwtEndless;
}
/**
@@ -448,6 +466,26 @@ public class ScmConfiguration implements Configuration {
return anonymousMode;
}
/**
* Returns Jwt expiration in {@code n} .
*
* @return Jwt expiration in {@code number}
* @since 3.8.0
*/
public int getJwtExpirationInH() {
return jwtExpirationInH;
}
/**
* Returns {@code true} if the cookie xsrf protection is enabled.
*
* @return {@code true} if the cookie xsrf protection is enabled
* @since 3.8.0
*/
public boolean isJwtEndless() {
return enabledJwtEndless;
}
/**
* Returns {@code true} if anonymous mode is enabled.
*
@@ -728,6 +766,26 @@ public class ScmConfiguration implements Configuration {
this.enabledFileSearch = enabledFileSearch;
}
/**
* Set {@code n} to configure jwt expiration time in hours
*
* @param jwtExpirationInH {@code n} to configure jwt expiration time in hours
* @since 3.8.0
*/
public void setJwtExpirationInH(int jwtExpirationInH) {
this.jwtExpirationInH = jwtExpirationInH;
}
/**
* Set {@code true} to enable endless jwt.
*
* @param enabledJwtEndless {@code true} to enable endless jwt.
* @since 2.45.0
*/
public void setEnabledJwtExpiration(boolean enabledJwtEndless) {
this.enabledJwtEndless = enabledJwtEndless;
}
public void setNamespaceStrategy(String namespaceStrategy) {
this.namespaceStrategy = namespaceStrategy;
}

View File

@@ -241,6 +241,8 @@ class AnonymousAccessITCase {
.addNull("proxyUser")
.add("realmDescription", "SONIA :: SCM Manager")
.add("skipFailedAuthenticators", false)
.add("jwtExpirationInH", 1)
.add("enabledJwtEndless", false)
.build().toString();
}
}

View File

@@ -281,7 +281,9 @@ public class TestData {
" \"loginInfoUrl\": \"https://login-info.scm-manager.org/api/v1/login-info\",\n" +
" \"releaseFeedUrl\": \"https://scm-manager.org/download/rss.xml\",\n" +
" \"mailDomainName\": \"scm-manager.local\", \n" +
" \"enabledApiKeys\": \"true\"\n" +
" \"enabledApiKeys\": \"true\",\n" +
" \"jwtExpirationInH\": 1,\n" +
" \"enabledJwtEndless\": false\n" +
"}")
.put(createResourceUrl("config"))
.then()

View File

@@ -13,7 +13,7 @@
},
"dependencies": {
"@ffmpeg-installer/ffmpeg": "^1.0.20",
"@scm-manager/integration-test-runner": "^3.4.3",
"@scm-manager/integration-test-runner": "^3.5.0",
"fluent-ffmpeg": "^2.1.2"
},
"devDependencies": {
@@ -26,4 +26,4 @@
"publishConfig": {
"access": "public"
}
}
}

View File

@@ -53,6 +53,8 @@ describe("Test config hooks", () => {
alertsUrl: "",
releaseFeedUrl: "",
skipFailedAuthenticators: false,
jwtExpirationInH: 1,
enabledJwtEndless: false,
_links: {
update: {
href: "/config",

View File

@@ -21,6 +21,7 @@ import { createAttributesForTesting } from "../devBuild";
import useAutofocus from "./useAutofocus";
import { createFormFieldWrapper, FieldProps, FieldType, isLegacy, isUsingRef } from "./FormFieldTypes";
import { createA11yId } from "../createA11yId";
import FieldMessage from "@scm-manager/ui-core/src/base/forms/base/field-message/FieldMessage";
type BaseProps = {
label?: string;
@@ -40,6 +41,7 @@ type BaseProps = {
defaultValue?: string | number;
readOnly?: boolean;
required?: boolean;
warning?: string;
};
export const InnerInputField: FC<FieldProps<BaseProps, HTMLInputElement, string>> = ({
@@ -60,6 +62,7 @@ export const InnerInputField: FC<FieldProps<BaseProps, HTMLInputElement, string>
defaultValue,
readOnly,
required,
warning,
...props
}) => {
const field = useAutofocus<HTMLInputElement>(autofocus, props.innerRef);
@@ -123,6 +126,7 @@ export const InnerInputField: FC<FieldProps<BaseProps, HTMLInputElement, string>
{...createAttributesForTesting(testId)}
/>
</div>
{warning ? <FieldMessage variant="warning">{warning}</FieldMessage> : null}
{helper}
</fieldset>
);

View File

@@ -47,4 +47,6 @@ export type Config = HalRepresentation & {
emergencyContacts: string[];
enabledApiKeys: boolean;
enabledFileSearch: boolean;
jwtExpirationInH?: number;
enabledJwtEndless?: boolean;
};

View File

@@ -25,6 +25,13 @@
"notConfiguredHint": "Authentifizierungs URL ist nicht gesetzt"
}
},
"jwtSettings": {
"subtitle": "JWT Einstellungen",
"label": "Ablaufzeit",
"help": "Legen Sie die Ablaufzeit des JWT in Stunden fest. Wenn Sie die Zeit auf 'endlos' setzen möchten, verwenden Sie die Option 'endlessJwt' in der 'config.yml'.",
"hoursWarning": "Es wird nicht empfohlen, die Ablaufzeit auf mehr als 24 Stunden einzustellen",
"endlessWarning": "Die Ablaufzeit ist auf endlos eingestellt. Siehe 'config.yml'."
},
"proxySettings": {
"subtitle": "Proxy Einstellungen",
"enable": "Proxy aktivieren",

View File

@@ -25,6 +25,13 @@
"notConfiguredHint": "Authentication URL is not configured"
}
},
"jwtSettings": {
"subtitle": "JWT Settings",
"label": "Expiration time",
"help": "Set the JWT expiration time in hours. If you want to set the time to endless consider the 'endlessJWT' option inside the 'config.yml'.",
"hoursWarning": "It is not recommended to set the expiration time over 24 hours.",
"endlessWarning": "The expiration time is set to endless."
},
"proxySettings": {
"subtitle": "Proxy Settings",
"enable": "Enable Proxy",

View File

@@ -26,6 +26,7 @@ import PluginSettings from "./PluginSettings";
import FunctionSettings from "./FunctionSettings";
import InvalidateCaches from "./InvalidateCaches";
import InvalidateSearchIndex from "./InvalidateSearchIndex";
import JwtSettings from "./JwtSettings";
type Props = {
submitForm: (p: Config) => void;
@@ -60,6 +61,7 @@ const ConfigForm: FC<Props> = ({
dateFormat: "",
anonymousAccessEnabled: false,
anonymousMode: "OFF",
enabledFileSearch: true,
baseUrl: "",
forceBaseUrl: false,
loginAttemptLimit: 0,
@@ -77,6 +79,8 @@ const ConfigForm: FC<Props> = ({
mailDomainName: "",
emergencyContacts: [],
enabledApiKeys: true,
jwtExpirationInH: 1,
enabledJwtEndless: false,
_links: {},
});
const [showNotification, setShowNotification] = useState(false);
@@ -184,6 +188,13 @@ const ConfigForm: FC<Props> = ({
hasUpdatePermission={configUpdatePermission}
/>
<hr />
<JwtSettings
enabledJwtEndless={innerConfig.enabledJwtEndless || false}
jwtExpirationInH={innerConfig.jwtExpirationInH || 1}
onChange={onChange}
hasUpdatePermission={configUpdatePermission}
/>
<hr />
<ProxySettings
proxyPassword={innerConfig.proxyPassword ? innerConfig.proxyPassword : ""}
proxyPort={innerConfig.proxyPort ? innerConfig.proxyPort : 0}

View File

@@ -0,0 +1,64 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
import React, {FC, useState} from "react";
import { useTranslation } from "react-i18next";
import { InputField, Subtitle } from "@scm-manager/ui-components";
import { ConfigChangeHandler } from "@scm-manager/ui-types";
type Props = {
jwtExpirationInH: number;
enabledJwtEndless: boolean;
onChange: ConfigChangeHandler;
hasUpdatePermission: boolean;
};
const JwtSettings: FC<Props> = ({ jwtExpirationInH, onChange, hasUpdatePermission, enabledJwtEndless }) => {
const { t } = useTranslation("config");
const [warning, setWarning] = useState<string | undefined>(undefined);
const jwtOverTwentyFourWarning = t("jwtSettings.hoursWarning");
const jwtIsSetToEndless = t("jwtSettings.endlessWarning");
const handleJwtTimeChange = (value: string) => {
if (Number(value) > 24) {
setWarning(jwtOverTwentyFourWarning);
} else {
setWarning(undefined);
}
onChange(true, Number(value), "jwtExpirationInH");
};
return (
<div>
<Subtitle subtitle={t("jwtSettings.subtitle")} />
<div className="columns">
<div className="column">
<InputField
type="number"
label={t("jwtSettings.label")}
onChange={handleJwtTimeChange}
value={jwtExpirationInH}
disabled={!hasUpdatePermission || enabledJwtEndless}
helpText={t("jwtSettings.help")}
warning={enabledJwtEndless ? jwtIsSetToEndless : warning}
/>
</div>
</div>
</div>
);
};
export default JwtSettings;

View File

@@ -57,6 +57,8 @@ public class ConfigDto extends HalRepresentation implements UpdateConfigDto {
private String alertsUrl;
private String releaseFeedUrl;
private String mailDomainName;
private int jwtExpirationInH;
private boolean enabledJwtEndless;
private Set<String> emergencyContacts;
@Override

View File

@@ -35,6 +35,7 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Response;
import sonia.scm.admin.ScmConfigurationStore;
import sonia.scm.config.SecureKeyService;
import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.repository.NamespaceStrategyValidator;
@@ -57,18 +58,21 @@ public class ConfigResource {
private final NamespaceStrategyValidator namespaceStrategyValidator;
private final JsonMerger jsonMerger;
private final SecureKeyService secureKeyService;
@Inject
public ConfigResource(ScmConfigurationStore store, ConfigDtoToScmConfigurationMapper dtoToConfigMapper,
ScmConfigurationToConfigDtoMapper configToDtoMapper,
NamespaceStrategyValidator namespaceStrategyValidator,
JsonMerger jsonMerger) {
JsonMerger jsonMerger,
SecureKeyService secureKeyService) {
this.dtoToConfigMapper = dtoToConfigMapper;
this.configToDtoMapper = configToDtoMapper;
this.store = store;
this.namespaceStrategyValidator = namespaceStrategyValidator;
this.jsonMerger = jsonMerger;
this.secureKeyService = secureKeyService;
}
/**
@@ -201,6 +205,9 @@ public class ConfigResource {
private void updateConfig(ConfigDto updatedConfigDto) {
// ensure the namespace strategy is valid
namespaceStrategyValidator.check(updatedConfigDto.getNamespaceStrategy());
if (store.get().getJwtExpirationInH() > updatedConfigDto.getJwtExpirationInH()) {
secureKeyService.clearAllTokens();
}
store.store(dtoToConfigMapper.map(updatedConfigDto));
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.config;
import jakarta.inject.Inject;
import sonia.scm.security.SecureKeyResolver;
public class SecureKeyService {
private final SecureKeyResolver secureKeyResolver;
@Inject
public SecureKeyService(SecureKeyResolver secureKeyResolver) {
this.secureKeyResolver = secureKeyResolver;
}
public void clearAllTokens() {
secureKeyResolver.deleteStore();
}
}

View File

@@ -29,6 +29,7 @@ import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.Subject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.config.ScmConfiguration;
import java.time.Clock;
import java.time.Instant;
@@ -57,10 +58,11 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder {
private final SecureKeyResolver keyResolver;
private final JwtConfig jwtConfig;
private final Clock clock;
private final ScmConfiguration scmConfiguration;
private String subject;
private String issuer;
private long expiresIn = 1;
private long expiresIn;
private TimeUnit expiresInUnit = TimeUnit.HOURS;
private long refreshableFor = DEFAULT_REFRESHABLE;
private TimeUnit refreshableForUnit = DEFAULT_REFRESHABLE_UNIT;
@@ -70,11 +72,13 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder {
private final Map<String,Object> custom = Maps.newHashMap();
JwtAccessTokenBuilder(KeyGenerator keyGenerator, SecureKeyResolver keyResolver, JwtConfig jwtConfig, Clock clock) {
JwtAccessTokenBuilder(KeyGenerator keyGenerator, SecureKeyResolver keyResolver, JwtConfig jwtConfig, Clock clock, ScmConfiguration scmConfiguration) {
this.keyGenerator = keyGenerator;
this.keyResolver = keyResolver;
this.jwtConfig = jwtConfig;
this.clock = clock;
this.scmConfiguration = scmConfiguration;
this.expiresIn = scmConfiguration.getJwtExpirationInH();
}
@Override
@@ -179,6 +183,8 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder {
if(!jwtConfig.isEndlessJwtEnabled()) {
claims.setExpiration(new Date(now.toEpochMilli() + expiration));
} else {
scmConfiguration.setEnabledJwtExpiration(true);
}
if (refreshableFor > 0) {
@@ -196,8 +202,7 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder {
if ( issuer != null ) {
claims.setIssuer(issuer);
}
// sign token and create compact version
String compact = Jwts.builder()
.setClaims(claims)

View File

@@ -17,6 +17,7 @@
package sonia.scm.security;
import jakarta.inject.Inject;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.plugin.Extension;
import java.time.Clock;
@@ -35,25 +36,27 @@ public final class JwtAccessTokenBuilderFactory implements AccessTokenBuilderFac
private final JwtConfig jwtConfig;
private final Set<AccessTokenEnricher> enrichers;
private final Clock clock;
private final ScmConfiguration scmConfiguration;
@Inject
public JwtAccessTokenBuilderFactory(
KeyGenerator keyGenerator, SecureKeyResolver keyResolver, Set<AccessTokenEnricher> enrichers, JwtConfig jwtConfig) {
this(keyGenerator, keyResolver, jwtConfig, enrichers, Clock.systemDefaultZone());
KeyGenerator keyGenerator, SecureKeyResolver keyResolver, Set<AccessTokenEnricher> enrichers, JwtConfig jwtConfig, ScmConfiguration scmConfiguration) {
this(keyGenerator, keyResolver, jwtConfig, enrichers, Clock.systemDefaultZone(), scmConfiguration);
}
JwtAccessTokenBuilderFactory(
KeyGenerator keyGenerator, SecureKeyResolver keyResolver, JwtConfig jwtConfig, Set<AccessTokenEnricher> enrichers, Clock clock) {
KeyGenerator keyGenerator, SecureKeyResolver keyResolver, JwtConfig jwtConfig, Set<AccessTokenEnricher> enrichers, Clock clock, ScmConfiguration scmConfiguration) {
this.keyGenerator = keyGenerator;
this.keyResolver = keyResolver;
this.jwtConfig = jwtConfig;
this.enrichers = enrichers;
this.clock = clock;
this.scmConfiguration = scmConfiguration;
}
@Override
public JwtAccessTokenBuilder create() {
JwtAccessTokenBuilder builder = new JwtAccessTokenBuilder(keyGenerator, keyResolver, jwtConfig, clock);
JwtAccessTokenBuilder builder = new JwtAccessTokenBuilder(keyGenerator, keyResolver, jwtConfig, clock, scmConfiguration);
// enrich access token builder
enrichers.forEach((enricher) -> {

View File

@@ -30,7 +30,7 @@ public class JwtConfig {
@ConfigValue(
key = "endlessJwt",
defaultValue = "false",
description = "The lifespan of the issued JWT tokens should be endless. Logged-in users are no longer automatically logged out.")
description = "The lifespan of the issued JWT tokens should be endless. Logged-in users are no longer automatically logged out. Any other expiration time will be overridden")
boolean endlessJwt) {
this.endlessJwt = endlessJwt;
}

View File

@@ -131,4 +131,7 @@ public class SecureKeyResolver extends SigningKeyResolverAdapter
return new SecureKey(bytes, System.currentTimeMillis());
}
public void deleteStore() {
store.clear();
}
}

View File

@@ -63,6 +63,8 @@ class ConfigDtoToScmConfigurationMapperTest {
assertThat(config.getLoginInfoUrl()).isEqualTo("https://scm-manager.org/login-info");
assertThat(config.getMailDomainName()).isEqualTo("hitchhiker.mail");
assertThat(config.getEmergencyContacts()).contains(expectedUsers);
assertThat(config.getJwtExpirationInH()).isEqualTo(10);
assertThat(config.isJwtEndless()).isFalse();
}
@Test
@@ -105,6 +107,8 @@ class ConfigDtoToScmConfigurationMapperTest {
configDto.setMailDomainName("hitchhiker.mail");
configDto.setEmergencyContacts(Sets.newSet(expectedUsers));
configDto.setEnabledUserConverter(false);
configDto.setJwtExpirationInH(10);
configDto.setEnabledJwtEndless(false);
return configDto;
}

View File

@@ -30,6 +30,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.admin.ScmConfigurationStore;
import sonia.scm.config.SecureKeyService;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.repository.NamespaceStrategyValidator;
import sonia.scm.store.InMemoryConfigurationStoreFactory;
@@ -69,10 +70,22 @@ class ConfigResourceTest {
private ConfigDtoToScmConfigurationMapperImpl dtoToConfigMapper;
@InjectMocks
private ScmConfigurationToConfigDtoMapperImpl configToDtoMapper;
@Mock
private SecureKeyService secureKeyService;
@BeforeEach
void prepareEnvironment() {
ConfigResource configResource = new ConfigResource(new ScmConfigurationStore(new InMemoryConfigurationStoreFactory(), new ScmConfiguration()), dtoToConfigMapper, configToDtoMapper, namespaceStrategyValidator, jsonMerger);
ConfigResource configResource = new ConfigResource(
new ScmConfigurationStore(
new InMemoryConfigurationStoreFactory(),
new ScmConfiguration()
),
dtoToConfigMapper,
configToDtoMapper,
namespaceStrategyValidator,
jsonMerger,
secureKeyService
);
dispatcher.addSingletonResource(configResource);
}
@@ -108,7 +121,7 @@ class ConfigResourceTest {
permissions = "configuration:read,write:global"
)
void shouldUpdateConfig() throws URISyntaxException, IOException {
MockHttpRequest request = put("sonia/scm/api/v2/config-test-update.json");
MockHttpRequest request = put();
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -128,7 +141,7 @@ class ConfigResourceTest {
@Test
void shouldNotUpdateConfigWhenNotAuthorized() throws URISyntaxException, IOException {
MockHttpRequest request = put("sonia/scm/api/v2/config-test-update.json");
MockHttpRequest request = put();
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
@@ -185,8 +198,8 @@ class ConfigResourceTest {
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN);
}
private MockHttpRequest put(String resourceName) throws IOException, URISyntaxException {
URL url = Resources.getResource(resourceName);
private MockHttpRequest put() throws IOException, URISyntaxException {
URL url = Resources.getResource("sonia/scm/api/v2/config-test-update.json");
byte[] configJson = Resources.toByteArray(url);
return MockHttpRequest.put("/" + ConfigResource.CONFIG_PATH_V2)
.contentType(VndMediaType.CONFIG)

View File

@@ -98,6 +98,8 @@ class ScmConfigurationToConfigDtoMapperTest {
assertThat(dto.getReleaseFeedUrl()).isEqualTo("https://www.scm-manager.org/download/rss.xml");
assertThat(dto.getMailDomainName()).isEqualTo("scm-manager.local");
assertThat(dto.getEmergencyContacts()).contains(expectedUsers);
assertThat(dto.getJwtExpirationInH()).isEqualTo(10);
assertThat(dto.isEnabledJwtEndless()).isFalse();
assertLinks(dto);
}
@@ -161,6 +163,8 @@ class ScmConfigurationToConfigDtoMapperTest {
config.setAlertsUrl("https://alerts.scm-manager.org/api/v1/alerts");
config.setReleaseFeedUrl("https://www.scm-manager.org/download/rss.xml");
config.setEmergencyContacts(Sets.newSet(expectedUsers));
config.setJwtExpirationInH(10);
config.setEnabledJwtExpiration(false);
return config;
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.config;
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.mockito.Mockito.verify;
import sonia.scm.security.SecureKeyResolver;
@ExtendWith(MockitoExtension.class)
class SecureKeyServiceTest {
private SecureKeyService secureKeyService;
@Mock
private SecureKeyResolver configRepository;
@BeforeEach
void prepareEnvironment() {
this.secureKeyService = new SecureKeyService(configRepository);
}
@Test
void shouldDeleteStore() {
this.secureKeyService.clearAllTokens();
verify(configRepository).deleteStore();
}
}

View File

@@ -19,6 +19,7 @@ package sonia.scm.security;
import com.google.common.collect.Sets;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.apache.commons.lang.time.DateUtils;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.Subject;
@@ -30,6 +31,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.config.ScmConfiguration;
import java.time.Clock;
import java.time.Instant;
@@ -48,7 +50,6 @@ import static sonia.scm.security.SecureKeyTestUtil.createSecureKey;
/**
* Unit test for {@link JwtAccessTokenBuilder}.
*
*/
@ExtendWith(MockitoExtension.class)
class JwtAccessTokenBuilderTest {
@@ -62,6 +63,8 @@ class JwtAccessTokenBuilderTest {
@Mock
private JwtConfig jwtConfig;
private ScmConfiguration scmConfiguration;
private Set<AccessTokenEnricher> enrichers;
private JwtAccessTokenBuilderFactory factory;
@@ -85,10 +88,12 @@ class JwtAccessTokenBuilderTest {
@BeforeEach
void setUpDependencies() {
this.scmConfiguration = new ScmConfiguration();
this.scmConfiguration.setJwtExpirationInH(10);
lenient().when(keyGenerator.createKey()).thenReturn("42");
lenient().when(secureKeyResolver.getSecureKey(anyString())).thenReturn(createSecureKey());
enrichers = Sets.newHashSet();
factory = new JwtAccessTokenBuilderFactory(keyGenerator, secureKeyResolver, enrichers, jwtConfig);
factory = new JwtAccessTokenBuilderFactory(keyGenerator, secureKeyResolver, enrichers, jwtConfig, scmConfiguration);
}
@Nested
@@ -99,7 +104,7 @@ class JwtAccessTokenBuilderTest {
*/
@BeforeEach
void setUpObjectUnderTest() {
factory = new JwtAccessTokenBuilderFactory(keyGenerator, secureKeyResolver, enrichers, jwtConfig);
factory = new JwtAccessTokenBuilderFactory(keyGenerator, secureKeyResolver, enrichers, jwtConfig, scmConfiguration);
}
/**
@@ -149,7 +154,7 @@ class JwtAccessTokenBuilderTest {
@BeforeEach
void setUpObjectUnderTest() {
factory = new JwtAccessTokenBuilderFactory(keyGenerator, secureKeyResolver, jwtConfig, enrichers, clock);
factory = new JwtAccessTokenBuilderFactory(keyGenerator, secureKeyResolver, jwtConfig, enrichers, clock, scmConfiguration);
}
@Test
@@ -189,11 +194,9 @@ class JwtAccessTokenBuilderTest {
@Nested
class FromApiKeyRealm {
private Scope scope;
@BeforeEach
void mockApiKeyRealm() {
scope = Scope.valueOf("dummy:scope:*");
Scope scope = Scope.valueOf("dummy:scope:*");
lenient().when(principalCollection.getRealmNames()).thenReturn(singleton("ApiTokenRealm"));
lenient().when(principalCollection.oneByType(Scope.class)).thenReturn(scope);
}
@@ -276,6 +279,7 @@ class JwtAccessTokenBuilderTest {
assertThat(token.getSubject()).isEqualTo("Red");
assertThat(token.getIssuer()).isNotEmpty();
assertThat(token.getIssuer()).contains("https://scm-manager.org");
assertThat(scmConfiguration.isJwtEndless()).isEqualTo(true);
}
@Test
@@ -291,6 +295,24 @@ class JwtAccessTokenBuilderTest {
assertThat(token.getSubject()).isEqualTo("Red");
assertThat(token.getIssuer()).isNotEmpty();
assertThat(token.getIssuer()).contains("https://scm-manager.org");
assertThat(scmConfiguration.isJwtEndless()).isEqualTo(false);
}
}
@Nested
class WithJwtSetTimeFeature {
@Test
void testBuildWithDefaultJwtTime() {
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.getSubject()).isEqualTo("Red");
assertThat(token.getIssuer()).isNotEmpty();
assertThat(token.getIssuer()).contains("https://scm-manager.org");
assertThat(token.getExpiration()).isEqualTo(DateUtils.addHours(token.getIssuedAt(), 10));
}
}
}

View File

@@ -25,6 +25,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.user.User;
import java.sql.Date;
@@ -53,6 +54,8 @@ class JwtAccessTokenRefresherTest {
private SecureKeyResolver keyResolver;
@Mock
private JwtConfig jwtConfig;
private ScmConfiguration scmConfiguration;
@Mock
private JwtAccessTokenRefreshStrategy refreshStrategy;
@Mock
@@ -71,9 +74,16 @@ class JwtAccessTokenRefresherTest {
Clock creationClock = mock(Clock.class);
when(creationClock.instant()).thenReturn(TOKEN_CREATION);
tokenBuilder = new JwtAccessTokenBuilderFactory(keyGenerator, keyResolver, jwtConfig, Collections.emptySet(), creationClock).create();
tokenBuilder = new JwtAccessTokenBuilderFactory(keyGenerator, keyResolver, jwtConfig, Collections.emptySet(), creationClock, scmConfiguration).create();
JwtAccessTokenBuilderFactory refreshBuilderFactory = new JwtAccessTokenBuilderFactory(keyGenerator, keyResolver, jwtConfig, Collections.emptySet(), refreshClock);
JwtAccessTokenBuilderFactory refreshBuilderFactory = new JwtAccessTokenBuilderFactory(
keyGenerator,
keyResolver,
jwtConfig,
Collections.emptySet(),
refreshClock,
scmConfiguration
);
refresher = new JwtAccessTokenRefresher(refreshBuilderFactory, refreshStrategy, refreshClock);
when(refreshClock.instant()).thenReturn(NOW);
lenient().when(refreshStrategy.shouldBeRefreshed(any())).thenReturn(true);
@@ -91,6 +101,11 @@ class JwtAccessTokenRefresherTest {
when(subject.getPrincipal()).thenReturn(new User("trillian"));
}
@BeforeEach
void initConfig() {
this.scmConfiguration = new ScmConfiguration();
}
@AfterEach
void tearDownSubject() {
ThreadContext.unbindSubject();

View File

@@ -21,6 +21,7 @@ import com.github.sdorra.shiro.SubjectAware;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import sonia.scm.config.ScmConfiguration;
import java.time.Clock;
import java.time.Instant;
@@ -43,15 +44,11 @@ import static sonia.scm.security.SecureKeyTestUtil.createSecureKey;
public class PercentageJwtAccessTokenRefreshStrategyTest {
private static final Instant TOKEN_CREATION = Instant.now().truncatedTo(SECONDS);
private final KeyGenerator keyGenerator = () -> "key";
private final JwtConfig jwtConfig = mock(JwtConfig.class);
private final Clock refreshClock = mock(Clock.class);
@Rule
public ShiroRule shiro = new ShiroRule();
private KeyGenerator keyGenerator = () -> "key";
private JwtConfig jwtConfig = mock(JwtConfig.class);
private Clock refreshClock = mock(Clock.class);
private JwtAccessTokenBuilder tokenBuilder;
private PercentageJwtAccessTokenRefreshStrategy refreshStrategy;
@@ -59,11 +56,18 @@ public class PercentageJwtAccessTokenRefreshStrategyTest {
public void initToken() {
SecureKeyResolver keyResolver = mock(SecureKeyResolver.class);
when(keyResolver.getSecureKey(any())).thenReturn(createSecureKey());
ScmConfiguration scmConfiguration = new ScmConfiguration();
Clock creationClock = mock(Clock.class);
when(creationClock.instant()).thenReturn(TOKEN_CREATION);
tokenBuilder = new JwtAccessTokenBuilderFactory(keyGenerator, keyResolver, jwtConfig, Collections.emptySet(), creationClock).create();
tokenBuilder = new JwtAccessTokenBuilderFactory(
keyGenerator,
keyResolver,
jwtConfig,
Collections.emptySet(),
creationClock,
scmConfiguration
).create();
tokenBuilder.expiresIn(1, HOURS);
tokenBuilder.refreshableFor(1, HOURS);

View File

@@ -32,7 +32,6 @@ 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;
@@ -40,16 +39,22 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.argThat;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
public class SecureKeyResolverTest
{
public class SecureKeyResolverTest {
@Test
public void testGetSecureKey()
{
private SecureKeyResolver resolver;
@Mock
private ConfigurationEntryStore<SecureKey> store;
@Mock
private JwtSettingsStore jwtSettingsStore;
private final JwtSettings settings = new JwtSettings(false, 100);
@Test
public void testGetSecureKey() {
SecureKey key = resolver.getSecureKey("test");
assertNotNull(key);
@@ -61,6 +66,12 @@ public class SecureKeyResolverTest
assertSame(key, sameKey);
}
@Test
public void clearSecureKey() {
resolver.deleteStore();
verify(store).clear();
}
@Test
public void shouldReturnRegeneratedKey() {
when(jwtSettingsStore.get()).thenReturn(settings);
@@ -78,37 +89,32 @@ public class SecureKeyResolverTest
assertThat(sameRegeneratedKey.getCreationDate()).isEqualTo(regeneratedKey.getCreationDate());
}
@Test
public void testResolveSigningKeyBytes()
{
@Test
public void testResolveSigningKeyBytes() {
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"));
Jwts.claims().setSubject("test"));
assertArrayEquals(key.getBytes(), bytes);
}
@Test
public void testResolveSigningKeyBytesWithoutKey()
{
@Test
public void testResolveSigningKeyBytesWithoutKey() {
byte[] bytes = resolver.resolveSigningKeyBytes(null, Jwts.claims().setSubject("test"));
assertThat(bytes[0]).isEqualTo((byte) 42);
}
@Test(expected = IllegalArgumentException.class)
public void testResolveSigningKeyBytesWithoutSubject()
{
@Test(expected = IllegalArgumentException.class)
public void testResolveSigningKeyBytesWithoutSubject() {
resolver.resolveSigningKeyBytes(null, Jwts.claims());
}
@Before
public void setUp()
{
@Before
public void setUp() {
ConfigurationEntryStoreFactory factory = mock(ConfigurationEntryStoreFactory.class);
when(factory.withType(any())).thenCallRealMethod();
@@ -121,16 +127,4 @@ public class SecureKeyResolverTest
doAnswer(invocation -> ((byte[]) invocation.getArguments()[0])[0] = 42).when(random).nextBytes(any());
resolver = new SecureKeyResolver(factory, jwtSettingsStore, random);
}
//~--- fields ---------------------------------------------------------------
private SecureKeyResolver resolver;
@Mock
private ConfigurationEntryStore<SecureKey> store;
@Mock
private JwtSettingsStore jwtSettingsStore;
private JwtSettings settings = new JwtSettings(false, 100);
}

View File

@@ -3059,10 +3059,10 @@
eslint-plugin-react-hooks "^2.1.2"
jest "^26.6.3"
"@scm-manager/integration-test-runner@^3.4.3":
version "3.4.3"
resolved "https://registry.yarnpkg.com/@scm-manager/integration-test-runner/-/integration-test-runner-3.4.3.tgz#6a2e44f5c360fb1c40c3701cf9e8ddadd5031666"
integrity sha512-tA3B5iDAsNWQgXUiMhnrz7sX5dc0674R5Xb+Fch5kSysxMjwn5gMeDUIXA6j5S6OXsp8jlIj/y70m5foplO2WQ==
"@scm-manager/integration-test-runner@^3.5.0":
version "3.5.0"
resolved "https://registry.yarnpkg.com/@scm-manager/integration-test-runner/-/integration-test-runner-3.5.0.tgz#fdc9b1392d764a3db4aefff041c47a7c4f5bedce"
integrity sha512-/Aj8FldRdv3boOQJyFlT6AJb4vtxeiuXj7EF578QpT2DqbD/fyUC3qeZnfo78wS7KvSzJKhxrJrLIoeFpg+4iw==
dependencies:
"@ffmpeg-installer/ffmpeg" "^1.0.20"
"@octokit/rest" "^18.0.9"