RED-8477: SSO settings endpoint for SAML

(cherry picked from commit f98394c1366a7ab5266fa07749e51f5c20798185)
This commit is contained in:
Maverick Studer 2024-02-20 09:02:10 +01:00 committed by Ali Oezyetimoglu
parent af37030c68
commit da59130a7e
7 changed files with 76 additions and 24 deletions

View File

@ -23,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@Tag(name = "Identity provider configuration endpoints", description = "Provides operations related to the identity providers")
@ApiResponses(value = {@ApiResponse(responseCode = "429", description = "Too many requests."), @ApiResponse(responseCode = "403", description = "Forbidden")})
@ -56,7 +57,7 @@ public interface IdentityProviderConfigurationResource {
@PostMapping(value = API_PATH, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Creates a new identity provider", description = "None")
@ApiResponses(value = {@ApiResponse(responseCode = "201", description = "Successfully created the identity provider"), @ApiResponse(responseCode = "400", description = "Malformed request parameters or body"), @ApiResponse(responseCode = "409", description = "Duplicate")})
ResponseEntity<IdentityProviderModel> createIdentityProvider(@io.swagger.v3.oas.annotations.parameters.RequestBody @RequestBody IdentityProviderRequest identityProvider);
ResponseEntity<IdentityProviderModel> createIdentityProvider(@Valid @io.swagger.v3.oas.annotations.parameters.RequestBody @RequestBody IdentityProviderRequest identityProvider);
@ResponseStatus(value = HttpStatus.CREATED)
@ -64,7 +65,7 @@ public interface IdentityProviderConfigurationResource {
@PostMapping(value = API_PATH + IMPORT_SUB_PATH, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Creates a new identity provider", description = "None")
@ApiResponses(value = {@ApiResponse(responseCode = "201", description = "Successfully created the identity provider"), @ApiResponse(responseCode = "400", description = "Malformed request parameters or body"), @ApiResponse(responseCode = "409", description = "Duplicate")})
ResponseEntity<IdentityProviderModel> createIdentityProviderFromDescriptor(@io.swagger.v3.oas.annotations.parameters.RequestBody @RequestBody IdentityProviderWithDescriptorRequest identityProvider);
ResponseEntity<IdentityProviderModel> createIdentityProviderFromDescriptor(@Valid @io.swagger.v3.oas.annotations.parameters.RequestBody @RequestBody IdentityProviderWithDescriptorRequest identityProvider);
@ResponseStatus(value = HttpStatus.OK)
@ -73,7 +74,7 @@ public interface IdentityProviderConfigurationResource {
@Operation(summary = "Updates an existing identity provider", description = "None")
@ApiResponses(value = {@ApiResponse(responseCode = "200", description = "Successfully updated the identity provider"), @ApiResponse(responseCode = "404", description = "Not found"), @ApiResponse(responseCode = "400", description = "Malformed request parameters or body")})
ResponseEntity<IdentityProviderModel> updateIdentityProvider(@Parameter(name = IDENTITY_PROVIDER_ALIAS_PARAM, description = "The alias of the identity provider to retrieve.", required = true) @PathVariable(IDENTITY_PROVIDER_ALIAS_PARAM) String identityProviderAlias,
@io.swagger.v3.oas.annotations.parameters.RequestBody @RequestBody IdentityProviderRequest identityProvider);
@Valid @io.swagger.v3.oas.annotations.parameters.RequestBody @RequestBody IdentityProviderRequest identityProvider);
@ResponseStatus(value = HttpStatus.NO_CONTENT)

View File

@ -1,5 +1,7 @@
package com.knecon.fforesight.tenantusermanagement.controller;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
@ -23,7 +25,9 @@ public class ControllerAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorMessage> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
return new ResponseEntity<>(new ErrorMessage(e.getMessage()), HttpStatus.BAD_REQUEST);
var errorList = e.getFieldErrors();
String errorListAsString = errorList.stream().map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage()).collect(Collectors.joining(", "));
return new ResponseEntity<>(new ErrorMessage(String.format("You have empty/wrong formatted parameters: %s", errorListAsString)), HttpStatus.BAD_REQUEST);
}

View File

@ -25,6 +25,7 @@ import org.springframework.web.server.ResponseStatusException;
import com.knecon.fforesight.tenantcommons.TenantContext;
import com.knecon.fforesight.tenantusermanagement.api.external.IdentityProviderConfigurationResource;
import com.knecon.fforesight.tenantusermanagement.api.external.PublicResource;
import com.knecon.fforesight.tenantusermanagement.exception.ConflictException;
import com.knecon.fforesight.tenantusermanagement.exception.IdentityProviderExistsAlreadyException;
import com.knecon.fforesight.tenantusermanagement.exception.IdentityProviderNotFoundException;
import com.knecon.fforesight.tenantusermanagement.model.IdentityProviderList;
@ -82,28 +83,43 @@ public class IdentityProviderConfigurationController implements IdentityProvider
@Override
@PreAuthorize("hasAuthority('" + WRITE_IDENTITY_PROVIDER_CONFIGURATION + "')")
public ResponseEntity<IdentityProviderModel> createIdentityProvider(IdentityProviderRequest identityProvider) {
public ResponseEntity<IdentityProviderModel> createIdentityProvider(IdentityProviderRequest identityProviderRequest) {
IdentityProviderModel identityProviderModel = IdentityProviderMappingService.toModelFromRequest(identityProvider);
if (identityProviderRequest.getAlias() == null) {
throw new BadRequestException("Alias must not be null.");
}
if(identityProviderRequest.getDisplayName().isEmpty()) {
identityProviderRequest.setDisplayName(identityProviderRequest.getAlias());
}
throwConflictExceptionIfDisplayNameAlreadyExists(identityProviderRequest.getDisplayName());
IdentityProviderModel identityProviderModel = IdentityProviderMappingService.toModelFromRequest(identityProviderRequest);
return callKeyCloakIdentityProvidersCreateForModel(identityProviderModel);
}
@Override
@PreAuthorize("hasAuthority('" + WRITE_IDENTITY_PROVIDER_CONFIGURATION + "')")
public ResponseEntity<IdentityProviderModel> createIdentityProviderFromDescriptor(IdentityProviderWithDescriptorRequest identityProvider) {
public ResponseEntity<IdentityProviderModel> createIdentityProviderFromDescriptor(IdentityProviderWithDescriptorRequest identityProviderWithDescriptorRequest) {
if(identityProviderWithDescriptorRequest.getDisplayName().isEmpty()) {
identityProviderWithDescriptorRequest.setDisplayName(identityProviderWithDescriptorRequest.getAlias());
}
throwConflictExceptionIfDisplayNameAlreadyExists(identityProviderWithDescriptorRequest.getDisplayName());
Map<String, Object> requestMap = new HashMap<>();
requestMap.put("providerId", identityProvider.getProviderId());
requestMap.put("fromUrl", identityProvider.getSamlEntityDescriptorURL());
requestMap.put("providerId", identityProviderWithDescriptorRequest.getProviderId());
requestMap.put("fromUrl", identityProviderWithDescriptorRequest.getSamlEntityDescriptorURL());
Map<String, String> configurationsMap = realmService.realm(getTenantId()).identityProviders().importFrom(requestMap);
if (configurationsMap == null || configurationsMap.isEmpty()) {
throw new BadRequestException("Could not set config from provided descriptor.");
}
configurationsMap.put("entityId", identityProvider.getEntityId());
IdentityProviderModel identityProviderModel = IdentityProviderMappingService.toModelFromDescriptorRequestAndConfig(identityProvider, configurationsMap);
configurationsMap.put("entityId", identityProviderWithDescriptorRequest.getEntityId());
IdentityProviderModel identityProviderModel = IdentityProviderMappingService.toModelFromDescriptorRequestAndConfig(identityProviderWithDescriptorRequest,
configurationsMap);
return callKeyCloakIdentityProvidersCreateForModel(identityProviderModel);
}
@ -120,13 +136,7 @@ public class IdentityProviderConfigurationController implements IdentityProvider
return new ResponseEntity<>(createdIdentityProvider, HttpStatus.valueOf(response.getStatus()));
}
case CONFLICT -> throw new IdentityProviderExistsAlreadyException(identityProviderModel.getAlias());
default -> {
if (httpStatus.is4xxClientError()) {
throw new ResponseStatusException(httpStatus, "Bad request to keycloak API");
} else {
throw new ResponseStatusException(httpStatus, httpStatus.getReasonPhrase());
}
}
default -> throw new ResponseStatusException(httpStatus, extractKeycloakErrorMessageInfos(response.readEntity(String.class)));
}
}
}
@ -134,15 +144,15 @@ public class IdentityProviderConfigurationController implements IdentityProvider
@Override
@PreAuthorize("hasAuthority('" + WRITE_IDENTITY_PROVIDER_CONFIGURATION + "')")
public ResponseEntity<IdentityProviderModel> updateIdentityProvider(String identityProviderAlias, IdentityProviderRequest identityProvider) {
public ResponseEntity<IdentityProviderModel> updateIdentityProvider(String identityProviderAlias, IdentityProviderRequest identityProviderRequest) {
identityProvider.setAlias(identityProviderAlias);
identityProviderRequest.setAlias(identityProviderAlias);
getIdentityProviderRepresentation(identityProviderAlias);
IdentityProviderModel identityProviderModel = IdentityProviderMappingService.toModelFromRequest(identityProvider);
IdentityProviderModel identityProviderModel = IdentityProviderMappingService.toModelFromRequest(identityProviderRequest);
identityProviderModel.setSpecificDefaults();
var restTemplate = new RestTemplate();
var url = getKeycloakIdentityProviderInstancesUrl() + identityProvider.getAlias();
var url = getKeycloakIdentityProviderInstancesUrl() + identityProviderRequest.getAlias();
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.add("Authorization", "Bearer " + getToken());
@ -151,7 +161,7 @@ public class IdentityProviderConfigurationController implements IdentityProvider
ResponseEntity<?> response = restTemplate.exchange(url, HttpMethod.PUT, httpEntity, String.class);
var httpStatus = HttpStatus.valueOf(response.getStatusCode().value());
if (httpStatus == HttpStatus.NO_CONTENT) {
IdentityProviderModel createdIdentityProvider = getIdentityProvider(identityProvider.getAlias());
IdentityProviderModel createdIdentityProvider = getIdentityProvider(identityProviderRequest.getAlias());
return new ResponseEntity<>(createdIdentityProvider, HttpStatus.OK);
} else if (httpStatus.is4xxClientError()) {
throw new ResponseStatusException(httpStatus, "Bad request to keycloak API");
@ -184,6 +194,16 @@ public class IdentityProviderConfigurationController implements IdentityProvider
}
private void throwConflictExceptionIfDisplayNameAlreadyExists(String displayName) {
if (getIdentityProviders().getIdentityProviders()
.stream()
.anyMatch(idp -> idp.getDisplayName().isEmpty() ? idp.getAlias().equals(displayName): idp.getDisplayName().equals(displayName))) {
throw new ConflictException("Identity provider with this display name already exists.");
}
}
private static String extractKeycloakErrorMessageInfos(String keyCloakErrorMessage) {
String errorMessageRegex = "\\{\"errorMessage\":\"(.*?)\"}";

View File

@ -1,6 +1,7 @@
package com.knecon.fforesight.tenantusermanagement.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@ -22,12 +23,15 @@ public class IdentityProviderConfigRequest {
private Integer guiOrder = 0;
@Schema(description = "Service provider entity ID : It is used to uniquely identify this SAML Service Provider.", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private String entityId;
@Schema(description = "Identity provider entity ID : It is used to validate the Issuer for received SAML assertions. If empty, no Issuer validation is performed.", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private String idpEntityId;
@Schema(description = "The Url that must be used to send authentication requests (SAML AuthnRequest).", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private String singleSignOnServiceUrl;
@Builder.Default

View File

@ -1,6 +1,7 @@
package com.knecon.fforesight.tenantusermanagement.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@ -18,6 +19,7 @@ public class IdentityProviderRequest {
@Builder.Default
@Schema(description = "Configuration of the identity provider", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private IdentityProviderConfigRequest config = new IdentityProviderConfigRequest();
@Builder.Default

View File

@ -1,6 +1,7 @@
package com.knecon.fforesight.tenantusermanagement.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@ -14,6 +15,7 @@ import lombok.NoArgsConstructor;
public class IdentityProviderWithDescriptorRequest {
@Schema(description = "Alias of the identity provider used for identification", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private String alias;
@Builder.Default
@ -25,9 +27,10 @@ public class IdentityProviderWithDescriptorRequest {
private String providerId = "saml";
@Schema(description = "The SAML entity descriptor as a URL to external IDP metadata, used to fill other fields for a faster identity provider creation process", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private String samlEntityDescriptorURL;
@Schema(description = "Service provider entity ID that will be used to uniquely identify this SAML Service Provider", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private String entityId;
}

View File

@ -97,6 +97,24 @@ public class IdentityProviderConfigurationTest extends AbstractTenantUserManagem
e = assertThrows(FeignException.class, () -> identityProviderConfigurationClient.getIdentityProvider(testAliasDescriptor));
assertEquals(404, e.status());
var requestWithoutAlias = provideIdentityProviderRequestCreate();
requestWithoutAlias.setAlias(null);
e = assertThrows(FeignException.class, () -> identityProviderConfigurationClient.createIdentityProvider(requestWithoutAlias));
assertEquals(400, e.status());
var requestWithSameDisplayName = provideIdentityProviderRequestCreate();
requestWithSameDisplayName.setAlias("otherAlias");
requestWithSameDisplayName.setDisplayName(testAlias);
e = assertThrows(FeignException.class, () -> identityProviderConfigurationClient.createIdentityProvider(requestWithSameDisplayName));
assertEquals(409, e.status());
var requestWithMalformedSingleSignOnURL = provideIdentityProviderRequestCreate();
requestWithMalformedSingleSignOnURL.setAlias("andAnotherAlias");
requestWithMalformedSingleSignOnURL.getConfig().setSingleSignOnServiceUrl("this is no valid URL :(");
e = assertThrows(FeignException.class, () -> identityProviderConfigurationClient.createIdentityProvider(requestWithMalformedSingleSignOnURL));
assertEquals(400, e.status());
}