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 0000000000000000000000000000000000000000..4306185c402a5ab599a29d886b55d16efcacd6f8 --- /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 0000000000000000000000000000000000000000..54865a56d99c9741d8cb5055a648ce7171fdbedf --- /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 8e6203e084c79bb6cf7903fc0483cbd7f2ce2101..1ec7904d54572c7a97e9d79ec6c7e66bda264a87 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 3d26217964e1c27bbfcf51d75a3e6fe9cf1f6156..808386e679cda04c671dbc9a5ac6f13612036aa7 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 70de7ce09bcb7e8dcc316f03011602d38cf53277..de50b5560a43609c494e956ae5763d7ee5c873bb 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 0000000000000000000000000000000000000000..d413f9ae73f9bf4fac8088b6fe4b5c14a5214f02 --- /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 0000000000000000000000000000000000000000..c65203f84dfe6ee81c40704e004acdc81840fd69 --- /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 51696d617bb6f3a0148be0e27e442795f4ed0953..e9ad569c57a9f70d344c4287c60b74e1757d28f9 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 0898ab93d6a8b2ecaef7b9cd2a6ea0b7e319e75c..88cc14afedb32598a5c095a983a7598aeb9773bb 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 dec418afdad74f158c617caec4aaa4f6bfd37b8d..0bcf6981431cd2f45fb45cb0eee852eeea305f1b 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 1b0dbd4dde8d0a1cad489af8df9ac998f9792419..4ba585dd101549a552048fef032d7c42aa91c91c 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 1096ed9c31c02041d5885e4ea81c9e4039116b60..4b5abd0328c730e97fec624db558902030a98fdc 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 ecaebf0b26704ab9eebe964262b84a0571d08f67..8a690c8bd55ff8e1f0d02e2d874c63bb4678dbf8 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 844b7e347d89e4cd7e34c5bbf5c58e97c049379a..a19af703d3474898c7f95b347d34d7a80a0bb733 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 8f9d9720718cdcad8163ab08a04b2424d22beb94..1228f8f6f05f65f8e2a3fdbd07285f87487a94b0 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 672fb4e4f5939b668683905d2b17c49af0f4e7e8..32222007dc67f4556746dd812fc03e93e1a66279 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 51d8bb33276c5012176b15a1cfc82c3f404bbe33..5eeec942c05fd7d432aa49e8e2fb3e7b8ac66aed 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 6df900c283d6f24fd0f5fcc85cb8d85944e34d51..30b01e5a87d888ce9b5a17ee4d98944902d9cf1c 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 3b2fa1acda3327f0733ac30bccf0a70ed03054ce..850bfd12325a7c036b93775801b5120b43d125e6 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 0000000000000000000000000000000000000000..920986c46ad3a5ae5981447becc4db00fd62cf3f --- /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 a5b7cab32cb12397b1c65cf3e596c7d60b5f4236..7e0733a34de7ed0e145220ff1adb7477a64cf6df 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 9a5d76044d15f2e843be71b21a4c713569073100..b19a9fd6c3e5b5a31e4c5431a7a4d5770077e582 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 3c3e82d93fba305242c08b9770a4400dd981abf2..de5b90c97f0e3f1d311b4e7246e4ffdc10d3ffe7 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 ecfbb805bb054038cb82b9111a256aa03df4cb18..54bb968ceed65709175492fafd78f380a0fe15c7 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 0f3c58a3850ee812070cdceaf37da6e9d1a5c05e..d48800df2d51ab3c913676eba47b7bf72813edef 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 97798f72f83996ea275cd321ba6bbda54bea8f1c..be55e7b4a37242f76db0f32e488fded090a064a1 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 c1bfa6fd34dd3d62b4600861c063a41350b31b2d..1f4451027e2e3bffca2ffe1bea5a8099c458f04a 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 a629e1759b64bab5637e2ac905b6efcdf3d0cb61..402c0f4c8149d2fca9a16a9f85c36df161bdce34 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 d8fec2027b116319aaeed6f0cae3145033d2d98f..b0bad54983e12c0f3a567180607d3a3b4d3c2a59 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 f92ca714a74807836a34d8898c18aa2420e731b2..6516e537c870b1a057a57f0433ad8cc91f72b791 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 9f03dc6b2042b3c7c51e6b10200545c889cc682b..744f7de806a62428d9b688d934dfae4f2cf02ec9 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 fb9172d795c1f0059a051e67764e72634c0c871a..075077fe7a5a6b7b51ead77a8197e624efdde846 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 5e80a811280ea92385c2f41be074a5dd8eb08cb2..9266bbfb8bbd9abaa705bc471abb4b63a015dad3 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 0000000000000000000000000000000000000000..c24d2b36287b4ca0d497799c23af1c798b33dc5e --- /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 0000000000000000000000000000000000000000..1740fdbf074b917dd8f622fa50abf12a39fa0c88 --- /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 0000000000000000000000000000000000000000..01374444ddfaed3e4f074935fc65643aa3b23c0a --- /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 Binary files /dev/null and b/src/test/java/org/olat/core/util/xml/certificate.pfx differ diff --git a/src/test/java/org/olat/test/AllTestsJunit4.java b/src/test/java/org/olat/test/AllTestsJunit4.java index e0959663c82ee01320f23f98fefc51b1640180f2..a8f044f2d7b2f33c8425ce248f81760b106bd58a 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,