[KEYCLOAK-12998] Prevent duplicate resources from being added to the keycloak-saml subsystem
- Fixes an issue in parser where the closing tag of the IDP element was in the wrong place, which could break the server configuration - Parser now checks for duplicates of elements described with maxOccurs=1 in the schema - Add handler for SP and IDP now check for existing SPs or IDPs in the config, preventing addition of a duplicate resource via CLI - Subsystem test was enhanced so it now tests some invalid configs with duplicate elements
This commit is contained in:
parent
753c21e9ef
commit
76717134ba
10 changed files with 385 additions and 32 deletions
|
@ -16,6 +16,9 @@
|
||||||
*/
|
*/
|
||||||
package org.keycloak.subsystem.saml.as7;
|
package org.keycloak.subsystem.saml.as7;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.jboss.as.controller.OperationFailedException;
|
||||||
import org.jboss.as.server.deployment.DeploymentUnit;
|
import org.jboss.as.server.deployment.DeploymentUnit;
|
||||||
import org.jboss.as.web.deployment.WarMetaData;
|
import org.jboss.as.web.deployment.WarMetaData;
|
||||||
import org.jboss.dmr.ModelNode;
|
import org.jboss.dmr.ModelNode;
|
||||||
|
@ -34,19 +37,27 @@ public class Configuration {
|
||||||
private Configuration() {
|
private Configuration() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateModel(ModelNode operation, ModelNode model) {
|
void updateModel(ModelNode operation, ModelNode model) throws OperationFailedException {
|
||||||
ModelNode node = config;
|
this.updateModel(operation, model, false);
|
||||||
ModelNode addr = operation.get("address");
|
|
||||||
for (Property item : addr.asPropertyList()) {
|
|
||||||
node = getNodeForAddressElement(node, item);
|
|
||||||
}
|
|
||||||
node.set(model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ModelNode getNodeForAddressElement(ModelNode node, Property item) {
|
void updateModel(final ModelNode operation, final ModelNode model, final boolean checkSingleton) throws OperationFailedException {
|
||||||
String key = item.getValue().asString();
|
ModelNode node = config;
|
||||||
ModelNode keymodel = node.get(item.getName());
|
|
||||||
return keymodel.get(key);
|
final List<Property> addressNodes = operation.get("address").asPropertyList();
|
||||||
|
final int lastIndex = addressNodes.size() - 1;
|
||||||
|
for (int i = 0; i < addressNodes.size(); i++) {
|
||||||
|
Property addressNode = addressNodes.get(i);
|
||||||
|
// if checkSingleton is true, we verify if the key for the last element (e.g. SP or IDP) in the address path is already defined
|
||||||
|
if (i == lastIndex && checkSingleton) {
|
||||||
|
if (node.get(addressNode.getName()).isDefined()) {
|
||||||
|
// found an existing resource, throw an exception
|
||||||
|
throw new OperationFailedException("Duplicate resource: " + addressNode.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = node.get(addressNode.getName()).get(addressNode.getValue().asString());
|
||||||
|
}
|
||||||
|
node.set(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ModelNode getSecureDeployment(DeploymentUnit deploymentUnit) {
|
public ModelNode getSecureDeployment(DeploymentUnit deploymentUnit) {
|
||||||
|
|
|
@ -36,7 +36,7 @@ class IdentityProviderAddHandler extends AbstractAddStepHandler {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void performRuntime(OperationContext context, ModelNode operation, ModelNode model, ServiceVerificationHandler verificationHandler, List<ServiceController<?>> newControllers) throws OperationFailedException {
|
protected void performRuntime(OperationContext context, ModelNode operation, ModelNode model, ServiceVerificationHandler verificationHandler, List<ServiceController<?>> newControllers) throws OperationFailedException {
|
||||||
Configuration.INSTANCE.updateModel(operation, model);
|
Configuration.INSTANCE.updateModel(operation, model, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -76,13 +76,19 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
ModelNode addSecureDeployment = Util.createAddOperation(addr);
|
ModelNode addSecureDeployment = Util.createAddOperation(addr);
|
||||||
list.add(addSecureDeployment);
|
list.add(addSecureDeployment);
|
||||||
|
|
||||||
|
Set<String> parsedElements = new HashSet<>();
|
||||||
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
||||||
String tagName = reader.getLocalName();
|
String tagName = reader.getLocalName();
|
||||||
|
if (parsedElements.contains(tagName)) {
|
||||||
|
// all sub-elements of the secure deployment type should occur only once.
|
||||||
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
|
}
|
||||||
if (tagName.equals(Constants.XML.SERVICE_PROVIDER)) {
|
if (tagName.equals(Constants.XML.SERVICE_PROVIDER)) {
|
||||||
readServiceProvider(reader, list, addr);
|
readServiceProvider(reader, list, addr);
|
||||||
} else {
|
} else {
|
||||||
throw ParseUtils.unexpectedElement(reader);
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
}
|
}
|
||||||
|
parsedElements.add(tagName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,8 +115,13 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
attr.parseAndSetParameter(value, addServiceProvider, reader);
|
attr.parseAndSetParameter(value, addServiceProvider, reader);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set parsedElements = new HashSet<>();
|
||||||
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
||||||
String tagName = reader.getLocalName();
|
String tagName = reader.getLocalName();
|
||||||
|
if (parsedElements.contains(tagName)) {
|
||||||
|
// all sub-elements of the service provider type should occur only once.
|
||||||
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
|
}
|
||||||
|
|
||||||
if (Constants.XML.KEYS.equals(tagName)) {
|
if (Constants.XML.KEYS.equals(tagName)) {
|
||||||
readKeys(list, reader, addr);
|
readKeys(list, reader, addr);
|
||||||
|
@ -125,6 +136,7 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
} else {
|
} else {
|
||||||
throw ParseUtils.unexpectedElement(reader);
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
}
|
}
|
||||||
|
parsedElements.add(tagName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,8 +164,13 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
attr.parseAndSetParameter(value, addIdentityProvider, reader);
|
attr.parseAndSetParameter(value, addIdentityProvider, reader);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<String> parsedElements = new HashSet<>();
|
||||||
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
||||||
String tagName = reader.getLocalName();
|
String tagName = reader.getLocalName();
|
||||||
|
if (parsedElements.contains(tagName)) {
|
||||||
|
// all sub-elements of the identity provider type should occur only once.
|
||||||
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
|
}
|
||||||
|
|
||||||
if (Constants.XML.SINGLE_SIGN_ON.equals(tagName)) {
|
if (Constants.XML.SINGLE_SIGN_ON.equals(tagName)) {
|
||||||
readSingleSignOn(addIdentityProvider, reader);
|
readSingleSignOn(addIdentityProvider, reader);
|
||||||
|
@ -168,6 +185,7 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
} else {
|
} else {
|
||||||
throw ParseUtils.unexpectedElement(reader);
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
}
|
}
|
||||||
|
parsedElements.add(tagName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,8 +283,13 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
attr.parseAndSetParameter(value, addKey, reader);
|
attr.parseAndSetParameter(value, addKey, reader);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<String> parsedElements = new HashSet<>();
|
||||||
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
||||||
String tagName = reader.getLocalName();
|
String tagName = reader.getLocalName();
|
||||||
|
if (parsedElements.contains(tagName)) {
|
||||||
|
// all sub-elements of the key type should occur only once.
|
||||||
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
|
}
|
||||||
|
|
||||||
if (Constants.XML.KEY_STORE.equals(tagName)) {
|
if (Constants.XML.KEY_STORE.equals(tagName)) {
|
||||||
readKeyStore(addKey, reader);
|
readKeyStore(addKey, reader);
|
||||||
|
@ -278,6 +301,7 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
} else {
|
} else {
|
||||||
throw ParseUtils.unexpectedElement(reader);
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
}
|
}
|
||||||
|
parsedElements.add(tagName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -308,8 +332,13 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
throw ParseUtils.missingRequired(reader, asSet(Constants.XML.PASSWORD));
|
throw ParseUtils.missingRequired(reader, asSet(Constants.XML.PASSWORD));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<String> parsedElements = new HashSet<>();
|
||||||
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
||||||
String tagName = reader.getLocalName();
|
String tagName = reader.getLocalName();
|
||||||
|
if (parsedElements.contains(tagName)) {
|
||||||
|
// all sub-elements of the keystore type should occur only once.
|
||||||
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
|
}
|
||||||
if (Constants.XML.PRIVATE_KEY.equals(tagName)) {
|
if (Constants.XML.PRIVATE_KEY.equals(tagName)) {
|
||||||
readPrivateKey(reader, addKeyStore);
|
readPrivateKey(reader, addKeyStore);
|
||||||
} else if (Constants.XML.CERTIFICATE.equals(tagName)) {
|
} else if (Constants.XML.CERTIFICATE.equals(tagName)) {
|
||||||
|
@ -317,6 +346,7 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
} else {
|
} else {
|
||||||
throw ParseUtils.unexpectedElement(reader);
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
}
|
}
|
||||||
|
parsedElements.add(tagName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -500,9 +530,9 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
writeKeys(writer, idpAttributes.get(Constants.Model.KEY));
|
writeKeys(writer, idpAttributes.get(Constants.Model.KEY));
|
||||||
writeHttpClient(writer, idpAttributes.get(Constants.Model.HTTP_CLIENT));
|
writeHttpClient(writer, idpAttributes.get(Constants.Model.HTTP_CLIENT));
|
||||||
writeAllowedClockSkew(writer, idpAttributes.get(Constants.Model.ALLOWED_CLOCK_SKEW));
|
writeAllowedClockSkew(writer, idpAttributes.get(Constants.Model.ALLOWED_CLOCK_SKEW));
|
||||||
}
|
|
||||||
writer.writeEndElement();
|
writer.writeEndElement();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void writeSingleSignOn(XMLExtendedStreamWriter writer, ModelNode model) throws XMLStreamException {
|
void writeSingleSignOn(XMLExtendedStreamWriter writer, ModelNode model) throws XMLStreamException {
|
||||||
if (!model.isDefined()) {
|
if (!model.isDefined()) {
|
||||||
|
|
|
@ -38,7 +38,7 @@ class ServiceProviderAddHandler extends AbstractAddStepHandler {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void performRuntime(OperationContext context, ModelNode operation, ModelNode model, ServiceVerificationHandler verificationHandler, List<ServiceController<?>> newControllers) throws OperationFailedException {
|
protected void performRuntime(OperationContext context, ModelNode operation, ModelNode model, ServiceVerificationHandler verificationHandler, List<ServiceController<?>> newControllers) throws OperationFailedException {
|
||||||
Configuration.INSTANCE.updateModel(operation, model);
|
Configuration.INSTANCE.updateModel(operation, model, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -17,8 +17,34 @@
|
||||||
package org.keycloak.subsystem.saml.as7;
|
package org.keycloak.subsystem.saml.as7;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.StringReader;
|
||||||
|
import java.io.StringWriter;
|
||||||
|
|
||||||
|
import javax.xml.parsers.DocumentBuilder;
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory;
|
||||||
|
import javax.xml.parsers.ParserConfigurationException;
|
||||||
|
import javax.xml.stream.XMLStreamException;
|
||||||
|
import javax.xml.transform.OutputKeys;
|
||||||
|
import javax.xml.transform.Transformer;
|
||||||
|
import javax.xml.transform.TransformerException;
|
||||||
|
import javax.xml.transform.TransformerFactory;
|
||||||
|
import javax.xml.transform.dom.DOMSource;
|
||||||
|
import javax.xml.transform.stream.StreamResult;
|
||||||
|
import javax.xml.xpath.XPath;
|
||||||
|
import javax.xml.xpath.XPathConstants;
|
||||||
|
import javax.xml.xpath.XPathExpressionException;
|
||||||
|
import javax.xml.xpath.XPathFactory;
|
||||||
|
|
||||||
import org.jboss.as.subsystem.test.AbstractSubsystemBaseTest;
|
import org.jboss.as.subsystem.test.AbstractSubsystemBaseTest;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.rules.ExpectedException;
|
||||||
|
import org.w3c.dom.Document;
|
||||||
|
import org.w3c.dom.Element;
|
||||||
|
import org.w3c.dom.NodeList;
|
||||||
|
import org.xml.sax.InputSource;
|
||||||
|
import org.xml.sax.SAXException;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -28,12 +54,118 @@ import org.jboss.as.subsystem.test.AbstractSubsystemBaseTest;
|
||||||
*/
|
*/
|
||||||
public class SubsystemParsingTestCase extends AbstractSubsystemBaseTest {
|
public class SubsystemParsingTestCase extends AbstractSubsystemBaseTest {
|
||||||
|
|
||||||
|
private String subsystemXml = null;
|
||||||
|
|
||||||
|
private String subsystemTemplate = null;
|
||||||
|
|
||||||
|
private Document document = null;
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
public final ExpectedException exception = ExpectedException.none();
|
||||||
|
|
||||||
public SubsystemParsingTestCase() {
|
public SubsystemParsingTestCase() {
|
||||||
super(KeycloakSamlExtension.SUBSYSTEM_NAME, new KeycloakSamlExtension());
|
super(KeycloakSamlExtension.SUBSYSTEM_NAME, new KeycloakSamlExtension());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String getSubsystemXml() throws IOException {
|
protected String getSubsystemXml() throws IOException {
|
||||||
return readResource("keycloak-saml-1.3.xml");
|
return this.subsystemXml;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void initialize() throws IOException {
|
||||||
|
this.subsystemTemplate = readResource("keycloak-saml-1.3.xml");
|
||||||
|
try {
|
||||||
|
DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
|
||||||
|
this.document = builder.parse(new InputSource(new StringReader(this.subsystemTemplate)));
|
||||||
|
} catch (ParserConfigurationException | SAXException e) {
|
||||||
|
throw new IOException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildSubsystemXml(final Element element, final String expression) throws IOException {
|
||||||
|
if (element != null) {
|
||||||
|
try {
|
||||||
|
// locate the element and insert the node
|
||||||
|
XPath xPath = XPathFactory.newInstance().newXPath();
|
||||||
|
NodeList nodeList = (NodeList) xPath.compile(expression).evaluate(this.document, XPathConstants.NODESET);
|
||||||
|
nodeList.item(0).appendChild(element);
|
||||||
|
// transform again to XML
|
||||||
|
TransformerFactory tf = TransformerFactory.newInstance();
|
||||||
|
Transformer transformer = tf.newTransformer();
|
||||||
|
transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
|
||||||
|
StringWriter writer = new StringWriter();
|
||||||
|
transformer.transform(new DOMSource(this.document), new StreamResult(writer));
|
||||||
|
this.subsystemXml = writer.getBuffer().toString();
|
||||||
|
} catch(TransformerException | XPathExpressionException e) {
|
||||||
|
throw new IOException(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.subsystemXml = this.subsystemTemplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void testSubsystem() throws Exception {
|
||||||
|
this.buildSubsystemXml(null, null);
|
||||||
|
super.testSubsystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDuplicateServiceProviders() throws Exception {
|
||||||
|
// create a simple service provider element.
|
||||||
|
Element spElement = this.document.createElement(Constants.XML.SERVICE_PROVIDER);
|
||||||
|
spElement.setAttribute(Constants.XML.ENTITY_ID, "duplicate-sp");
|
||||||
|
this.buildSubsystemXml(spElement, "/subsystem/secure-deployment[1]");
|
||||||
|
|
||||||
|
this.exception.expect(XMLStreamException.class);
|
||||||
|
this.exception.expectMessage("JBAS014789: Unexpected element");
|
||||||
|
super.testSubsystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDuplicateIdentityProviders() throws Exception {
|
||||||
|
// create a duplicate identity provider element.
|
||||||
|
Element idpElement = this.document.createElement(Constants.XML.IDENTITY_PROVIDER);
|
||||||
|
idpElement.setAttribute(Constants.XML.ENTITY_ID, "test-idp");
|
||||||
|
Element singleSignOn = this.document.createElement(Constants.XML.SINGLE_SIGN_ON);
|
||||||
|
singleSignOn.setAttribute(Constants.XML.BINDING_URL, "https://localhost:7887");
|
||||||
|
Element singleLogout = this.document.createElement(Constants.XML.SINGLE_LOGOUT);
|
||||||
|
singleLogout.setAttribute(Constants.XML.POST_BINDING_URL, "httpsL//localhost:8998");
|
||||||
|
idpElement.appendChild(singleSignOn);
|
||||||
|
idpElement.appendChild(singleLogout);
|
||||||
|
this.buildSubsystemXml(idpElement, "/subsystem/secure-deployment[1]/SP");
|
||||||
|
|
||||||
|
this.exception.expect(XMLStreamException.class);
|
||||||
|
this.exception.expectMessage("JBAS014789: Unexpected element");
|
||||||
|
super.testSubsystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDuplicateKeysInSP() throws Exception {
|
||||||
|
Element keysElement = this.document.createElement(Constants.XML.KEYS);
|
||||||
|
Element keyElement = this.document.createElement(Constants.XML.KEY);
|
||||||
|
keyElement.setAttribute(Constants.XML.ENCRYPTION, "false");
|
||||||
|
keyElement.setAttribute(Constants.XML.SIGNING, "false");
|
||||||
|
keysElement.appendChild(keyElement);
|
||||||
|
this.buildSubsystemXml(keysElement, "/subsystem/secure-deployment[1]/SP");
|
||||||
|
|
||||||
|
this.exception.expect(XMLStreamException.class);
|
||||||
|
this.exception.expectMessage("JBAS014789: Unexpected element");
|
||||||
|
super.testSubsystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDuplicateKeysInIDP() throws Exception {
|
||||||
|
Element keysElement = this.document.createElement(Constants.XML.KEYS);
|
||||||
|
Element keyElement = this.document.createElement(Constants.XML.KEY);
|
||||||
|
keyElement.setAttribute(Constants.XML.ENCRYPTION, "false");
|
||||||
|
keyElement.setAttribute(Constants.XML.SIGNING, "false");
|
||||||
|
keysElement.appendChild(keyElement);
|
||||||
|
this.buildSubsystemXml(keysElement, "/subsystem/secure-deployment[1]/SP/IDP");
|
||||||
|
|
||||||
|
this.exception.expect(XMLStreamException.class);
|
||||||
|
this.exception.expectMessage("JBAS014789: Unexpected element");
|
||||||
|
super.testSubsystem();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,9 @@
|
||||||
*/
|
*/
|
||||||
package org.keycloak.subsystem.adapter.saml.extension;
|
package org.keycloak.subsystem.adapter.saml.extension;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.jboss.as.controller.OperationFailedException;
|
||||||
import org.jboss.as.server.deployment.DeploymentUnit;
|
import org.jboss.as.server.deployment.DeploymentUnit;
|
||||||
import org.jboss.as.web.common.WarMetaData;
|
import org.jboss.as.web.common.WarMetaData;
|
||||||
import org.jboss.dmr.ModelNode;
|
import org.jboss.dmr.ModelNode;
|
||||||
|
@ -34,19 +37,27 @@ public class Configuration {
|
||||||
private Configuration() {
|
private Configuration() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateModel(ModelNode operation, ModelNode model) {
|
void updateModel(ModelNode operation, ModelNode model) throws OperationFailedException {
|
||||||
ModelNode node = config;
|
this.updateModel(operation, model, false);
|
||||||
ModelNode addr = operation.get("address");
|
|
||||||
for (Property item : addr.asPropertyList()) {
|
|
||||||
node = getNodeForAddressElement(node, item);
|
|
||||||
}
|
|
||||||
node.set(model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ModelNode getNodeForAddressElement(ModelNode node, Property item) {
|
void updateModel(final ModelNode operation, final ModelNode model, final boolean checkSingleton) throws OperationFailedException {
|
||||||
String key = item.getValue().asString();
|
ModelNode node = config;
|
||||||
ModelNode keymodel = node.get(item.getName());
|
|
||||||
return keymodel.get(key);
|
final List<Property> addressNodes = operation.get("address").asPropertyList();
|
||||||
|
final int lastIndex = addressNodes.size() - 1;
|
||||||
|
for (int i = 0; i < addressNodes.size(); i++) {
|
||||||
|
Property addressNode = addressNodes.get(i);
|
||||||
|
// if checkSingleton is true, we verify if the key for the last element (e.g. SP or IDP) in the address path is already defined
|
||||||
|
if (i == lastIndex && checkSingleton) {
|
||||||
|
if (node.get(addressNode.getName()).isDefined()) {
|
||||||
|
// found an existing resource, throw an exception
|
||||||
|
throw new OperationFailedException("Duplicate resource: " + addressNode.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = node.get(addressNode.getName()).get(addressNode.getValue().asString());
|
||||||
|
}
|
||||||
|
node.set(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ModelNode getSecureDeployment(DeploymentUnit deploymentUnit) {
|
public ModelNode getSecureDeployment(DeploymentUnit deploymentUnit) {
|
||||||
|
|
|
@ -32,6 +32,6 @@ class IdentityProviderAddHandler extends AbstractAddStepHandler {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void performRuntime(OperationContext context, ModelNode operation, ModelNode model) throws OperationFailedException {
|
protected void performRuntime(OperationContext context, ModelNode operation, ModelNode model) throws OperationFailedException {
|
||||||
Configuration.INSTANCE.updateModel(operation, model);
|
Configuration.INSTANCE.updateModel(operation, model, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,8 +33,10 @@ import org.jboss.staxmapper.XMLExtendedStreamWriter;
|
||||||
import javax.xml.stream.XMLStreamConstants;
|
import javax.xml.stream.XMLStreamConstants;
|
||||||
import javax.xml.stream.XMLStreamException;
|
import javax.xml.stream.XMLStreamException;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The subsystem parser, which uses stax to read and write to and from xml
|
* The subsystem parser, which uses stax to read and write to and from xml
|
||||||
|
@ -74,13 +76,19 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
ModelNode addSecureDeployment = Util.createAddOperation(addr);
|
ModelNode addSecureDeployment = Util.createAddOperation(addr);
|
||||||
list.add(addSecureDeployment);
|
list.add(addSecureDeployment);
|
||||||
|
|
||||||
|
Set<String> parsedElements = new HashSet<>();
|
||||||
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
||||||
String tagName = reader.getLocalName();
|
String tagName = reader.getLocalName();
|
||||||
|
if (parsedElements.contains(tagName)) {
|
||||||
|
// all sub-elements of the secure deployment type should occur only once.
|
||||||
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
|
}
|
||||||
if (tagName.equals(Constants.XML.SERVICE_PROVIDER)) {
|
if (tagName.equals(Constants.XML.SERVICE_PROVIDER)) {
|
||||||
readServiceProvider(reader, list, addr);
|
readServiceProvider(reader, list, addr);
|
||||||
} else {
|
} else {
|
||||||
throw ParseUtils.unexpectedElement(reader);
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
}
|
}
|
||||||
|
parsedElements.add(tagName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,9 +115,13 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
attr.parseAndSetParameter(value, addServiceProvider, reader);
|
attr.parseAndSetParameter(value, addServiceProvider, reader);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<String> parsedElements = new HashSet<>();
|
||||||
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
||||||
String tagName = reader.getLocalName();
|
String tagName = reader.getLocalName();
|
||||||
|
if (parsedElements.contains(tagName)) {
|
||||||
|
// all sub-elements of the service provider type should occur only once.
|
||||||
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
|
}
|
||||||
if (Constants.XML.KEYS.equals(tagName)) {
|
if (Constants.XML.KEYS.equals(tagName)) {
|
||||||
readKeys(list, reader, addr);
|
readKeys(list, reader, addr);
|
||||||
} else if (Constants.XML.PRINCIPAL_NAME_MAPPING.equals(tagName)) {
|
} else if (Constants.XML.PRINCIPAL_NAME_MAPPING.equals(tagName)) {
|
||||||
|
@ -123,6 +135,7 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
} else {
|
} else {
|
||||||
throw ParseUtils.unexpectedElement(reader);
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
}
|
}
|
||||||
|
parsedElements.add(tagName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -150,8 +163,13 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
attr.parseAndSetParameter(value, addIdentityProvider, reader);
|
attr.parseAndSetParameter(value, addIdentityProvider, reader);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<String> parsedElements = new HashSet<>();
|
||||||
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
||||||
String tagName = reader.getLocalName();
|
String tagName = reader.getLocalName();
|
||||||
|
if (parsedElements.contains(tagName)) {
|
||||||
|
// all sub-elements of the identity provider type should occur only once.
|
||||||
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
|
}
|
||||||
|
|
||||||
if (Constants.XML.SINGLE_SIGN_ON.equals(tagName)) {
|
if (Constants.XML.SINGLE_SIGN_ON.equals(tagName)) {
|
||||||
readSingleSignOn(addIdentityProvider, reader);
|
readSingleSignOn(addIdentityProvider, reader);
|
||||||
|
@ -166,6 +184,7 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
} else {
|
} else {
|
||||||
throw ParseUtils.unexpectedElement(reader);
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
}
|
}
|
||||||
|
parsedElements.add(tagName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -263,8 +282,13 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
attr.parseAndSetParameter(value, addKey, reader);
|
attr.parseAndSetParameter(value, addKey, reader);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<String> parsedElements = new HashSet<>();
|
||||||
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
||||||
String tagName = reader.getLocalName();
|
String tagName = reader.getLocalName();
|
||||||
|
if (parsedElements.contains(tagName)) {
|
||||||
|
// all sub-elements of the key type should occur only once.
|
||||||
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
|
}
|
||||||
|
|
||||||
if (Constants.XML.KEY_STORE.equals(tagName)) {
|
if (Constants.XML.KEY_STORE.equals(tagName)) {
|
||||||
readKeyStore(addKey, reader);
|
readKeyStore(addKey, reader);
|
||||||
|
@ -276,6 +300,7 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
} else {
|
} else {
|
||||||
throw ParseUtils.unexpectedElement(reader);
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
}
|
}
|
||||||
|
parsedElements.add(tagName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -306,8 +331,13 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
throw ParseUtils.missingRequired(reader, Constants.XML.PASSWORD);
|
throw ParseUtils.missingRequired(reader, Constants.XML.PASSWORD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<String> parsedElements = new HashSet<>();
|
||||||
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
while (reader.hasNext() && nextTag(reader) != END_ELEMENT) {
|
||||||
String tagName = reader.getLocalName();
|
String tagName = reader.getLocalName();
|
||||||
|
if (parsedElements.contains(tagName)) {
|
||||||
|
// all sub-elements of the keystore type should occur only once.
|
||||||
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
|
}
|
||||||
if (Constants.XML.PRIVATE_KEY.equals(tagName)) {
|
if (Constants.XML.PRIVATE_KEY.equals(tagName)) {
|
||||||
readPrivateKey(reader, addKeyStore);
|
readPrivateKey(reader, addKeyStore);
|
||||||
} else if (Constants.XML.CERTIFICATE.equals(tagName)) {
|
} else if (Constants.XML.CERTIFICATE.equals(tagName)) {
|
||||||
|
@ -315,6 +345,7 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
} else {
|
} else {
|
||||||
throw ParseUtils.unexpectedElement(reader);
|
throw ParseUtils.unexpectedElement(reader);
|
||||||
}
|
}
|
||||||
|
parsedElements.add(tagName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -498,9 +529,9 @@ class KeycloakSubsystemParser implements XMLStreamConstants, XMLElementReader<Li
|
||||||
writeKeys(writer, idpAttributes.get(Constants.Model.KEY));
|
writeKeys(writer, idpAttributes.get(Constants.Model.KEY));
|
||||||
writeHttpClient(writer, idpAttributes.get(Constants.Model.HTTP_CLIENT));
|
writeHttpClient(writer, idpAttributes.get(Constants.Model.HTTP_CLIENT));
|
||||||
writeAllowedClockSkew(writer, idpAttributes.get(Constants.Model.ALLOWED_CLOCK_SKEW));
|
writeAllowedClockSkew(writer, idpAttributes.get(Constants.Model.ALLOWED_CLOCK_SKEW));
|
||||||
}
|
|
||||||
writer.writeEndElement();
|
writer.writeEndElement();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void writeSingleSignOn(XMLExtendedStreamWriter writer, ModelNode model) throws XMLStreamException {
|
void writeSingleSignOn(XMLExtendedStreamWriter writer, ModelNode model) throws XMLStreamException {
|
||||||
if (!model.isDefined()) {
|
if (!model.isDefined()) {
|
||||||
|
|
|
@ -34,6 +34,6 @@ class ServiceProviderAddHandler extends AbstractAddStepHandler {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void performRuntime(OperationContext context, ModelNode operation, ModelNode model) throws OperationFailedException {
|
protected void performRuntime(OperationContext context, ModelNode operation, ModelNode model) throws OperationFailedException {
|
||||||
Configuration.INSTANCE.updateModel(operation, model);
|
Configuration.INSTANCE.updateModel(operation, model, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,14 +17,40 @@
|
||||||
package org.keycloak.subsystem.adapter.saml.extension;
|
package org.keycloak.subsystem.adapter.saml.extension;
|
||||||
|
|
||||||
import org.jboss.as.subsystem.test.AbstractSubsystemBaseTest;
|
import org.jboss.as.subsystem.test.AbstractSubsystemBaseTest;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.rules.ExpectedException;
|
||||||
|
import org.w3c.dom.Document;
|
||||||
|
import org.w3c.dom.Element;
|
||||||
|
import org.w3c.dom.NodeList;
|
||||||
|
import org.xml.sax.InputSource;
|
||||||
|
import org.xml.sax.SAXException;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.StringReader;
|
||||||
|
import java.io.StringWriter;
|
||||||
|
|
||||||
|
import javax.xml.parsers.DocumentBuilder;
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory;
|
||||||
|
import javax.xml.parsers.ParserConfigurationException;
|
||||||
|
import javax.xml.stream.XMLStreamException;
|
||||||
|
import javax.xml.transform.OutputKeys;
|
||||||
|
import javax.xml.transform.Transformer;
|
||||||
|
import javax.xml.transform.TransformerException;
|
||||||
|
import javax.xml.transform.TransformerFactory;
|
||||||
|
import javax.xml.transform.dom.DOMSource;
|
||||||
|
import javax.xml.transform.stream.StreamResult;
|
||||||
|
import javax.xml.xpath.XPath;
|
||||||
|
import javax.xml.xpath.XPathConstants;
|
||||||
|
import javax.xml.xpath.XPathExpressionException;
|
||||||
|
import javax.xml.xpath.XPathFactory;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests all management expects for subsystem, parsing, marshaling, model definition and other
|
* Tests all management expects for subsystem, parsing, marshaling, model definition and other
|
||||||
* Here is an example that allows you a fine grained controller over what is tested and how. So it can give you ideas what can be done and tested.
|
* Here is an example that allows you a fine grained controller over what is tested and how. So it can give you ideas what can be done and tested.
|
||||||
* If you have no need for advanced testing of subsystem you look at {@link SubsystemBaseParsingTestCase} that testes same stuff but most of the code
|
* If you have no need for advanced testing of subsystem you look at {@link AbstractSubsystemBaseTest} that testes same stuff but most of the code
|
||||||
* is hidden inside of test harness
|
* is hidden inside of test harness
|
||||||
*
|
*
|
||||||
* @author <a href="kabir.khan@jboss.com">Kabir Khan</a>
|
* @author <a href="kabir.khan@jboss.com">Kabir Khan</a>
|
||||||
|
@ -33,13 +59,22 @@ import java.io.IOException;
|
||||||
*/
|
*/
|
||||||
public class SubsystemParsingTestCase extends AbstractSubsystemBaseTest {
|
public class SubsystemParsingTestCase extends AbstractSubsystemBaseTest {
|
||||||
|
|
||||||
|
private String subsystemXml = null;
|
||||||
|
|
||||||
|
private String subsystemTemplate = null;
|
||||||
|
|
||||||
|
private Document document = null;
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
public final ExpectedException exception = ExpectedException.none();
|
||||||
|
|
||||||
public SubsystemParsingTestCase() {
|
public SubsystemParsingTestCase() {
|
||||||
super(KeycloakSamlExtension.SUBSYSTEM_NAME, new KeycloakSamlExtension());
|
super(KeycloakSamlExtension.SUBSYSTEM_NAME, new KeycloakSamlExtension());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String getSubsystemXml() throws IOException {
|
protected String getSubsystemXml() throws IOException {
|
||||||
return readResource("keycloak-saml-1.3.xml");
|
return this.subsystemXml;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -53,4 +88,107 @@ public class SubsystemParsingTestCase extends AbstractSubsystemBaseTest {
|
||||||
"/subsystem-templates/keycloak-saml-adapter.xml"
|
"/subsystem-templates/keycloak-saml-adapter.xml"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void initialize() throws IOException {
|
||||||
|
this.subsystemTemplate = readResource("keycloak-saml-1.3.xml");
|
||||||
|
try {
|
||||||
|
DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
|
||||||
|
this.document = builder.parse(new InputSource(new StringReader(this.subsystemTemplate)));
|
||||||
|
} catch (ParserConfigurationException | SAXException e) {
|
||||||
|
throw new IOException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildSubsystemXml(final Element element, final String expression) throws IOException {
|
||||||
|
if (element != null) {
|
||||||
|
try {
|
||||||
|
// locate the element and insert the node
|
||||||
|
XPath xPath = XPathFactory.newInstance().newXPath();
|
||||||
|
NodeList nodeList = (NodeList) xPath.compile(expression).evaluate(this.document, XPathConstants.NODESET);
|
||||||
|
nodeList.item(0).appendChild(element);
|
||||||
|
// transform again to XML
|
||||||
|
TransformerFactory tf = TransformerFactory.newInstance();
|
||||||
|
Transformer transformer = tf.newTransformer();
|
||||||
|
transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
|
||||||
|
StringWriter writer = new StringWriter();
|
||||||
|
transformer.transform(new DOMSource(this.document), new StreamResult(writer));
|
||||||
|
this.subsystemXml = writer.getBuffer().toString();
|
||||||
|
} catch(TransformerException | XPathExpressionException e) {
|
||||||
|
throw new IOException(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.subsystemXml = this.subsystemTemplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void testSubsystem() throws Exception {
|
||||||
|
this.buildSubsystemXml(null, null);
|
||||||
|
super.testSubsystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void testSchema() throws Exception {
|
||||||
|
this.buildSubsystemXml(null, null);
|
||||||
|
super.testSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDuplicateServiceProviders() throws Exception {
|
||||||
|
// create a simple service provider element.
|
||||||
|
Element spElement = this.document.createElement(Constants.XML.SERVICE_PROVIDER);
|
||||||
|
spElement.setAttribute(Constants.XML.ENTITY_ID, "duplicate-sp");
|
||||||
|
this.buildSubsystemXml(spElement, "/subsystem/secure-deployment[1]");
|
||||||
|
|
||||||
|
this.exception.expect(XMLStreamException.class);
|
||||||
|
this.exception.expectMessage("WFLYCTL0198: Unexpected element");
|
||||||
|
super.testSubsystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDuplicateIdentityProviders() throws Exception {
|
||||||
|
// create a duplicate identity provider element.
|
||||||
|
Element idpElement = this.document.createElement(Constants.XML.IDENTITY_PROVIDER);
|
||||||
|
idpElement.setAttribute(Constants.XML.ENTITY_ID, "test-idp");
|
||||||
|
Element singleSignOn = this.document.createElement(Constants.XML.SINGLE_SIGN_ON);
|
||||||
|
singleSignOn.setAttribute(Constants.XML.BINDING_URL, "https://localhost:7887");
|
||||||
|
Element singleLogout = this.document.createElement(Constants.XML.SINGLE_LOGOUT);
|
||||||
|
singleLogout.setAttribute(Constants.XML.POST_BINDING_URL, "httpsL//localhost:8998");
|
||||||
|
idpElement.appendChild(singleSignOn);
|
||||||
|
idpElement.appendChild(singleLogout);
|
||||||
|
this.buildSubsystemXml(idpElement, "/subsystem/secure-deployment[1]/SP");
|
||||||
|
|
||||||
|
this.exception.expect(XMLStreamException.class);
|
||||||
|
this.exception.expectMessage("WFLYCTL0198: Unexpected element");
|
||||||
|
super.testSubsystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDuplicateKeysInSP() throws Exception {
|
||||||
|
Element keysElement = this.document.createElement(Constants.XML.KEYS);
|
||||||
|
Element keyElement = this.document.createElement(Constants.XML.KEY);
|
||||||
|
keyElement.setAttribute(Constants.XML.ENCRYPTION, "false");
|
||||||
|
keyElement.setAttribute(Constants.XML.SIGNING, "false");
|
||||||
|
keysElement.appendChild(keyElement);
|
||||||
|
this.buildSubsystemXml(keysElement, "/subsystem/secure-deployment[1]/SP");
|
||||||
|
|
||||||
|
this.exception.expect(XMLStreamException.class);
|
||||||
|
this.exception.expectMessage("WFLYCTL0198: Unexpected element");
|
||||||
|
super.testSubsystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDuplicateKeysInIDP() throws Exception {
|
||||||
|
Element keysElement = this.document.createElement(Constants.XML.KEYS);
|
||||||
|
Element keyElement = this.document.createElement(Constants.XML.KEY);
|
||||||
|
keyElement.setAttribute(Constants.XML.ENCRYPTION, "false");
|
||||||
|
keyElement.setAttribute(Constants.XML.SIGNING, "false");
|
||||||
|
keysElement.appendChild(keyElement);
|
||||||
|
this.buildSubsystemXml(keysElement, "/subsystem/secure-deployment[1]/SP/IDP");
|
||||||
|
|
||||||
|
this.exception.expect(XMLStreamException.class);
|
||||||
|
this.exception.expectMessage("WFLYCTL0198: Unexpected element");
|
||||||
|
super.testSubsystem();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue