With this change, the creation of API keys will throw an error if one tries to create a new API key. To make this error distinguishable from other errors, we use a 404 (not found) in this case (a 409 would be indistinguishable from a "real" conflict, 401 or 403 could be misleading). Doing this, the cli client can print better error messages.

In addition, this removes the links to API keys in user hal objects, when API keys are disabled.

Committed-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Rene Pfeuffer
2023-02-28 10:01:27 +01:00
committed by SCM-Manager
parent cb8c951cb8
commit 8cef21e32c
11 changed files with 172 additions and 23 deletions

View File

@@ -0,0 +1,46 @@
/*
* 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.api.v2;
import sonia.scm.api.rest.ContextualExceptionMapper;
import sonia.scm.api.v2.resources.ExceptionWithContextToErrorDtoMapper;
import sonia.scm.security.ApiKeysDisabledException;
import javax.inject.Inject;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.Provider;
/**
* @since 2.43.0
*/
@Provider
public class ApiKeysDisabledExceptionMapper extends ContextualExceptionMapper<ApiKeysDisabledException> {
@Inject
public ApiKeysDisabledExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) {
// We will use the http status code 404 (not found) here, to make a distinction
// between the case of disabled api keys and an already existing api key.
super(ApiKeysDisabledException.class, Response.Status.NOT_FOUND, mapper);
}
}

View File

@@ -45,7 +45,6 @@ import javax.ws.rs.PathParam;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.StreamingOutput;
import java.util.List;
@@ -104,11 +103,13 @@ public class CliResource {
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "404", description = "API keys disabled globally")
@ApiResponse(responseCode = "409", description = "There already is an API key with this name")
@ApiResponse(responseCode = "500", description = "internal server error")
public Response login(CliAuthenticationDto auth) {
public String login(CliAuthenticationDto auth) {
String username = SecurityUtils.getSubject().getPrincipal().toString();
ApiKeyService.CreationResult newKey = service.createNewKey(username, auth.getApiKey(), "*");
return Response.ok(newKey.getToken()).build();
return newKey.getToken();
}
@DELETE
@@ -117,14 +118,13 @@ public class CliResource {
@ApiResponse(responseCode = "204", description = "delete success or nothing to delete")
@ApiResponse(responseCode = "400", description = "bad request, required parameter is missing")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
public Response logout(@PathParam("apiKey") String apiKeyName) {
public void logout(@PathParam("apiKey") String apiKeyName) {
String username = SecurityUtils.getSubject().getPrincipal().toString();
service.getKeys(username)
.stream()
.filter(apiKey -> apiKey.getDisplayName().equals(apiKeyName))
.findFirst()
.ifPresent(apiKey -> service.remove(username, apiKey.getId()));
return Response.noContent().build();
}
@Data

View File

@@ -29,6 +29,7 @@ import de.otto.edison.hal.Links;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ObjectFactory;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.group.GroupPermissions;
import sonia.scm.security.PermissionPermissions;
import sonia.scm.user.User;
@@ -49,6 +50,8 @@ public abstract class UserToUserDtoMapper extends BaseMapper<User, UserDto> {
@Inject
private UserManager userManager;
@Inject
private ScmConfiguration scmConfiguration;
@Override
@Mapping(target = "attributes", ignore = true)
@@ -67,7 +70,9 @@ public abstract class UserToUserDtoMapper extends BaseMapper<User, UserDto> {
if (UserPermissions.modify(user).isPermitted()) {
linksBuilder.single(link("update", resourceLinks.user().update(user.getName())));
linksBuilder.single(link("publicKeys", resourceLinks.user().publicKeys(user.getName())));
linksBuilder.single(link("apiKeys", resourceLinks.user().apiKeys(user.getName())));
if (scmConfiguration.isEnabledApiKeys()) {
linksBuilder.single(link("apiKeys", resourceLinks.user().apiKeys(user.getName())));
}
if (user.isExternal()) {
linksBuilder.single(link("convertToInternal", resourceLinks.user().toInternal(user.getName())));
} else {

View File

@@ -34,6 +34,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.ContextEntry;
import sonia.scm.HandlerEventType;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.store.DataStore;
import sonia.scm.store.DataStoreFactory;
import sonia.scm.user.UserEvent;
@@ -53,6 +54,7 @@ import static java.util.stream.Collectors.toList;
import static org.apache.commons.lang.RandomStringUtils.random;
import static sonia.scm.AlreadyExistsException.alreadyExists;
@SuppressWarnings("UnstableApiUsage")
public class ApiKeyService {
private static final Logger LOG = LoggerFactory.getLogger(ApiKeyService.class);
@@ -63,24 +65,29 @@ public class ApiKeyService {
private final KeyGenerator keyGenerator;
private final Supplier<String> passphraseGenerator;
private final ApiKeyTokenHandler tokenHandler;
private final ScmConfiguration scmConfiguration;
private final Striped<ReadWriteLock> locks = Striped.readWriteLock(10);
@Inject
ApiKeyService(DataStoreFactory storeFactory, KeyGenerator keyGenerator, PasswordService passwordService, ApiKeyTokenHandler tokenHandler) {
this(storeFactory, passwordService, keyGenerator, tokenHandler, () -> random(PASSPHRASE_LENGTH, 0, 0, true, true, null, new SecureRandom()));
ApiKeyService(DataStoreFactory storeFactory, KeyGenerator keyGenerator, PasswordService passwordService, ApiKeyTokenHandler tokenHandler, ScmConfiguration scmConfiguration) {
this(storeFactory, passwordService, keyGenerator, tokenHandler, () -> random(PASSPHRASE_LENGTH, 0, 0, true, true, null, new SecureRandom()), scmConfiguration);
}
ApiKeyService(DataStoreFactory storeFactory, PasswordService passwordService, KeyGenerator keyGenerator, ApiKeyTokenHandler tokenHandler, Supplier<String> passphraseGenerator) {
ApiKeyService(DataStoreFactory storeFactory, PasswordService passwordService, KeyGenerator keyGenerator, ApiKeyTokenHandler tokenHandler, Supplier<String> passphraseGenerator, ScmConfiguration scmConfiguration) {
this.store = storeFactory.withType(ApiKeyCollection.class).withName("apiKeys").build();
this.passwordService = passwordService;
this.keyGenerator = keyGenerator;
this.tokenHandler = tokenHandler;
this.passphraseGenerator = passphraseGenerator;
this.scmConfiguration = scmConfiguration;
}
public CreationResult createNewKey(String username, String keyDisplayName, String permissionRole) {
UserPermissions.changeApiKeys(username).check();
if (!scmConfiguration.isEnabledApiKeys()) {
throw new ApiKeysDisabledException();
}
String passphrase = passphraseGenerator.get();
String hashedPassphrase = passwordService.encryptPassword(passphrase);
String id = keyGenerator.createKey();

View File

@@ -0,0 +1,46 @@
/*
* 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;
import sonia.scm.ExceptionWithContext;
import static java.util.Collections.emptyList;
/**
* @since 2.43.0
*/
public class ApiKeysDisabledException extends ExceptionWithContext {
private static final String CODE = "3ITWbABz91";
protected ApiKeysDisabledException() {
super(emptyList(), "API keys are disabled");
}
@Override
public String getCode() {
return CODE;
}
}