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:
Eduard Heimbuch
2021-03-25 12:06:22 +01:00
committed by GitHub
parent 96d2e2cc1b
commit 73c1609d92
15 changed files with 126 additions and 18 deletions

View 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))

View File

@@ -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;
}

View File

@@ -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()

View File

@@ -41,6 +41,7 @@ describe("Test config hooks", () => {
enableProxy: false,
enabledUserConverter: false,
enabledXsrfProtection: false,
enabledApiKeys: false,
forceBaseUrl: false,
loginAttemptLimit: 0,
loginAttemptLimitTimeout: 0,

View File

@@ -50,4 +50,5 @@ export type Config = HalRepresentation & {
loginInfoUrl: string;
releaseFeedUrl: string;
mailDomainName: string;
enabledApiKeys: boolean;
};

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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())));
}

View File

@@ -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");

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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");