Implemented parsing TAN response (HITAN)

This commit is contained in:
dankl 2019-10-13 02:43:28 +02:00 committed by dankito
parent 3ccef79596
commit c20bf13c5c
6 changed files with 188 additions and 29 deletions

View File

@ -17,6 +17,8 @@ enum class InstituteSegmentId(override val id: String) : ISegmentId {
TanInfo("HITANS"), TanInfo("HITANS"),
Tan("HITAN"),
Balance("HISAL"), Balance("HISAL"),
AccountTransactionsMt940("HIKAZ") AccountTransactionsMt940("HIKAZ")

View File

@ -5,6 +5,7 @@ import net.dankito.fints.messages.segmente.id.ISegmentId
import net.dankito.fints.messages.segmente.id.MessageSegmentId import net.dankito.fints.messages.segmente.id.MessageSegmentId
import net.dankito.fints.response.segments.ReceivedMessageHeader import net.dankito.fints.response.segments.ReceivedMessageHeader
import net.dankito.fints.response.segments.ReceivedSegment import net.dankito.fints.response.segments.ReceivedSegment
import net.dankito.fints.response.segments.TanResponse
open class Response( open class Response(
@ -18,6 +19,15 @@ open class Response(
open val successful: Boolean open val successful: Boolean
get() = didReceiveResponse && didResponseContainErrors == false get() = didReceiveResponse && didResponseContainErrors == false
open val isStrongAuthenticationRequired: Boolean
get() {
getFirstSegmentById<TanResponse>(InstituteSegmentId.Tan)?.let { tanResponse ->
return tanResponse.isStrongAuthenticationRequired
}
return false
}
open val messageHeader: ReceivedMessageHeader? open val messageHeader: ReceivedMessageHeader?
get() = getFirstSegmentById(MessageSegmentId.MessageHeader) get() = getFirstSegmentById(MessageSegmentId.MessageHeader)

View File

@ -84,7 +84,9 @@ open class ResponseParser @JvmOverloads constructor(
InstituteSegmentId.UserParameters.id -> parseUserParameters(segment, dataElementGroups) InstituteSegmentId.UserParameters.id -> parseUserParameters(segment, dataElementGroups)
InstituteSegmentId.AccountInfo.id -> parseAccountInfo(segment, dataElementGroups) InstituteSegmentId.AccountInfo.id -> parseAccountInfo(segment, dataElementGroups)
InstituteSegmentId.TanInfo.id -> parseTanInfo(segment, dataElementGroups) InstituteSegmentId.TanInfo.id -> parseTanInfo(segment, dataElementGroups)
InstituteSegmentId.Tan.id -> parseTanResponse(segment, dataElementGroups)
InstituteSegmentId.Balance.id -> parseBalanceSegment(segment, dataElementGroups) InstituteSegmentId.Balance.id -> parseBalanceSegment(segment, dataElementGroups)
InstituteSegmentId.AccountTransactionsMt940.id -> parseMt940AccountTransactions(segment, dataElementGroups) InstituteSegmentId.AccountTransactionsMt940.id -> parseMt940AccountTransactions(segment, dataElementGroups)
@ -249,6 +251,23 @@ open class ResponseParser @JvmOverloads constructor(
} }
protected open fun parseTanResponse(segment: String, dataElementGroups: List<String>): TanResponse {
val binaryJobHashValue = if (dataElementGroups.size > 2) parseStringToNullIfEmpty(dataElementGroups[2]) else null
val binaryChallengeHHD_UC = if (dataElementGroups.size > 5) parseStringToNullIfEmpty(dataElementGroups[5]) else null
return TanResponse(
parseCodeEnum(dataElementGroups[1], TanProcess.values()),
binaryJobHashValue?.let { extractBinaryData(it) },
if (dataElementGroups.size > 3) parseStringToNullIfEmpty(dataElementGroups[3]) else null,
if (dataElementGroups.size > 4) parseStringToNullIfEmpty(dataElementGroups[4]) else null,
binaryChallengeHHD_UC?.let { extractBinaryData(it) },
if (dataElementGroups.size > 6) parseNullableDateTime(dataElementGroups[6]) else null,
if (dataElementGroups.size > 7) parseStringToNullIfEmpty(dataElementGroups[7]) else null,
segment
)
}
protected open fun parseBalanceSegment(segment: String, dataElementGroups: List<String>): BalanceSegment { protected open fun parseBalanceSegment(segment: String, dataElementGroups: List<String>): BalanceSegment {
// dataElementGroups[1] is account details // dataElementGroups[1] is account details
@ -487,13 +506,27 @@ open class ResponseParser @JvmOverloads constructor(
return amount return amount
} }
protected open fun parseNullableDateTime(dataElementGroup: String): Date? {
val dataElements = getDataElements(dataElementGroup)
if (dataElements.size >= 2) {
parseNullableDate(dataElements[0])?.let { date ->
parseNullableTime(dataElements[1])?.let { time ->
return Date(date.time + time.time)
}
}
}
return null
}
protected open fun parseDate(dateString: String): Date { protected open fun parseDate(dateString: String): Date {
return Datum.HbciDateFormat.parse(dateString) return Datum.HbciDateFormat.parse(dateString)
} }
protected open fun parseNullableDate(dateString: String): Date? { protected open fun parseNullableDate(dateString: String): Date? {
try { try {
return Datum.HbciDateFormat.parse(dateString) return parseDate(dateString)
} catch (ignored: Exception) { } } catch (ignored: Exception) { }
return null return null
@ -503,6 +536,14 @@ open class ResponseParser @JvmOverloads constructor(
return Uhrzeit.HbciTimeFormat.parse(timeString) return Uhrzeit.HbciTimeFormat.parse(timeString)
} }
protected open fun parseNullableTime(timeString: String): Date? {
try {
return parseTime(timeString)
} catch (ignored: Exception) { }
return null
}
protected open fun extractBinaryData(binaryData: String): String { protected open fun extractBinaryData(binaryData: String): String {
if (binaryData.startsWith('@')) { if (binaryData.startsWith('@')) {
val headerEndIndex = binaryData.indexOf('@', 2) val headerEndIndex = binaryData.indexOf('@', 2)

View File

@ -0,0 +1,49 @@
package net.dankito.fints.response.segments
import net.dankito.fints.messages.datenelemente.implementierte.tan.TanProcess
import java.util.*
open class TanResponse(
val tanProcess: TanProcess,
val jobHashValue: String?, // M: bei Auftrags-Hashwertverfahren<>0 und TAN-Prozess=1. N: sonst
val jobReference: String?, // M: bei TAN-Prozess=2, 3, 4. O: bei TAN-Prozess=1
/**
* Dieses Datenelement enthält im Falle des Zwei-Schritt-TAN-Verfahrens die Challenge zu einem
* eingereichten Auftrag. Aus der Challenge wird vom Kunden die eigentliche TAN ermittelt.
* Die Challenge wird unabhängig vom Prozessvariante 1 oder 2 in der Kreditinstitutsantwort im
* Segment HITAN übermittelt.
*
* Ist der BPD-Parameter Challenge strukturiert mit J belegt, so können im Text folgende
* Formatsteuerzeichen enthalten sein, die kundenseitig entsprechend zu interpretieren sind.
* Eine Kaskadierung von Steuerzeichen ist nicht erlaubt.
*
* <br> Zeilenumbruch
* <p> Neuer Absatz
* <b> ... </b> Fettdruck
* <i> ... </i> Kursivdruck
* <u> ... </u> Unterstreichen
* <ul> ... </ul> Beginn / Ende Aufzählung
* <ol> ... </ol> Beginn / Ende Nummerierte Liste
* <li> ... </li> Listenelement einer Aufzählung / Nummerierten Liste
*/
val challenge: String?, // M: bei TAN-Prozess=1, 3, 4. O: bei TAN-Prozess=2
val challengeHHD_UC: String?,
val validityDateTimeForChallenge: Date?,
val tanMediaIdentifier: String? = null, // M: bei TAN-Prozess=1, 3, 4 und „Anzahl unterstützter aktiver TAN-Medien“ nicht vorhanden. O: sonst
segmentString: String
) :
ReceivedSegment(segmentString) {
companion object {
const val NoChallengeResponse = "nochallenge"
const val NoJobReferenceResponse = "noref"
}
open val isStrongAuthenticationRequired: Boolean
get() = challenge != NoChallengeResponse
}

View File

@ -52,6 +52,10 @@ abstract class FinTsTestBase {
return Datum.HbciDateFormat.format(date) return Datum.HbciDateFormat.format(date)
} }
protected open fun unmaskString(string: String): String {
return string.replace("?'", "'").replace("?+", "+").replace("?:", ":")
}
protected open fun normalizeBinaryData(message: String): String { protected open fun normalizeBinaryData(message: String): String {
return message.replace(0.toChar(), ' ') return message.replace(0.toChar(), ' ')
} }

View File

@ -5,6 +5,7 @@ import net.dankito.fints.messages.datenelemente.implementierte.Dialogsprache
import net.dankito.fints.messages.datenelemente.implementierte.HbciVersion import net.dankito.fints.messages.datenelemente.implementierte.HbciVersion
import net.dankito.fints.messages.datenelemente.implementierte.signatur.Sicherheitsverfahren import net.dankito.fints.messages.datenelemente.implementierte.signatur.Sicherheitsverfahren
import net.dankito.fints.messages.datenelemente.implementierte.signatur.VersionDesSicherheitsverfahrens import net.dankito.fints.messages.datenelemente.implementierte.signatur.VersionDesSicherheitsverfahrens
import net.dankito.fints.messages.datenelemente.implementierte.tan.TanProcess
import net.dankito.fints.messages.datenelementgruppen.implementierte.signatur.Sicherheitsprofil import net.dankito.fints.messages.datenelementgruppen.implementierte.signatur.Sicherheitsprofil
import net.dankito.fints.messages.segmente.id.ISegmentId import net.dankito.fints.messages.segmente.id.ISegmentId
import net.dankito.fints.messages.segmente.id.MessageSegmentId import net.dankito.fints.messages.segmente.id.MessageSegmentId
@ -214,34 +215,6 @@ class ResponseParserTest : FinTsTestBase() {
} }
@Test
fun parseBalance() {
// given
val balance = 1234.56.toBigDecimal()
val date = LocalDate.of(1988, 3, 27).asUtilDate()
val bankCode = "12345678"
val accountId = "0987654321"
val accountProductName = "Sichteinlagen"
// when
val result = underTest.parse("HISAL:8:5:3+$accountId::280:$bankCode+$accountProductName+EUR+" +
"C:${convertAmount(balance)}:EUR:${convertDate(date)}+C:0,:EUR:20191006++${convertAmount(balance)}:EUR")
// then
assertSuccessfullyParsedSegment(result, InstituteSegmentId.Balance, 8, 5, 3)
result.getFirstSegmentById<BalanceSegment>(InstituteSegmentId.Balance)?.let { segment ->
assertThat(segment.balance).isEqualTo(balance)
assertThat(segment.currency).isEqualTo("EUR")
assertThat(segment.date).isEqualTo(date)
assertThat(segment.accountProductName).isEqualTo(accountProductName)
assertThat(segment.balanceOfPreBookedTransactions).isNull()
}
?: run { Assert.fail("No segment of type Balance found in ${result.receivedSegments}") }
}
@Test @Test
fun parseTanInfo() { fun parseTanInfo() {
@ -267,6 +240,86 @@ class ResponseParserTest : FinTsTestBase() {
?: run { Assert.fail("No segment of type TanInfo found in ${result.receivedSegments}") } ?: run { Assert.fail("No segment of type TanInfo found in ${result.receivedSegments}") }
} }
@Test
fun parseTanResponse_NoStrongAuthenticationRequired() {
// when
val result = underTest.parse("HITAN:6:6:5+4++noref+nochallenge")
// then
assertSuccessfullyParsedSegment(result, InstituteSegmentId.Tan, 6, 6, 5)
assertThat(result.isStrongAuthenticationRequired).isFalse()
result.getFirstSegmentById<TanResponse>(InstituteSegmentId.Tan)?.let { segment ->
assertThat(segment.tanProcess).isEqualTo(TanProcess.TanProcess4)
assertThat(segment.jobHashValue).isNull()
assertThat(segment.jobReference).isEqualTo(TanResponse.NoJobReferenceResponse)
assertThat(segment.challenge).isEqualTo(TanResponse.NoChallengeResponse)
assertThat(segment.challengeHHD_UC).isNull()
assertThat(segment.validityDateTimeForChallenge).isNull()
assertThat(segment.tanMediaIdentifier).isNull()
}
?: run { Assert.fail("No segment of type TanResponse found in ${result.receivedSegments}") }
}
@Test
fun parseTanResponse_StrongAuthenticationRequired() {
// given
val jobReference = "4937-10-13-02.30.03.700259"
val challenge = "Sie möchten eine \"Umsatzabfrage\" freigeben?: Bitte bestätigen Sie den \"Startcode 80085335\" mit der Taste \"OK\"."
val challengeHHD_UC = "100880085335"
val tanMediaIdentifier = "Kartennummer ******0892"
// when
val result = underTest.parse("'HITAN:5:6:4+4++$jobReference+$challenge+@12@$challengeHHD_UC++$tanMediaIdentifier'")
// then
assertSuccessfullyParsedSegment(result, InstituteSegmentId.Tan, 5, 6, 4)
assertThat(result.isStrongAuthenticationRequired).isTrue()
result.getFirstSegmentById<TanResponse>(InstituteSegmentId.Tan)?.let { segment ->
assertThat(segment.tanProcess).isEqualTo(TanProcess.TanProcess4)
assertThat(segment.jobHashValue).isNull()
assertThat(segment.jobReference).isEqualTo(jobReference)
assertThat(segment.challenge).isEqualTo(unmaskString(challenge))
assertThat(segment.challengeHHD_UC).isEqualTo(challengeHHD_UC)
assertThat(segment.validityDateTimeForChallenge).isNull()
assertThat(segment.tanMediaIdentifier).isEqualTo(tanMediaIdentifier)
}
?: run { Assert.fail("No segment of type TanResponse found in ${result.receivedSegments}") }
}
@Test
fun parseBalance() {
// given
val balance = 1234.56.toBigDecimal()
val date = LocalDate.of(1988, 3, 27).asUtilDate()
val bankCode = "12345678"
val accountId = "0987654321"
val accountProductName = "Sichteinlagen"
// when
val result = underTest.parse("HISAL:8:5:3+$accountId::280:$bankCode+$accountProductName+EUR+" +
"C:${convertAmount(balance)}:EUR:${convertDate(date)}+C:0,:EUR:20191006++${convertAmount(balance)}:EUR")
// then
assertSuccessfullyParsedSegment(result, InstituteSegmentId.Balance, 8, 5, 3)
result.getFirstSegmentById<BalanceSegment>(InstituteSegmentId.Balance)?.let { segment ->
assertThat(segment.balance).isEqualTo(balance)
assertThat(segment.currency).isEqualTo("EUR")
assertThat(segment.date).isEqualTo(date)
assertThat(segment.accountProductName).isEqualTo(accountProductName)
assertThat(segment.balanceOfPreBookedTransactions).isNull()
}
?: run { Assert.fail("No segment of type Balance found in ${result.receivedSegments}") }
}
private fun assertSuccessfullyParsedSegment(result: Response, segmentId: ISegmentId, segmentNumber: Int, private fun assertSuccessfullyParsedSegment(result: Response, segmentId: ISegmentId, segmentNumber: Int,
segmentVersion: Int, referenceSegmentNumber: Int? = null) { segmentVersion: Int, referenceSegmentNumber: Int? = null) {