Skip to content

Commit

Permalink
APID-104: add SOAP message signing when enabled in configuration (#29)
Browse files Browse the repository at this point in the history
* APID-104: add SOAP message signing when enabled in configuration

* APID-104: change name of signing flag to hopefully make intent clearer

* APID-104: rationalise import

* APID-104: add leak detection exemption for keystore which is used in unit tests

* APID-104: remove unnecessary brackets
  • Loading branch information
worthydolt authored Mar 31, 2021
1 parent c77a210 commit dfedcde
Show file tree
Hide file tree
Showing 9 changed files with 117 additions and 11 deletions.
Binary file added app/resources/KeyStore.jks
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ class AppConfig @Inject()(config: Configuration, servicesConfig: ServicesConfig)

val ccn2Username: String = config.get[String]("ccn2Username")
val ccn2Password: String = config.get[String]("ccn2Password")
val cryptoKeystoreLocation: String = config.get[String]("cryptoKeystoreLocation")
val keystoreAlias:String = config.get[String]("keystoreAlias")
val keystorePassword: String = config.get[String]("keystorePassword")
val enableMessageSigning: Boolean = config.get[Boolean]("enableMessageSigning")

val retryInterval: Duration = Duration(config.getOptional[String]("retry.interval").getOrElse("60 sec"))
val retryDuration: Duration = Duration(config.getOptional[String]("retry.duration").getOrElse("5 min"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ class OutboundService @Inject()(outboundConnector: OutboundConnector,
val envelope: SOAPEnvelope = getSOAP12Factory.getDefaultEnvelope
addHeaders(message, operation, envelope)
addBody(message, operation, envelope)
val enrichedEnvelope: String = wsSecurityService.addUsernameToken(envelope)
val enrichedEnvelope: String = if (appConfig.enableMessageSigning) wsSecurityService.addSignature(envelope) else wsSecurityService.addUsernameToken(envelope)
val url: String = wsdlDefinition.getAllServices.asScala.values.head.asInstanceOf[Service]
.getPorts.asScala.values.head.asInstanceOf[Port]
.getExtensibilityElements.asScala.filter(_.isInstanceOf[SOAP12Address]).head.asInstanceOf[SOAP12Address]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,48 @@ package uk.gov.hmrc.apiplatformoutboundsoap.services

import org.apache.axiom.soap.SOAPEnvelope
import org.apache.axis2.util.XMLUtils.toDOM
import org.apache.wss4j.common.WSS4JConstants
import org.apache.wss4j.common.WSS4JConstants.PASSWORD_TEXT
import org.apache.wss4j.common.crypto.{Crypto, CryptoFactory}
import org.apache.wss4j.common.util.XMLUtils.prettyDocumentToString
import org.apache.wss4j.dom.message.{WSSecHeader, WSSecUsernameToken}
import org.apache.wss4j.dom.WSConstants
import org.apache.wss4j.dom.message.{WSSecHeader, WSSecSignature, WSSecUsernameToken}
import uk.gov.hmrc.apiplatformoutboundsoap.config.AppConfig

import java.util.Properties
import javax.inject.{Inject, Singleton}

@Singleton
class WsSecurityService @Inject()(appConfig: AppConfig) {
val ROLE = "CCN2.Platform"

def addUsernameToken(soapEnvelope: SOAPEnvelope): String = {
val envelopeDocument = toDOM(soapEnvelope).getOwnerDocument
val crytoProperties: Properties = new Properties()
crytoProperties.setProperty("org.apache.wss4j.crypto.merlin.keystore.file", appConfig.cryptoKeystoreLocation)
crytoProperties.setProperty("org.apache.wss4j.crypto.merlin.keystore.password", appConfig.keystorePassword)
lazy val crypto: Crypto = CryptoFactory.getInstance(crytoProperties)

val secHeader: WSSecHeader = new WSSecHeader(ROLE, envelopeDocument)
secHeader.insertSecurityHeader()
def addUsernameToken(soapEnvelope: SOAPEnvelope): String = {
val secHeader: WSSecHeader = createSecurityHeader(soapEnvelope)
val builder = new WSSecUsernameToken(secHeader)
builder.setPasswordType(PASSWORD_TEXT)
builder.setUserInfo(appConfig.ccn2Username, appConfig.ccn2Password)
prettyDocumentToString(builder.build())
}

def addSignature(soapEnvelope: SOAPEnvelope): String = {
val secHeader: WSSecHeader = createSecurityHeader(soapEnvelope)
val builder = new WSSecSignature(secHeader)
builder.setKeyIdentifierType(WSConstants.ISSUER_SERIAL)
builder.setUserInfo(appConfig.keystoreAlias, appConfig.keystorePassword)
builder.setDigestAlgo(WSS4JConstants.SHA256)
builder.setSignatureAlgorithm(WSS4JConstants.RSA_SHA256)
prettyDocumentToString(builder.build(crypto))
}

private def createSecurityHeader(soapEnvelope: SOAPEnvelope) = {
val envelopeDocument = toDOM(soapEnvelope).getOwnerDocument
val secHeader: WSSecHeader = new WSSecHeader(ROLE, envelopeDocument)
secHeader.insertSecurityHeader()
secHeader
}
}
3 changes: 3 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@ lazy val microservice = Project(appName, file("."))
.settings(
unmanagedResourceDirectories in IntegrationTest += baseDirectory.value / "test" / "resources"
)
.settings(
unmanagedResourceDirectories in Compile += baseDirectory.value / "app" / "resources"
)
.settings(resolvers += Resolver.jcenterRepo)
.settings(scalacOptions ++= Seq("-Ypartial-unification"))
4 changes: 4 additions & 0 deletions conf/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ mongodb {

ccn2Username = "joe.bloggs"
ccn2Password = "foobar"
keystoreAlias = "Joe.Bloggs"
keystorePassword = "password"
cryptoKeystoreLocation = "KeyStore.jks"
enableMessageSigning = false

microservice {
metrics {
Expand Down
7 changes: 6 additions & 1 deletion repository.yaml
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
repoVisibility: public_0C3F0CE3E6E6448FAD341E7BFA50FCD333E06A20CFF05FCACE61154DDBBADF71
repoVisibility: public_0C3F0CE3E6E6448FAD341E7BFA50FCD333E06A20CFF05FCACE61154DDBBADF71

leakDetectionExemptions:
- ruleId: 'rule-1'
filePaths:
- '/app/resources/KeyStore.jks'
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ class OutboundServiceSpec extends AnyWordSpec with Matchers with GuiceOneAppPerS
messageCaptor.getValue.destinationUrl shouldBe "http://example.com/CCN2.Service.Customs.EU.ICS.RiskAnalysisOrchestrationBAS"
}

"send the expected SOAP envelope to the connector" in new Setup {
"send the expected SOAP envelope to the security service which adds username token" in new Setup {
val messageCaptor: ArgumentCaptor[SOAPEnvelope] = ArgumentCaptor.forClass(classOf[SOAPEnvelope])
when(wsSecurityServiceMock.addUsernameToken(messageCaptor.capture())).thenReturn(expectedSoapEnvelope())
when(outboundConnectorMock.postMessage(*)).thenReturn(successful(expectedStatus))
Expand All @@ -220,6 +220,18 @@ class OutboundServiceSpec extends AnyWordSpec with Matchers with GuiceOneAppPerS
getXmlDiff(messageCaptor.getValue.toString, expectedSoapEnvelope()).build().hasDifferences shouldBe false
}

"send the expected SOAP envelope to the security service which adds signature" in new Setup {
val messageCaptor: ArgumentCaptor[SOAPEnvelope] = ArgumentCaptor.forClass(classOf[SOAPEnvelope])
when(appConfigMock.enableMessageSigning).thenReturn(true)
when(wsSecurityServiceMock.addSignature(messageCaptor.capture())).thenReturn(expectedSoapEnvelope())
when(outboundConnectorMock.postMessage(*)).thenReturn(successful(expectedStatus))
when(outboundMessageRepositoryMock.persist(*)(*)).thenReturn(successful(()))

await(underTest.sendMessage(messageRequest))

getXmlDiff(messageCaptor.getValue.toString, expectedSoapEnvelope()).build().hasDifferences shouldBe false
}

"send the optional addressing headers if present in the request" in new Setup {
val messageCaptor: ArgumentCaptor[SOAPEnvelope] = ArgumentCaptor.forClass(classOf[SOAPEnvelope])
when(wsSecurityServiceMock.addUsernameToken(messageCaptor.capture())).thenReturn(expectedSoapEnvelope(optionalAddressingHeaders))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ class WsSecurityServiceSpec extends AnyWordSpec with Matchers with MockitoSugar
val ccn2Username = "Joe Bloggs"
val ccn2Password: String = randomUUID.toString
val mockAppConfig: AppConfig = mock[AppConfig]
when(mockAppConfig.ccn2Username).thenReturn(ccn2Username)
when(mockAppConfig.ccn2Password).thenReturn(ccn2Password)
when(mockAppConfig.cryptoKeystoreLocation).thenReturn("KeyStore.jks")
when(mockAppConfig.keystoreAlias).thenReturn("Joe.Bloggs")
when(mockAppConfig.keystorePassword).thenReturn("password")
val underTest = new WsSecurityService(mockAppConfig)

val expectedResult: String =
Expand All @@ -53,10 +58,50 @@ class WsSecurityServiceSpec extends AnyWordSpec with Matchers with MockitoSugar
|</soapenv:Envelope>""".stripMargin.replaceAll("\n", "")
}

val expectedResultWithSignature: String =
"""<?xml version="1.0" encoding="UTF-8"?>
|<soapenv:Envelope xmlns:soapenv="http://www.w3.org/2003/05/soap-envelope">
|<soapenv:Header>
|<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" soapenv:mustUnderstand="true" soapenv:role="CCN2.Platform">
|<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="SIG-7f87542b-37d3-4dc5-849b-44df20c6a74e">
|<ds:SignedInfo>
|<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
|<ec:InclusiveNamespaces xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#" PrefixList="soapenv"/>
|</ds:CanonicalizationMethod>
|<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
|<ds:Reference URI="#id-608f2a88-3c9f-492f-8cde-b4f5ddcb485c">
|<ds:Transforms>
|<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|</ds:Transforms>
|<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
|<ds:DigestValue>vdo/p3UAkm3/mqCawqnS7huTpVu51mNtiFuCgdxYJ6I=</ds:DigestValue>
|</ds:Reference>
|</ds:SignedInfo>
|<ds:SignatureValue>YdN/PGpQxaeGp7iWVl8AGPK3PGomtYhRaNkz66pScqY4jmp224rwkzCwo5URuUuy0QUVUn12l5aH
|5boiGYqdHKywVd7kfldluGtHXObJpj8L9aypdkPou1hvxR/IHvOPtEh+g6hRP8TLjMiC+naw5hVq
|k4HNU34AviU3wJKcFrT2phTUDKJb8J2QE2vkTi/vtgSwTOSjmNdD8qsHj1jk+4gRqdEPb8gx4J5S
|mN7ZqWzVgpsyPPjYYGJkVE4MbuQ6jWAm9ZeT2AUHfMvtw/4MIvCcaIRpW3/508+3MxwjPZm6D+wV
|Hg5lZWK58+1DXbXOvxlNimrRZzj2V8fuiDqWGQ==</ds:SignatureValue>
|<ds:KeyInfo Id="KI-f5dc16a2-704c-4361-bc69-a875ff294b6b">
|<wsse:SecurityTokenReference wsu:Id="STR-f4491d8c-abba-45d6-8194-4d1f2ce6ed23" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
|<ds:X509Data>
|<ds:X509IssuerSerial>
|<ds:X509IssuerName>CN=Sam,OU=Coding,O=HMRC,L=Leeds,ST=Yorkshire,C=GB</ds:X509IssuerName>
|<ds:X509SerialNumber>787851343</ds:X509SerialNumber>
|</ds:X509IssuerSerial>
|</ds:X509Data>
|</wsse:SecurityTokenReference>
|</ds:KeyInfo>
|</ds:Signature>
|</wsse:Security>
|</soapenv:Header>
|<soapenv:Body xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" wsu:Id="id-608f2a88-3c9f-492f-8cde-b4f5ddcb485c"/>
|</soapenv:Envelope>
|
|""".stripMargin

"addUsernameToken" should {
"add the username token security header" in new Setup {
when(mockAppConfig.ccn2Username).thenReturn(ccn2Username)
when(mockAppConfig.ccn2Password).thenReturn(ccn2Password)
val envelope: SOAPEnvelope = getSOAP12Factory.getDefaultEnvelope

val result: String = underTest.addUsernameToken(envelope)
Expand All @@ -65,11 +110,21 @@ class WsSecurityServiceSpec extends AnyWordSpec with Matchers with MockitoSugar
}
}

"addSignature" should {
"add a signature header to the SOAP envelope" in new Setup {
val envelope: SOAPEnvelope = getSOAP12Factory.getDefaultEnvelope

val result: String = underTest.addSignature(envelope)
getXmlDiff(result, expectedResultWithSignature).build().hasDifferences shouldBe false
}
}

private def getXmlDiff(actual: String, expected: String): DiffBuilder = {
compare(expected)
.withTest(actual)
.withNodeMatcher(new DefaultNodeMatcher(byName))
.withAttributeFilter(attribute => !"wsu:Id".equalsIgnoreCase(attribute.getName))
.withAttributeFilter(attribute => !Seq("wsu:Id", "Id", "URI").contains(attribute.getName))
.withNodeFilter(node => !Seq("ds:DigestValue", "ds:SignatureValue").contains(node.getNodeName))
.checkForIdentical
.ignoreWhitespace
}
Expand Down

0 comments on commit dfedcde

Please sign in to comment.