Skip to content
Snippets Groups Projects
Commit 1acdbafc authored by OZGCloud's avatar OZGCloud
Browse files

OZG-7092 [wip] refactor service

parent d893f06b
Branches
No related tags found
1 merge request!1OZG-7092 Anpassung TokenChecker
Showing
with 496 additions and 706 deletions
package de.ozgcloud.token;
import org.mapstruct.CollectionMappingStrategy;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
@Mapper(unmappedTargetPolicy = ReportingPolicy.WARN, collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED)
public interface CheckErrorMapper {
}
......@@ -18,33 +18,28 @@
* unter der Lizenz sind dem Lizenztext zu entnehmen.
*/
package de.ozgcloud.token.saml;
import java.util.Map;
import org.opensaml.saml.saml2.encryption.Decrypter;
import org.opensaml.xmlsec.signature.support.SignatureTrustEngine;
package de.ozgcloud.token;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import net.shibboleth.utilities.java.support.resolver.CriteriaSet;
@Builder
@Getter
@EqualsAndHashCode
public class SamlSetting {
private SignatureTrustEngine trustEngine;
private CriteriaSet criteriaSet;
private Decrypter decrypter;
private Map<String, String> mappings;
private boolean idIsPostfachId;
public class TokenAttribute {
public static final String POSTFACH_ID_KEY = "postfachId";
public static final String TRUST_LEVEL_KEY = "trustLevel";
private String name;
private String value;
public boolean isPostfachId() {
return POSTFACH_ID_KEY.equals(name);
}
@Override
public String toString() {
return "SamlSetting{" +
"mappings=" + mappings +
", idIsPostKorbMapping=" + idIsPostfachId +
'}';
public boolean isTrustLevel() {
return TRUST_LEVEL_KEY.equals(name);
}
}
/*
* Copyright (c) 2024.
* Lizenziert unter der EUPL, Version 1.2 oder - sobald
* diese von der Europäischen Kommission genehmigt wurden -
* Folgeversionen der EUPL ("Lizenz");
* Sie dürfen dieses Werk ausschließlich gemäß
* dieser Lizenz nutzen.
* Eine Kopie der Lizenz finden Sie hier:
*
* https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* Sofern nicht durch anwendbare Rechtsvorschriften
* gefordert oder in schriftlicher Form vereinbart, wird
* die unter der Lizenz verbreitete Software "so wie sie
* ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
* ausdrücklich oder stillschweigend - verbreitet.
* Die sprachspezifischen Genehmigungen und Beschränkungen
* unter der Lizenz sind dem Lizenztext zu entnehmen.
*/
package de.ozgcloud.token;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.opensaml.saml.saml2.core.Response;
import org.springframework.stereotype.Service;
import de.ozgcloud.token.common.errorhandling.TokenVerificationException;
import de.ozgcloud.token.saml.Saml2DecryptionService;
import de.ozgcloud.token.saml.Saml2ParseService;
import de.ozgcloud.token.saml.Saml2VerificationService;
import de.ozgcloud.token.saml.SamlSetting;
import de.ozgcloud.token.saml.SamlServiceRegistry;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class TokenCheckService {
public static final String POSTFACH_ID_KEY = "postfachId";
public static final String TRUST_LEVEL_KEY = "trustLevel";
private final SamlServiceRegistry samlServiceRegistry;
private final Saml2DecryptionService decryptionService;
private final Saml2ParseService parseService;
private final Saml2VerificationService verificationService;
public TokenValidationResult checkToken(final String token) {
var errors = verificationService.verify(token);
if (errors.isEmpty()) {
return getCheckTokenResult(token);
}
throw new TokenVerificationException("Errors occurred while checking token", errors);
}
TokenValidationResult getCheckTokenResult(final String token) {
var response = parseService.parse(token);
var samlSetting = samlServiceRegistry.getService(response.getIssuer().getValue());
return buildCheckTokenResult(samlSetting, response);
}
TokenValidationResult buildCheckTokenResult(SamlSetting samlSetting, Response response) {
var decryptedAttributes = decryptionService.decryptAttributes(response, samlSetting);
return TokenValidationResult.builder()
.attributes(decryptedAttributes)
.postfachId(getPostfachId(samlSetting, response, decryptedAttributes))
.trustLevel(findAttributeByKey(TRUST_LEVEL_KEY, decryptedAttributes, samlSetting))
.build();
}
String getPostfachId(final SamlSetting samlSetting, final Response response, final List<TokenField> decryptedAttributes) {
if (samlSetting.isIdIsPostfachId()) {
return response.getID();
} else {
return findAttributeByKey(POSTFACH_ID_KEY, decryptedAttributes, samlSetting);
}
}
String findAttributeByKey(String key, List<TokenField> attributes, SamlSetting samlSetting) {
var name = samlSetting.getMappings().get(key);
return attributes.stream().filter(attribute -> attribute.getName().equals(name))
.findFirst().map(TokenField::getValue).orElse(StringUtils.EMPTY);
}
}
......@@ -81,7 +81,7 @@ public class TokenValidationProperties {
/**
* Use the user id as Postkorbhandle. For Muk
*/
private boolean userIdAsPostfachId = false;
private boolean useIdAsPostfachId = false;
/**
* The mappings the PostfachHandle and the TrustLevel. The value of the mapping
......
......@@ -20,14 +20,21 @@
package de.ozgcloud.token;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
import lombok.Singular;
@Builder
@Getter
public class TokenField {
public class TokenValidationResult {
private String name;
private String value;
private final boolean valid;
private final String postfachId;
private final String trustLevel;
@Singular
private final List<TokenAttribute> attributes;
private final String errorMesssage;
}
......@@ -20,19 +20,22 @@
package de.ozgcloud.token.common.errorhandling;
import java.util.List;
import org.springframework.security.saml2.core.Saml2Error;
import de.ozgcloud.common.errorhandling.TechnicalException;
import lombok.Getter;
@Getter
public class TokenVerificationException extends TechnicalException {
private final List<Saml2Error> errorList;
public TokenVerificationException(final String msg, final List<Saml2Error> errorList) {
public TokenVerificationException(String msg) {
super(msg);
this.errorList = errorList;
}
public TokenVerificationException(String msg, Throwable exception) {
super(msg, exception);
}
@Override
public String getMessage() {
return "[SAML]" + super.getMessage();
}
}
/*
* Copyright (c) 2024.
* Lizenziert unter der EUPL, Version 1.2 oder - sobald
* diese von der Europäischen Kommission genehmigt wurden -
* Folgeversionen der EUPL ("Lizenz");
* Sie dürfen dieses Werk ausschließlich gemäß
* dieser Lizenz nutzen.
* Eine Kopie der Lizenz finden Sie hier:
*
* https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* Sofern nicht durch anwendbare Rechtsvorschriften
* gefordert oder in schriftlicher Form vereinbart, wird
* die unter der Lizenz verbreitete Software "so wie sie
* ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
* ausdrücklich oder stillschweigend - verbreitet.
* Die sprachspezifischen Genehmigungen und Beschränkungen
* unter der Lizenz sind dem Lizenztext zu entnehmen.
*/
package de.ozgcloud.token.saml;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import org.opensaml.saml.saml2.core.Response;
import org.opensaml.saml.security.impl.SAMLSignatureProfileValidator;
import org.opensaml.security.SecurityException;
import org.opensaml.xmlsec.signature.support.SignatureException;
import org.springframework.security.saml2.core.Saml2Error;
import org.springframework.security.saml2.core.Saml2ErrorCodes;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
@Log4j2
@RequiredArgsConstructor
@Service
public class Saml2VerificationService {
public static final String INVALID_SIGNATURE = "Invalid signature for object!";
public static final String ERROR_VALIDATING_SIGNATURE = "Error on validating signature!";
public static final String INVALID_SIGNATURE_PROFILE = "Invalid signature profile for object!";
public static final String SIGNATURE_MISSING = "Signature missing!";
static final String FORMAT = " [%s]: ";
private final Saml2ParseService parser;
private final SamlServiceRegistry samlServiceRegistry;
private final SAMLSignatureProfileValidator profileValidator;
public List<Saml2Error> verify(String samlToken) {
var response = parser.parse(samlToken);
return getSaml2Errors(response);
}
List<Saml2Error> getSaml2Errors(Response response) {
List<Saml2Error> errors = new ArrayList<>();
if (response.isSigned()) {
validateProfile(response, errors);
validateSignature(response, errors);
} else {
errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, SIGNATURE_MISSING));
}
return Collections.unmodifiableList(errors);
}
void validateProfile(Response response, List<Saml2Error> errors) {
try {
profileValidator.validate(Objects.requireNonNull(response.getSignature()));
} catch (SignatureException ex) {
LOG.warn("Error validating SAML Token: ", ex);
errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, INVALID_SIGNATURE_PROFILE + FORMAT.formatted(response.getID())));
}
}
void validateSignature(Response response, List<Saml2Error> errors) {
var samlSetting = samlServiceRegistry.getService(response.getIssuer().getValue());
try {
if (!samlSetting.getTrustEngine().validate(Objects.requireNonNull(response.getSignature()), samlSetting.getCriteriaSet())) {
errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, INVALID_SIGNATURE + FORMAT.formatted(response.getID())));
}
} catch (SecurityException e) {
LOG.warn("Error validating SAML Token: ", e);
errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, ERROR_VALIDATING_SIGNATURE + FORMAT.formatted(response.getID())));
}
}
}
......@@ -52,7 +52,6 @@ public class SamlConfiguration {
.signatureTrustEngine(samlTrustEngineFactory.buildSamlTrustEngine(tokenValidationProperty))
.decrypter(samlDecrypterFactory.buildDecrypter(tokenValidationProperty))
.verificationCriteria(buildVerificationCriteria(tokenValidationProperty.getIdpEntityId()))
.userIdAsPostfachId(tokenValidationProperty.isUserIdAsPostfachId())
.build();
}
......
/*
* Copyright (c) 2024.
* Copyright (C) 2024 Das Land Schleswig-Holstein vertreten durch den
* Ministerpräsidenten des Landes Schleswig-Holstein
* Staatskanzlei
* Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
*
* Lizenziert unter der EUPL, Version 1.2 oder - sobald
* diese von der Europäischen Kommission genehmigt wurden -
* Folgeversionen der EUPL ("Lizenz");
......@@ -23,40 +27,86 @@ package de.ozgcloud.token.saml;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Optional;
import java.util.Set;
import org.opensaml.core.xml.XMLObject;
import org.opensaml.core.xml.io.UnmarshallingException;
import org.opensaml.saml.saml2.core.Issuer;
import org.opensaml.saml.saml2.core.Response;
import org.opensaml.saml.saml2.core.impl.ResponseUnmarshaller;
import org.springframework.security.saml2.Saml2Exception;
import org.springframework.stereotype.Service;
import de.ozgcloud.common.errorhandling.TechnicalException;
import de.ozgcloud.token.TokenAttribute;
import de.ozgcloud.token.TokenValidationResult;
import de.ozgcloud.token.common.errorhandling.TokenVerificationException;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import net.shibboleth.utilities.java.support.xml.ParserPool;
import net.shibboleth.utilities.java.support.xml.XMLParserException;
@Service
@RequiredArgsConstructor
public class Saml2ParseService {
@Log4j2
public class SamlTokenService {
private final SamlServiceRegistry samlServiceRegistry;
private final ParserPool parserPool;
private final ResponseUnmarshaller unmarshaller;
private final ResponseUnmarshaller responseUnmarshaller;
public Response parse(String request) {
public TokenValidationResult validate(String token) {
try {
return (Response) createXmlObject(request.getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
throw new TechnicalException("Error on creating XmlObject!", e);
return buildValidTokenResult(validate(parseToken(token)));
} catch (TokenVerificationException e) {
LOG.debug("Token validation failed", e);
return buildInvalidTokenResult(e);
}
}
XMLObject createXmlObject(byte[] requestBytes) throws IOException {
try (var inputStream = new ByteArrayInputStream(requestBytes);) {
Response parseToken(String token) {
try (var inputStream = new ByteArrayInputStream(token.getBytes(StandardCharsets.UTF_8));) {
var element = parserPool.parse(inputStream).getDocumentElement();
return unmarshaller.unmarshall(element);
} catch (XMLParserException | UnmarshallingException e) {
throw new Saml2Exception("Failed to deserialize LogoutRequest!", e);
return (Response) responseUnmarshaller.unmarshall(element);
} catch (IOException | XMLParserException | UnmarshallingException e) {
throw new TokenVerificationException("Cannot read token: " + e.getMessage(), e);
}
}
Set<TokenAttribute> validate(Response token) {
var tokenIssuer = getTokenIssuer(token);
return getValidationService(tokenIssuer).validate(token);
}
SamlTokenValidationService getValidationService(String tokenIssuer) {
return samlServiceRegistry.getService(tokenIssuer)
.orElseThrow(() -> new TechnicalException("No validation service found for issuer %s".formatted(tokenIssuer)));
}
String getTokenIssuer(Response token) {
return Optional.ofNullable(token.getIssuer()).map(Issuer::getValue)
.orElseThrow(() -> new TokenVerificationException("No token issuer found"));
}
TokenValidationResult buildValidTokenResult(Collection<TokenAttribute> tokenAttributes) {
var resultBuilder = TokenValidationResult.builder().valid(true);
for (var attr : tokenAttributes) {
if (attr.isPostfachId()) {
resultBuilder.postfachId(attr.getValue());
} else if (attr.isTrustLevel()) {
resultBuilder.trustLevel(attr.getValue());
} else {
resultBuilder.attribute(attr);
}
}
return resultBuilder.build();
}
TokenValidationResult buildInvalidTokenResult(TokenVerificationException exception) {
return TokenValidationResult.builder()
.valid(false)
.errorMesssage(exception.getMessage())
.build();
}
}
/*
* Copyright (c) 2024.
* Lizenziert unter der EUPL, Version 1.2 oder - sobald
* diese von der Europäischen Kommission genehmigt wurden -
* Folgeversionen der EUPL ("Lizenz");
* Sie dürfen dieses Werk ausschließlich gemäß
* dieser Lizenz nutzen.
* Eine Kopie der Lizenz finden Sie hier:
*
* https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* Sofern nicht durch anwendbare Rechtsvorschriften
* gefordert oder in schriftlicher Form vereinbart, wird
* die unter der Lizenz verbreitete Software "so wie sie
* ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
* ausdrücklich oder stillschweigend - verbreitet.
* Die sprachspezifischen Genehmigungen und Beschränkungen
* unter der Lizenz sind dem Lizenztext zu entnehmen.
*/
package de.ozgcloud.token.saml;
import java.io.IOException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.opensaml.core.xml.XMLObject;
import org.opensaml.saml.common.xml.SAMLConstants;
import org.opensaml.saml.saml2.metadata.EntitiesDescriptor;
import org.opensaml.saml.saml2.metadata.EntityDescriptor;
import org.opensaml.saml.saml2.metadata.KeyDescriptor;
import org.opensaml.security.credential.UsageType;
import org.opensaml.xmlsec.keyinfo.KeyInfoSupport;
import org.springframework.core.io.Resource;
import org.springframework.security.converter.RsaKeyConverters;
import org.springframework.security.saml2.Saml2Exception;
import org.springframework.security.saml2.core.Saml2X509Credential;
import org.springframework.util.Assert;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SamlTokenUtils {
public static final String NO_CERTIFICATE_LOCATION_SPECIFIED = "No certificate location specified";
public static final String NO_PRIVATE_KEY_LOCATION_SPECIFIED = "No private key location specified";
public static final String CERTIFICATE_LOCATION = "Certificate location '";
public static final String DOES_NOT_EXIST = "' does not exist";
public static final String PRIVATE_KEY_LOCATION = "Private key location '";
public static final String MISSING_THE_NECESSARY_IDPSSODESCRIPTOR_ELEMENT = "Metadata response is missing the necessary IDPSSODescriptor element";
public static final String SAML_ASSERTIONS_VERIFICATION_EMPTY = "Metadata response is missing verification certificates, necessary for verifying SAML assertions";
public static Map<String, Boolean> createFeatureMap() {
return Map.of(
FEATURES_EXTERNAL_GENERAL_ENTITIES, Boolean.FALSE,
FEATURES_EXTERNAL_PARAMETER_ENTITIES, Boolean.FALSE,
FEATURES_DISALLOW_DOCTYPE_DECL, Boolean.TRUE,
VALIDATION_SCHEMA_NORMALIZED_VALUE, Boolean.FALSE,
FEATURE_SECURE_PROCESSING, Boolean.TRUE);
}
public static Saml2X509Credential getDecryptionCredential(Resource key, Resource cert) {
var privateKey = readPrivateKey(key);
var certificate = readCertificateFromResource(cert);
return new Saml2X509Credential(privateKey, certificate, Saml2X509Credential.Saml2X509CredentialType.DECRYPTION);
}
private static RSAPrivateKey readPrivateKey(Resource location) {
Assert.state(Objects.nonNull(location), NO_PRIVATE_KEY_LOCATION_SPECIFIED);
Assert.state(location.exists(), () -> PRIVATE_KEY_LOCATION + location + DOES_NOT_EXIST);
try (var inputStream = location.getInputStream()) {
return RsaKeyConverters.pkcs8().convert(inputStream);
} catch (IOException e) {
throw new IllegalArgumentException(e);
}
}
private static X509Certificate readCertificateFromResource(Resource location) {
Assert.state(Objects.nonNull(location), NO_CERTIFICATE_LOCATION_SPECIFIED);
Assert.state(location.exists(), () -> CERTIFICATE_LOCATION + location + DOES_NOT_EXIST);
try (var inputStream = location.getInputStream()) {
return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(inputStream);
} catch (IOException | CertificateException e) {
throw new IllegalArgumentException(e);
}
}
public static Optional<EntityDescriptor> findEntityDescriptor(XMLObject metadata) {
Optional<EntityDescriptor> descriptor = Optional.empty();
if (metadata instanceof EntityDescriptor entityDescriptor) {
descriptor = Optional.of(entityDescriptor);
} else if (metadata instanceof EntitiesDescriptor entitiesDescriptor) {
descriptor = entitiesDescriptor.getEntityDescriptors().stream().findFirst();
}
return descriptor.filter(entityDescriptor -> entityDescriptor.getIDPSSODescriptor(SAMLConstants.SAML20P_NS) != null).stream().findFirst();
}
public static List<Saml2X509Credential> getVerificationCertificates(EntityDescriptor descriptor) {
var idpssoDescriptor = descriptor.getIDPSSODescriptor(SAMLConstants.SAML20P_NS);
if (idpssoDescriptor == null) {
throw new Saml2Exception(MISSING_THE_NECESSARY_IDPSSODESCRIPTOR_ELEMENT);
}
List<Saml2X509Credential> verification = new ArrayList<>();
for (KeyDescriptor keyDescriptor : idpssoDescriptor.getKeyDescriptors()) {
if (keyDescriptor.getUse().equals(UsageType.SIGNING)) {
var certificates = certificates(keyDescriptor);
for (X509Certificate certificate : certificates) {
verification.add(Saml2X509Credential.verification(certificate));
}
}
}
if (verification.isEmpty()) {
throw new Saml2Exception(SAML_ASSERTIONS_VERIFICATION_EMPTY);
}
return verification;
}
private static List<X509Certificate> certificates(KeyDescriptor keyDescriptor) {
try {
return KeyInfoSupport.getCertificates(keyDescriptor.getKeyInfo());
} catch (CertificateException ex) {
throw new Saml2Exception(ex);
}
}
}
/*
* Copyright (c) 2024.
* Copyright (C) 2024 Das Land Schleswig-Holstein vertreten durch den
* Ministerpräsidenten des Landes Schleswig-Holstein
* Staatskanzlei
* Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
*
* Lizenziert unter der EUPL, Version 1.2 oder - sobald
* diese von der Europäischen Kommission genehmigt wurden -
* Folgeversionen der EUPL ("Lizenz");
......@@ -20,9 +24,14 @@
package de.ozgcloud.token.saml;
import java.util.List;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.opensaml.core.xml.XMLObject;
import org.opensaml.core.xml.schema.XSAny;
import org.opensaml.core.xml.schema.XSBoolean;
......@@ -34,69 +43,114 @@ import org.opensaml.saml.saml2.core.AttributeStatement;
import org.opensaml.saml.saml2.core.EncryptedAssertion;
import org.opensaml.saml.saml2.core.Response;
import org.opensaml.saml.saml2.encryption.Decrypter;
import org.opensaml.saml.security.impl.SAMLSignatureProfileValidator;
import org.opensaml.security.SecurityException;
import org.opensaml.xmlsec.encryption.support.DecryptionException;
import org.springframework.security.saml2.Saml2Exception;
import org.springframework.stereotype.Service;
import org.opensaml.xmlsec.signature.Signature;
import org.opensaml.xmlsec.signature.support.SignatureException;
import org.opensaml.xmlsec.signature.support.SignatureTrustEngine;
import de.ozgcloud.token.TokenField;
import lombok.NoArgsConstructor;
import lombok.extern.log4j.Log4j2;
import de.ozgcloud.token.TokenAttribute;
import de.ozgcloud.token.TokenValidationProperties.TokenValidationProperty;
import de.ozgcloud.token.common.errorhandling.TokenVerificationException;
import lombok.Builder;
import net.shibboleth.utilities.java.support.resolver.CriteriaSet;
@Log4j2
@Service
@NoArgsConstructor
public class Saml2DecryptionService {
@Builder
public class SamlTokenValidationService {
public List<TokenField> decryptAttributes(Response response, SamlSetting samlSetting) {
decryptResponseElements(response, samlSetting.getDecrypter());
private final SignatureTrustEngine signatureTrustEngine;
private final Decrypter decrypter;
private final SAMLSignatureProfileValidator profileValidator;
private final TokenValidationProperty tokenValidationProperty;
private final CriteriaSet verificationCriteria;
return getAttributes(response);
public Set<TokenAttribute> validate(Response token) {
validateToken(token);
var tokenAttributes = decryptAttributes(token);
return buildTokenAttributes(tokenAttributes, token);
}
void decryptResponseElements(Response response, Decrypter decrypter) {
response.getEncryptedAssertions().stream()
.map(encryptedAssertion -> decryptAssertion(encryptedAssertion, decrypter))
.forEach(assertion -> response.getAssertions().add(assertion));
void validateToken(Response token) {
if (Objects.isNull(token.getSignature())) {
throw new TokenVerificationException("Token signature is missing");
}
validateSignatureProfile(token.getSignature());
if (!validateSignature(token.getSignature())) {
throw new TokenVerificationException("Invalid token signature");
}
}
Assertion decryptAssertion(EncryptedAssertion assertion, Decrypter decrypter) {
void validateSignatureProfile(Signature signature) {
try {
return decrypter.decrypt(assertion);
} catch (DecryptionException ex) {
throw new Saml2Exception(ex);
profileValidator.validate(signature);
} catch (SignatureException e) {
throw new TokenVerificationException("Invalid signature profile", e);
}
}
List<TokenField> getAttributes(Response response) {
return getAttributeStatement(response).getAttributes().stream().map(this::extractNameAndValue)
.toList();
boolean validateSignature(Signature signature) {
try {
return signatureTrustEngine.validate(signature, verificationCriteria);
} catch (SecurityException e) {
throw new TokenVerificationException("Error on validating signature.", e);
}
}
private AttributeStatement getAttributeStatement(Response response) {
return (AttributeStatement) response.getAssertions().getFirst().getStatements().get(1);
public Map<String, String> decryptAttributes(Response token) {
return token.getEncryptedAssertions().stream()
.map(this::decryptAssertion)
.findFirst()
.flatMap(this::getAttributeStatement)
.map(AttributeStatement::getAttributes).stream()
.flatMap(Collection::stream)
.collect(Collectors.toMap(Attribute::getName, this::getAttributeValues));
}
private TokenField extractNameAndValue(Attribute attribute) {
return TokenField.builder().name(attribute.getName()).value(getAttributeValues(attribute)).build();
Assertion decryptAssertion(EncryptedAssertion assertion) {
try {
return decrypter.decrypt(assertion);
} catch (DecryptionException ex) {
throw new TokenVerificationException("Cannot decrypt token", ex);
}
}
Optional<AttributeStatement> getAttributeStatement(Assertion assertion) {
return assertion.getStatements().stream()
.filter(AttributeStatement.class::isInstance)
.map(AttributeStatement.class::cast)
.findFirst();
}
String getAttributeValues(Attribute attribute) {
var values = attribute.getAttributeValues();
return values.stream().map(this::getAttributeValue).collect(Collectors.joining(";"));
return attribute.getAttributeValues().stream().map(this::getAttributeValue).collect(Collectors.joining(";"));
}
String getAttributeValue(XMLObject value) {
if (Objects.isNull(value)) {
return StringUtils.EMPTY;
}
return switch (value) {
case XSString xsString -> xsString.getValue();
case XSAny xsAny -> xsAny.getTextContent();
case XSInteger xsInteger -> String.valueOf(xsInteger.getValue());
case XSBoolean xsBoolean -> String.valueOf(xsBoolean.getValue());
case null -> "";
default -> {
LOG.warn("Unknown value type received: {}", value.getClass().getName());
yield value.toString();
}
default -> value.toString();
};
}
Set<TokenAttribute> buildTokenAttributes(Map<String, String> tokenAttributes, Response token) {
return adjustPostfachIdAttribute(tokenAttributes, token).entrySet().stream().map(this::buildTokenAttribute).collect(Collectors.toSet());
}
Map<String, String> adjustPostfachIdAttribute(Map<String, String> tokenAttributes, Response token) {
if (tokenValidationProperty.isUseIdAsPostfachId()) {
tokenAttributes.put(TokenAttribute.POSTFACH_ID_KEY, token.getID());
}
return tokenAttributes;
}
TokenAttribute buildTokenAttribute(Map.Entry<String, String> attribute) {
return TokenAttribute.builder().name(attribute.getKey()).value(attribute.getValue()).build();
}
}
......@@ -72,7 +72,8 @@ class SamlTrustEngineFactory {
}
CollectionCredentialResolver buildCredentialResolver(TokenValidationProperty entity) {
var credentials = getCertificatesFromMetadata(entity.getMetadata()).map(key -> buildBasicX509Credential(key, entity.getIdpEntityId()))
var credentials = getCertificatesFromMetadata(entity.getMetadata())
.map(certificate -> buildBasicX509Credential(certificate, entity.getIdpEntityId()))
.toList();
if (credentials.isEmpty()) {
throw new TechnicalException("Metadata response is missing verification certificates, necessary for verifying SAML assertions");
......
......@@ -18,7 +18,7 @@ ozgcloud:
key: "classpath:test2-enc.key"
certificate: "classpath:test2-enc.crt"
metadata: "classpath:metadata/muk-idp-e4k.xml"
userIdAsPostfachId: true
useIdAsPostfachId: true
mappings:
trustLevel: "ElsterVertrauensniveauAuthentifizierung"
server:
......
......@@ -33,7 +33,7 @@ public class CheckTokenResultTestFactory {
public static final String POSTFACH_ID = UUID.randomUUID().toString();
public static final String TRUST_LEVEL = "LOW";
public static final TokenField OTHER_FIELD = TokenAttributeTestFactory.create();
public static final TokenAttribute OTHER_FIELD = TokenAttributeTestFactory.create();
public static final String ERROR_MESSAGE = TokenVerificationExceptionTestFactory.MESSAGE;
static TokenValidationResult createValid() {
......
......@@ -7,12 +7,12 @@ public class TokenAttributeTestFactory {
public static final String NAME = LoremIpsum.getInstance().getWords(1);
public static final String VALUE = LoremIpsum.getInstance().getWords(1);
public static TokenField create() {
public static TokenAttribute create() {
return createBuilder().build();
}
public static TokenField.TokenFieldBuilder createBuilder() {
return TokenField.builder()
public static TokenAttribute.TokenAttributeBuilder createBuilder() {
return TokenAttribute.builder()
.name(NAME)
.value(VALUE);
}
......
......@@ -38,10 +38,6 @@ import org.opensaml.saml.saml2.core.Issuer;
import org.opensaml.saml.saml2.core.Response;
import de.ozgcloud.common.test.TestUtils;
import de.ozgcloud.token.saml.Saml2DecryptionService;
import de.ozgcloud.token.saml.Saml2ParseService;
import de.ozgcloud.token.saml.Saml2VerificationService;
import de.ozgcloud.token.saml.SamlSetting;
import de.ozgcloud.token.saml.SamlServiceRegistry;
import de.ozgcloud.token.saml.SamlTokenTestUtils;
import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
......@@ -97,7 +93,7 @@ class TokenCheckServiceITCase {
@Test
void shouldGetPostfachHandleFromBayernIdToken() {
var attributes = List.of(new TokenField(POSTFACH_ID_NAME_BAYERN_ID, POSTFACH_ID_BAYERN_ID));
var attributes = List.of(new TokenAttribute(POSTFACH_ID_NAME_BAYERN_ID, POSTFACH_ID_BAYERN_ID));
when(decryptionService.decryptAttributes(any(), any(SamlSetting.class))).thenReturn(
attributes);
......@@ -108,7 +104,7 @@ class TokenCheckServiceITCase {
@Test
void shouldGetTrustLevel() {
var attributes = List.of(new TokenField(TRUST_LEVEL_NAME_BAYERN_ID, TRUST_LEVEL));
var attributes = List.of(new TokenAttribute(TRUST_LEVEL_NAME_BAYERN_ID, TRUST_LEVEL));
when(decryptionService.decryptAttributes(any(), any(SamlSetting.class))).thenReturn(
attributes);
......
/*
* Copyright (c) 2024.
* Lizenziert unter der EUPL, Version 1.2 oder - sobald
* diese von der Europäischen Kommission genehmigt wurden -
* Folgeversionen der EUPL ("Lizenz");
* Sie dürfen dieses Werk ausschließlich gemäß
* dieser Lizenz nutzen.
* Eine Kopie der Lizenz finden Sie hier:
*
* https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* Sofern nicht durch anwendbare Rechtsvorschriften
* gefordert oder in schriftlicher Form vereinbart, wird
* die unter der Lizenz verbreitete Software "so wie sie
* ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
* ausdrücklich oder stillschweigend - verbreitet.
* Die sprachspezifischen Genehmigungen und Beschränkungen
* unter der Lizenz sind dem Lizenztext zu entnehmen.
*/
package de.ozgcloud.token;
import static de.ozgcloud.token.saml.SamlTokenTestUtils.*;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.opensaml.saml.saml2.core.Issuer;
import org.opensaml.saml.saml2.core.Response;
import com.thedeanda.lorem.LoremIpsum;
import de.ozgcloud.common.test.TestUtils;
import de.ozgcloud.token.common.errorhandling.TokenVerificationException;
import de.ozgcloud.token.common.errorhandling.TokenVerificationExceptionTestFactory;
import de.ozgcloud.token.saml.Saml2DecryptionService;
import de.ozgcloud.token.saml.Saml2ParseService;
import de.ozgcloud.token.saml.Saml2VerificationService;
import de.ozgcloud.token.saml.SamlSetting;
import de.ozgcloud.token.saml.SamlServiceRegistry;
class TokenCheckServiceTest {
@InjectMocks
@Spy
private TokenCheckService service;
@Mock
private Saml2VerificationService verificationService;
@Mock
private Saml2DecryptionService decryptionService;
@Mock
private SamlServiceRegistry samlServiceRegistry;
@Mock
private Saml2ParseService parseService;
@Nested
class TestCheckToken {
private final String token = TestUtils.loadTextFile("SamlResponseBayernId.xml");
@Test
void shouldCallVerificationService() {
doReturn(CheckTokenResultTestFactory.create()).when(service).getCheckTokenResult(any());
checkToken();
verify(verificationService).verify(token);
}
@Nested
class OnValidToken {
@BeforeEach
void givenValidToken() {
when(verificationService.verify(any())).thenReturn(Collections.emptyList());
doReturn(CheckTokenResultTestFactory.create()).when(service).getCheckTokenResult(any());
}
@Test
void shouldCallGetCheckTokenResult() {
checkToken();
verify(service).getCheckTokenResult(token);
}
@Test
void shouldReturnCheckTokenResult() {
var result = checkToken();
assertThat(result).isEqualTo(CheckTokenResultTestFactory.create());
}
}
@Nested
class OnInvalidToken {
@BeforeEach
void givenInvalidToken() {
when(verificationService.verify(any())).thenReturn(List.of(TokenVerificationExceptionTestFactory.createSaml2Error()));
}
@Test
void shouldThrowTokenVerificationException() {
assertThrows(TokenVerificationException.class, () -> checkToken());
}
}
private TokenValidationResult checkToken() {
return service.checkToken(token);
}
}
@Nested
class TestGetTokenValidationResult {
@Mock
private SamlSetting samlSetting;
@Mock
private Response response;
@Mock
private Issuer issuer;
private final String token = TestUtils.loadTextFile("SamlResponseBayernId.xml");
private final TokenValidationResult tokenValidationResult = CheckTokenResultTestFactory.create();
@BeforeEach
void mock() {
when(parseService.parse(any())).thenReturn(response);
when(response.getIssuer()).thenReturn(issuer);
when(issuer.getValue()).thenReturn(IDP_ENTITY_ID_BAYERN_ID);
when(samlServiceRegistry.getService(any())).thenReturn(samlSetting);
doReturn(tokenValidationResult).when(service).buildCheckTokenResult(any(), any());
}
@Test
void shouldParseToken() {
getCheckTokenResult();
verify(parseService).parse(token);
}
@Test
void shouldGetConfiguration() {
getCheckTokenResult();
verify(samlServiceRegistry).getService(IDP_ENTITY_ID_BAYERN_ID);
}
@Test
void shouldCallBuildCheckTokenResult() {
getCheckTokenResult();
verify(service).buildCheckTokenResult(samlSetting, response);
}
@Test
void shouldReturnCheckTokenResult() {
var result = getCheckTokenResult();
assertThat(result).isEqualTo(tokenValidationResult);
}
private TokenValidationResult getCheckTokenResult() {
return service.getCheckTokenResult(token);
}
}
@Nested
class TestBuildTokenValidationResult {
@Mock
private SamlSetting samlSetting;
@Mock
private Response response;
private final TokenField tokenField = TokenAttributeTestFactory.create();
private final List<TokenField> decryptedAttributes = List.of(tokenField);
@BeforeEach
void mock() {
when(decryptionService.decryptAttributes(response, samlSetting)).thenReturn(decryptedAttributes);
doReturn(CheckTokenResultTestFactory.POSTFACH_ID).when(service).getPostfachId(any(), any(), any());
doReturn(CheckTokenResultTestFactory.TRUST_LEVEL).when(service).findAttributeByKey(any(), any(), any());
}
@Test
void shouldDecryptAttributes() {
buildCheckTokenResult();
verify(decryptionService).decryptAttributes(response, samlSetting);
}
@Test
void shouldCallGetPostfachId() {
buildCheckTokenResult();
verify(service).getPostfachId(samlSetting, response, decryptedAttributes);
}
@Test
void shouldCallFindAttributeKey() {
buildCheckTokenResult();
verify(service).findAttributeByKey(TokenCheckService.TRUST_LEVEL_KEY, decryptedAttributes, samlSetting);
}
@Test
void shouldReturnCheckTokenResult() {
var result = buildCheckTokenResult();
assertThat(result).isEqualTo(CheckTokenResultTestFactory.create());
}
private TokenValidationResult buildCheckTokenResult() {
return service.buildCheckTokenResult(samlSetting, response);
}
}
@Nested
class TestGetPostfachId {
@Mock
private SamlSetting samlSetting;
@Mock
private Response response;
private final TokenField tokenField = TokenAttributeTestFactory.create();
private final List<TokenField> decryptedAttributes = List.of(tokenField);
@Test
void shouldCheckIsIdIsPostfachId() {
getPostfachId();
verify(samlSetting).isIdIsPostfachId();
}
@Nested
class OnIdIsPostfachId {
@BeforeEach
void mock() {
when(samlSetting.isIdIsPostfachId()).thenReturn(true);
when(response.getID()).thenReturn(CheckTokenResultTestFactory.POSTFACH_ID);
}
@Test
void shouldReturnResponseId() {
var postfachId = getPostfachId();
assertThat(postfachId).isEqualTo(CheckTokenResultTestFactory.POSTFACH_ID);
}
}
@Nested
class OnIdIsNotPostfachId {
@BeforeEach
void mock() {
when(samlSetting.isIdIsPostfachId()).thenReturn(false);
}
@Test
void shouldCallFindAttributeByKey() {
getPostfachId();
verify(service).findAttributeByKey(TokenCheckService.POSTFACH_ID_KEY, decryptedAttributes, samlSetting);
}
@Test
void shouldReturnAttributeValue() {
var id = UUID.randomUUID().toString();
doReturn(id).when(service).findAttributeByKey(any(), any(), any());
var postfachId = getPostfachId();
assertThat(postfachId).isEqualTo(id);
}
}
private String getPostfachId() {
return service.getPostfachId(samlSetting, response, decryptedAttributes);
}
}
@Nested
class TestFindAttributeByKey {
@Mock
private SamlSetting samlSetting;
private final String existingKey = LoremIpsum.getInstance().getWords(1);
private final String nonExistingKey = LoremIpsum.getInstance().getWords(1);
private final String attributeName = LoremIpsum.getInstance().getWords(1);
private final String nonExistingName = LoremIpsum.getInstance().getWords(1);
private final String attributeValue = LoremIpsum.getInstance().getWords(1);
private final Map<String, String> mappings = Map.of(existingKey, attributeName, nonExistingKey, nonExistingName);
private final TokenField tokenField1 = TokenAttributeTestFactory.create();
private final TokenField tokenField2 = TokenAttributeTestFactory.createBuilder()
.name(attributeName)
.value(attributeValue)
.build();
private final List<TokenField> attributes = List.of(tokenField1, tokenField2);
@BeforeEach
void setUp() {
when(samlSetting.getMappings()).thenReturn(mappings);
}
@Test
void shouldReturnAttributeValue() {
var value = service.findAttributeByKey(existingKey, attributes, samlSetting);
assertThat(value).isEqualTo(attributeValue);
}
@Test
void shouldReturnEmptyString() {
var value = service.findAttributeByKey(nonExistingKey, attributes, samlSetting);
assertThat(value).isEmpty();
}
}
}
\ No newline at end of file
......@@ -27,7 +27,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean;
import de.ozgcloud.token.saml.SamlServiceRegistry;
import de.ozgcloud.token.saml.SamlTokenUtils;
import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
import net.shibboleth.utilities.java.support.xml.BasicParserPool;
import net.shibboleth.utilities.java.support.xml.ParserPool;
......
/*
* Copyright (C) 2024 Das Land Schleswig-Holstein vertreten durch den
* Ministerpräsidenten des Landes Schleswig-Holstein
* Staatskanzlei
* Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
*
* Lizenziert unter der EUPL, Version 1.2 oder - sobald
* diese von der Europäischen Kommission genehmigt wurden -
* Folgeversionen der EUPL ("Lizenz");
* Sie dürfen dieses Werk ausschließlich gemäß
* dieser Lizenz nutzen.
* Eine Kopie der Lizenz finden Sie hier:
*
* https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* Sofern nicht durch anwendbare Rechtsvorschriften
* gefordert oder in schriftlicher Form vereinbart, wird
* die unter der Lizenz verbreitete Software "so wie sie
* ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
* ausdrücklich oder stillschweigend - verbreitet.
* Die sprachspezifischen Genehmigungen und Beschränkungen
* unter der Lizenz sind dem Lizenztext zu entnehmen.
*/
package de.ozgcloud.token.saml;
import org.junit.jupiter.api.Nested;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import com.thedeanda.lorem.LoremIpsum;
class SamlTokenServiceTest {
private static final String SAML_TOKEN = LoremIpsum.getInstance().getWords(7);
@Spy
@InjectMocks
private SamlTokenService service;
@Mock
private SamlServiceRegistry samlServiceRegistry;
@Nested
class TestValidate {
}
}
\ No newline at end of file
/*
* Copyright (C) 2024 Das Land Schleswig-Holstein vertreten durch den
* Ministerpräsidenten des Landes Schleswig-Holstein
* Staatskanzlei
* Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
*
* Lizenziert unter der EUPL, Version 1.2 oder - sobald
* diese von der Europäischen Kommission genehmigt wurden -
* Folgeversionen der EUPL ("Lizenz");
* Sie dürfen dieses Werk ausschließlich gemäß
* dieser Lizenz nutzen.
* Eine Kopie der Lizenz finden Sie hier:
*
* https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* Sofern nicht durch anwendbare Rechtsvorschriften
* gefordert oder in schriftlicher Form vereinbart, wird
* die unter der Lizenz verbreitete Software "so wie sie
* ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
* ausdrücklich oder stillschweigend - verbreitet.
* Die sprachspezifischen Genehmigungen und Beschränkungen
* unter der Lizenz sind dem Lizenztext zu entnehmen.
*/
package de.ozgcloud.token.saml;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.opensaml.core.xml.config.XMLObjectProviderRegistry;
import org.opensaml.security.credential.impl.CollectionCredentialResolver;
import org.opensaml.xmlsec.signature.support.impl.ExplicitKeySignatureTrustEngine;
import org.springframework.core.io.Resource;
import org.springframework.security.saml2.core.Saml2X509Credential;
import de.ozgcloud.common.errorhandling.TechnicalException;
import de.ozgcloud.token.TokenValidationProperties.TokenValidationProperty;
import net.shibboleth.utilities.java.support.xml.ParserPool;
class SamlTrustEngineFactoryTest {
@Spy
@InjectMocks
private SamlTrustEngineFactory factory;
@Mock
private XMLObjectProviderRegistry registry;
@Mock
private ParserPool parserPool;
@Nested
class TestBuildSamlTrustEngine {
@Mock
private TokenValidationProperty tokenValidationProperty;
@Mock
private CollectionCredentialResolver credentialResolver;
@BeforeEach
void init() {
doReturn(credentialResolver).when(factory).buildCredentialResolver(any());
}
@Test
void shouldCallBuildCredentialResolver() {
factory.buildSamlTrustEngine(tokenValidationProperty);
verify(factory).buildCredentialResolver(tokenValidationProperty);
}
@Test
void shouldSetCredentialResolver() {
var result = factory.buildSamlTrustEngine(tokenValidationProperty);
assertThat(result).isInstanceOf(ExplicitKeySignatureTrustEngine.class).extracting("credentialResolver").isEqualTo(credentialResolver);
}
@Test
void shouldSetKeyInfoResolver() {
var result = factory.buildSamlTrustEngine(tokenValidationProperty);
assertThat(result).isInstanceOf(ExplicitKeySignatureTrustEngine.class).extracting("keyInfoResolver").isNotNull();
}
}
@Nested
class TestBuildCredentialResolver {
@Mock
private TokenValidationProperty tokenValidationProperty;
@Mock
private Saml2X509Credential credential;
@Mock
private Resource metadata;
@BeforeEach
void init() {
when(tokenValidationProperty.getMetadata()).thenReturn(metadata);
}
@Test
void shouldCallGetCertificatesFromMetadata() {
when(factory.getCertificatesFromMetadata(any())).thenReturn(Stream.of(credential));
factory.buildCredentialResolver(tokenValidationProperty);
verify(factory).getCertificatesFromMetadata(metadata);
}
@Test
void shouldThrowExceptionWhenNoCertificates() {
when(factory.getCertificatesFromMetadata(any())).thenReturn(Stream.empty());
assertThatThrownBy(() -> factory.buildCredentialResolver(tokenValidationProperty)).isInstanceOf(TechnicalException.class)
.hasMessage("Metadata response is missing verification certificates, necessary for verifying SAML assertions");
}
@Test
void shouldBuildCredentialResolver() {
when(factory.getCertificatesFromMetadata(any())).thenReturn(Stream.of("key"));
doReturn(new CollectionCredentialResolver(null)).when(factory).buildCredentialResolver(any());
var result = factory.buildCredentialResolver(tokenValidationProperty);
assertThat(result).isNotNull();
}
}
}
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment