mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-07 22:15:45 +01:00
Add flag to global config to enable/disable api keys as additional authentication method (#1606)
Add flag to global config to enable/disable API keys as additional authentication method. Fixes #1599
This commit is contained in:
2
gradle/changelog/api_keys_config.yaml
Normal file
2
gradle/changelog/api_keys_config.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: Added
|
||||
description: Add global flag to enable/disable api keys ([#1606](https://github.com/scm-manager/scm-manager/pull/1606))
|
||||
@@ -197,6 +197,14 @@ public class ScmConfiguration implements Configuration {
|
||||
@XmlElement(name = "user-converter")
|
||||
private boolean enabledUserConverter = false;
|
||||
|
||||
/**
|
||||
* Enables api keys for all users.
|
||||
*
|
||||
* @since 2.16.0
|
||||
*/
|
||||
@XmlElement(name = "api-keys")
|
||||
private boolean enabledApiKeys = true;
|
||||
|
||||
@XmlElement(name = "namespace-strategy")
|
||||
private String namespaceStrategy = "UsernameNamespaceStrategy";
|
||||
|
||||
@@ -246,6 +254,7 @@ public class ScmConfiguration implements Configuration {
|
||||
this.releaseFeedUrl = other.releaseFeedUrl;
|
||||
this.mailDomainName = other.mailDomainName;
|
||||
this.enabledUserConverter = other.enabledUserConverter;
|
||||
this.enabledApiKeys = other.enabledApiKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,6 +416,16 @@ public class ScmConfiguration implements Configuration {
|
||||
return enabledUserConverter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} if the api keys are enabled.
|
||||
*
|
||||
* @return {@code true} if the api keys is enabled
|
||||
* @since 2.16.0
|
||||
*/
|
||||
public boolean isEnabledApiKeys() {
|
||||
return enabledApiKeys;
|
||||
}
|
||||
|
||||
public boolean isEnableProxy() {
|
||||
return enableProxy;
|
||||
}
|
||||
@@ -584,6 +603,16 @@ public class ScmConfiguration implements Configuration {
|
||||
this.enabledUserConverter = enabledUserConverter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set {@code true} to enable api keys.
|
||||
*
|
||||
* @param enabledApiKeys {@code true} to enable api keys
|
||||
* @since 2.16.0
|
||||
*/
|
||||
public void setEnabledApiKeys(boolean enabledApiKeys) {
|
||||
this.enabledApiKeys = enabledApiKeys;
|
||||
}
|
||||
|
||||
public void setNamespaceStrategy(String namespaceStrategy) {
|
||||
this.namespaceStrategy = namespaceStrategy;
|
||||
}
|
||||
|
||||
@@ -270,7 +270,8 @@ public class TestData {
|
||||
" \"namespaceStrategy\": \"UsernameNamespaceStrategy\", \n" +
|
||||
" \"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" +
|
||||
" \"mailDomainName\": \"scm-manager.local\", \n" +
|
||||
" \"enabledApiKeys\": \"true\"\n" +
|
||||
"}")
|
||||
.put(createResourceUrl("config"))
|
||||
.then()
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("Test config hooks", () => {
|
||||
enableProxy: false,
|
||||
enabledUserConverter: false,
|
||||
enabledXsrfProtection: false,
|
||||
enabledApiKeys: false,
|
||||
forceBaseUrl: false,
|
||||
loginAttemptLimit: 0,
|
||||
loginAttemptLimitTimeout: 0,
|
||||
|
||||
@@ -50,4 +50,5 @@ export type Config = HalRepresentation & {
|
||||
loginInfoUrl: string;
|
||||
releaseFeedUrl: string;
|
||||
mailDomainName: string;
|
||||
enabledApiKeys: boolean;
|
||||
};
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"mail-domain-name": "Fallback E-Mail Domain Name",
|
||||
"enabled-xsrf-protection": "XSRF Protection aktivieren",
|
||||
"enabled-user-converter": "Benutzer Konverter aktivieren",
|
||||
"enabled-api-keys": "API Schlüssel aktivieren",
|
||||
"namespace-strategy": "Namespace Strategie",
|
||||
"login-info-url": "Login Info URL"
|
||||
},
|
||||
@@ -86,6 +87,7 @@
|
||||
"loginAttemptLimitTimeoutHelpText": "Timeout in Sekunden für Benutzer, die vorübergehend wegen zu vieler fehlgeschlagener Anmeldeversuche, deaktiviert wurden.",
|
||||
"enableXsrfProtectionHelpText": "Xsrf Cookie Protection aktivieren. Hinweis: Dieses Feature befindet sich noch im Experimentalstatus.",
|
||||
"enabledUserConverterHelpText": "Benutzer Konverter aktivieren. Interne Benutzer werden beim Einloggen über ein Fremdsystem zu externen Benutzern konvertiert.",
|
||||
"enabledApiKeysHelpText": "API Schlüssel aktivieren. Alle Benutzer dürfen API Schlüssel als zusätzliche Methode zur Authentifizierung nutzen.",
|
||||
"nameSpaceStrategyHelpText": "Strategie für Namespaces.",
|
||||
"loginInfoUrlHelpText": "URL zu der Login Information (Plugin und Feature Tipps auf der Login Seite). Um die Login Information zu deaktivieren, kann das Feld leer gelassen werden."
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"mail-domain-name": "Fallback Mail Domain Name",
|
||||
"enabled-xsrf-protection": "Enabled XSRF Protection",
|
||||
"enabled-user-converter": "Enabled User Converter",
|
||||
"enabled-api-keys": "Enabled API Keys",
|
||||
"namespace-strategy": "Namespace Strategy",
|
||||
"login-info-url": "Login Info URL"
|
||||
},
|
||||
@@ -86,6 +87,7 @@
|
||||
"loginAttemptLimitTimeoutHelpText": "Timeout in seconds for users which are temporary disabled, because of too many failed login attempts.",
|
||||
"enableXsrfProtectionHelpText": "Enable XSRF Cookie Protection. Note: This feature is still experimental.",
|
||||
"enabledUserConverterHelpText": "Enable User Converter. Internal users will automatically be converted to external on their first login using an external system.",
|
||||
"enabledApiKeysHelpText": "Enable API Keys. API Keys can be used as additional authentication method by every user.",
|
||||
"nameSpaceStrategyHelpText": "The namespace strategy.",
|
||||
"loginInfoUrlHelpText": "URL to login information (plugin and feature tips at login page). If this is omitted, no login information will be displayed."
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ class ConfigForm extends React.Component<Props, State> {
|
||||
mailDomainName={config.mailDomainName}
|
||||
enabledXsrfProtection={config.enabledXsrfProtection}
|
||||
enabledUserConverter={config.enabledUserConverter}
|
||||
enabledApiKeys={config.enabledApiKeys}
|
||||
namespaceStrategy={config.namespaceStrategy}
|
||||
onChange={(isValid, changedValue, name) => this.onChange(isValid, changedValue, name)}
|
||||
hasUpdatePermission={configUpdatePermission}
|
||||
|
||||
@@ -39,6 +39,7 @@ type Props = WithTranslation & {
|
||||
mailDomainName: string;
|
||||
enabledXsrfProtection: boolean;
|
||||
enabledUserConverter: boolean;
|
||||
enabledApiKeys: boolean;
|
||||
namespaceStrategy: string;
|
||||
namespaceStrategies?: NamespaceStrategies;
|
||||
onChange: (p1: boolean, p2: any, p3: string) => void;
|
||||
@@ -56,6 +57,7 @@ class GeneralSettings extends React.Component<Props> {
|
||||
mailDomainName,
|
||||
enabledXsrfProtection,
|
||||
enabledUserConverter,
|
||||
enabledApiKeys,
|
||||
anonymousMode,
|
||||
namespaceStrategy,
|
||||
hasUpdatePermission,
|
||||
@@ -163,6 +165,16 @@ class GeneralSettings extends React.Component<Props> {
|
||||
helpText={t("help.mailDomainNameHelpText")}
|
||||
/>
|
||||
</div>
|
||||
<div className="column is-half">
|
||||
<Checkbox
|
||||
label={t("general-settings.enabled-api-keys")}
|
||||
onChange={this.handleEnabledApiKeysChange}
|
||||
checked={enabledApiKeys}
|
||||
title={t("general-settings.enabled-api-keys")}
|
||||
disabled={!hasUpdatePermission}
|
||||
helpText={t("help.enabledApiKeysHelpText")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -195,6 +207,9 @@ class GeneralSettings extends React.Component<Props> {
|
||||
handleMailDomainNameChange = (value: string) => {
|
||||
this.props.onChange(true, value, "mailDomainName");
|
||||
};
|
||||
handleEnabledApiKeysChange = (value: boolean) => {
|
||||
this.props.onChange(true, value, "enabledApiKeys");
|
||||
};
|
||||
}
|
||||
|
||||
export default withTranslation("config")(GeneralSettings);
|
||||
|
||||
@@ -57,6 +57,7 @@ public class ConfigDto extends HalRepresentation implements UpdateConfigDto {
|
||||
private long loginAttemptLimitTimeout;
|
||||
private boolean enabledXsrfProtection;
|
||||
private boolean enabledUserConverter;
|
||||
private boolean enabledApiKeys;
|
||||
private String namespaceStrategy;
|
||||
private String loginInfoUrl;
|
||||
private String releaseFeedUrl;
|
||||
|
||||
@@ -28,10 +28,10 @@ import com.google.common.base.Strings;
|
||||
import de.otto.edison.hal.Embedded;
|
||||
import de.otto.edison.hal.Links;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.group.GroupCollector;
|
||||
import sonia.scm.user.EMail;
|
||||
import sonia.scm.user.User;
|
||||
import sonia.scm.user.UserManager;
|
||||
import sonia.scm.user.UserPermissions;
|
||||
import sonia.scm.web.EdisonHalAppender;
|
||||
|
||||
@@ -44,15 +44,15 @@ import static de.otto.edison.hal.Links.linkingTo;
|
||||
public class MeDtoFactory extends HalAppenderMapper {
|
||||
|
||||
private final ResourceLinks resourceLinks;
|
||||
private final UserManager userManager;
|
||||
private final GroupCollector groupCollector;
|
||||
private final ScmConfiguration scmConfiguration;
|
||||
private final EMail eMail;
|
||||
|
||||
@Inject
|
||||
public MeDtoFactory(ResourceLinks resourceLinks, UserManager userManager, GroupCollector groupCollector, EMail eMail) {
|
||||
public MeDtoFactory(ResourceLinks resourceLinks, GroupCollector groupCollector, ScmConfiguration scmConfiguration, EMail eMail) {
|
||||
this.resourceLinks = resourceLinks;
|
||||
this.userManager = userManager;
|
||||
this.groupCollector = groupCollector;
|
||||
this.scmConfiguration = scmConfiguration;
|
||||
this.eMail = eMail;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ public class MeDtoFactory extends HalAppenderMapper {
|
||||
if (!user.isExternal() && UserPermissions.changePassword(user).isPermitted()) {
|
||||
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
|
||||
}
|
||||
if (UserPermissions.changeApiKeys(user).isPermitted()) {
|
||||
if (scmConfiguration.isEnabledApiKeys() && UserPermissions.changeApiKeys(user).isPermitted()) {
|
||||
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self(user.getName())));
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.apache.shiro.authz.AuthorizationException;
|
||||
import org.apache.shiro.realm.AuthenticatingRealm;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.plugin.Extension;
|
||||
import sonia.scm.repository.RepositoryRole;
|
||||
import sonia.scm.repository.RepositoryRoleManager;
|
||||
@@ -53,12 +54,14 @@ public class ApiKeyRealm extends AuthenticatingRealm {
|
||||
private final ApiKeyService apiKeyService;
|
||||
private final DAORealmHelper helper;
|
||||
private final RepositoryRoleManager repositoryRoleManager;
|
||||
private final ScmConfiguration scmConfiguration;
|
||||
|
||||
@Inject
|
||||
public ApiKeyRealm(ApiKeyService apiKeyService, DAORealmHelperFactory helperFactory, RepositoryRoleManager repositoryRoleManager) {
|
||||
public ApiKeyRealm(ApiKeyService apiKeyService, DAORealmHelperFactory helperFactory, RepositoryRoleManager repositoryRoleManager, ScmConfiguration scmConfiguration) {
|
||||
this.apiKeyService = apiKeyService;
|
||||
this.helper = helperFactory.create(NAME);
|
||||
this.repositoryRoleManager = repositoryRoleManager;
|
||||
this.scmConfiguration = scmConfiguration;
|
||||
setAuthenticationTokenClass(BearerToken.class);
|
||||
setCredentialsMatcher(new AllowAllCredentialsMatcher());
|
||||
}
|
||||
@@ -66,7 +69,7 @@ public class ApiKeyRealm extends AuthenticatingRealm {
|
||||
@Override
|
||||
@SuppressWarnings("java:S4738") // java.util.Base64 has no canDecode method
|
||||
public boolean supports(AuthenticationToken token) {
|
||||
if (token instanceof UsernamePasswordToken || token instanceof BearerToken) {
|
||||
if (scmConfiguration.isEnabledApiKeys() && (token instanceof UsernamePasswordToken || token instanceof BearerToken)) {
|
||||
boolean isBase64 = BaseEncoding.base64().canDecode(getPassword(token));
|
||||
if (!isBase64) {
|
||||
LOG.debug("Ignoring non base 64 token; this is probably a JWT token or a normal password");
|
||||
|
||||
@@ -37,10 +37,10 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import sonia.scm.SCMContext;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.group.GroupCollector;
|
||||
import sonia.scm.user.EMail;
|
||||
import sonia.scm.user.User;
|
||||
import sonia.scm.user.UserManager;
|
||||
import sonia.scm.user.UserTestData;
|
||||
|
||||
import java.net.URI;
|
||||
@@ -56,15 +56,15 @@ class MeDtoFactoryTest {
|
||||
|
||||
private final URI baseUri = URI.create("https://scm.hitchhiker.com/scm/");
|
||||
|
||||
@Mock
|
||||
private UserManager userManager;
|
||||
|
||||
@Mock
|
||||
private GroupCollector groupCollector;
|
||||
|
||||
@Mock
|
||||
private Subject subject;
|
||||
|
||||
@Mock
|
||||
private ScmConfiguration scmConfiguration;
|
||||
|
||||
@Mock
|
||||
private EMail eMail;
|
||||
|
||||
@@ -74,7 +74,7 @@ class MeDtoFactoryTest {
|
||||
void setUpContext() {
|
||||
ThreadContext.bind(subject);
|
||||
ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
|
||||
meDtoFactory = new MeDtoFactory(resourceLinks, userManager, groupCollector, eMail);
|
||||
meDtoFactory = new MeDtoFactory(resourceLinks, groupCollector, scmConfiguration, eMail);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -160,8 +160,6 @@ class MeDtoFactoryTest {
|
||||
User user = UserTestData.createTrillian();
|
||||
prepareSubject(user);
|
||||
|
||||
when(userManager.isTypeDefault(user)).thenReturn(true);
|
||||
|
||||
MeDto dto = meDtoFactory.create();
|
||||
assertThat(dto.getLinks().getLinkBy("password")).isNotPresent();
|
||||
}
|
||||
@@ -228,4 +226,38 @@ class MeDtoFactoryTest {
|
||||
assertThat(dto.getMail()).isNull();
|
||||
assertThat(dto.getFallbackMail()).isEqualTo("trillian@hitchhiker.local");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAppendApiKeysLink() {
|
||||
User user = UserTestData.createTrillian();
|
||||
prepareSubject(user);
|
||||
|
||||
when(scmConfiguration.isEnabledApiKeys()).thenReturn(true);
|
||||
when(subject.isPermitted("user:changeApiKeys:trillian")).thenReturn(true);
|
||||
|
||||
MeDto dto = meDtoFactory.create();
|
||||
assertThat(dto.getLinks().getLinkBy("apiKeys").get().getHref()).isEqualTo("https://scm.hitchhiker.com/scm/v2/users/trillian/api_keys");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotAppendApiKeysLinkIfMissingPermission() {
|
||||
User user = UserTestData.createTrillian();
|
||||
prepareSubject(user);
|
||||
|
||||
when(subject.isPermitted("user:changeApiKeys:trillian")).thenReturn(false);
|
||||
|
||||
MeDto dto = meDtoFactory.create();
|
||||
assertThat(dto.getLinks().getLinkBy("apiKeys")).isNotPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotAppendApiKeysLinkIfConfigDisabled() {
|
||||
User user = UserTestData.createTrillian();
|
||||
prepareSubject(user);
|
||||
when(scmConfiguration.isEnabledApiKeys()).thenReturn(false);
|
||||
when(subject.isPermitted("user:changeApiKeys:trillian")).thenReturn(true);
|
||||
|
||||
MeDto dto = meDtoFactory.create();
|
||||
assertThat(dto.getLinks().getLinkBy("apiKeys")).isNotPresent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import sonia.scm.ContextEntry;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.group.GroupCollector;
|
||||
import sonia.scm.security.ApiKey;
|
||||
import sonia.scm.security.ApiKeyService;
|
||||
@@ -98,6 +99,9 @@ public class MeResourceTest {
|
||||
@Mock
|
||||
private ApiKeyService apiKeyService;
|
||||
|
||||
@Mock
|
||||
private ScmConfiguration scmConfiguration;
|
||||
|
||||
@Mock
|
||||
private EMail eMail;
|
||||
|
||||
@@ -132,6 +136,7 @@ public class MeResourceTest {
|
||||
@Test
|
||||
public void shouldReturnCurrentlyAuthenticatedUser() throws URISyntaxException, UnsupportedEncodingException {
|
||||
applyUserToSubject(originalUser);
|
||||
when(scmConfiguration.isEnabledApiKeys()).thenReturn(true);
|
||||
|
||||
MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2);
|
||||
request.accept(VndMediaType.ME);
|
||||
@@ -283,14 +288,14 @@ public class MeResourceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldIgnoreInvalidNewApiKey() throws URISyntaxException, UnsupportedEncodingException {
|
||||
public void shouldIgnoreInvalidNewApiKey() throws URISyntaxException {
|
||||
when(apiKeyService.createNewKey("trillian","guide", "READ"))
|
||||
.thenReturn(new ApiKeyService.CreationResult("abc", "1"));
|
||||
|
||||
final MockHttpRequest request = MockHttpRequest
|
||||
.post("/" + MeResource.ME_PATH_V2 + "api_keys/")
|
||||
.contentType(VndMediaType.API_KEY)
|
||||
.content("{\"displayName\":\"guide\",\"pemissionRole\":\"\"}".getBytes());
|
||||
.content("{\"displayName\":\"guide\",\"permissionRole\":\"\"}".getBytes());
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
|
||||
@@ -33,6 +33,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.repository.RepositoryRole;
|
||||
import sonia.scm.repository.RepositoryRoleManager;
|
||||
|
||||
@@ -60,6 +61,8 @@ class ApiKeyRealmTest {
|
||||
DAORealmHelper.AuthenticationInfoBuilder authenticationInfoBuilder;
|
||||
@Mock
|
||||
RepositoryRoleManager repositoryRoleManager;
|
||||
@Mock
|
||||
ScmConfiguration scmConfiguration;
|
||||
|
||||
ApiKeyRealm realm;
|
||||
|
||||
@@ -67,7 +70,7 @@ class ApiKeyRealmTest {
|
||||
void initRealmHelper() {
|
||||
lenient().when(helperFactory.create("ApiTokenRealm")).thenReturn(helper);
|
||||
lenient().when(helper.authenticationInfoBuilder(any())).thenReturn(authenticationInfoBuilder);
|
||||
realm = new ApiKeyRealm(apiKeyService, helperFactory, repositoryRoleManager);
|
||||
realm = new ApiKeyRealm(apiKeyService, helperFactory, repositoryRoleManager, scmConfiguration);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -106,6 +109,16 @@ class ApiKeyRealmTest {
|
||||
assertThat(supports).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreIfConfigIsDisabled() {
|
||||
when(scmConfiguration.isEnabledApiKeys()).thenReturn(false);
|
||||
AuthenticationToken token = mock(AuthenticationToken.class);
|
||||
|
||||
boolean supports = realm.supports(token);
|
||||
|
||||
assertThat(supports).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreNonBase64Tokens() {
|
||||
UsernamePasswordToken token = new UsernamePasswordToken("trillian", "My&SecretPassword");
|
||||
|
||||
Reference in New Issue
Block a user