From 1c1e87ff7742531f5501b4aee0ce131a625b2219 Mon Sep 17 00:00:00 2001 From: srosse <none@none> Date: Wed, 22 Feb 2017 13:19:44 +0100 Subject: [PATCH] OO-2535: implements XML Digital Signature for QTI 2.1 assessment result --- .../org/olat/core/util/crypto/CryptoUtil.java | 69 ++++ .../crypto/X509CertificatePrivateKeyPair.java | 49 +++ .../org/olat/core/util/mail/MailBundle.java | 31 +- .../org/olat/core/util/mail/MailContent.java | 6 + .../util/mail/manager/MailManagerImpl.java | 50 +-- .../util/mail/model/SimpleMailContent.java | 93 +++++ .../util/xml/XMLDigitalSignatureUtil.java | 390 ++++++++++++++++++ .../course/nodes/iq/IQEditController.java | 4 + .../iq/QTI21AssessmentRunController.java | 77 +++- .../olat/course/nodes/iq/QTI21EditForm.java | 35 +- .../nodes/iq/_content/assessment_run.html | 7 + .../nodes/iq/_i18n/LocalStrings_de.properties | 7 + .../nodes/iq/_i18n/LocalStrings_en.properties | 7 + .../org/olat/ims/qti21/OutcomesListener.java | 13 + .../olat/ims/qti21/QTI21DeliveryOptions.java | 23 ++ .../java/org/olat/ims/qti21/QTI21Module.java | 78 ++++ .../java/org/olat/ims/qti21/QTI21Service.java | 48 ++- .../manager/AssessmentTestSessionDAO.java | 6 + .../ims/qti21/manager/QTI21ServiceImpl.java | 136 +++++- .../qti21/model/DigitalSignatureOptions.java | 79 ++++ .../qti21/model/InMemoryOutcomeListener.java | 8 + .../ui/AssessmentEntryOutcomesListener.java | 37 ++ .../qti21/ui/AssessmentResultController.java | 42 +- .../ui/AssessmentTestDisplayController.java | 43 +- .../ims/qti21/ui/QTI21AdminController.java | 122 +++++- .../ui/QTI21AssessmentDetailsController.java | 48 ++- .../ui/QTI21DeliveryOptionsController.java | 33 +- .../qti21/ui/QTI21TestSessionTableModel.java | 2 + .../olat/ims/qti21/ui/ResourcesMapper.java | 1 - .../qti21/ui/_content/assessment_results.html | 6 + .../qti21/ui/_i18n/LocalStrings_de.properties | 15 + .../qti21/ui/_i18n/LocalStrings_en.properties | 15 + .../resources/serviceconfig/olat.properties | 13 + .../util/xml/XMLDigitalSignatureUtilTest.java | 224 ++++++++++ .../olat/core/util/xml/assessmentResult.xml | 215 ++++++++++ .../util/xml/assessmentResult_tampered.xml | 215 ++++++++++ .../org/olat/core/util/xml/certificate.pfx | Bin 0 -> 2501 bytes .../java/org/olat/test/AllTestsJunit4.java | 1 + 38 files changed, 2129 insertions(+), 119 deletions(-) create mode 100644 src/main/java/org/olat/core/util/crypto/CryptoUtil.java create mode 100644 src/main/java/org/olat/core/util/crypto/X509CertificatePrivateKeyPair.java create mode 100644 src/main/java/org/olat/core/util/mail/model/SimpleMailContent.java create mode 100644 src/main/java/org/olat/core/util/xml/XMLDigitalSignatureUtil.java create mode 100644 src/main/java/org/olat/ims/qti21/model/DigitalSignatureOptions.java create mode 100644 src/test/java/org/olat/core/util/xml/XMLDigitalSignatureUtilTest.java create mode 100644 src/test/java/org/olat/core/util/xml/assessmentResult.xml create mode 100644 src/test/java/org/olat/core/util/xml/assessmentResult_tampered.xml create mode 100644 src/test/java/org/olat/core/util/xml/certificate.pfx diff --git a/src/main/java/org/olat/core/util/crypto/CryptoUtil.java b/src/main/java/org/olat/core/util/crypto/CryptoUtil.java new file mode 100644 index 00000000000..4306185c402 --- /dev/null +++ b/src/main/java/org/olat/core/util/crypto/CryptoUtil.java @@ -0,0 +1,69 @@ +/** + * <a href="http://www.openolat.org"> + * OpenOLAT - Online Learning and Training</a><br> + * <p> + * Licensed under the Apache License, Version 2.0 (the "License"); <br> + * you may not use this file except in compliance with the License.<br> + * You may obtain a copy of the License at the + * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a> + * <p> + * Unless required by applicable law or agreed to in writing,<br> + * software distributed under the License is distributed on an "AS IS" BASIS, <br> + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br> + * See the License for the specific language governing permissions and <br> + * limitations under the License. + * <p> + * Initial code contributed and copyrighted by<br> + * frentix GmbH, http://www.frentix.com + * <p> + */ +package org.olat.core.util.crypto; + +import java.io.File; +import java.io.FileInputStream; +import java.security.Key; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Enumeration; + +/** + * + * Initial date: 16 févr. 2017<br> + * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com + * + */ +public class CryptoUtil { + + /** + * + * @param certificate + * @return + * @throws Exception + */ + public static X509CertificatePrivateKeyPair getX509CertificatePrivateKeyPairPfx(File certificate, String password) throws Exception { + + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(new FileInputStream(certificate), password.toCharArray()); + + PrivateKey privateKey = null; + X509Certificate x509Cert = null; + + Enumeration<String> aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + Certificate cert = keyStore.getCertificate(alias); + if (cert instanceof X509Certificate) { + x509Cert = (X509Certificate)cert; + Key key = keyStore.getKey(alias, null); + if(key instanceof PrivateKey) { + privateKey = (PrivateKey)key; + } + break; + } + } + + return new X509CertificatePrivateKeyPair(x509Cert, privateKey); + } +} diff --git a/src/main/java/org/olat/core/util/crypto/X509CertificatePrivateKeyPair.java b/src/main/java/org/olat/core/util/crypto/X509CertificatePrivateKeyPair.java new file mode 100644 index 00000000000..54865a56d99 --- /dev/null +++ b/src/main/java/org/olat/core/util/crypto/X509CertificatePrivateKeyPair.java @@ -0,0 +1,49 @@ +/** + * <a href="http://www.openolat.org"> + * OpenOLAT - Online Learning and Training</a><br> + * <p> + * Licensed under the Apache License, Version 2.0 (the "License"); <br> + * you may not use this file except in compliance with the License.<br> + * You may obtain a copy of the License at the + * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a> + * <p> + * Unless required by applicable law or agreed to in writing,<br> + * software distributed under the License is distributed on an "AS IS" BASIS, <br> + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br> + * See the License for the specific language governing permissions and <br> + * limitations under the License. + * <p> + * Initial code contributed and copyrighted by<br> + * frentix GmbH, http://www.frentix.com + * <p> + */ +package org.olat.core.util.crypto; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +/** + * + * Initial date: 16 févr. 2017<br> + * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com + * + */ +public class X509CertificatePrivateKeyPair { + + private final PrivateKey privateKey; + private final X509Certificate x509Cert; + + public X509CertificatePrivateKeyPair(X509Certificate x509Cert, PrivateKey privateKey) { + this.x509Cert = x509Cert; + this.privateKey = privateKey; + } + + public PrivateKey getPrivateKey() { + return privateKey; + } + + public X509Certificate getX509Cert() { + return x509Cert; + } + +} diff --git a/src/main/java/org/olat/core/util/mail/MailBundle.java b/src/main/java/org/olat/core/util/mail/MailBundle.java index 8e6203e084c..1ec7904d545 100644 --- a/src/main/java/org/olat/core/util/mail/MailBundle.java +++ b/src/main/java/org/olat/core/util/mail/MailBundle.java @@ -25,6 +25,7 @@ import java.util.Collections; import java.util.List; import org.olat.core.id.Identity; +import org.olat.core.util.mail.model.SimpleMailContent; /** * @@ -129,34 +130,6 @@ public class MailBundle { } } } - content = new SimpleContent(subject, body, attachmentList); - } - - private static class SimpleContent implements MailContent { - - private String subject; - private String body; - private List<File> attachments; - - public SimpleContent(String subject, String body, List<File> attachments) { - this.subject = subject; - this.body = body; - this.attachments = attachments; - } - - @Override - public String getSubject() { - return subject; - } - - @Override - public String getBody() { - return body; - } - - @Override - public List<File> getAttachments() { - return attachments; - } + content = new SimpleMailContent(subject, body, attachmentList); } } diff --git a/src/main/java/org/olat/core/util/mail/MailContent.java b/src/main/java/org/olat/core/util/mail/MailContent.java index 3d26217964e..808386e679c 100644 --- a/src/main/java/org/olat/core/util/mail/MailContent.java +++ b/src/main/java/org/olat/core/util/mail/MailContent.java @@ -32,8 +32,14 @@ public interface MailContent { public String getSubject(); + public void setSubject(String subject); + public String getBody(); + public void setBody(String body); + public List<File> getAttachments(); + + public void setAttachments(List<File> attachments); } diff --git a/src/main/java/org/olat/core/util/mail/manager/MailManagerImpl.java b/src/main/java/org/olat/core/util/mail/manager/MailManagerImpl.java index 70de7ce09bc..de50b5560a4 100644 --- a/src/main/java/org/olat/core/util/mail/manager/MailManagerImpl.java +++ b/src/main/java/org/olat/core/util/mail/manager/MailManagerImpl.java @@ -104,6 +104,7 @@ import org.olat.core.util.mail.model.DBMailImpl; import org.olat.core.util.mail.model.DBMailLight; import org.olat.core.util.mail.model.DBMailLightImpl; import org.olat.core.util.mail.model.DBMailRecipient; +import org.olat.core.util.mail.model.SimpleMailContent; import org.olat.core.util.vfs.FileStorage; import org.olat.core.util.vfs.VFSContainer; import org.olat.core.util.vfs.VFSItem; @@ -717,7 +718,7 @@ public class MailManagerImpl implements MailManager, InitializingBean { } else { decoratedBody = content.getBody(); } - return new MessageContent(content.getSubject(), decoratedBody, content.getAttachments()); + return new SimpleMailContent(content.getSubject(), decoratedBody, content.getAttachments()); } protected MailContent createWithContext(Identity recipient, MailTemplate template, MailerResult result) { @@ -744,7 +745,7 @@ public class MailManagerImpl implements MailManager, InitializingBean { String body = bodyWriter.toString(); List<File> checkedFiles = MailHelper.checkAttachments(template.getAttachments(), result); File[] attachments = checkedFiles.toArray(new File[checkedFiles.size()]); - return new MessageContent(subject, body, attachments); + return new SimpleMailContent(subject, body, attachments); } /** @@ -778,51 +779,6 @@ public class MailManagerImpl implements MailManager, InitializingBean { mailerResult.setReturnCode(MailerResult.TEMPLATE_GENERAL_ERROR); } } - - private static class MessageContent implements MailContent { - private final String subject; - private final String body; - private final List<File> attachments; - - public MessageContent(String subject, String body, File[] attachmentArr) { - this.subject = subject; - this.body = body; - - attachments = new ArrayList<File>(); - if(attachmentArr != null && attachmentArr.length > 0) { - for(File attachment:attachmentArr) { - if(attachment != null && attachment.exists()) { - attachments.add(attachment); - } - } - } - } - - public MessageContent(String subject, String body, List<File> attachmentList) { - this.subject = subject; - this.body = body; - if(attachmentList == null) { - this.attachments = new ArrayList<File>(1); - } else { - this.attachments = new ArrayList<File>(attachmentList); - } - } - - @Override - public String getSubject() { - return subject; - } - - @Override - public String getBody() { - return body; - } - - @Override - public List<File> getAttachments() { - return attachments; - } - } @Override public MailerResult forwardToRealInbox(Identity identity, DBMail mail, MailerResult result) { diff --git a/src/main/java/org/olat/core/util/mail/model/SimpleMailContent.java b/src/main/java/org/olat/core/util/mail/model/SimpleMailContent.java new file mode 100644 index 00000000000..d413f9ae73f --- /dev/null +++ b/src/main/java/org/olat/core/util/mail/model/SimpleMailContent.java @@ -0,0 +1,93 @@ +/** + * <a href="http://www.openolat.org"> + * OpenOLAT - Online Learning and Training</a><br> + * <p> + * Licensed under the Apache License, Version 2.0 (the "License"); <br> + * you may not use this file except in compliance with the License.<br> + * You may obtain a copy of the License at the + * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a> + * <p> + * Unless required by applicable law or agreed to in writing,<br> + * software distributed under the License is distributed on an "AS IS" BASIS, <br> + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br> + * See the License for the specific language governing permissions and <br> + * limitations under the License. + * <p> + * Initial code contributed and copyrighted by<br> + * frentix GmbH, http://www.frentix.com + * <p> + */ +package org.olat.core.util.mail.model; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import org.olat.core.util.mail.MailContent; + +/** + * + * Initial date: 17 févr. 2017<br> + * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com + * + */ +public class SimpleMailContent implements MailContent { + + private String subject; + private String body; + private List<File> attachments; + + public SimpleMailContent(String subject, String body, File[] attachmentArr) { + this.subject = subject; + this.body = body; + + attachments = new ArrayList<File>(); + if(attachmentArr != null && attachmentArr.length > 0) { + for(File attachment:attachmentArr) { + if(attachment != null && attachment.exists()) { + attachments.add(attachment); + } + } + } + } + + public SimpleMailContent(String subject, String body, List<File> attachmentList) { + this.subject = subject; + this.body = body; + if(attachmentList == null) { + this.attachments = new ArrayList<File>(1); + } else { + this.attachments = new ArrayList<File>(attachmentList); + } + } + + @Override + public String getSubject() { + return subject; + } + + @Override + public void setSubject(String subject) { + this.subject = subject; + } + + @Override + public String getBody() { + return body; + } + + @Override + public void setBody(String body) { + this.body = body; + } + + @Override + public List<File> getAttachments() { + return attachments; + } + + @Override + public void setAttachments(List<File> attachments) { + this.attachments = attachments; + } +} diff --git a/src/main/java/org/olat/core/util/xml/XMLDigitalSignatureUtil.java b/src/main/java/org/olat/core/util/xml/XMLDigitalSignatureUtil.java new file mode 100644 index 00000000000..c65203f84df --- /dev/null +++ b/src/main/java/org/olat/core/util/xml/XMLDigitalSignatureUtil.java @@ -0,0 +1,390 @@ +/** + * <a href="http://www.openolat.org"> + * OpenOLAT - Online Learning and Training</a><br> + * <p> + * Licensed under the Apache License, Version 2.0 (the "License"); <br> + * you may not use this file except in compliance with the License.<br> + * You may obtain a copy of the License at the + * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a> + * <p> + * Unless required by applicable law or agreed to in writing,<br> + * software distributed under the License is distributed on an "AS IS" BASIS, <br> + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br> + * See the License for the specific language governing permissions and <br> + * limitations under the License. + * <p> + * Initial code contributed and copyrighted by<br> + * frentix GmbH, http://www.frentix.com + * <p> + */ +package org.olat.core.util.xml; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.Writer; +import java.nio.file.Files; +import java.security.GeneralSecurityException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javax.xml.crypto.Data; +import javax.xml.crypto.MarshalException; +import javax.xml.crypto.OctetStreamData; +import javax.xml.crypto.URIDereferencer; +import javax.xml.crypto.URIReference; +import javax.xml.crypto.URIReferenceException; +import javax.xml.crypto.XMLCryptoContext; +import javax.xml.crypto.dsig.CanonicalizationMethod; +import javax.xml.crypto.dsig.DigestMethod; +import javax.xml.crypto.dsig.Reference; +import javax.xml.crypto.dsig.SignatureMethod; +import javax.xml.crypto.dsig.SignedInfo; +import javax.xml.crypto.dsig.Transform; +import javax.xml.crypto.dsig.XMLSignature; +import javax.xml.crypto.dsig.XMLSignatureException; +import javax.xml.crypto.dsig.XMLSignatureFactory; +import javax.xml.crypto.dsig.dom.DOMSignContext; +import javax.xml.crypto.dsig.dom.DOMValidateContext; +import javax.xml.crypto.dsig.keyinfo.KeyInfo; +import javax.xml.crypto.dsig.keyinfo.KeyInfoFactory; +import javax.xml.crypto.dsig.keyinfo.X509Data; +import javax.xml.crypto.dsig.spec.C14NMethodParameterSpec; +import javax.xml.crypto.dsig.spec.TransformParameterSpec; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.TransformerFactoryConfigurationError; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import org.olat.core.logging.OLog; +import org.olat.core.logging.Tracing; +import org.olat.core.util.StringHelper; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.w3c.dom.Text; +import org.xml.sax.SAXException; + +/** + * + * Initial date: 16 févr. 2017<br> + * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com + * + */ +public class XMLDigitalSignatureUtil { + + private static final OLog log = Tracing.createLoggerFor(XMLDigitalSignatureUtil.class); + + /** + * Validate a XML file with a XML Digital Signature saved in an extenral file. + * + * + * @param xmlFile + * @param xmlSignatureFile + * @param publicKey + * @return + * @throws ParserConfigurationException + * @throws SAXException + * @throws IOException + * @throws MarshalException + * @throws XMLSignatureException + */ + public static boolean validate(String uri, File xmlFile, File xmlSignatureFile, PublicKey publicKey) + throws ParserConfigurationException, SAXException, IOException, MarshalException, XMLSignatureException { + + Document doc = getDocument(xmlSignatureFile); + NodeList nl = doc.getElementsByTagName("Signature"); + if (nl.getLength() == 0) { + return false; + } + + DOMValidateContext validContext = new DOMValidateContext(publicKey, nl.item(0)); + validContext.setBaseURI(uri); + validContext.setURIDereferencer(new FileURIDereferencer(uri, xmlFile)); + + XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM"); + XMLSignature signature = fac.unmarshalXMLSignature(validContext); + boolean validFlag = signature.validate(validContext); + if(!validFlag) { + // log and throw if not valid + boolean sv = signature.getSignatureValue().validate(validContext); + String msg = "signature validation status: " + sv; + + int numOfReferences = signature.getSignedInfo().getReferences().size(); + for (int j=0; j<numOfReferences; j++) { + Reference ref = (Reference)signature.getSignedInfo().getReferences().get(j); + boolean refValid = ref.validate(validContext); + msg += " ref["+j+"] validity status: " + refValid; + } + log.warn(msg); + } + return validFlag; + } + + public static boolean validate(File signedXmlFile, PublicKey publicKey) + throws ParserConfigurationException, SAXException, IOException, MarshalException, XMLSignatureException { + + Document doc = getDocument(signedXmlFile); + + NodeList nl = doc.getElementsByTagName("Signature"); + if (nl.getLength() == 0) { + return false; + } + + DOMValidateContext validContext = new DOMValidateContext(publicKey, nl.item(0)); + + XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM"); + XMLSignature signature = fac.unmarshalXMLSignature(validContext); + boolean validFlag = signature.validate(validContext); + if(!validFlag) { + // log and throw if not valid + boolean sv = signature.getSignatureValue().validate(validContext); + String msg = "signature validation status: " + sv; + + int numOfReferences = signature.getSignedInfo().getReferences().size(); + for (int j=0; j<numOfReferences; j++) { + Reference ref = (Reference)signature.getSignedInfo().getReferences().get(j); + boolean refValid = ref.validate(validContext); + msg += " ref["+j+"] validity status: " + refValid; + } + log.warn(msg); + } + return validFlag; + } + + /** + * Produce a signed a XML file. The signature is added in the XML file. + * + * @param xmlFile The original XML file + * @param xmlSignedFile The signed XML file + * @param x509Cert + * @param privateKey + * @throws IOException + * @throws SAXException + * @throws ParserConfigurationException + * @throws NoSuchAlgorithmException + * @throws GeneralSecurityException + * @throws MarshalException + * @throws XMLSignatureException + * @throws TransformerException + */ + public static void signEmbedded(File xmlFile, File xmlSignedFile, X509Certificate x509Cert, PrivateKey privateKey) + throws IOException, SAXException, ParserConfigurationException, NoSuchAlgorithmException, GeneralSecurityException, MarshalException, XMLSignatureException, TransformerException { + + Document doc = getDocument(xmlFile); + + // Create the signature factory for creating the signature. + XMLSignatureFactory sigFactory = XMLSignatureFactory.getInstance("DOM"); + + List<Transform> transforms = new ArrayList<Transform>(); + + Transform envelopped = sigFactory.newTransform(Transform.ENVELOPED, (TransformParameterSpec) null); + transforms.add(envelopped); + + // Create the canonicalization transform to be applied after the XSLT. + CanonicalizationMethod c14n = sigFactory.newCanonicalizationMethod( + CanonicalizationMethod.INCLUSIVE, (C14NMethodParameterSpec) null); + transforms.add(c14n); + + // Create the Reference to the XML to be signed specifying the hash algorithm to be used + // and the list of transforms to apply. Also specify the XML to be signed as the current + // document (specified by the first parameter being an empty string). + Reference reference = sigFactory.newReference( + "", + sigFactory.newDigestMethod(DigestMethod.SHA256, null), + transforms, + null, + null); + + // Create the Signed Info node of the signature by specifying the canonicalization method + // to use (INCLUSIVE), the signing method (RSA_SHA1), and the Reference node to be signed. + SignedInfo si = sigFactory.newSignedInfo(c14n, + sigFactory.newSignatureMethod(SignatureMethod.RSA_SHA1, null), + Collections.singletonList(reference)); + + // Create the KeyInfo node containing the public key information to include in the signature. + KeyInfoFactory kif = sigFactory.getKeyInfoFactory(); + X509Data xd = kif.newX509Data(Collections.singletonList(x509Cert)); + KeyInfo ki = kif.newKeyInfo(Collections.singletonList(xd)); + + // Get the node to attach the signature. + Node signatureInfoNode = doc.getDocumentElement(); + + // Create a signing context using the private key. + DOMSignContext dsc = new DOMSignContext(privateKey, signatureInfoNode); + + // Create the signature from the signing context and key info + XMLSignature signature = sigFactory.newXMLSignature(si, ki); + signature.sign(dsc); + + write(doc, xmlSignedFile); + } + + /** + * Create a separate XML file with the XML Digital Signature. + * + * of the specified XML file. + * @param xmlFile The XML File to sign + * @param outputSignatureFile Where the Digital Signature is saved + * @param signatureDoc A DOM which hold the signature (optional but if you give one, the root element must exists) + * @throws ParserConfigurationException + * @throws GeneralSecurityException + * @throws NoSuchAlgorithmException + * @throws XMLSignatureException + * @throws MarshalException + * @throws TransformerException + */ + public static void signDetached(String uri, File xmlFile, File outputSignatureFile, Document signatureDoc, + String keyName, X509Certificate x509Cert, PrivateKey privateKey) + throws IOException, SAXException, ParserConfigurationException, NoSuchAlgorithmException, GeneralSecurityException, MarshalException, XMLSignatureException, TransformerException { + + Document doc = getDocument(xmlFile); + + // Create the signature factory for creating the signature. + XMLSignatureFactory sigFactory = XMLSignatureFactory.getInstance("DOM"); + + List<Transform> transforms = new ArrayList<Transform>(); + + //Transform envelopped = sigFactory.newTransform(Transform.ENVELOPED, (TransformParameterSpec) null); + //transforms.add(envelopped); + + // Create the canonicalization transform to be applied after the XSLT. + CanonicalizationMethod c14n = sigFactory.newCanonicalizationMethod( + CanonicalizationMethod.EXCLUSIVE, (C14NMethodParameterSpec) null); + transforms.add(c14n); + + // Create the Reference to the XML to be signed specifying the hash algorithm to be used + // and the list of transforms to apply. Also specify the XML to be signed as the current + // document (specified by the first parameter being an empty string). + Reference reference = sigFactory.newReference( + uri, + sigFactory.newDigestMethod(DigestMethod.SHA256, null), + transforms, + null, + null); + + // Create the Signed Info node of the signature by specifying the canonicalization method + // to use (INCLUSIVE), the signing method (RSA_SHA1), and the Reference node to be signed. + SignedInfo si = sigFactory.newSignedInfo(c14n, + sigFactory.newSignatureMethod(SignatureMethod.RSA_SHA1, null), + Collections.singletonList(reference)); + + // Create the KeyInfo node containing the public key information to include in the signature. + KeyInfoFactory kif = sigFactory.getKeyInfoFactory(); + X509Data xd = kif.newX509Data(Collections.singletonList(x509Cert)); + + List<Object> keyInfoList = new ArrayList<>(); + if(StringHelper.containsNonWhitespace(keyName)) { + keyInfoList.add(kif.newKeyName(keyName)); + } + keyInfoList.add(xd); + KeyInfo ki = kif.newKeyInfo(keyInfoList); + + // Get the node to attach the signature. + Node signatureInfoNode = doc.getDocumentElement(); + + // Create a signing context using the private key. + DOMSignContext dsc = new DOMSignContext(privateKey, signatureInfoNode); + dsc.setBaseURI(uri); + dsc.setURIDereferencer(new FileURIDereferencer(uri, xmlFile)); + + // Create the signature from the signing context and key info + XMLSignature signature = sigFactory.newXMLSignature(si, ki); + signature.sign(dsc); + + NodeList nl = doc.getElementsByTagName("Signature"); + if (nl.getLength() == 1) { + if(signatureDoc != null && signatureDoc.getDocumentElement() != null) { + Element rootEl = signatureDoc.getDocumentElement(); + rootEl.appendChild(signatureDoc.importNode(nl.item(0), true)); + write(rootEl, outputSignatureFile); + } else { + write(nl.item(0), outputSignatureFile); + } + } + } + + private static void write(Node node, File outputFile) + throws IOException, TransformerException, TransformerFactoryConfigurationError, IllegalArgumentException { + try (Writer ssw = new FileWriter(outputFile)) { + TransformerFactory tf = TransformerFactory.newInstance(); + Transformer trans = tf.newTransformer(); + trans.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); + trans.transform(new DOMSource(node), new StreamResult(ssw)); + } catch (TransformerException | TransformerFactoryConfigurationError | IllegalArgumentException e) { + throw e; + } catch (IOException e) { + throw e; + } + } + + public static Document getDocument(File xmlFile) + throws ParserConfigurationException, SAXException, IOException { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + dbFactory.setNamespaceAware(true); + return dbFactory.newDocumentBuilder().parse(xmlFile); + } + + public static Document createDocument() + throws ParserConfigurationException, SAXException, IOException { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + dbFactory.setNamespaceAware(true); + return dbFactory.newDocumentBuilder().newDocument(); + } + + public static String getElementText(Document doc, String elementName) { + StringBuilder sb = new StringBuilder(); + + if(doc != null) { + NodeList nl = doc.getElementsByTagName(elementName); + if(nl.getLength() == 1) { + Node element = nl.item(0); + for(Node child=element.getFirstChild(); child != null; child = child.getNextSibling()) { + if(child instanceof Text) { + Text text = (Text)child; + sb.append(text.getTextContent()); + } + } + } + } + + return sb.toString(); + } + + private static class FileURIDereferencer implements URIDereferencer { + + private final String uri; + private final File xmlFile; + + public FileURIDereferencer(String uri, File xmlFile) { + this.uri = uri; + this.xmlFile = xmlFile; + } + + @Override + public Data dereference(URIReference uriReference, XMLCryptoContext context) throws URIReferenceException { + try { + if(uri.equals(uriReference.getURI())) { + byte[] bytes = Files.readAllBytes(xmlFile.toPath()); + ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes); + return new OctetStreamData(inputStream); + } + return null; + } catch (Exception e) { + throw new URIReferenceException(e); + } + } + } +} diff --git a/src/main/java/org/olat/course/nodes/iq/IQEditController.java b/src/main/java/org/olat/course/nodes/iq/IQEditController.java index 51696d617bb..e9ad569c57a 100644 --- a/src/main/java/org/olat/course/nodes/iq/IQEditController.java +++ b/src/main/java/org/olat/course/nodes/iq/IQEditController.java @@ -124,6 +124,10 @@ public class IQEditController extends ActivateableTabbableDefaultController impl public final static String CONFIG_CORRECTION_MODE = "correctionMode"; /** Test in full window mode*/ public final static String CONFIG_ALLOW_ANONYM = "allowAnonym"; + /** Digitally signed the assessment results */ + public final static String CONFIG_DIGITAL_SIGNATURE = "digitalSignature"; + /** Send the signature per mail */ + public final static String CONFIG_DIGITAL_SIGNATURE_SEND_MAIL = "digitalSignatureMail"; public final static String CORRECTION_AUTO = "auto"; public final static String CORRECTION_MANUAL = "manual"; diff --git a/src/main/java/org/olat/course/nodes/iq/QTI21AssessmentRunController.java b/src/main/java/org/olat/course/nodes/iq/QTI21AssessmentRunController.java index 0898ab93d6a..88cc14afedb 100644 --- a/src/main/java/org/olat/course/nodes/iq/QTI21AssessmentRunController.java +++ b/src/main/java/org/olat/course/nodes/iq/QTI21AssessmentRunController.java @@ -23,8 +23,10 @@ import java.io.File; import java.net.URI; import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.concurrent.atomic.AtomicBoolean; +import org.olat.core.CoreSpringFactory; import org.olat.core.commons.fullWebApp.LayoutMain3ColsController; import org.olat.core.gui.UserRequest; import org.olat.core.gui.components.Component; @@ -37,6 +39,10 @@ import org.olat.core.gui.control.Event; import org.olat.core.gui.control.WindowControl; import org.olat.core.gui.control.controller.BasicController; import org.olat.core.gui.control.generic.iframe.IFrameDisplayController; +import org.olat.core.gui.media.FileMediaResource; +import org.olat.core.gui.media.MediaResource; +import org.olat.core.gui.media.NotFoundMediaResource; +import org.olat.core.gui.translator.Translator; import org.olat.core.id.Identity; import org.olat.core.id.OLATResourceable; import org.olat.core.logging.activity.ThreadLocalUserActivityLogger; @@ -46,6 +52,7 @@ import org.olat.core.util.UserSession; import org.olat.core.util.Util; import org.olat.core.util.event.EventBus; import org.olat.core.util.event.GenericEventListener; +import org.olat.core.util.mail.MailBundle; import org.olat.core.util.resource.OresHelper; import org.olat.core.util.vfs.VFSContainer; import org.olat.course.DisposedCourseRestartController; @@ -57,6 +64,7 @@ import org.olat.course.nodes.IQSELFCourseNode; import org.olat.course.nodes.IQTESTCourseNode; import org.olat.course.nodes.QTICourseNode; import org.olat.course.nodes.SelfAssessableCourseNode; +import org.olat.course.run.environment.CourseEnvironment; import org.olat.course.run.scoring.ScoreEvaluation; import org.olat.course.run.userview.UserCourseEnvironment; import org.olat.fileresource.FileResourceManager; @@ -67,6 +75,7 @@ import org.olat.ims.qti21.QTI21DeliveryOptions; import org.olat.ims.qti21.QTI21DeliveryOptions.ShowResultsOnFinish; import org.olat.ims.qti21.QTI21LoggingAction; import org.olat.ims.qti21.QTI21Service; +import org.olat.ims.qti21.model.DigitalSignatureOptions; import org.olat.ims.qti21.ui.AssessmentResultController; import org.olat.ims.qti21.ui.AssessmentTestDisplayController; import org.olat.ims.qti21.ui.QTI21Event; @@ -76,6 +85,7 @@ import org.olat.modules.ModuleConfiguration; import org.olat.modules.assessment.AssessmentEntry; import org.olat.modules.assessment.model.AssessmentEntryStatus; import org.olat.repository.RepositoryEntry; +import org.olat.user.UserManager; import org.olat.util.logging.activity.LoggingResourceable; import org.springframework.beans.factory.annotation.Autowired; @@ -90,7 +100,7 @@ public class QTI21AssessmentRunController extends BasicController implements Gen private static final OLATResourceable assessmentEventOres = OresHelper.createOLATResourceableType(AssessmentEvent.class); private static final OLATResourceable assessmentInstanceOres = OresHelper.createOLATResourceableType(AssessmentInstance.class); - private Link startButton, showResultsButton, hideResultsButton; + private Link startButton, showResultsButton, hideResultsButton, signatureDownloadLink; private final VelocityContainer mainVC; private boolean assessmentStopped = true; @@ -236,6 +246,23 @@ public class QTI21AssessmentRunController extends BasicController implements Gen UserNodeAuditManager am = userCourseEnv.getCourseEnvironment().getAuditManager(); mainVC.contextPut("log", am.getUserNodeLog(courseNode, identity)); } + + if(deliveryOptions.isDigitalSignature()) { + AssessmentTestSession session = qtiService.getAssessmentTestSession(assessmentEntry.getAssessmentId()); + if(session != null) { + File signature = qtiService.getAssessmentResultSignature(session); + if(signature != null && signature.exists()) { + signatureDownloadLink = LinkFactory.createLink("digital.signature.download.link", mainVC, this); + signatureDownloadLink.setIconLeftCSS("o_icon o_icon-fw o_icon_download"); + signatureDownloadLink.setTarget("_blank"); + + Date issueDate = qtiService.getAssessmentResultSignatureIssueDate(session); + if(issueDate != null) { + mainVC.contextPut("signatureIssueDate", Formatter.getInstance(getLocale()).formatDateAndTime(issueDate)); + } + } + } + } } } @@ -353,6 +380,8 @@ public class QTI21AssessmentRunController extends BasicController implements Gen doShowResults(ureq); } else if (source == hideResultsButton) { doHideResults(); + } else if (source == signatureDownloadLink) { + doDownloadSignature(ureq); } } @@ -422,6 +451,23 @@ public class QTI21AssessmentRunController extends BasicController implements Gen private void doHideResults() { mainVC.contextPut("showResults", Boolean.FALSE); } + + private void doDownloadSignature(UserRequest ureq) { + MediaResource resource = null; + if(courseNode instanceof IQTESTCourseNode) { + IQTESTCourseNode testCourseNode = (IQTESTCourseNode)courseNode; + AssessmentEntry assessmentEntry = testCourseNode.getUserAssessmentEntry(userCourseEnv); + AssessmentTestSession session = qtiService.getAssessmentTestSession(assessmentEntry.getAssessmentId()); + File signature = qtiService.getAssessmentResultSignature(session); + if(signature.exists()) { + resource = new FileMediaResource(signature); + } + } + if(resource == null) { + resource = new NotFoundMediaResource(""); + } + ureq.getDispatchResult().setResultingMediaResource(resource); + } private void doStart(UserRequest ureq) { removeAsListenerAndDispose(displayCtrl); @@ -474,6 +520,8 @@ public class QTI21AssessmentRunController extends BasicController implements Gen finalOptions.setShowResultsOnFinish(ShowResultsOnFinish.fromIQEquivalent(config.getStringValue(IQEditController.CONFIG_KEY_SUMMARY), ShowResultsOnFinish.compact)); finalOptions.setShowMenu(config.getBooleanSafe(IQEditController.CONFIG_KEY_ENABLEMENU, testOptions.isShowMenu())); finalOptions.setAllowAnonym(config.getBooleanSafe(IQEditController.CONFIG_ALLOW_ANONYM, testOptions.isAllowAnonym())); + finalOptions.setDigitalSignature(config.getBooleanSafe(IQEditController.CONFIG_DIGITAL_SIGNATURE, testOptions.isDigitalSignature())); + finalOptions.setDigitalSignatureMail(config.getBooleanSafe(IQEditController.CONFIG_DIGITAL_SIGNATURE_SEND_MAIL, testOptions.isDigitalSignatureMail())); return finalOptions; } @@ -506,6 +554,33 @@ public class QTI21AssessmentRunController extends BasicController implements Gen fireEvent(ureq, event); } + + @Override + public void decorateConfirmation(AssessmentTestSession candidateSession, DigitalSignatureOptions options, Locale locale) { + decorateCourseConfirmation(candidateSession, options, userCourseEnv.getCourseEnvironment(), courseNode, testEntry, locale); + } + + public static void decorateCourseConfirmation(AssessmentTestSession candidateSession, DigitalSignatureOptions options, + CourseEnvironment courseEnv, CourseNode courseNode, RepositoryEntry testEntry, Locale locale) { + MailBundle bundle = new MailBundle(); + bundle.setToId(candidateSession.getIdentity()); + String fullname = CoreSpringFactory.getImpl(UserManager.class).getUserDisplayName(candidateSession.getIdentity()); + + String[] args = new String[] { + courseEnv.getCourseTitle(), // {0} + courseNode.getShortTitle(), // {1} + testEntry.getDisplayname(), // {2} + fullname, // {3} + Formatter.getInstance(locale).formatDateAndTime(candidateSession.getFinishTime()) + }; + + Translator translator = Util.createPackageTranslator(QTI21AssessmentRunController.class, locale); + String subject = translator.translate("digital.signature.mail.subject", args); + String body = translator.translate("digital.signature.mail.body", args); + bundle.setContent(subject, body); + options.setMailBundle(bundle); + options.setSubIdentName(courseNode.getShortTitle()); + } @Override public void updateOutcomes(Float score, Boolean pass) { diff --git a/src/main/java/org/olat/course/nodes/iq/QTI21EditForm.java b/src/main/java/org/olat/course/nodes/iq/QTI21EditForm.java index dec418afdad..0bcf6981431 100644 --- a/src/main/java/org/olat/course/nodes/iq/QTI21EditForm.java +++ b/src/main/java/org/olat/course/nodes/iq/QTI21EditForm.java @@ -38,7 +38,9 @@ import org.olat.core.util.StringHelper; import org.olat.ims.qti.process.AssessmentInstance; import org.olat.ims.qti21.QTI21DeliveryOptions; import org.olat.ims.qti21.QTI21DeliveryOptions.ShowResultsOnFinish; +import org.olat.ims.qti21.QTI21Module; import org.olat.modules.ModuleConfiguration; +import org.springframework.beans.factory.annotation.Autowired; /** * @@ -62,6 +64,7 @@ public class QTI21EditForm extends FormBasicController { private MultipleSelectionElement displayQuestionProgressEl, displayScoreProgressEl; private MultipleSelectionElement showResultsOnFinishEl; private MultipleSelectionElement allowAnonymEl; + private MultipleSelectionElement digitalSignatureEl, digitalSignatureMailEl; private SingleSelection typeShowResultsOnFinishEl; private DateChooser startDateElement, endDateElement; @@ -73,6 +76,9 @@ public class QTI21EditForm extends FormBasicController { private static final String[] correctionModeKeys = new String[]{ "auto", "manual" }; + @Autowired + private QTI21Module qtiModule; + public QTI21EditForm(UserRequest ureq, WindowControl wControl, ModuleConfiguration modConfig, QTI21DeliveryOptions deliveryOptions, boolean needManulCorrection) { super(ureq, wControl); @@ -134,7 +140,24 @@ public class QTI21EditForm extends FormBasicController { boolean fullWindow = modConfig.getBooleanSafe(IQEditController.CONFIG_FULLWINDOW); fullWindowEl = uifactory.addCheckboxesHorizontal("fullwindow", "qti.form.fullwindow", formLayout, new String[]{"x"}, new String[]{""}); - fullWindowEl.select("x", fullWindow); + if(fullWindow) { + fullWindowEl.select("x", fullWindow); + } + + boolean digitalSignature = modConfig.getBooleanSafe(IQEditController.CONFIG_DIGITAL_SIGNATURE, deliveryOptions.isDigitalSignature()); + digitalSignatureEl = uifactory.addCheckboxesHorizontal("digital.signature", "digital.signature", formLayout, new String[]{"x"}, new String[]{""}); + if(digitalSignature) { + digitalSignatureEl.select("x", digitalSignature); + } + digitalSignatureEl.setVisible(qtiModule.isDigitalSignatureEnabled()); + digitalSignatureEl.addActionListener(FormEvent.ONCHANGE); + + boolean digitalSignatureSendMail = modConfig.getBooleanSafe(IQEditController.CONFIG_DIGITAL_SIGNATURE_SEND_MAIL, deliveryOptions.isDigitalSignatureMail()); + digitalSignatureMailEl = uifactory.addCheckboxesHorizontal("digital.signature.mail", "digital.signature.mail", formLayout, new String[]{"x"}, new String[]{""}); + if(digitalSignatureSendMail) { + digitalSignatureMailEl.select("x", digitalSignatureSendMail); + } + digitalSignatureMailEl.setVisible(qtiModule.isDigitalSignatureEnabled() && digitalSignatureEl.isAtLeastSelected(1)); boolean showTitles = modConfig.getBooleanSafe(IQEditController.CONFIG_KEY_QUESTIONTITLE, deliveryOptions.isShowTitles()); showTitlesEl = uifactory.addCheckboxesHorizontal("showTitles", "qti.form.questiontitle", formLayout, onKeys, onValues); @@ -285,6 +308,8 @@ public class QTI21EditForm extends FormBasicController { update(); } else if(showResultsDateDependentButton == source || showResultsOnHomePage == source) { update(); + } else if(digitalSignatureEl == source) { + digitalSignatureMailEl.setVisible(digitalSignatureEl.isAtLeastSelected(1)); } super.formInnerEvent(ureq, source, event); } @@ -330,6 +355,14 @@ public class QTI21EditForm extends FormBasicController { modConfig.setBooleanEntry(IQEditController.CONFIG_KEY_SCOREPROGRESS, displayScoreProgressEl.isSelected(0)); modConfig.setBooleanEntry(IQEditController.CONFIG_ALLOW_ANONYM, allowAnonymEl.isSelected(0)); + if(qtiModule.isDigitalSignatureEnabled() && digitalSignatureEl.isSelected(0)) { + modConfig.setBooleanEntry(IQEditController.CONFIG_DIGITAL_SIGNATURE, true); + modConfig.setBooleanEntry(IQEditController.CONFIG_DIGITAL_SIGNATURE_SEND_MAIL, digitalSignatureMailEl.isSelected(0)); + } else { + modConfig.setBooleanEntry(IQEditController.CONFIG_DIGITAL_SIGNATURE, false); + modConfig.setBooleanEntry(IQEditController.CONFIG_DIGITAL_SIGNATURE_SEND_MAIL, false); + } + modConfig.setBooleanEntry(IQEditController.CONFIG_KEY_ENABLESCOREINFO, scoreInfo.isSelected(0)); modConfig.setBooleanEntry(IQEditController.CONFIG_KEY_DATE_DEPENDENT_RESULTS, showResultsDateDependentButton.isSelected(0)); diff --git a/src/main/java/org/olat/course/nodes/iq/_content/assessment_run.html b/src/main/java/org/olat/course/nodes/iq/_content/assessment_run.html index 1b0dbd4dde8..4ba585dd101 100644 --- a/src/main/java/org/olat/course/nodes/iq/_content/assessment_run.html +++ b/src/main/java/org/olat/course/nodes/iq/_content/assessment_run.html @@ -38,6 +38,13 @@ #end </td> </tr> + #if($r.available("digital.signature.download.link")) + <tr> + <th>$r.translate("digital.signature.download")</th> + <td>$r.render("digital.signature.download.link") + #if($r.isNotNull($signatureIssueDate)) $r.translate("digital.signature.download.date", $signatureIssueDate)#end</td> + </tr> + #end </tbody> </table> </div> diff --git a/src/main/java/org/olat/course/nodes/iq/_i18n/LocalStrings_de.properties b/src/main/java/org/olat/course/nodes/iq/_i18n/LocalStrings_de.properties index 1096ed9c31c..4b5abd0328c 100644 --- a/src/main/java/org/olat/course/nodes/iq/_i18n/LocalStrings_de.properties +++ b/src/main/java/org/olat/course/nodes/iq/_i18n/LocalStrings_de.properties @@ -27,6 +27,13 @@ correction.manual=Manuell correction.mode=Korrektur correcttest=Test korrigieren coursefolder=Ablageordner Kurs "{0}" +digital.signature=Digital Unterschrift +digital.signature.mail=Unterschrift per Mail schicken +digital.signature.mail.subject=Best\u00E4tigung Test {2} im Kurs {0} und baustein {1} +digital.signature.mail.body=Best\u00E4tigung Test {0} im Kurs {0} und baustein {1}. sie finde unten als Anhang die Quittung Ihres Test. +digital.signature.download=Digital Unterschrift +digital.signature.download.link=Herunterladen +digital.signature.download.date=( Erstellt am {0} ) disclaimer=Information disclaimer.file.invalid=Gewisse Informationen k\u00F6nnen nicht angezeigt werden weil die referenzierte Datei {0} nicht mehr vorhanden ist. Bitte benachrichtigen Sie die Kursleitung. error.assessment.pulled=Das Test wurde von Ihrem Tutor eingezogen. diff --git a/src/main/java/org/olat/course/nodes/iq/_i18n/LocalStrings_en.properties b/src/main/java/org/olat/course/nodes/iq/_i18n/LocalStrings_en.properties index ecaebf0b267..8a690c8bd55 100644 --- a/src/main/java/org/olat/course/nodes/iq/_i18n/LocalStrings_en.properties +++ b/src/main/java/org/olat/course/nodes/iq/_i18n/LocalStrings_en.properties @@ -27,6 +27,13 @@ correction.manual=Manual correction.mode=Correction correcttest=Correct test coursefolder=Storage folder of course "{0}" +digital.signature=Digital signature +digital.signature.mail=Send signature per mail +digital.signature.mail.subject=Confirmation test {2} in course {0} in element {1} +digital.signature.mail.body=Confirmation test {2} in course {0} in element {1}. The receipt of your test is below as attachment. +digital.signature.download=Digital Signature +digital.signature.download.link=Download +digital.signature.download.date=( Issued the {0} ) disclaimer=Information disclaimer.file.invalid=Some information cannot be displayed because the referenced file {0} is not available anymore. Please contact your course administrator. error.assessment.pulled=The assessment is finished. diff --git a/src/main/java/org/olat/ims/qti21/OutcomesListener.java b/src/main/java/org/olat/ims/qti21/OutcomesListener.java index 844b7e347d8..a19af703d34 100644 --- a/src/main/java/org/olat/ims/qti21/OutcomesListener.java +++ b/src/main/java/org/olat/ims/qti21/OutcomesListener.java @@ -19,6 +19,10 @@ */ package org.olat.ims.qti21; +import java.util.Locale; + +import org.olat.ims.qti21.model.DigitalSignatureOptions; + /** * * Initial date: 20.05.2015<br> @@ -27,6 +31,15 @@ package org.olat.ims.qti21; */ public interface OutcomesListener { + /** + * Add more useful informations to the signature as a mail bundle to send the signature per email. + * + * @param candidateSession + * @param options + * @param locale + */ + public void decorateConfirmation(AssessmentTestSession candidateSession, DigitalSignatureOptions options, Locale locale); + /** * Update the outcomes. * diff --git a/src/main/java/org/olat/ims/qti21/QTI21DeliveryOptions.java b/src/main/java/org/olat/ims/qti21/QTI21DeliveryOptions.java index 8f9d9720718..1228f8f6f05 100644 --- a/src/main/java/org/olat/ims/qti21/QTI21DeliveryOptions.java +++ b/src/main/java/org/olat/ims/qti21/QTI21DeliveryOptions.java @@ -46,6 +46,9 @@ public class QTI21DeliveryOptions { private boolean allowAnonym; + private boolean digitalSignature; + private boolean digitalSignatureMail; + private Integer templateProcessingLimit; private ShowResultsOnFinish showResultsOnFinish; @@ -130,6 +133,22 @@ public class QTI21DeliveryOptions { this.maxAttempts = maxAttempts; } + public boolean isDigitalSignature() { + return digitalSignature; + } + + public void setDigitalSignature(boolean digitalSignature) { + this.digitalSignature = digitalSignature; + } + + public boolean isDigitalSignatureMail() { + return digitalSignatureMail; + } + + public void setDigitalSignatureMail(boolean digitalSignatureMail) { + this.digitalSignatureMail = digitalSignatureMail; + } + public ShowResultsOnFinish getShowResultsOnFinish() { return showResultsOnFinish; } @@ -158,6 +177,8 @@ public class QTI21DeliveryOptions { defaultSettings.allowAnonym = false; defaultSettings.blockAfterSuccess = false; defaultSettings.maxAttempts = 0; + defaultSettings.digitalSignature = false; + defaultSettings.digitalSignatureMail = false; defaultSettings.showResultsOnFinish = ShowResultsOnFinish.none; return defaultSettings; } @@ -175,6 +196,8 @@ public class QTI21DeliveryOptions { clone.allowAnonym = allowAnonym; clone.blockAfterSuccess = blockAfterSuccess; clone.maxAttempts = maxAttempts; + clone.digitalSignature = digitalSignature; + clone.digitalSignatureMail = digitalSignatureMail; clone.showResultsOnFinish = showResultsOnFinish; return clone; } diff --git a/src/main/java/org/olat/ims/qti21/QTI21Module.java b/src/main/java/org/olat/ims/qti21/QTI21Module.java index 672fb4e4f59..32222007dc6 100644 --- a/src/main/java/org/olat/ims/qti21/QTI21Module.java +++ b/src/main/java/org/olat/ims/qti21/QTI21Module.java @@ -19,8 +19,18 @@ */ package org.olat.ims.qti21; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + import org.olat.core.configuration.AbstractSpringModule; +import org.olat.core.logging.OLog; +import org.olat.core.logging.Tracing; +import org.olat.core.util.FileUtils; import org.olat.core.util.StringHelper; +import org.olat.core.util.WebappHelper; import org.olat.core.util.coordinate.CoordinatorManager; import org.olat.ims.qti21.repository.handlers.QTI21AssessmentTestHandler; import org.olat.repository.handlers.RepositoryHandlerFactory; @@ -37,11 +47,19 @@ import org.springframework.stereotype.Service; @Service public class QTI21Module extends AbstractSpringModule { + private static final OLog log = Tracing.createLoggerFor(QTI21Module.class); + @Autowired private QTI21AssessmentTestHandler assessmentHandler; @Value("${qti21.math.assessment.extension.enabled:false}") private boolean mathAssessExtensionEnabled; + @Value("${qti21.digital.signature.enabled:false}") + private boolean digitalSignatureEnabled; + @Value("${qti21.digital.signature.certificate:}") + private String digitalSignatureCertificate; + @Value("${qti21.digital.signature.certificate.password:}") + private String digitalSignatureCertificatePassword; @Autowired public QTI21Module(CoordinatorManager coordinatorManager) { @@ -62,6 +80,21 @@ public class QTI21Module extends AbstractSpringModule { if(StringHelper.containsNonWhitespace(mathExtensionObj)) { mathAssessExtensionEnabled = "enabled".equals(mathExtensionObj); } + + String digitalSignatureObj = getStringPropertyValue("digital.signature", true); + if(StringHelper.containsNonWhitespace(digitalSignatureObj)) { + digitalSignatureEnabled = "enabled".equals(digitalSignatureObj); + } + + String digitalSignatureCertificateObj = getStringPropertyValue("qti21.digital.signature.certificate", true); + if(StringHelper.containsNonWhitespace(digitalSignatureCertificateObj)) { + digitalSignatureCertificate = digitalSignatureCertificateObj; + } + + String digitalSignatureCertificatePasswordObj = getStringPropertyValue("qti21.digital.signature.certificate.password", true); + if(StringHelper.containsNonWhitespace(digitalSignatureObj)) { + digitalSignatureCertificatePassword = digitalSignatureCertificatePasswordObj; + } } public boolean isMathAssessExtensionEnabled() { @@ -72,4 +105,49 @@ public class QTI21Module extends AbstractSpringModule { mathAssessExtensionEnabled = enabled; setStringProperty("math.extension", enabled ? "enabled" : "disabled", true); } + + public boolean isDigitalSignatureEnabled() { + return digitalSignatureEnabled; + } + + public void setDigitalSignatureEnabled(boolean enabled) { + this.digitalSignatureEnabled = enabled; + setStringProperty("digital.signature", enabled ? "enabled" : "disabled", true); + } + + public String getDigitalSignatureCertificate() { + return digitalSignatureCertificate; + } + + public File getDigitalSignatureCertificateFile() { + File file = new File(digitalSignatureCertificate); + if(!file.isAbsolute()) { + String userDataDirectory = WebappHelper.getUserDataRoot(); + file = Paths.get(userDataDirectory, "system", "configuration", digitalSignatureCertificate).toFile(); + } + return file; + } + + public void setDigitalSignatureCertificateFile(File file, String filename) { + try { + String userDataDirectory = WebappHelper.getUserDataRoot(); + File newFile = Paths.get(userDataDirectory, "system", "configuration", filename).toFile(); + String newName = FileUtils.rename(newFile); + File uniqueFile = Paths.get(userDataDirectory, "system", "configuration", newName).toFile(); + Files.copy(file.toPath(), uniqueFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + digitalSignatureCertificate = uniqueFile.getName(); + setStringProperty("qti21.digital.signature.certificate", digitalSignatureCertificate, true); + } catch (IOException e) { + log.error("", e); + } + } + + public String getDigitalSignatureCertificatePassword() { + return digitalSignatureCertificatePassword; + } + + public void setDigitalSignatureCertificatePassword(String digitalSignatureCertificatePassword) { + this.digitalSignatureCertificatePassword = digitalSignatureCertificatePassword; + setStringProperty("qti21.digital.signature.certificate.password", digitalSignatureCertificatePassword, true); + } } diff --git a/src/main/java/org/olat/ims/qti21/QTI21Service.java b/src/main/java/org/olat/ims/qti21/QTI21Service.java index 51d8bb33276..5eeec942c05 100644 --- a/src/main/java/org/olat/ims/qti21/QTI21Service.java +++ b/src/main/java/org/olat/ims/qti21/QTI21Service.java @@ -29,6 +29,7 @@ import java.util.Map; import org.olat.basesecurity.IdentityRef; import org.olat.core.gui.components.form.flexible.impl.MultipartFileInfos; import org.olat.core.id.Identity; +import org.olat.ims.qti21.model.DigitalSignatureOptions; import org.olat.ims.qti21.model.ParentPartItemRefs; import org.olat.ims.qti21.model.ResponseLegality; import org.olat.ims.qti21.model.audit.CandidateEvent; @@ -275,10 +276,32 @@ public interface QTI21Service { public AssessmentTestSession recordTestAssessmentResult(AssessmentTestSession candidateSession, TestSessionState testSessionState, AssessmentResult assessmentResult, AssessmentSessionAuditLogger auditLogger); - public AssessmentTestSession finishTestSession(AssessmentTestSession candidateSession, TestSessionState testSessionState, AssessmentResult assessmentResul, Date timestamp); + /** + * Finish the test session. The assessment result is for the last time and would not updated anymore. + * + * @param candidateSession + * @param testSessionState + * @param assessmentResul + * @param timestamp + * @param digitalSignature + * @param bundle + * @return + */ + public AssessmentTestSession finishTestSession(AssessmentTestSession candidateSession, TestSessionState testSessionState, AssessmentResult assessmentResul, + Date timestamp, DigitalSignatureOptions signatureOptions, Identity assessedIdentity); public void cancelTestSession(AssessmentTestSession candidateSession, TestSessionState testSessionState); + /** + * Sign the assessment result. Be careful, the file must not be changed + * after that! + * + * @param candidateSession + * @param sendMail + * @param mail + */ + public void signAssessmentResult(AssessmentTestSession candidateSession, DigitalSignatureOptions signatureOptions, Identity assessedIdentity); + public CandidateEvent recordCandidateTestEvent(AssessmentTestSession candidateSession, RepositoryEntryRef testEntry, RepositoryEntryRef entry, CandidateTestEventType textEventType, TestSessionState testSessionState, NotificationRecorder notificationRecorder); @@ -286,9 +309,30 @@ public interface QTI21Service { CandidateTestEventType textEventType, CandidateItemEventType itemEventType, TestPlanNodeKey itemKey, TestSessionState testSessionState, NotificationRecorder notificationRecorder); - + /** + * Return the assessment result for the specified test session. + * + * @param candidateSession + * @return The assessment result + */ public AssessmentResult getAssessmentResult(AssessmentTestSession candidateSession); + /** + * Return the file where the XML Digital Signature of the assessment result + * is saved or null if it not exists. + * + * @return The file + */ + public File getAssessmentResultSignature(AssessmentTestSession candidateSession); + + /** + * Return the issue date saved in the XML Digital Signature + * + * @param candidateSession + * @return + */ + public Date getAssessmentResultSignatureIssueDate(AssessmentTestSession candidateSession); + public AssessmentTestSession finishItemSession(AssessmentTestSession candidateSession, AssessmentResult assessmentResul, Date timestamp); diff --git a/src/main/java/org/olat/ims/qti21/manager/AssessmentTestSessionDAO.java b/src/main/java/org/olat/ims/qti21/manager/AssessmentTestSessionDAO.java index 6df900c283d..30b01e5a87d 100644 --- a/src/main/java/org/olat/ims/qti21/manager/AssessmentTestSessionDAO.java +++ b/src/main/java/org/olat/ims/qti21/manager/AssessmentTestSessionDAO.java @@ -243,6 +243,12 @@ public class AssessmentTestSessionDAO { return sessions == null || sessions.isEmpty() ? null : sessions.get(0); } + /** + * Load the assessment test session and only fetch the user. + * + * @param testSessionKey + * @return + */ public AssessmentTestSession loadFullByKey(Long testSessionKey) { StringBuilder sb = new StringBuilder(); sb.append("select session from qtiassessmenttestsession session") diff --git a/src/main/java/org/olat/ims/qti21/manager/QTI21ServiceImpl.java b/src/main/java/org/olat/ims/qti21/manager/QTI21ServiceImpl.java index 3b2fa1acda3..850bfd12325 100644 --- a/src/main/java/org/olat/ims/qti21/manager/QTI21ServiceImpl.java +++ b/src/main/java/org/olat/ims/qti21/manager/QTI21ServiceImpl.java @@ -49,15 +49,25 @@ import javax.xml.transform.stream.StreamResult; import org.apache.commons.io.IOUtils; import org.olat.basesecurity.IdentityRef; import org.olat.core.gui.components.form.flexible.impl.MultipartFileInfos; +import org.olat.core.helpers.Settings; import org.olat.core.id.Identity; import org.olat.core.id.Persistable; +import org.olat.core.id.User; import org.olat.core.logging.OLATRuntimeException; import org.olat.core.logging.OLog; import org.olat.core.logging.Tracing; import org.olat.core.util.FileUtils; +import org.olat.core.util.Formatter; import org.olat.core.util.StringHelper; import org.olat.core.util.cache.CacheWrapper; +import org.olat.core.util.coordinate.Cacher; import org.olat.core.util.coordinate.CoordinatorManager; +import org.olat.core.util.crypto.CryptoUtil; +import org.olat.core.util.crypto.X509CertificatePrivateKeyPair; +import org.olat.core.util.mail.MailBundle; +import org.olat.core.util.mail.MailManager; +import org.olat.core.util.mail.MailerResult; +import org.olat.core.util.xml.XMLDigitalSignatureUtil; import org.olat.core.util.xml.XStreamHelper; import org.olat.fileresource.FileResourceManager; import org.olat.fileresource.types.ImsQTI21Resource; @@ -75,6 +85,7 @@ import org.olat.ims.qti21.QTI21Module; import org.olat.ims.qti21.QTI21Service; import org.olat.ims.qti21.manager.audit.AssessmentSessionAuditFileLog; import org.olat.ims.qti21.manager.audit.AssessmentSessionAuditOLog; +import org.olat.ims.qti21.model.DigitalSignatureOptions; import org.olat.ims.qti21.model.InMemoryAssessmentTestMarks; import org.olat.ims.qti21.model.InMemoryAssessmentTestSession; import org.olat.ims.qti21.model.ParentPartItemRefs; @@ -92,6 +103,7 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.w3c.dom.Document; +import org.w3c.dom.Node; import com.thoughtworks.xstream.XStream; @@ -168,6 +180,8 @@ public class QTI21ServiceImpl implements QTI21Service, UserDataDeletable, Initia private QTI21Module qtiModule; @Autowired private CoordinatorManager coordinatorManager; + @Autowired + private MailManager mailManager; private JqtiExtensionManager jqtiExtensionManager; @@ -197,8 +211,9 @@ public class QTI21ServiceImpl implements QTI21Service, UserDataDeletable, Initia jqtiExtensionManager.init(); - assessmentTestsCache = coordinatorManager.getInstance().getCoordinator().getCacher().getCache("QTIWorks", "assessmentTests"); - assessmentItemsCache = coordinatorManager.getInstance().getCoordinator().getCacher().getCache("QTIWorks", "assessmentItems"); + Cacher cacher = coordinatorManager.getInstance().getCoordinator().getCacher(); + assessmentTestsCache = cacher.getCache("QTIWorks", "assessmentTests"); + assessmentItemsCache = cacher.getCache("QTIWorks", "assessmentItems"); } @Override @@ -595,7 +610,9 @@ public class QTI21ServiceImpl implements QTI21Service, UserDataDeletable, Initia public AssessmentTestSession recordTestAssessmentResult(AssessmentTestSession candidateSession, TestSessionState testSessionState, AssessmentResult assessmentResult, AssessmentSessionAuditLogger auditLogger) { // First record full result XML to filesystem - storeAssessmentResultFile(candidateSession, assessmentResult); + if(candidateSession.getFinishTime() == null) { + storeAssessmentResultFile(candidateSession, assessmentResult); + } // Then record test outcome variables to DB recordOutcomeVariables(candidateSession, assessmentResult.getTestResult(), auditLogger); // Set duration @@ -606,9 +623,115 @@ public class QTI21ServiceImpl implements QTI21Service, UserDataDeletable, Initia } return candidateSession; } + + @Override + public void signAssessmentResult(AssessmentTestSession candidateSession, DigitalSignatureOptions signatureOptions, Identity assessedIdentity) { + if(!qtiModule.isDigitalSignatureEnabled() || !signatureOptions.isDigitalSignature()) return;//nothing to do + + try { + File resultFile = getAssessmentResultFile(candidateSession); + File signatureFile = new File(resultFile.getParentFile(), "assessmentResultSignature.xml"); + File certificateFile = qtiModule.getDigitalSignatureCertificateFile(); + X509CertificatePrivateKeyPair kp =CryptoUtil.getX509CertificatePrivateKeyPairPfx( + certificateFile, qtiModule.getDigitalSignatureCertificatePassword()); + + StringBuilder uri = new StringBuilder(); + uri.append(Settings.getServerContextPathURI()).append("/") + .append("RepositoryEntry/").append(candidateSession.getRepositoryEntry().getKey()); + if(StringHelper.containsNonWhitespace(candidateSession.getSubIdent())) { + uri.append("/CourseNode/").append(candidateSession.getSubIdent()); + } + uri.append("/TestSession/").append(candidateSession.getKey()) + .append("/assessmentResult.xml"); + Document signatureDoc = createSignatureDocumentWrapper(uri.toString(), assessedIdentity, signatureOptions); + + XMLDigitalSignatureUtil.signDetached(uri.toString(), resultFile, signatureFile, signatureDoc, + certificateFile.getName(), kp.getX509Cert(), kp.getPrivateKey()); + + if(signatureOptions.isDigitalSignature() && signatureOptions.getMailBundle() != null) { + MailBundle mail = signatureOptions.getMailBundle(); + List<File> attachments = new ArrayList<>(2); + attachments.add(signatureFile); + mail.getContent().setAttachments(attachments); + MailerResult result = mailManager.sendMessage(mail); + if(result.getReturnCode() != MailerResult.OK) { + log.error("Confirmation mail cannot be send"); + } + } + + } catch (Exception e) { + log.error("", e); + } + } + + private Document createSignatureDocumentWrapper(String url, Identity assessedIdentity, DigitalSignatureOptions signatureOptions) { + try { + Document signatureDocument = XMLDigitalSignatureUtil.createDocument(); + Node rootNode = signatureDocument.appendChild(signatureDocument.createElement("assessmentTestSignature")); + Node urlNode = rootNode.appendChild(signatureDocument.createElement("url")); + urlNode.appendChild(signatureDocument.createTextNode(url)); + Node dateNode = rootNode.appendChild(signatureDocument.createElement("date")); + dateNode.appendChild(signatureDocument.createTextNode(Formatter.formatDatetime(new Date()))); + + if(signatureOptions.getEntry() != null) { + Node courseNode = rootNode.appendChild(signatureDocument.createElement("course")); + courseNode.appendChild(signatureDocument.createTextNode(signatureOptions.getEntry().getDisplayname())); + } + if(signatureOptions.getSubIdentName() != null) { + Node courseNodeNode = rootNode.appendChild(signatureDocument.createElement("courseNode")); + courseNodeNode.appendChild(signatureDocument.createTextNode(signatureOptions.getSubIdentName())); + } + if(signatureOptions.getTestEntry() != null) { + Node testNode = rootNode.appendChild(signatureDocument.createElement("test")); + testNode.appendChild(signatureDocument.createTextNode(signatureOptions.getTestEntry().getDisplayname())); + } + + if(assessedIdentity != null && assessedIdentity.getUser() != null) { + User user = assessedIdentity.getUser(); + Node firstNameNode = rootNode.appendChild(signatureDocument.createElement("firstName")); + firstNameNode.appendChild(signatureDocument.createTextNode(user.getFirstName())); + Node lastNameNode = rootNode.appendChild(signatureDocument.createElement("lastName")); + lastNameNode.appendChild(signatureDocument.createTextNode(user.getLastName())); + } + + return signatureDocument; + } catch ( Exception e) { + log.error("", e); + return null; + } + } + + @Override + public File getAssessmentResultSignature(AssessmentTestSession candidateSession) { + File resultFile = getAssessmentResultFile(candidateSession); + File signatureFile = new File(resultFile.getParentFile(), "assessmentResultSignature.xml"); + return signatureFile.exists() ? signatureFile : null; + } @Override - public AssessmentTestSession finishTestSession(AssessmentTestSession candidateSession, TestSessionState testSessionState, AssessmentResult assessmentResul, Date timestamp) { + public Date getAssessmentResultSignatureIssueDate(AssessmentTestSession candidateSession) { + Date issueDate = null; + File signatureFile = null; + try { + signatureFile = getAssessmentResultSignature(candidateSession); + if(signatureFile != null) { + Document doc = XMLDigitalSignatureUtil.getDocument(signatureFile); + if(doc != null) { + String date = XMLDigitalSignatureUtil.getElementText(doc, "date"); + if(StringHelper.containsNonWhitespace(date)) { + issueDate = Formatter.parseDatetime(date); + } + } + } + } catch (Exception e) { + log.error("Cannot read the issue date of the signature: " + signatureFile, e); + } + return issueDate; + } + + @Override + public AssessmentTestSession finishTestSession(AssessmentTestSession candidateSession, TestSessionState testSessionState, AssessmentResult assessmentResult, + Date timestamp, DigitalSignatureOptions digitalSignature, Identity assessedIdentity) { /* Mark session as finished */ candidateSession.setFinishTime(timestamp); // Set duration @@ -620,6 +743,11 @@ public class QTI21ServiceImpl implements QTI21Service, UserDataDeletable, Initia if(candidateSession instanceof Persistable) { candidateSession = testSessionDao.update(candidateSession); } + + storeAssessmentResultFile(candidateSession, assessmentResult); + if(qtiModule.isDigitalSignatureEnabled() && digitalSignature.isDigitalSignature()) { + signAssessmentResult(candidateSession, digitalSignature, assessedIdentity); + } /* Finally schedule LTI result return (if appropriate and sane) */ //maybeScheduleLtiOutcomes(candidateSession, assessmentResult); diff --git a/src/main/java/org/olat/ims/qti21/model/DigitalSignatureOptions.java b/src/main/java/org/olat/ims/qti21/model/DigitalSignatureOptions.java new file mode 100644 index 00000000000..920986c46ad --- /dev/null +++ b/src/main/java/org/olat/ims/qti21/model/DigitalSignatureOptions.java @@ -0,0 +1,79 @@ +/** + * <a href="http://www.openolat.org"> + * OpenOLAT - Online Learning and Training</a><br> + * <p> + * Licensed under the Apache License, Version 2.0 (the "License"); <br> + * you may not use this file except in compliance with the License.<br> + * You may obtain a copy of the License at the + * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a> + * <p> + * Unless required by applicable law or agreed to in writing,<br> + * software distributed under the License is distributed on an "AS IS" BASIS, <br> + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br> + * See the License for the specific language governing permissions and <br> + * limitations under the License. + * <p> + * Initial code contributed and copyrighted by<br> + * frentix GmbH, http://www.frentix.com + * <p> + */ +package org.olat.ims.qti21.model; + +import org.olat.core.util.mail.MailBundle; +import org.olat.repository.RepositoryEntry; + +/** + * + * Initial date: 17 févr. 2017<br> + * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com + * + */ +public class DigitalSignatureOptions { + + private final boolean digitalSignature; + private final boolean sendMail; + private MailBundle mailBundle; + + private String subIdentName; + private final RepositoryEntry entry; + private final RepositoryEntry testEntry; + + public DigitalSignatureOptions(boolean digitalSignature, boolean sendMail, RepositoryEntry entry, RepositoryEntry testEntry) { + this.digitalSignature = digitalSignature; + this.sendMail = sendMail; + this.entry = entry; + this.testEntry = testEntry; + } + + public boolean isDigitalSignature() { + return digitalSignature; + } + + public boolean isSendMail() { + return sendMail; + } + + public String getSubIdentName() { + return subIdentName; + } + + public RepositoryEntry getEntry() { + return entry; + } + + public RepositoryEntry getTestEntry() { + return testEntry; + } + + public void setSubIdentName(String subIdentName) { + this.subIdentName = subIdentName; + } + + public MailBundle getMailBundle() { + return mailBundle; + } + + public void setMailBundle(MailBundle mailBundle) { + this.mailBundle = mailBundle; + } +} diff --git a/src/main/java/org/olat/ims/qti21/model/InMemoryOutcomeListener.java b/src/main/java/org/olat/ims/qti21/model/InMemoryOutcomeListener.java index a5b7cab32cb..7e0733a34de 100644 --- a/src/main/java/org/olat/ims/qti21/model/InMemoryOutcomeListener.java +++ b/src/main/java/org/olat/ims/qti21/model/InMemoryOutcomeListener.java @@ -19,6 +19,9 @@ */ package org.olat.ims.qti21.model; +import java.util.Locale; + +import org.olat.ims.qti21.AssessmentTestSession; import org.olat.ims.qti21.OutcomesListener; /** @@ -29,6 +32,11 @@ import org.olat.ims.qti21.OutcomesListener; */ public class InMemoryOutcomeListener implements OutcomesListener { + @Override + public void decorateConfirmation(AssessmentTestSession candidateSession, DigitalSignatureOptions options, Locale locale) { + //do nothing + } + @Override public void updateOutcomes(Float score, Boolean pass) { // diff --git a/src/main/java/org/olat/ims/qti21/ui/AssessmentEntryOutcomesListener.java b/src/main/java/org/olat/ims/qti21/ui/AssessmentEntryOutcomesListener.java index 9a5d76044d1..b19a9fd6c3e 100644 --- a/src/main/java/org/olat/ims/qti21/ui/AssessmentEntryOutcomesListener.java +++ b/src/main/java/org/olat/ims/qti21/ui/AssessmentEntryOutcomesListener.java @@ -20,14 +20,24 @@ package org.olat.ims.qti21.ui; import java.math.BigDecimal; +import java.util.Locale; import java.util.concurrent.atomic.AtomicBoolean; +import org.olat.core.CoreSpringFactory; +import org.olat.core.gui.translator.Translator; import org.olat.core.logging.activity.ThreadLocalUserActivityLogger; +import org.olat.core.util.Formatter; +import org.olat.core.util.Util; +import org.olat.core.util.mail.MailBundle; +import org.olat.ims.qti21.AssessmentTestSession; import org.olat.ims.qti21.OutcomesListener; import org.olat.ims.qti21.QTI21LoggingAction; +import org.olat.ims.qti21.model.DigitalSignatureOptions; import org.olat.modules.assessment.AssessmentEntry; import org.olat.modules.assessment.AssessmentService; import org.olat.modules.assessment.model.AssessmentEntryStatus; +import org.olat.repository.RepositoryEntry; +import org.olat.user.UserManager; /** * @@ -53,7 +63,34 @@ public class AssessmentEntryOutcomesListener implements OutcomesListener { this.authorMode = authorMode; this.needManualCorrection = needManualCorrection; } + + @Override + public void decorateConfirmation(AssessmentTestSession candidateSession, DigitalSignatureOptions options, Locale locale) { + decorateResourceConfirmation(candidateSession, options, locale); + } + public static void decorateResourceConfirmation(AssessmentTestSession candidateSession, DigitalSignatureOptions options, Locale locale) { + MailBundle bundle = new MailBundle(); + bundle.setToId(candidateSession.getIdentity()); + String fullname = CoreSpringFactory.getImpl(UserManager.class).getUserDisplayName(candidateSession.getIdentity()); + + Translator translator = Util.createPackageTranslator(QTI21RuntimeController.class, locale); + RepositoryEntry entry = candidateSession.getRepositoryEntry(); + RepositoryEntry testEntry = candidateSession.getTestEntry(); + String[] args = new String[] { + entry.getDisplayname(), // {0} + "", // {1} + testEntry.getDisplayname(), // {2} + fullname, // {3} + Formatter.getInstance(locale).formatDateAndTime(candidateSession.getFinishTime()) + }; + + String subject = translator.translate("digital.signature.mail.subject", args); + String body = translator.translate("digital.signature.mail.body", args); + bundle.setContent(subject, body); + options.setMailBundle(bundle); + } + @Override public void updateOutcomes(Float updatedScore, Boolean updatedPassed) { AssessmentEntryStatus assessmentStatus = AssessmentEntryStatus.inProgress; diff --git a/src/main/java/org/olat/ims/qti21/ui/AssessmentResultController.java b/src/main/java/org/olat/ims/qti21/ui/AssessmentResultController.java index 3c3e82d93fb..de5b90c97f0 100644 --- a/src/main/java/org/olat/ims/qti21/ui/AssessmentResultController.java +++ b/src/main/java/org/olat/ims/qti21/ui/AssessmentResultController.java @@ -27,7 +27,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import javax.servlet.http.HttpServletRequest; + import org.olat.admin.user.UserShortDescription; +import org.olat.core.dispatcher.mapper.Mapper; import org.olat.core.gui.UserRequest; import org.olat.core.gui.components.Component; import org.olat.core.gui.components.form.flexible.FormItemContainer; @@ -37,7 +40,11 @@ import org.olat.core.gui.control.Controller; import org.olat.core.gui.control.Event; import org.olat.core.gui.control.WindowControl; import org.olat.core.gui.control.creator.ControllerCreator; +import org.olat.core.gui.media.FileMediaResource; +import org.olat.core.gui.media.MediaResource; +import org.olat.core.gui.media.NotFoundMediaResource; import org.olat.core.id.Identity; +import org.olat.core.util.CodeHelper; import org.olat.core.util.StringHelper; import org.olat.course.assessment.AssessmentHelper; import org.olat.fileresource.types.ImsQTI21Resource; @@ -88,8 +95,9 @@ import uk.ac.ed.ph.jqtiplus.xmlutils.locators.ResourceLocator; * */ public class AssessmentResultController extends FormBasicController { - + private final String mapperUri; + private String signatureMapperUri; private final ShowResultsOnFinish resultsOnfinish; private final boolean anonym; @@ -138,6 +146,12 @@ public class AssessmentResultController extends FormBasicController { resolvedAssessmentTest = qtiService.loadAndResolveAssessmentTest(fUnzippedDirRoot, false, false); + File signature = qtiService.getAssessmentResultSignature(candidateSession); + if(signature != null) { + signatureMapperUri = registerCacheableMapper(null, "QTI21Signature::" + CodeHelper.getForeverUniqueID(), + new SignatureMapper(signature)); + } + testSessionState = qtiService.loadTestSessionState(candidateSession); assessmentResult = qtiService.getAssessmentResult(candidateSession); candidateSessionContext = new TerminatedStaticCandidateSessionContext(candidateSession); @@ -172,6 +186,11 @@ public class AssessmentResultController extends FormBasicController { if(testResult != null) { extractOutcomeVariable(testResult.getItemVariables(), results); } + + if(signatureMapperUri != null) { + String signatureUrl = signatureMapperUri + "/assessmentResultSignature.xml"; + layoutCont.contextPut("signatureUrl", signatureUrl); + } if(resultsOnfinish == ShowResultsOnFinish.sections || resultsOnfinish == ShowResultsOnFinish.details) { initFormSections(layoutCont); @@ -486,4 +505,25 @@ public class AssessmentResultController extends FormBasicController { return interactionResults; } } + + public class SignatureMapper implements Mapper { + + private final File signature; + + public SignatureMapper(File signature) { + this.signature = signature; + } + + @Override + public MediaResource handle(String relPath, HttpServletRequest request) { + + MediaResource resource; + if(signature.exists()) { + resource = new FileMediaResource(signature); + } else { + resource = new NotFoundMediaResource(relPath); + } + return resource; + } + } } \ No newline at end of file diff --git a/src/main/java/org/olat/ims/qti21/ui/AssessmentTestDisplayController.java b/src/main/java/org/olat/ims/qti21/ui/AssessmentTestDisplayController.java index ecfbb805bb0..54bb968ceed 100644 --- a/src/main/java/org/olat/ims/qti21/ui/AssessmentTestDisplayController.java +++ b/src/main/java/org/olat/ims/qti21/ui/AssessmentTestDisplayController.java @@ -74,8 +74,10 @@ import org.olat.ims.qti21.OutcomesListener; import org.olat.ims.qti21.QTI21Constants; import org.olat.ims.qti21.QTI21DeliveryOptions; import org.olat.ims.qti21.QTI21DeliveryOptions.ShowResultsOnFinish; +import org.olat.ims.qti21.QTI21Module; import org.olat.ims.qti21.QTI21Service; import org.olat.ims.qti21.manager.ResponseFormater; +import org.olat.ims.qti21.model.DigitalSignatureOptions; import org.olat.ims.qti21.model.InMemoryAssessmentTestMarks; import org.olat.ims.qti21.model.ParentPartItemRefs; import org.olat.ims.qti21.model.ResponseLegality; @@ -98,11 +100,11 @@ import org.springframework.beans.factory.annotation.Autowired; import uk.ac.ed.ph.jqtiplus.JqtiPlus; import uk.ac.ed.ph.jqtiplus.exception.QtiCandidateStateException; import uk.ac.ed.ph.jqtiplus.node.item.interaction.Interaction; -import uk.ac.ed.ph.jqtiplus.node.result.AbstractResult; import uk.ac.ed.ph.jqtiplus.node.result.AssessmentResult; import uk.ac.ed.ph.jqtiplus.node.result.ItemResult; import uk.ac.ed.ph.jqtiplus.node.result.ItemVariable; import uk.ac.ed.ph.jqtiplus.node.result.OutcomeVariable; +import uk.ac.ed.ph.jqtiplus.node.result.TestResult; import uk.ac.ed.ph.jqtiplus.node.test.AssessmentTest; import uk.ac.ed.ph.jqtiplus.node.test.NavigationMode; import uk.ac.ed.ph.jqtiplus.node.test.SubmissionMode; @@ -179,6 +181,8 @@ public class AssessmentTestDisplayController extends BasicController implements private OutcomesListener outcomesListener; private AssessmentSessionAuditLogger candidateAuditLogger; + @Autowired + private QTI21Module qtiModule; @Autowired private QTI21Service qtiService; @Autowired @@ -243,7 +247,8 @@ public class AssessmentTestDisplayController extends BasicController implements /* Handle immediate end of test session */ if (testSessionController.getTestSessionState() != null && testSessionController.getTestSessionState().isEnded()) { AssessmentResult assessmentResult = null; - qtiService.finishTestSession(candidateSession, testSessionController.getTestSessionState(), assessmentResult, currentRequestTimestamp); + qtiService.finishTestSession(candidateSession, testSessionController.getTestSessionState(), assessmentResult, + currentRequestTimestamp, getDigitalSignatureOptions(), getIdentity()); mainVC = createVelocityContainer("end"); } else { mainVC = createVelocityContainer("run"); @@ -680,7 +685,8 @@ public class AssessmentTestDisplayController extends BasicController implements /* If we ended the testPart and there are now no more available testParts, then finish the session now */ if (nextItemNode==null && testSessionController.findNextEnterableTestPart()==null) { - candidateSession = qtiService.finishTestSession(candidateSession, testSessionState, assessmentResult, requestTimestamp); + candidateSession = qtiService.finishTestSession(candidateSession, testSessionState, assessmentResult, + requestTimestamp, getDigitalSignatureOptions(), getIdentity()); } // Record and log event @@ -892,7 +898,8 @@ public class AssessmentTestDisplayController extends BasicController implements // Record current result state final AssessmentResult assessmentResult = computeAndRecordTestAssessmentResult(ureq, testSessionState, nextTestPart == null); if(nextTestPart == null) { - candidateSession = qtiService.finishTestSession(candidateSession, testSessionState, assessmentResult, requestTimestamp); + candidateSession = qtiService.finishTestSession(candidateSession, testSessionState, assessmentResult, + requestTimestamp, getDigitalSignatureOptions(), getIdentity()); } } @@ -956,8 +963,7 @@ public class AssessmentTestDisplayController extends BasicController implements testSessionController.exitTest(currentTimestamp); candidateSession.setTerminationTime(currentTimestamp); candidateSession = qtiService.updateAssessmentTestSession(candidateSession); - } - else { + } else { eventType = CandidateTestEventType.ADVANCE_TEST_PART; } } @@ -1083,7 +1089,8 @@ public class AssessmentTestDisplayController extends BasicController implements /* Handle immediate end of test session */ if (ended) { - qtiService.finishTestSession(candidateSession, testSessionState, assessmentResult, timestamp); + qtiService.finishTestSession(candidateSession, testSessionState, assessmentResult, + timestamp, getDigitalSignatureOptions(), getIdentity()); } else { TestPart currentTestPart = testSessionController.getCurrentTestPart(); if(currentTestPart != null && currentTestPart.getNavigationMode() == NavigationMode.NONLINEAR) { @@ -1097,6 +1104,16 @@ public class AssessmentTestDisplayController extends BasicController implements return testSessionController; } + private DigitalSignatureOptions getDigitalSignatureOptions() { + boolean sendMail = deliveryOptions.isDigitalSignatureMail(); + boolean digitalSignature = deliveryOptions.isDigitalSignature() && qtiModule.isDigitalSignatureEnabled(); + DigitalSignatureOptions options = new DigitalSignatureOptions(digitalSignature, sendMail, entry, testEntry); + if(digitalSignature) { + outcomesListener.decorateConfirmation(candidateSession, options, getLocale()); + } + return options; + } + private TestSessionController createNewTestSessionStateAndController(NotificationRecorder notificationRecorder) { TestProcessingMap testProcessingMap = getTestProcessingMap(); /* Generate a test plan for this session */ @@ -1175,7 +1192,7 @@ public class AssessmentTestDisplayController extends BasicController implements return assessmentResult; } - private void processOutcomeVariables(AbstractResult resultNode, boolean submit) { + private void processOutcomeVariables(TestResult resultNode, boolean submit) { Float score = null; Boolean pass = null; @@ -1496,14 +1513,14 @@ public class AssessmentTestDisplayController extends BasicController implements fireEvent(ureq, new Event("suspend")); } - private void doSaveMenuWidth(UserRequest ureq, String menuWidth) { - this.menuWidth = menuWidth; - if(StringHelper.containsNonWhitespace(menuWidth)) { - flc.contextPut("menuWidth", menuWidth); + private void doSaveMenuWidth(UserRequest ureq, String newMenuWidth) { + this.menuWidth = newMenuWidth; + if(StringHelper.containsNonWhitespace(newMenuWidth)) { + flc.contextPut("menuWidth", newMenuWidth); if(testEntry != null) { UserSession usess = ureq.getUserSession(); if (usess.isAuthenticated() && !usess.getRoles().isGuestOnly()) { - usess.getGuiPreferences().putAndSave(this.getClass(), getMenuPrefsKey(), menuWidth); + usess.getGuiPreferences().putAndSave(this.getClass(), getMenuPrefsKey(), newMenuWidth); } } } diff --git a/src/main/java/org/olat/ims/qti21/ui/QTI21AdminController.java b/src/main/java/org/olat/ims/qti21/ui/QTI21AdminController.java index 0f3c58a3850..d48800df2d5 100644 --- a/src/main/java/org/olat/ims/qti21/ui/QTI21AdminController.java +++ b/src/main/java/org/olat/ims/qti21/ui/QTI21AdminController.java @@ -19,14 +19,22 @@ */ package org.olat.ims.qti21.ui; +import java.io.File; + import org.olat.core.gui.UserRequest; import org.olat.core.gui.components.form.flexible.FormItem; import org.olat.core.gui.components.form.flexible.FormItemContainer; +import org.olat.core.gui.components.form.flexible.elements.FileElement; import org.olat.core.gui.components.form.flexible.elements.MultipleSelectionElement; +import org.olat.core.gui.components.form.flexible.elements.TextElement; import org.olat.core.gui.components.form.flexible.impl.FormBasicController; import org.olat.core.gui.components.form.flexible.impl.FormEvent; +import org.olat.core.gui.components.form.flexible.impl.FormLayoutContainer; import org.olat.core.gui.control.Controller; import org.olat.core.gui.control.WindowControl; +import org.olat.core.util.StringHelper; +import org.olat.core.util.crypto.CryptoUtil; +import org.olat.core.util.crypto.X509CertificatePrivateKeyPair; import org.olat.ims.qti21.QTI21Module; import org.springframework.beans.factory.annotation.Autowired; @@ -38,17 +46,21 @@ import org.springframework.beans.factory.annotation.Autowired; * */ public class QTI21AdminController extends FormBasicController { + + private static final String PASSWORD_PLACEHOLDER = "xOOx32x00x"; - private static final String[] mathExtensionKeys = new String[]{ "on" }; + private static final String[] onKeys = new String[]{ "on" }; + private static final String[] onValues = new String[]{ "" }; - private MultipleSelectionElement mathExtensionEl; + private MultipleSelectionElement mathExtensionEl, digitalSignatureEl; + private FileElement certificateEl; + private TextElement certificatePasswordEl; @Autowired private QTI21Module qtiModule; public QTI21AdminController(UserRequest ureq, WindowControl wControl) { super(ureq, wControl); - initForm(ureq); } @@ -56,36 +68,116 @@ public class QTI21AdminController extends FormBasicController { protected void initForm(FormItemContainer formLayout, Controller listener, UserRequest ureq) { setFormTitle("admin.title"); - String[] mathExtensionValues = new String[]{ "" }; + digitalSignatureEl = uifactory.addCheckboxesHorizontal("digital.signature", "digital.signature", formLayout, + onKeys, onValues); + if(qtiModule.isDigitalSignatureEnabled()) { + digitalSignatureEl.select(onKeys[0], true); + } + digitalSignatureEl.setExampleKey("digital.signature.text", null); + digitalSignatureEl.addActionListener(FormEvent.ONCHANGE); + + certificateEl = uifactory.addFileElement(getWindowControl(), "digital.signature.certificate", "digital.signature.certificate", formLayout); + certificateEl.setExampleKey("digital.signature.certificate.example", null); + certificateEl.setHelpText(translate("digital.signature.certificate.hint")); + if(StringHelper.containsNonWhitespace(qtiModule.getDigitalSignatureCertificate())) { + File certificate = qtiModule.getDigitalSignatureCertificateFile(); + certificateEl.setInitialFile(certificate); + } + + String certificatePassword = qtiModule.getDigitalSignatureCertificatePassword(); + String password = StringHelper.containsNonWhitespace(certificatePassword) ? PASSWORD_PLACEHOLDER : ""; + certificatePasswordEl = uifactory.addPasswordElement("digital.signature.certificate.password", "digital.signature.certificate.password", + 256, password, formLayout); + mathExtensionEl = uifactory.addCheckboxesHorizontal("math.extension", "math.extension", formLayout, - mathExtensionKeys, mathExtensionValues); + onKeys, onValues); if(qtiModule.isMathAssessExtensionEnabled()) { - mathExtensionEl.select(mathExtensionKeys[0], true); + mathExtensionEl.select(onKeys[0], true); } mathExtensionEl.setExampleKey("math.extension.text", null); mathExtensionEl.addActionListener(FormEvent.ONCHANGE); + + FormLayoutContainer buttonsCont = FormLayoutContainer.createButtonLayout("buttons", getTranslator()); + formLayout.add(buttonsCont); + uifactory.addFormSubmitButton("save", buttonsCont); + } + + private void updateUI() { + certificateEl.setVisible(digitalSignatureEl.isSelected(0)); + certificatePasswordEl.setVisible(digitalSignatureEl.isSelected(0)); } @Override protected void doDispose() { // } + + @Override + protected boolean validateFormLogic(UserRequest ureq) { + boolean allOk = true; + + if(certificateEl.getUploadFile() != null) { + File uploadedCertificate = certificateEl.getUploadFile(); + if(uploadedCertificate != null && uploadedCertificate.exists()) { + validateCertificatePassword(uploadedCertificate); + } + } else { + String password = certificatePasswordEl.getValue(); + if(!PASSWORD_PLACEHOLDER.equals(password) && certificateEl.getInitialFile() != null) { + validateCertificatePassword(certificateEl.getInitialFile()); + } + } + return allOk & super.validateFormLogic(ureq); + } + private boolean validateCertificatePassword(File file) { + boolean allOk = true; + + try { + String password = certificatePasswordEl.getValue(); + X509CertificatePrivateKeyPair kp = CryptoUtil.getX509CertificatePrivateKeyPairPfx(file, password); + if(kp.getX509Cert() == null) { + certificateEl.setErrorKey("error.digital.certificate.noX509", null); + allOk &= false; + } else if(kp.getPrivateKey() == null) { + certificateEl.setErrorKey("error.digital.certificate.noPrivateKey", null); + allOk &= false; + } + } catch (Exception e) { + logError("", e); + String message = e.getMessage() == null ? "" : e.getMessage(); + String [] errorArgs = new String[]{ message }; + certificateEl.setErrorKey("error.digital.certificate.cannotread", errorArgs); + allOk &= false; + } + + return allOk; + } + @Override protected void formInnerEvent(UserRequest ureq, FormItem source, FormEvent event) { - if(mathExtensionEl == source) { - qtiModule.setMathAssessExtensionEnabled(mathExtensionEl.isSelected(0)); + if(digitalSignatureEl == source) { + updateUI(); } super.formInnerEvent(ureq, source, event); } @Override protected void formOK(UserRequest ureq) { - // + qtiModule.setMathAssessExtensionEnabled(mathExtensionEl.isSelected(0)); + qtiModule.setDigitalSignatureEnabled(digitalSignatureEl.isSelected(0)); + if(digitalSignatureEl.isSelected(0)) { + File uploadedCertificate = certificateEl.getUploadFile(); + if(uploadedCertificate != null && uploadedCertificate.exists()) { + qtiModule.setDigitalSignatureCertificateFile(uploadedCertificate, certificateEl.getUploadFileName()); + File newFile = qtiModule.getDigitalSignatureCertificateFile(); + certificateEl.reset();// make sure the same certificate is not load twice + certificateEl.setInitialFile(newFile); + } + String password = certificatePasswordEl.getValue(); + if(!PASSWORD_PLACEHOLDER.equals(password)) { + qtiModule.setDigitalSignatureCertificatePassword(password); + } + } } - - - - - -} +} \ No newline at end of file diff --git a/src/main/java/org/olat/ims/qti21/ui/QTI21AssessmentDetailsController.java b/src/main/java/org/olat/ims/qti21/ui/QTI21AssessmentDetailsController.java index 97798f72f83..be55e7b4a37 100644 --- a/src/main/java/org/olat/ims/qti21/ui/QTI21AssessmentDetailsController.java +++ b/src/main/java/org/olat/ims/qti21/ui/QTI21AssessmentDetailsController.java @@ -53,17 +53,25 @@ import org.olat.core.id.Identity; import org.olat.core.id.OLATResourceable; import org.olat.core.util.coordinate.CoordinatorManager; import org.olat.core.util.resource.OresHelper; +import org.olat.course.CourseFactory; import org.olat.course.nodes.IQTESTCourseNode; +import org.olat.course.nodes.iq.IQEditController; +import org.olat.course.nodes.iq.QTI21AssessmentRunController; +import org.olat.course.run.environment.CourseEnvironment; import org.olat.course.run.scoring.ScoreEvaluation; import org.olat.course.run.userview.UserCourseEnvironment; import org.olat.fileresource.FileResourceManager; import org.olat.ims.qti21.AssessmentSessionAuditLogger; import org.olat.ims.qti21.AssessmentTestSession; +import org.olat.ims.qti21.QTI21DeliveryOptions; import org.olat.ims.qti21.QTI21DeliveryOptions.ShowResultsOnFinish; +import org.olat.ims.qti21.QTI21Module; import org.olat.ims.qti21.QTI21Service; +import org.olat.ims.qti21.model.DigitalSignatureOptions; import org.olat.ims.qti21.ui.QTI21TestSessionTableModel.TSCols; import org.olat.ims.qti21.ui.assessment.IdentityAssessmentTestCorrectionController; import org.olat.ims.qti21.ui.event.RetrieveAssessmentTestSessionEvent; +import org.olat.modules.ModuleConfiguration; import org.olat.modules.assessment.AssessmentEntry; import org.olat.modules.assessment.AssessmentService; import org.olat.modules.assessment.AssessmentToolOptions; @@ -90,6 +98,7 @@ public class QTI21AssessmentDetailsController extends FormBasicController { private QTI21TestSessionTableModel tableModel; private RepositoryEntry entry; + private RepositoryEntry testEntry; private final String subIdent; private final boolean manualCorrections; private final Identity assessedIdentity; @@ -110,6 +119,8 @@ public class QTI21AssessmentDetailsController extends FormBasicController { @Autowired private UserManager userManager; @Autowired + private QTI21Module qtiModule; + @Autowired protected QTI21Service qtiService; @Autowired private RepositoryManager repositoryManager; @@ -135,7 +146,7 @@ public class QTI21AssessmentDetailsController extends FormBasicController { subIdent = courseNode.getIdent(); readOnly = coachCourseEnv.isCourseReadOnly(); this.assessedUserCourseEnv = assessedUserCourseEnv; - RepositoryEntry testEntry = courseNode.getReferencedRepositoryEntry(); + testEntry = courseNode.getReferencedRepositoryEntry(); assessedIdentity = assessedUserCourseEnv.getIdentityEnvironment().getIdentity(); manualCorrections = qtiService.needManualCorrection(testEntry); @@ -158,6 +169,7 @@ public class QTI21AssessmentDetailsController extends FormBasicController { RepositoryEntry assessableEntry, Identity assessedIdentity) { super(ureq, wControl, "assessment_details"); entry = assessableEntry; + testEntry = assessableEntry; subIdent = null; readOnly = false; courseNode = null; @@ -193,8 +205,7 @@ public class QTI21AssessmentDetailsController extends FormBasicController { tableModel = new QTI21TestSessionTableModel(columnsModel, getTranslator()); tableEl = uifactory.addTableElement(getWindowControl(), "sessions", tableModel, 20, false, getTranslator(), formLayout); tableEl.setEmtpyTableMessageKey("results.empty"); - - + if(reSecurity.isEntryAdmin() && !readOnly) { AssessmentToolOptions asOptions = new AssessmentToolOptions(); asOptions.setAdmin(reSecurity.isEntryAdmin()); @@ -342,7 +353,12 @@ public class QTI21AssessmentDetailsController extends FormBasicController { } private void doPullSession(AssessmentTestSession session) { + session = qtiService.getAssessmentTestSession(session.getKey()); + if(session.getFinishTime() == null) { + if(qtiModule.isDigitalSignatureEnabled()) { + qtiService.signAssessmentResult(session, getSignatureOptions(session), session.getIdentity()); + } session.setFinishTime(new Date()); } session.setTerminationTime(new Date()); @@ -356,6 +372,32 @@ public class QTI21AssessmentDetailsController extends FormBasicController { CoordinatorManager.getInstance().getCoordinator().getEventBus() .fireEventToListenersOf(new RetrieveAssessmentTestSessionEvent(session.getKey()), sessionOres); } + + private DigitalSignatureOptions getSignatureOptions(AssessmentTestSession session) { + RepositoryEntry sessionTestEntry = session.getTestEntry(); + QTI21DeliveryOptions deliveryOptions = qtiService.getDeliveryOptions(sessionTestEntry); + + boolean digitalSignature = deliveryOptions.isDigitalSignature(); + boolean sendMail = deliveryOptions.isDigitalSignatureMail(); + if(courseNode != null) { + ModuleConfiguration config = courseNode.getModuleConfiguration(); + digitalSignature = config.getBooleanSafe(IQEditController.CONFIG_DIGITAL_SIGNATURE, + deliveryOptions.isDigitalSignature()); + sendMail = config.getBooleanSafe(IQEditController.CONFIG_DIGITAL_SIGNATURE_SEND_MAIL, + deliveryOptions.isDigitalSignatureMail()); + } + + DigitalSignatureOptions options = new DigitalSignatureOptions(digitalSignature, sendMail, entry, testEntry); + if(digitalSignature) { + if(courseNode == null) { + AssessmentEntryOutcomesListener.decorateResourceConfirmation(session, options, getLocale()); + } else { + CourseEnvironment courseEnv = CourseFactory.loadCourse(entry).getCourseEnvironment(); + QTI21AssessmentRunController.decorateCourseConfirmation(session, options, courseEnv, courseNode, sessionTestEntry, getLocale()); + } + } + return options; + } private void doOpenResult(UserRequest ureq, AssessmentTestSession session) { if(resultCtrl != null) return; diff --git a/src/main/java/org/olat/ims/qti21/ui/QTI21DeliveryOptionsController.java b/src/main/java/org/olat/ims/qti21/ui/QTI21DeliveryOptionsController.java index c1bfa6fd34d..1f4451027e2 100644 --- a/src/main/java/org/olat/ims/qti21/ui/QTI21DeliveryOptionsController.java +++ b/src/main/java/org/olat/ims/qti21/ui/QTI21DeliveryOptionsController.java @@ -39,6 +39,7 @@ import org.olat.core.id.context.StateEntry; import org.olat.core.util.StringHelper; import org.olat.ims.qti21.QTI21DeliveryOptions; import org.olat.ims.qti21.QTI21DeliveryOptions.ShowResultsOnFinish; +import org.olat.ims.qti21.QTI21Module; import org.olat.ims.qti21.QTI21Service; import org.olat.repository.RepositoryEntry; import org.springframework.beans.factory.annotation.Autowired; @@ -61,6 +62,7 @@ public class QTI21DeliveryOptionsController extends FormBasicController implemen private MultipleSelectionElement displayQuestionProgressEl, displayScoreProgressEl; private MultipleSelectionElement showResultsOnFinishEl; private MultipleSelectionElement allowAnonymEl; + private MultipleSelectionElement digitalSignatureEl, digitalSignatureMailEl; private SingleSelection typeShowResultsOnFinishEl; private TextElement maxAttemptsEl; @@ -68,6 +70,8 @@ public class QTI21DeliveryOptionsController extends FormBasicController implemen private final RepositoryEntry testEntry; private final QTI21DeliveryOptions deliveryOptions; + @Autowired + private QTI21Module qtiModule; @Autowired private QTI21Service qtiService; @@ -150,6 +154,22 @@ public class QTI21DeliveryOptionsController extends FormBasicController implemen enableCancelEl.select(onKeys[0], true); } + boolean digitalSignature = deliveryOptions.isDigitalSignature(); + digitalSignatureEl = uifactory.addCheckboxesHorizontal("digital.signature", "digital.signature.test.option", formLayout, new String[]{"x"}, new String[]{""}); + if(digitalSignature) { + digitalSignatureEl.select("x", digitalSignature); + } + digitalSignatureEl.setVisible(qtiModule.isDigitalSignatureEnabled()); + digitalSignatureEl.addActionListener(FormEvent.ONCHANGE); + + boolean digitalSignatureSendMail = deliveryOptions.isDigitalSignatureMail(); + digitalSignatureMailEl = uifactory.addCheckboxesHorizontal("digital.signature.mail", "digital.signature.mail.test.option", formLayout, new String[]{"x"}, new String[]{""}); + if(digitalSignatureSendMail) { + digitalSignatureMailEl.select("x", digitalSignatureSendMail); + } + digitalSignatureMailEl.setVisible(qtiModule.isDigitalSignatureEnabled() && digitalSignatureEl.isAtLeastSelected(1)); + + showResultsOnFinishEl = uifactory.addCheckboxesHorizontal("resultOnFiniish", "qti.form.results.onfinish", formLayout, onKeys, onValues); showResultsOnFinishEl.addActionListener(FormEvent.ONCHANGE); showResultsOnFinishEl.setElementCssClass("o_sel_qti_show_results"); @@ -218,9 +238,10 @@ public class QTI21DeliveryOptionsController extends FormBasicController implemen protected void formInnerEvent(UserRequest ureq, FormItem source, FormEvent event) { if(limitAttemptsEl == source) { maxAttemptsEl.setVisible(limitAttemptsEl.isAtLeastSelected(1)); - } - if(showResultsOnFinishEl == source) { + } else if(showResultsOnFinishEl == source) { typeShowResultsOnFinishEl.setVisible(showResultsOnFinishEl.isAtLeastSelected(1)); + } else if(digitalSignatureEl == source) { + digitalSignatureMailEl.setVisible(digitalSignatureEl.isAtLeastSelected(1)); } super.formInnerEvent(ureq, source, event); } @@ -251,6 +272,14 @@ public class QTI21DeliveryOptionsController extends FormBasicController implemen } else { deliveryOptions.setShowResultsOnFinish(ShowResultsOnFinish.none); } + if(qtiModule.isDigitalSignatureEnabled() && digitalSignatureEl.isAtLeastSelected(1)) { + deliveryOptions.setDigitalSignature(true); + deliveryOptions.setDigitalSignatureMail(digitalSignatureMailEl.isAtLeastSelected(1)); + } else { + deliveryOptions.setDigitalSignature(false); + deliveryOptions.setDigitalSignatureMail(false); + } + qtiService.setDeliveryOptions(testEntry, deliveryOptions); changes = true; fireEvent(ureq, Event.DONE_EVENT); diff --git a/src/main/java/org/olat/ims/qti21/ui/QTI21TestSessionTableModel.java b/src/main/java/org/olat/ims/qti21/ui/QTI21TestSessionTableModel.java index a629e1759b6..402c0f4c814 100644 --- a/src/main/java/org/olat/ims/qti21/ui/QTI21TestSessionTableModel.java +++ b/src/main/java/org/olat/ims/qti21/ui/QTI21TestSessionTableModel.java @@ -66,6 +66,7 @@ public class QTI21TestSessionTableModel extends DefaultFlexiTableDataModel<Asses } return "<span class='o_ochre'>" + translator.translate("assessment.test.open") + "</span>"; } + case test: return session.getTestEntry().getDisplayname(); case results: { if(session.getFinishTime() != null) { return AssessmentHelper.getRoundedScore(session.getScore()); @@ -97,6 +98,7 @@ public class QTI21TestSessionTableModel extends DefaultFlexiTableDataModel<Asses public enum TSCols implements FlexiColumnDef { lastModified("table.header.lastModified"), duration("table.header.duration"), + test("table.header.test"), results("table.header.results"), open("table.header.action"), correction("table.header.action"); diff --git a/src/main/java/org/olat/ims/qti21/ui/ResourcesMapper.java b/src/main/java/org/olat/ims/qti21/ui/ResourcesMapper.java index d8fec2027b1..b0bad54983e 100644 --- a/src/main/java/org/olat/ims/qti21/ui/ResourcesMapper.java +++ b/src/main/java/org/olat/ims/qti21/ui/ResourcesMapper.java @@ -79,7 +79,6 @@ public class ResourcesMapper implements Mapper { if(file.exists()) { resource = new FileMediaResource(file); } else { - String submissionName = null; File storage = null; if(filename.startsWith("submissions/")) { diff --git a/src/main/java/org/olat/ims/qti21/ui/_content/assessment_results.html b/src/main/java/org/olat/ims/qti21/ui/_content/assessment_results.html index f92ca714a74..6516e537c87 100644 --- a/src/main/java/org/olat/ims/qti21/ui/_content/assessment_results.html +++ b/src/main/java/org/olat/ims/qti21/ui/_content/assessment_results.html @@ -68,6 +68,12 @@ </td> </tr> #end + #if($r.isNotNull($signatureUrl)) + <tr> + <th>$r.translate("digital.signature.download")</th> + <td><a href="$signatureUrl" target="_blank"><i class="o_icon o_icon-fw o_icon_download"> </i> $r.translate("digital.signature.download.link")</a></td> + </tr> + #end </tbody></table> </div> diff --git a/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_de.properties b/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_de.properties index 9f03dc6b204..744f7de806a 100644 --- a/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_de.properties +++ b/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_de.properties @@ -61,12 +61,27 @@ correct.solution=Korrekte L\u00F6sung correction=Korrigieren debug.outcomes=Output Daten debug.responses=Antworten Daten +digital.signature=Digital signature of test results +digital.signature.certificate=Zertifikat +digital.signature.certificate.example=Ein Zertifikat im .pfx Format mit "Private Key". +digital.signature.certificate.hint=Der Zertifikat muss in .pfx Format sein und den "Private Key" enthalten. +digital.signature.certificate.password=Certificat password +digital.signature.text=Digital signature of test results +digital.signature.mail.subject=Best\u00E4tigung Test {0} +digital.signature.mail.body=Best\u00E4tigung Test {0} +digital.signature.download=Digital Unterschrift +digital.signature.download.link=Herunterladen +digital.signature.test.option=Erstellt ein Digital Unterschrift +digital.signature.mail.test.option=Schickt Digital Unterschrift per Mail drawing.brushsize=Pinselgr\u00F6sse drawing.opacity=Deckkraft error.as.directed=Alle 4 Antwortm\u00F6glichkeiten m\u00FCssen entweder mit Richtig oder Falsch beantwortet werden. error.as.directed.kprim=Bitte beantworten Sie die Frage wie vorgegeben. error.assessment.item=Die Datei konnte nicht gelesen werden. Sie ist entweder korrupt oder mit dem falschen Format gespeichert. error.choice=Sie m\u00FCssen ein von den folgenden Optionen w\u00E4hlen. +error.digital.certificate.noX509=Es wurde kein X509 Zertifikat gefunden. +error.digital.certificate.noPrivateKey=Es wurde kein "Private key" im Zertifikat gefunden. Sie ist erforderlich. +error.digital.certificate.cannotread=Zertifikat konnte nicht gelesen werden. error.double=Falsches Zahlenformat. Beispiele\: 15.0, 5.5, 10 error.input.choice.max=W\u00E4hlen Sie die {0} zutreffenden Antworten. error.input.choice.min=W\u00E4hlen Sie mindestens {0} Antwort(en). diff --git a/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_en.properties b/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_en.properties index fb9172d795c..075077fe7a5 100644 --- a/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_en.properties +++ b/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_en.properties @@ -61,12 +61,27 @@ correct.solution=Correct solution correction=Grade debug.outcomes=Output data debug.responses=Responses data +digital.signature=Digital signature of test results +digital.signature.certificate=Certificate +digital.signature.certificate.example=A certificate in .pfx format with "private key". +digital.signature.certificate.hint=The certificate must be saved in .pfx format and contain the "private key". +digital.signature.certificate.password=Certificate password +digital.signature.text=Digital signature of test results +digital.signature.mail.subject=Confirmation test {0} +digital.signature.mail.body=Confirmation test {0} +digital.signature.download=Digital Signature +digital.signature.download.link=Download +digital.signature.test.option=Generate a digital signature +digital.signature.mail.test.option=Send the signature per mail drawing.brushsize=Brush size drawing.opacity=Opacity error.as.directed=Please complete this interaction as directed. error.as.directed.kprim=Please complete this interaction as directed. error.assessment.item=The file cannot be interpreted. It seems corrupted or with the wrong format. error.choice=You must select one of the following options +error.digital.certificate.noX509=The X509 certificate could not be found in the uploaded file. +error.digital.certificate.noPrivateKey=The "private key" could not be found. It is mandatory. +error.digital.certificate.cannotread=The certificate could not be read. error.double=Need to be a double error.input.choice.max=You must select at the most {0} choices. error.input.choice.min=You must select at least {0} choice(s). diff --git a/src/main/resources/serviceconfig/olat.properties b/src/main/resources/serviceconfig/olat.properties index 5e80a811280..9266bbfb8bb 100644 --- a/src/main/resources/serviceconfig/olat.properties +++ b/src/main/resources/serviceconfig/olat.properties @@ -326,6 +326,19 @@ onyx.plugin.exammodelocation=${onyx.plugin.wslocation}/onyxexamservices ### set or overwrite this switch to "true" if this olat-node is either a single-node or should be the controlling entity in a clustered-olat exam.mode.masternode=true +######################################################################## +# QTI 2.1 QtiWorks +######################################################################## + +# Enable or disable the Math Extension of QtiWorks (need Maxima) +qti21.math.assessment.extension.enabled=false +qti21.math.assessment.extension.enabled.values=true,false + +#Enable digital signature of the assessment results +qti21.digital.signature.enabled=false +#Path to a PFX certificate (with X509 certificate, private and public key) +qti21.digital.signature.certificate= + ######################################################################## # Certificates ######################################################################## diff --git a/src/test/java/org/olat/core/util/xml/XMLDigitalSignatureUtilTest.java b/src/test/java/org/olat/core/util/xml/XMLDigitalSignatureUtilTest.java new file mode 100644 index 00000000000..c24d2b36287 --- /dev/null +++ b/src/test/java/org/olat/core/util/xml/XMLDigitalSignatureUtilTest.java @@ -0,0 +1,224 @@ +/** + * <a href="http://www.openolat.org"> + * OpenOLAT - Online Learning and Training</a><br> + * <p> + * Licensed under the Apache License, Version 2.0 (the "License"); <br> + * you may not use this file except in compliance with the License.<br> + * You may obtain a copy of the License at the + * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a> + * <p> + * Unless required by applicable law or agreed to in writing,<br> + * software distributed under the License is distributed on an "AS IS" BASIS, <br> + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br> + * See the License for the specific language governing permissions and <br> + * limitations under the License. + * <p> + * Initial code contributed and copyrighted by<br> + * frentix GmbH, http://www.frentix.com + * <p> + */ +package org.olat.core.util.xml; + +import java.io.File; +import java.net.URL; +import java.nio.file.Files; + +import org.junit.Assert; +import org.junit.Test; +import org.olat.core.util.FileUtils; +import org.olat.core.util.crypto.CryptoUtil; +import org.olat.core.util.crypto.X509CertificatePrivateKeyPair; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +/** + * + * Initial date: 16 févr. 2017<br> + * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com + * + */ +public class XMLDigitalSignatureUtilTest { + + + /** + * Check if the cycle sign -> validation works + * + * @throws Exception + */ + @Test + public void signDetachedAndValidate() throws Exception { + X509CertificatePrivateKeyPair certificateInfo = getCertificatePrivateKeyPair(); + + URL xmlUrl = XMLDigitalSignatureUtilTest.class.getResource("assessmentResult.xml"); + File xmlFile = new File(xmlUrl.toURI()); + String xmlUri = xmlUrl.toURI().toString(); + + File xmlSignatureFile = File.createTempFile("assessment-result", "_signature.xml"); + XMLDigitalSignatureUtil.signDetached(xmlUri, xmlFile, xmlSignatureFile, null, + null, certificateInfo.getX509Cert(), certificateInfo.getPrivateKey()); + + Assert.assertTrue(xmlSignatureFile.length() > 0); + + boolean valid = XMLDigitalSignatureUtil.validate(xmlUri, xmlFile, xmlSignatureFile, + certificateInfo.getX509Cert().getPublicKey()); + Assert.assertTrue(valid); + + //clean up + Files.deleteIfExists(xmlSignatureFile.toPath()); + } + + @Test + public void signDetachedAndValidate_exoticUri() throws Exception { + X509CertificatePrivateKeyPair certificateInfo = getCertificatePrivateKeyPair(); + + URL xmlUrl = XMLDigitalSignatureUtilTest.class.getResource("assessmentResult.xml"); + File xmlFile = new File(xmlUrl.toURI()); + String xmlUri = "http://localhost:8081/RepositoryEntry/688455680/CourseNode/95133178953589/TestSession/2693/assessmentResult.xml"; + + File xmlSignatureFile = File.createTempFile("assessment-result", "_signature.xml"); + XMLDigitalSignatureUtil.signDetached(xmlUri, xmlFile, xmlSignatureFile, null, + null, certificateInfo.getX509Cert(), certificateInfo.getPrivateKey()); + + Assert.assertTrue(xmlSignatureFile.length() > 0); + + boolean valid = XMLDigitalSignatureUtil.validate(xmlUri, xmlFile, xmlSignatureFile, + certificateInfo.getX509Cert().getPublicKey()); + Assert.assertTrue(valid); + + //clean up + Files.deleteIfExists(xmlSignatureFile.toPath()); + } + + /** + * Test if the signature can be detached and imported in an other + * DOM structure. + * + * @throws Exception + */ + @Test + public void signDetachedAndValidate_containSignatureDocument() throws Exception { + X509CertificatePrivateKeyPair certificateInfo = getCertificatePrivateKeyPair(); + + URL xmlUrl = XMLDigitalSignatureUtilTest.class.getResource("assessmentResult.xml"); + File xmlFile = new File(xmlUrl.toURI()); + String xmlUri = "http://localhost:8081/RepositoryEntry/688455680/CourseNode/95133178953589/TestSession/2693/assessmentResult.xml"; + + Document signatureDocument = XMLDigitalSignatureUtil.createDocument(); + Node rootNode = signatureDocument.appendChild(signatureDocument.createElement("assessmentTestSignature")); + Node courseNode = rootNode.appendChild(signatureDocument.createElement("course")); + courseNode.appendChild(signatureDocument.createTextNode("Very difficult test")); + + File xmlSignatureFile = File.createTempFile("assessment-result", "_signature.xml"); + XMLDigitalSignatureUtil.signDetached(xmlUri, xmlFile, xmlSignatureFile, signatureDocument, + null, certificateInfo.getX509Cert(), certificateInfo.getPrivateKey()); + + Assert.assertTrue(xmlSignatureFile.length() > 0); + + boolean valid = XMLDigitalSignatureUtil.validate(xmlUri, xmlFile, xmlSignatureFile, + certificateInfo.getX509Cert().getPublicKey()); + Assert.assertTrue(valid); + + //load the signature and check that the course info and the Signature is there + Document reloadSignatureDocument = XMLDigitalSignatureUtil.getDocument(xmlSignatureFile); + NodeList courseNl = reloadSignatureDocument.getElementsByTagName("course"); + Assert.assertEquals(1, courseNl.getLength()); + NodeList signatureNl = reloadSignatureDocument.getElementsByTagName("Signature"); + Assert.assertEquals(1, signatureNl.getLength()); + + //clean up + Files.deleteIfExists(xmlSignatureFile.toPath()); + } + + @Test + public void signDetachedAndValidate_notValid() throws Exception { + X509CertificatePrivateKeyPair certificateInfo = getCertificatePrivateKeyPair(); + + URL xmlUrl = XMLDigitalSignatureUtilTest.class.getResource("assessmentResult.xml"); + File xmlFile = new File(xmlUrl.toURI()); + String xmlUri = xmlUrl.toURI().toString(); + + File xmlSignatureFile = File.createTempFile("assessment-result", "_signature.xml"); + XMLDigitalSignatureUtil.signDetached(xmlUri, xmlFile, xmlSignatureFile, null, + null, certificateInfo.getX509Cert(), certificateInfo.getPrivateKey()); + Assert.assertTrue(xmlSignatureFile.length() > 0); + + URL xmlTamperedUrl = XMLDigitalSignatureUtilTest.class.getResource("assessmentResult_tampered.xml"); + File xmlTamperedFile = new File(xmlTamperedUrl.toURI()); + boolean valid = XMLDigitalSignatureUtil.validate(xmlUri, xmlTamperedFile, xmlSignatureFile, + certificateInfo.getX509Cert().getPublicKey()); + Assert.assertFalse(valid); + + //clean up + Files.deleteIfExists(xmlSignatureFile.toPath()); + } + + @Test + public void signAndValidate() throws Exception { + X509CertificatePrivateKeyPair certificateInfo = getCertificatePrivateKeyPair(); + + URL xmlUrl = XMLDigitalSignatureUtilTest.class.getResource("assessmentResult.xml"); + File xmlFile = new File(xmlUrl.toURI()); + + File xmlSignedFile = File.createTempFile("assessment-result", "_signed.xml"); + XMLDigitalSignatureUtil.signEmbedded(xmlFile, xmlSignedFile, + certificateInfo.getX509Cert(), certificateInfo.getPrivateKey()); + + Assert.assertTrue(xmlSignedFile.length() > 0); + + boolean valid = XMLDigitalSignatureUtil.validate(xmlSignedFile, + certificateInfo.getX509Cert().getPublicKey()); + Assert.assertTrue(valid); + + //clean up + Files.deleteIfExists(xmlSignedFile.toPath()); + } + + /** + * Check that the signature validate the data too by slightly changing a value in + * the signed XML file. + * + * + * @throws Exception + */ + @Test + public void signAndValidate_notValid() throws Exception { + X509CertificatePrivateKeyPair certificateInfo = getCertificatePrivateKeyPair(); + + URL xmlUrl = XMLDigitalSignatureUtilTest.class.getResource("assessmentResult.xml"); + File xmlFile = new File(xmlUrl.toURI()); + + File xmlSignedFile = File.createTempFile("assessment-result", "_signed.xml"); + XMLDigitalSignatureUtil.signEmbedded(xmlFile, xmlSignedFile, + certificateInfo.getX509Cert(), certificateInfo.getPrivateKey()); + + Assert.assertTrue(xmlSignedFile.length() > 0); + + //the xml is signed and valid + boolean valid = XMLDigitalSignatureUtil.validate(xmlSignedFile, + certificateInfo.getX509Cert().getPublicKey()); + Assert.assertTrue(valid); + + //change it a little bit + String xml = FileUtils.load(xmlSignedFile, "UTF-8"); + String rogueXml = xml.replace("test7501c21c-c3db-468d-b5b8-c40339aaf323.xml", "test7501c21c-468d-b5b8-c40339aaf323.xml"); + Assert.assertNotEquals(xml, rogueXml); + + File xmlRogueFile = File.createTempFile("assessment-result", "_rogue.xml"); + FileUtils.save(xmlRogueFile, rogueXml, "UTF-8"); + //the xml is not valid + boolean validity = XMLDigitalSignatureUtil.validate(xmlRogueFile, + certificateInfo.getX509Cert().getPublicKey()); + Assert.assertFalse(validity); + + //clean up + Files.deleteIfExists(xmlSignedFile.toPath()); + Files.deleteIfExists(xmlRogueFile.toPath()); + } + + private X509CertificatePrivateKeyPair getCertificatePrivateKeyPair() throws Exception { + URL certificateUrl = XMLDigitalSignatureUtilTest.class.getResource("certificate.pfx"); + File certificate = new File(certificateUrl.toURI()); + return CryptoUtil.getX509CertificatePrivateKeyPairPfx(certificate, ""); + } +} diff --git a/src/test/java/org/olat/core/util/xml/assessmentResult.xml b/src/test/java/org/olat/core/util/xml/assessmentResult.xml new file mode 100644 index 00000000000..1740fdbf074 --- /dev/null +++ b/src/test/java/org/olat/core/util/xml/assessmentResult.xml @@ -0,0 +1,215 @@ +<assessmentResult xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xmlns="http://www.imsglobal.org/xsd/imsqti_result_v2p1" + xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqti_result_v2p1 http://www.imsglobal.org/xsd/imsqti_result_v2p1.xsd"> + <context> + <sessionIdentifier sourceID="http://localhost:8081/auth/RepositoryEntry/720863232/TestSession/2038" + identifier="testsession/2038"/> + </context> + <testResult identifier="test7501c21c-c3db-468d-b5b8-c40339aaf323.xml" + datestamp="2017-02-08T16:54:49"> + <outcomeVariable identifier="SCORE" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="PASS" cardinality="single" baseType="boolean"> + <value>true</value> + </outcomeVariable> + </testResult> + <itemResult identifier="sc144e214a9f4cfe90d2ecf0781c291f" datestamp="2017-02-08T16:54:49" + sequenceIndex="1" + sessionStatus="final"> + <outcomeVariable identifier="completionStatus" cardinality="single" baseType="identifier"> + <value>unknown</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKBASIC" cardinality="single" baseType="identifier" + view="testConstructor"> + <value>incorrect</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKMODAL" cardinality="multiple" baseType="identifier" + view="testConstructor"> + <value>Feedback95140378493387</value> + </outcomeVariable> + <outcomeVariable identifier="SOLUTIONMODAL" cardinality="single" baseType="identifier" + view="testConstructor"> + <value>Feedback95140378493385</value> + </outcomeVariable> + <outcomeVariable identifier="SCORE" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="MAXSCORE" cardinality="single" baseType="float"> + <value>1.0</value> + </outcomeVariable> + <outcomeVariable identifier="HINTFEEDBACKMODAL" cardinality="single" baseType="identifier"/> + <outcomeVariable identifier="MINSCORE" cardinality="single" baseType="float" + view="testConstructor"> + <value>0.0</value> + </outcomeVariable> + <responseVariable identifier="duration" cardinality="single" baseType="float"> + <candidateResponse> + <value>701.727</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="numAttempts" cardinality="single" baseType="integer"> + <candidateResponse> + <value>12</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="HINTREQUEST" cardinality="single" baseType="identifier"> + <candidateResponse> + <value>false</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="RESPONSE_1" cardinality="single" baseType="identifier"> + <correctResponse> + <value>sccde3ab0fdd487ea26a3f9594ebf40f</value> + </correctResponse> + <candidateResponse> + <value>sc3a3fd5e8cb4cdba7199e984202437d</value> + </candidateResponse> + </responseVariable> + </itemResult> + <itemResult identifier="mce58c219bae4156adf4e0e02803512f" datestamp="2017-02-08T16:54:49" + sequenceIndex="1" + sessionStatus="initial"> + <outcomeVariable identifier="completionStatus" cardinality="single" baseType="identifier"> + <value>unknown</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKBASIC" cardinality="single" baseType="identifier" + view="testConstructor"> + <value>none</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKMODAL" cardinality="multiple" baseType="identifier" + view="testConstructor"/> + <outcomeVariable identifier="SOLUTIONMODAL" cardinality="single" baseType="identifier" + view="testConstructor"/> + <outcomeVariable identifier="SCORE" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="MAXSCORE" cardinality="single" baseType="float"> + <value>1.0</value> + </outcomeVariable> + <outcomeVariable identifier="HINTFEEDBACKMODAL" cardinality="single" baseType="identifier"/> + <outcomeVariable identifier="MINSCORE" cardinality="single" baseType="float" + view="testConstructor"> + <value>0.0</value> + </outcomeVariable> + <responseVariable identifier="duration" cardinality="single" baseType="float"> + <candidateResponse> + <value>0.0</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="numAttempts" cardinality="single" baseType="integer"> + <candidateResponse> + <value>0</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="HINTREQUEST" cardinality="single" baseType="identifier"> + <candidateResponse/> + </responseVariable> + <responseVariable identifier="RESPONSE_1" cardinality="multiple" baseType="identifier"> + <correctResponse> + <value>mca7d05a516a4d1c9dbe4963b766a517</value> + <value>mc746c767ff24f3891f93546cbe4ef51</value> + </correctResponse> + <candidateResponse/> + </responseVariable> + </itemResult> + <itemResult identifier="kprimeeceecc4d94b08c9d0ea6f0a9eb" datestamp="2017-02-08T16:54:49" + sequenceIndex="1" + sessionStatus="initial"> + <outcomeVariable identifier="completionStatus" cardinality="single" baseType="identifier"> + <value>unknown</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKBASIC" cardinality="single" baseType="identifier" + view="testConstructor"> + <value>none</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKMODAL" cardinality="multiple" baseType="identifier" + view="testConstructor"/> + <outcomeVariable identifier="SOLUTIONMODAL" cardinality="single" baseType="identifier" + view="testConstructor"/> + <outcomeVariable identifier="SCORE" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="MAXSCORE" cardinality="single" baseType="float"> + <value>1.0</value> + </outcomeVariable> + <outcomeVariable identifier="HINTFEEDBACKMODAL" cardinality="single" baseType="identifier"/> + <outcomeVariable identifier="MINSCORE" cardinality="single" baseType="float" + view="testConstructor"> + <value>0.0</value> + </outcomeVariable> + <responseVariable identifier="duration" cardinality="single" baseType="float"> + <candidateResponse> + <value>0.0</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="numAttempts" cardinality="single" baseType="integer"> + <candidateResponse> + <value>0</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="HINTREQUEST" cardinality="single" baseType="identifier"> + <candidateResponse/> + </responseVariable> + <responseVariable identifier="KPRIM_RESPONSE_1" cardinality="multiple" baseType="directedPair"> + <correctResponse> + <value>d95140378493395 wrong</value> + <value>a95140378493392 wrong</value> + <value>b95140378493393 wrong</value> + <value>c95140378493394 wrong</value> + </correctResponse> + <candidateResponse/> + </responseVariable> + </itemResult> + <itemResult identifier="fib1e0afaf7f48bcbf2c7410df0a9062" datestamp="2017-02-08T16:54:49" + sequenceIndex="1" + sessionStatus="initial"> + <outcomeVariable identifier="completionStatus" cardinality="single" baseType="identifier"> + <value>unknown</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKBASIC" cardinality="single" baseType="identifier" + view="testConstructor"> + <value>none</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKMODAL" cardinality="multiple" baseType="identifier" + view="testConstructor"/> + <outcomeVariable identifier="SOLUTIONMODAL" cardinality="single" baseType="identifier" + view="testConstructor"/> + <outcomeVariable identifier="SCORE" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="MAXSCORE" cardinality="single" baseType="float"> + <value>1.0</value> + </outcomeVariable> + <outcomeVariable identifier="MINSCORE_RESPONSE_1" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="HINTFEEDBACKMODAL" cardinality="single" baseType="identifier"/> + <outcomeVariable identifier="MINSCORE" cardinality="single" baseType="float" + view="testConstructor"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="SCORE_RESPONSE_1" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <responseVariable identifier="duration" cardinality="single" baseType="float"> + <candidateResponse> + <value>0.0</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="numAttempts" cardinality="single" baseType="integer"> + <candidateResponse> + <value>0</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="HINTREQUEST" cardinality="single" baseType="identifier"> + <candidateResponse/> + </responseVariable> + <responseVariable identifier="RESPONSE_1" cardinality="single" baseType="string"> + <correctResponse> + <value>gap</value> + </correctResponse> + <candidateResponse/> + </responseVariable> + </itemResult> +</assessmentResult> \ No newline at end of file diff --git a/src/test/java/org/olat/core/util/xml/assessmentResult_tampered.xml b/src/test/java/org/olat/core/util/xml/assessmentResult_tampered.xml new file mode 100644 index 00000000000..01374444ddf --- /dev/null +++ b/src/test/java/org/olat/core/util/xml/assessmentResult_tampered.xml @@ -0,0 +1,215 @@ +<assessmentResult xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xmlns="http://www.imsglobal.org/xsd/imsqti_result_v2p1" + xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqti_result_v2p1 http://www.imsglobal.org/xsd/imsqti_result_v2p1.xsd"> + <context> + <sessionIdentifier sourceID="http://localhost:8081/auth/RepositoryEntry/720863232/TestSession/2038" + identifier="testsession/2038"/> + </context> + <testResult identifier="test7501c21c-c3db-468d-b5b8-c40339aaf323.xml" + datestamp="2017-02-08T16:54:49"> + <outcomeVariable identifier="SCORE" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="PASS" cardinality="single" baseType="boolean"> + <value>true</value> + </outcomeVariable> + </testResult> + <itemResult identifier="sc1e214a9f4cfe90d2ecf0781c291f" datestamp="2017-02-08T16:54:49" + sequenceIndex="1" + sessionStatus="final"> + <outcomeVariable identifier="completionStatus" cardinality="single" baseType="identifier"> + <value>unknown</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKBASIC" cardinality="single" baseType="identifier" + view="testConstructor"> + <value>incorrect</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKMODAL" cardinality="multiple" baseType="identifier" + view="testConstructor"> + <value>Feedback95140378493387</value> + </outcomeVariable> + <outcomeVariable identifier="SOLUTIONMODAL" cardinality="single" baseType="identifier" + view="testConstructor"> + <value>Feedback95140378493385</value> + </outcomeVariable> + <outcomeVariable identifier="SCORE" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="MAXSCORE" cardinality="single" baseType="float"> + <value>1.0</value> + </outcomeVariable> + <outcomeVariable identifier="HINTFEEDBACKMODAL" cardinality="single" baseType="identifier"/> + <outcomeVariable identifier="MINSCORE" cardinality="single" baseType="float" + view="testConstructor"> + <value>0.0</value> + </outcomeVariable> + <responseVariable identifier="duration" cardinality="single" baseType="float"> + <candidateResponse> + <value>701.727</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="numAttempts" cardinality="single" baseType="integer"> + <candidateResponse> + <value>12</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="HINTREQUEST" cardinality="single" baseType="identifier"> + <candidateResponse> + <value>false</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="RESPONSE_1" cardinality="single" baseType="identifier"> + <correctResponse> + <value>sccde3ab0fdd487ea26a3f9594ebf40f</value> + </correctResponse> + <candidateResponse> + <value>sc3a3fd5e8cb4cdba7199e984202437d</value> + </candidateResponse> + </responseVariable> + </itemResult> + <itemResult identifier="mce58c219bae4156adf4e0e02803512f" datestamp="2017-02-08T16:54:49" + sequenceIndex="1" + sessionStatus="initial"> + <outcomeVariable identifier="completionStatus" cardinality="single" baseType="identifier"> + <value>unknown</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKBASIC" cardinality="single" baseType="identifier" + view="testConstructor"> + <value>none</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKMODAL" cardinality="multiple" baseType="identifier" + view="testConstructor"/> + <outcomeVariable identifier="SOLUTIONMODAL" cardinality="single" baseType="identifier" + view="testConstructor"/> + <outcomeVariable identifier="SCORE" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="MAXSCORE" cardinality="single" baseType="float"> + <value>1.0</value> + </outcomeVariable> + <outcomeVariable identifier="HINTFEEDBACKMODAL" cardinality="single" baseType="identifier"/> + <outcomeVariable identifier="MINSCORE" cardinality="single" baseType="float" + view="testConstructor"> + <value>0.0</value> + </outcomeVariable> + <responseVariable identifier="duration" cardinality="single" baseType="float"> + <candidateResponse> + <value>0.0</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="numAttempts" cardinality="single" baseType="integer"> + <candidateResponse> + <value>0</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="HINTREQUEST" cardinality="single" baseType="identifier"> + <candidateResponse/> + </responseVariable> + <responseVariable identifier="RESPONSE_1" cardinality="multiple" baseType="identifier"> + <correctResponse> + <value>mca7d05a516a4d1c9dbe4963b766a517</value> + <value>mc746c767ff24f3891f93546cbe4ef51</value> + </correctResponse> + <candidateResponse/> + </responseVariable> + </itemResult> + <itemResult identifier="kprimeeceecc4d94b08c9d0ea6f0a9eb" datestamp="2017-02-08T16:54:49" + sequenceIndex="1" + sessionStatus="initial"> + <outcomeVariable identifier="completionStatus" cardinality="single" baseType="identifier"> + <value>unknown</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKBASIC" cardinality="single" baseType="identifier" + view="testConstructor"> + <value>none</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKMODAL" cardinality="multiple" baseType="identifier" + view="testConstructor"/> + <outcomeVariable identifier="SOLUTIONMODAL" cardinality="single" baseType="identifier" + view="testConstructor"/> + <outcomeVariable identifier="SCORE" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="MAXSCORE" cardinality="single" baseType="float"> + <value>1.0</value> + </outcomeVariable> + <outcomeVariable identifier="HINTFEEDBACKMODAL" cardinality="single" baseType="identifier"/> + <outcomeVariable identifier="MINSCORE" cardinality="single" baseType="float" + view="testConstructor"> + <value>0.0</value> + </outcomeVariable> + <responseVariable identifier="duration" cardinality="single" baseType="float"> + <candidateResponse> + <value>0.0</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="numAttempts" cardinality="single" baseType="integer"> + <candidateResponse> + <value>0</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="HINTREQUEST" cardinality="single" baseType="identifier"> + <candidateResponse/> + </responseVariable> + <responseVariable identifier="KPRIM_RESPONSE_1" cardinality="multiple" baseType="directedPair"> + <correctResponse> + <value>d95140378493395 wrong</value> + <value>a95140378493392 wrong</value> + <value>b95140378493393 wrong</value> + <value>c95140378493394 wrong</value> + </correctResponse> + <candidateResponse/> + </responseVariable> + </itemResult> + <itemResult identifier="fib1e0afaf7f48bcbf2c7410df0a9062" datestamp="2017-02-08T16:54:49" + sequenceIndex="1" + sessionStatus="initial"> + <outcomeVariable identifier="completionStatus" cardinality="single" baseType="identifier"> + <value>unknown</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKBASIC" cardinality="single" baseType="identifier" + view="testConstructor"> + <value>none</value> + </outcomeVariable> + <outcomeVariable identifier="FEEDBACKMODAL" cardinality="multiple" baseType="identifier" + view="testConstructor"/> + <outcomeVariable identifier="SOLUTIONMODAL" cardinality="single" baseType="identifier" + view="testConstructor"/> + <outcomeVariable identifier="SCORE" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="MAXSCORE" cardinality="single" baseType="float"> + <value>1.0</value> + </outcomeVariable> + <outcomeVariable identifier="MINSCORE_RESPONSE_1" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="HINTFEEDBACKMODAL" cardinality="single" baseType="identifier"/> + <outcomeVariable identifier="MINSCORE" cardinality="single" baseType="float" + view="testConstructor"> + <value>0.0</value> + </outcomeVariable> + <outcomeVariable identifier="SCORE_RESPONSE_1" cardinality="single" baseType="float"> + <value>0.0</value> + </outcomeVariable> + <responseVariable identifier="duration" cardinality="single" baseType="float"> + <candidateResponse> + <value>0.0</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="numAttempts" cardinality="single" baseType="integer"> + <candidateResponse> + <value>0</value> + </candidateResponse> + </responseVariable> + <responseVariable identifier="HINTREQUEST" cardinality="single" baseType="identifier"> + <candidateResponse/> + </responseVariable> + <responseVariable identifier="RESPONSE_1" cardinality="single" baseType="string"> + <correctResponse> + <value>gap</value> + </correctResponse> + <candidateResponse/> + </responseVariable> + </itemResult> +</assessmentResult> \ No newline at end of file diff --git a/src/test/java/org/olat/core/util/xml/certificate.pfx b/src/test/java/org/olat/core/util/xml/certificate.pfx new file mode 100644 index 0000000000000000000000000000000000000000..cd0b20db56b6fdb260a84ae69ec882008b894856 GIT binary patch literal 2501 zcmY+^cQhM}8U}EYSP^Y0s`eg18Y76A+My(rQf*PARn*qnBh;ow1g+YtMT?j<Yb$EE zR_*b9cC8puy?V~Q_q+Fx_q^vk&pFSZKRAxAl@b7i<LDx(>7Wt15l0LF8bA(?&Ig2} z^SX%L;5d-TKNWQj4#aj5F;fC4E*`@_4FJwb4gU861V9bH1fp#;`;5{7@eET^(o%%r zfb+>oX}Hng$06RYw#LlVAp$0xH{ZfSqu=+{;$j?DFImP~G9xR4aJd|+x;$FlKYBLN zdmc=TqJjb%%KZ;|GMoqG`jlUC8N6(f5b514tZn9;95^wu31#kM0dKYr*H4N0b-U`L zmLx_=r6@!LSm!wq2ovc54hpzdaVQR`y);kwySFzp!`W%-alk1*JZ+Pc?EIo$<xR9L zy|wNnQ&Iro6`J-b?*ssEzn*<ICWt2?g(hPrZsn@6z3nxM^|s;5BDA;%HVIzeRWXwx z?^5(Sk${eQ{GasS^$~iAuY+8h>7JA4bTw88CTGxFg`67)5WKjGEH==JoqK@rKKOPK z`yFdHdpX78ljG)0+^gVsU9DQ7HM`atSbQbLgj~GSQ70@peE%!iu-HAGz<Au_(2&Ob zspBfiH33yuc3c?G3vp<1FfF*_?SPy4m;r=u4ETs5VGEuXPxn%R&0Ru$DB*lzyG^30 zq?FQDe3nU;JfGgm?{YNvd3sU0z2umuuz@C_zztH!Z*6<FVW+F>E}tFaW2hKB#x9a< zoY7Qz5Dib!UZGtm6NMhHx$DaCZFLw4rzeQl!;3ubZTq6FCR_U@y^4-Grf-JAg^Ugq z#^W$jwtH2Xgz89Doo4lRSnYJ>u@=3}<RG&o3M_4*-t=RHTDv*#vNN_|IZ7UpyfmeQ zTxa_12l*x}t>;fwh%2IjX}NCG43xGD%Wij0XLni+D~OjJ^B=R~{i>szNe}EhUDMQ- zi?MyC%AwJ1Ks(2lX5!~wD@3fRWr<kvNUow`+?%BI%<FGmFVBZ999EAQTz5U3Sjw!5 z)%iY~Dx<kFpH+O#sEt*8TuVNuhv5vu>x}WxkCo39J35&)>$-^{aw|~s@4c5>Gv+Sq ze!!@XjLB!<Aqw2XxP%eK)K-6F2@Kt4WfwA}j;_{<Cd^tjU$!+MFQELVXyuwv9*@^} zY!?N0${Fo*!yHGWy!yjc8S&QEJO%roLK1BP0+lRxM6oIPG^6aY^2%L3CM?drJ5CU5 zLz66x`y1w?X)?1X882ch9OiHMxIr3QVAn9CJ2udMGmFwlY2))kb>zU9;P9?(ZS$r0 z{<^f6Wc@ahNXekjZBfseM2!gE-wzEYe5kLU!tWr@A6GFn#SK>yrM2*rGfiOcf|6`R z*2;OaD5>Yek{&ogi`>p3zY4|=og8qMCB(OihkJ~@HZ_k(IqNX(*zJF@P%1|$p1=)r z=Q(xBuk5}u|6*+k4HMA`Qpy9Yr)*elUve7|)(z!zWvjok+h#d%HkOdl32o#b%eO)v z=D=}OH~$BO92}K22uB6Gh@lrJOilkE9>4&~3rDsu09pS(HGqHA_&IBrW5O9|_D2m6 z4%8zbmY>Siq>Ekh$#W$=ls$7O%Wpl;;9c%=vF7zUO5dg76VOe}DeB9`D&ZB)cPtJ^ zxlc6hzh_lRSgU#4vneE&3|?`OXj>(fdfGp;+v<eC<5h~BBn5(%zBU=bi`PR8nMC-^ zs)Sn39fh)dr6Z4KZWk23lMddvpWRB^YWwaZ?q0<<8#?Qkr9ahs@8DyA#EOzlglpc& z$@jDm4~^?Gi_eBr>j6a@xzFxT=K;Ox4o#}%TyWuxpG^+M8))frmW9Pz+#kicsJG_8 zrmK@rVr{y4mEArjemoklbBVz3#IQK8u=+!u%La${?HA139ahl9jT+6x&$(%iLM8q2 z)uD16Y&)IzVR!t9?Z*lrhoRUY0T+ADs#;Z~@l32LaVS?*`ix_$(abhd!i6dk^9kkP z)n3_-(^Jj1|I6pNQBqKr`esRuQk>hGV=s!I#PxYDy?bjjA=>RtEMuwl8p7ry{pr%G zcLMk;p*nPsNvB-QEoOYyQJ-`FQSdIUkX!$^rl^jOJu3}Qa|GVNf_J~io3~?L+%<ke zObtkv<79k9OgY2c=v~)zE^sYTok~smE~^>PPFGgwyD3xV@JLxEMV2W{-mRDXq`W7d zm}=uCQ;cHmO!3)Mc^+AFRhy7@Ewr}b<wLN{f;uH4eK~2PEs#o*Q1=iTq`5^c(7f(6 zqJP>HgT9Qui<x0Q*y=?3GO5(6FTIbLPr$kkm$MkDth7%h2Sy$i<!Cpr78^_$eyeF3 zOJzVXYFPW`mEE&sxp8v!bw$?%m*K+EkL)&6nzTb@Z&4|o$?MN5e!q_TfufSqV`dkc zD|vhFl6bKGYu`A|+&AJwd*U;#A?q)V1Ot`de@j?ac0Ed7YaoZ-sHaE6$L1silMeFa zLcN`!Vw+^XyKzry`^-4Z$7IWKbUHvlJ^Qr;aQVT_w34)%b*Pqv`Y(e<LiD{uBLabu zC1i&;3(@lROSWBUdLtQ3c-kARIqyGB)jhtGd)fAd=tE^u%CI-f6pQc=L3yu|Fgq^C zrF*m$aJe`7G}N>@e`7EP#T7yU6r@;Ha7k;>jvxNcssc70%D_Q1`gCGUf*>rW=P--t z>Z(4MI_v6u(w72;Si}&Mn>LjQm(^|yuE$#p_E&1utq#9a4yI+=Qmt2@YFXqX#oDMX z-?7vy4^|eB_X=A#vSaiDm~wFTsq}YE2Hz6CNI%x&SR^y>S(<zLh9-A(d=@%XWcL1f zx}g?DD)X?Y@haPo2#ON#8=wZoyz8wPe24$(Ww3W`kli0?aQF3!@$ZwTi^3^K_m<YT z8tPWbuM@I~EX)RJsP8j|cLM5&y@E<Ili3-SdMm14$>7CV3BI=Rki-UsSB&^2bF@x8 zf55t!QeIU-gI@vo{(USlcL9%J_?fQ^+!|L;TiaCiAO)-|;f|)ce<8FiP=(27P*!hu zNr;r$G^SO1M1Ekha66ZDumisBmum5re=b8_Cdmr>SLL`|ZIj_o*+43DvKZEJFX+eK z*IQ+r07^Apk`yr50Tg7;;HZ4XC_o`ha<b&5s%D3pF%JSMWboyE>EJjM{aScKMdCBX z@-}P(SHJ8KC7a@zUI>;Kso=SvJ8$HJ6%E%2S6ere5`qi-S4TP)DOM2krdTacRA5o1 zr}|y4`P%VoH6dRia49$+oQ|4G{1T9oor(g)yr0^lO^RSYoOYKSDMCR?0-rOWL9|d? N1iDEy0rf}8e*sAqs2>0T literal 0 HcmV?d00001 diff --git a/src/test/java/org/olat/test/AllTestsJunit4.java b/src/test/java/org/olat/test/AllTestsJunit4.java index e0959663c82..a8f044f2d7b 100644 --- a/src/test/java/org/olat/test/AllTestsJunit4.java +++ b/src/test/java/org/olat/test/AllTestsJunit4.java @@ -69,6 +69,7 @@ import org.junit.runners.Suite; org.olat.core.util.mail.manager.MailManagerTest.class, org.olat.core.util.openxml.OpenXmlWorkbookTest.class, org.olat.core.util.openxml.OpenXMLDocumentTest.class, + org.olat.core.util.xml.XMLDigitalSignatureUtilTest.class, org.olat.core.id.context.BusinessControlFactoryTest.class, org.olat.core.id.context.HistoryManagerTest.class, org.olat.core.id.IdentityEnvironmentTest.class, -- GitLab