/*
* This file is part of LibEuFin.
* Copyright (C) 2019 Stanisci and Dold.
* LibEuFin is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus
import com.google.gson.Gson
import com.google.gson.JsonObject
import io.ktor.application.ApplicationCallPipeline
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.client.HttpClient
import io.ktor.features.*
import io.ktor.gson.gson
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.request.ApplicationReceivePipeline
import io.ktor.request.ApplicationReceiveRequest
import io.ktor.request.receive
import io.ktor.request.uri
import io.ktor.response.respond
import io.ktor.response.respondText
import io.ktor.routing.get
import io.ktor.routing.post
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.util.KtorExperimentalAPI
import kotlinx.coroutines.io.ByteReadChannel
import kotlinx.coroutines.io.jvm.javaio.toByteReadChannel
import kotlinx.coroutines.io.jvm.javaio.toInputStream
import kotlinx.io.core.ExperimentalIoApi
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction
import org.joda.time.DateTime
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.slf4j.event.Level
import tech.libeufin.util.*
import tech.libeufin.util.ebics_h004.HTDResponseOrderData
import java.text.DateFormat
import java.util.zip.InflaterInputStream
import javax.crypto.EncryptedPrivateKeyInfo
import javax.sql.rowset.serial.SerialBlob
data class NexusError(val statusCode: HttpStatusCode, val reason: String) : Exception()
val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus")
suspend fun handleEbicsSendMSG(
httpClient: HttpClient,
userId: String,
transportId: String?,
msg: String,
sync: Boolean
): String {
val subscriber = getEbicsSubscriberDetails(userId, transportId)
val response = when (msg.toUpperCase()) {
"HIA" -> {
val request = makeEbicsHiaRequest(subscriber)
httpClient.postToBank(
subscriber.ebicsUrl,
request
)
}
"INI" -> {
val request = makeEbicsIniRequest(subscriber)
httpClient.postToBank(
subscriber.ebicsUrl,
request
)
}
"HPB" -> {
/** should NOT put bank's keys into any table. */
val request = makeEbicsHpbRequest(subscriber)
val response = httpClient.postToBank(
subscriber.ebicsUrl,
request
)
if (sync) {
val parsedResponse = parseAndDecryptEbicsKeyManagementResponse(subscriber, response)
val orderData = parsedResponse.orderData ?: throw NexusError(
HttpStatusCode.InternalServerError,
"Cannot find data in a HPB response"
)
val hpbData = parseEbicsHpbOrder(orderData)
transaction {
val transport = getEbicsTransport(userId, transportId)
transport.bankAuthenticationPublicKey = SerialBlob(hpbData.authenticationPubKey.encoded)
transport.bankEncryptionPublicKey = SerialBlob(hpbData.encryptionPubKey.encoded)
}
}
return response
}
"HTD" -> {
val response = doEbicsDownloadTransaction(
httpClient, subscriber, "HTD", EbicsStandardOrderParams()
)
when (response) {
is EbicsDownloadBankErrorResult -> {
throw NexusError(
HttpStatusCode.BadGateway,
response.returnCode.errorCode
)
}
is EbicsDownloadSuccessResult -> {
val payload = XMLUtil.convertStringToJaxb(
response.orderData.toString(Charsets.UTF_8)
)
if (sync) {
transaction {
payload.value.partnerInfo.accountInfoList?.forEach {
val bankAccount = BankAccountEntity.new(id = it.id) {
accountHolder = it.accountHolder ?: "NOT-GIVEN"
iban = extractFirstIban(it.accountNumberList)
?: throw NexusError(HttpStatusCode.NotFound, reason = "bank gave no IBAN")
bankCode = extractFirstBic(it.bankCodeList) ?: throw NexusError(
HttpStatusCode.NotFound,
reason = "bank gave no BIC"
)
}
BankAccountMapEntity.new {
ebicsSubscriber = getEbicsTransport(userId, transportId)
this.nexusUser = getNexusUser(userId)
this.bankAccount = bankAccount
}
}
}
}
response.orderData.toString(Charsets.UTF_8)
}
}
}
"HEV" -> {
val request = makeEbicsHEVRequest(subscriber)
httpClient.postToBank(subscriber.ebicsUrl, request)
}
else -> throw NexusError(
HttpStatusCode.NotFound,
"Message $msg not found"
)
}
return response
}
@ExperimentalIoApi
@KtorExperimentalAPI
fun main() {
dbCreateTables()
val client = HttpClient() {
expectSuccess = false // this way, it does not throw exceptions on != 200 responses.
}
val server = embeddedServer(Netty, port = 5001) {
install(CallLogging) {
this.level = Level.DEBUG
this.logger = tech.libeufin.nexus.logger
}
install(ContentNegotiation) {
gson {
setDateFormat(DateFormat.LONG)
setPrettyPrinting()
}
}
install(StatusPages) {
exception { cause ->
logger.error("Exception while handling '${call.request.uri}'", cause)
call.respondText(
cause.reason,
ContentType.Text.Plain,
cause.statusCode
)
}
exception { cause ->
logger.error("Exception while handling '${call.request.uri}'", cause)
call.respondText(
cause.reason,
ContentType.Text.Plain,
cause.statusCode
)
}
exception { cause ->
logger.error("Uncaught exception while handling '${call.request.uri}'", cause)
logger.error(cause.toString())
call.respondText(
"Internal server error",
ContentType.Text.Plain,
HttpStatusCode.InternalServerError
)
}
}
intercept(ApplicationCallPipeline.Fallback) {
if (this.call.response.status() == null) {
call.respondText("Not found (no route matched).\n", ContentType.Text.Plain, HttpStatusCode.NotFound)
return@intercept finish()
}
}
receivePipeline.intercept(ApplicationReceivePipeline.Before) {
if (this.context.request.headers["Content-Encoding"] == "deflate") {
logger.debug("About to inflate received data")
val deflated = this.subject.value as ByteReadChannel
val inflated = InflaterInputStream(deflated.toInputStream())
proceedWith(ApplicationReceiveRequest(this.subject.typeInfo, inflated.toByteReadChannel()))
return@intercept
}
proceed()
return@intercept
}
routing {
/**
* Shows information about the requesting user.
*/
get("/user") {
val userId = authenticateRequest(call.request.headers["Authorization"])
val ret = transaction {
NexusUserEntity.findById(userId)
UserResponse(
username = userId,
superuser = userId.equals("admin")
)
}
call.respond(HttpStatusCode.OK, ret)
return@get
}
/**
* Add a new ordinary user in the system (requires "admin" privileges)
*/
post("/users") {
authenticateAdminRequest(call.request.headers["Authorization"])
val body = call.receive()
if (body.username.equals("admin")) throw NexusError(
HttpStatusCode.Forbidden,
"'admin' is a reserved username"
)
transaction {
NexusUserEntity.new(body.username) {
password = SerialBlob(CryptoUtil.hashStringSHA256(body.password))
}
}
call.respondText(
"New NEXUS user registered. ID: ${body.username}",
ContentType.Text.Plain,
HttpStatusCode.OK
)
return@post
}
/**
* Shows the bank accounts belonging to the requesting user.
*/
get("/bank-accounts") {
val userId = authenticateRequest(call.request.headers["Authorization"])
val bankAccounts = BankAccounts()
getBankAccountsFromNexusUserId(userId).forEach {
bankAccounts.accounts.add(
BankAccount(
holder = it.accountHolder,
iban = it.iban,
bic = it.bankCode,
account = it.id.value
)
)
}
return@get
}
/**
* Submit one particular payment at the bank.
*/
post("/bank-accounts/prepared-payments/submit") {
val userId = authenticateRequest(call.request.headers["Authorization"])
val body = call.receive()
val preparedPayment = getPreparedPayment(body.uuid)
transaction {
if (preparedPayment.nexusUser.id.value != userId) throw NexusError(
HttpStatusCode.Forbidden,
"No rights over such payment"
)
if (preparedPayment.submitted) {
throw NexusError(
HttpStatusCode.PreconditionFailed,
"Payment ${body.uuid} was submitted already"
)
}
}
val pain001document = createPain001document(preparedPayment)
if (body.transport != null) {
// type and name aren't null
when (body.transport.type) {
"ebics" -> {
submitPaymentEbics(
client, userId, body.transport.name, pain001document
)
}
else -> throw NexusError(
HttpStatusCode.NotFound,
"Transport type '${body.transport.type}' not implemented"
)
}
} else {
// default to ebics and "first" transport from user
submitPaymentEbics(
client, userId, null, pain001document
)
}
transaction {
preparedPayment.submitted = true
}
call.respondText("Payment ${body.uuid} submitted")
return@post
}
/**
* Shows information about one particular prepared payment.
*/
get("/bank-accounts/{accountid}/prepared-payments/{uuid}") {
val userId = authenticateRequest(call.request.headers["Authorization"])
val preparedPayment = getPreparedPayment(ensureNonNull(call.parameters["uuid"]))
if (preparedPayment.nexusUser.id.value != userId) throw NexusError(
HttpStatusCode.Forbidden,
"No rights over such payment"
)
call.respond(
PaymentStatus(
uuid = preparedPayment.id.value,
submitted = preparedPayment.submitted,
creditorName = preparedPayment.creditorName,
creditorBic = preparedPayment.creditorBic,
creditorIban = preparedPayment.creditorIban,
amount = "${preparedPayment.sum}:${preparedPayment.currency}",
subject = preparedPayment.subject,
submissionDate = DateTime(preparedPayment.submissionDate).toDashedDate(),
preparationDate = DateTime(preparedPayment.preparationDate).toDashedDate()
)
)
return@get
}
/**
* Adds a new prepared payment.
*/
post("/bank-accounts/{accountid}/prepared-payments") {
val userId = authenticateRequest(call.request.headers["Authorization"])
val bankAccount = getBankAccount(userId, ensureNonNull(call.parameters["accountid"]))
val body = call.receive()
val amount = parseAmount(body.amount)
val paymentEntity = addPreparedPayment(
Pain001Data(
creditorIban = body.iban,
creditorBic = body.bic,
creditorName = body.name,
debitorAccount = bankAccount.id.value,
sum = amount.amount,
currency = amount.currency,
subject = body.subject
),
extractNexusUser(userId)
)
call.respond(
HttpStatusCode.OK,
PreparedPaymentResponse(uuid = paymentEntity.id.value)
)
return@post
}
/**
* Downloads new transactions from the bank.
*
* NOTE: 'accountid' is not used. Transaction are asked on
* the basis of a transport subscriber (regardless of their
* bank account details)
*/
post("/bank-accounts/collected-transactions") {
val userId = authenticateRequest(call.request.headers["Authorization"])
val body = call.receive()
if (body.transport != null) {
when (body.transport.type) {
"ebics" -> {
downloadAndPersistC5xEbics(
"C53",
client,
userId,
body.start,
body.end,
body.transport.name
)
}
else -> throw NexusError(
HttpStatusCode.BadRequest,
"Transport type '${body.transport.type}' not implemented"
)
}
} else {
downloadAndPersistC5xEbics(
"C53",
client,
userId,
body.start,
body.end,
null
)
}
call.respondText("Collection performed")
return@post
}
/**
* Asks list of transactions ALREADY downloaded from the bank.
*/
get("/bank-accounts/{accountid}/collected-transactions") {
val userId = authenticateRequest(call.request.headers["Authorization"])
val bankAccount = expectNonNull(call.parameters["accountid"])
val start = call.request.queryParameters["start"]
val end = call.request.queryParameters["end"]
val ret = Transactions()
transaction {
RawBankTransactionEntity.find {
RawBankTransactionsTable.nexusUser eq userId and
(RawBankTransactionsTable.bankAccount eq bankAccount) and
RawBankTransactionsTable.bookingDate.between(
parseDashedDate(start ?: "1970-01-01").millis,
parseDashedDate(end ?: DateTime.now().toDashedDate()).millis
)
}.forEach {
ret.transactions.add(
Transaction(
account = it.bankAccount.id.value,
counterpartBic = it.counterpartBic,
counterpartIban = it.counterpartIban,
counterpartName = it.counterpartName,
date = DateTime(it.bookingDate).toDashedDate(),
subject = it.unstructuredRemittanceInformation,
amount = "${it.currency}:${it.amount}"
)
)
}
}
call.respond(ret)
return@get
}
/**
* Adds a new bank transport.
*/
post("/bank-transports") {
val userId = authenticateRequest(call.request.headers["Authorization"])
// user exists and is authenticated.
val body = call.receive()
val transport: Transport = getTransportFromJsonObject(body)
when (transport.type) {
"ebics" -> {
if (body.get("backup") != null) {
val backup = Gson().fromJson(
body.get("backup").asJsonObject,
EbicsKeysBackupJson::class.java
)
val (authKey, encKey, sigKey) = try {
Triple(
CryptoUtil.decryptKey(
EncryptedPrivateKeyInfo(base64ToBytes(backup.authBlob)),
backup.passphrase
),
CryptoUtil.decryptKey(
EncryptedPrivateKeyInfo(base64ToBytes(backup.encBlob)),
backup.passphrase
),
CryptoUtil.decryptKey(
EncryptedPrivateKeyInfo(base64ToBytes(backup.sigBlob)),
backup.passphrase
)
)
} catch (e: Exception) {
e.printStackTrace()
logger.info("Restoring keys failed, probably due to wrong passphrase")
throw NexusError(
HttpStatusCode.BadRequest,
"Bad backup given"
)
}
logger.info("Restoring keys, creating new user: $userId")
try {
transaction {
EbicsSubscriberEntity.new(transport.name) {
this.nexusUser = extractNexusUser(userId)
ebicsURL = backup.ebicsURL
hostID = backup.hostID
partnerID = backup.partnerID
userID = backup.userID
signaturePrivateKey = SerialBlob(sigKey.encoded)
encryptionPrivateKey = SerialBlob(encKey.encoded)
authenticationPrivateKey = SerialBlob(authKey.encoded)
}
}
} catch (e: Exception) {
print(e)
call.respond(
NexusErrorJson("Could not store the new account into database")
)
return@post
}
call.respondText("Backup restored")
return@post
}
if (body.get("data") != null) {
val data = Gson().fromJson(
body.get("data"),
EbicsNewTransport::class.java
)
val pairA = CryptoUtil.generateRsaKeyPair(2048)
val pairB = CryptoUtil.generateRsaKeyPair(2048)
val pairC = CryptoUtil.generateRsaKeyPair(2048)
transaction {
EbicsSubscriberEntity.new(transport.name) {
nexusUser = extractNexusUser(userId)
ebicsURL = data.ebicsURL
hostID = data.hostID
partnerID = data.partnerID
userID = data.userID
systemID = data.systemID
signaturePrivateKey = SerialBlob(pairA.private.encoded)
encryptionPrivateKey = SerialBlob(pairB.private.encoded)
authenticationPrivateKey = SerialBlob(pairC.private.encoded)
}
}
call.respondText("EBICS user successfully created")
return@post
}
throw NexusError(
HttpStatusCode.BadRequest,
"Neither restore or new transport were specified."
)
}
else -> {
throw NexusError(
HttpStatusCode.BadRequest,
"Invalid transport type '${transport.type}'"
)
}
}
}
/**
* Sends to the bank a message "MSG" according to the transport
* "transportName". Does not modify any DB table.
*/
post("/bank-transports/send{MSG}") {
val userId = authenticateRequest(call.request.headers["Authorization"])
val body = call.receive()
when (body.type) {
"ebics" -> {
val response = handleEbicsSendMSG(
httpClient = client,
userId = userId,
transportId = body.name,
msg = ensureNonNull(call.parameters["MSG"]),
sync = true
)
call.respondText(response)
}
else -> throw NexusError(
HttpStatusCode.NotImplemented,
"Transport '${body.type}' not implemented. Use 'ebics'"
)
}
return@post
}
/**
* Sends the bank a message "MSG" according to the transport
* "transportName". DOES alterate DB tables.
*/
post("/bank-transports/sync{MSG}") {
val userId = authenticateRequest(call.request.headers["Authorization"])
val body = call.receive()
when (body.type) {
"ebics" -> {
val response = handleEbicsSendMSG(
httpClient = client,
userId = userId,
transportId = body.name,
msg = ensureNonNull(call.parameters["MSG"]),
sync = true
)
call.respondText(response)
}
else -> throw NexusError(
HttpStatusCode.NotImplemented,
"Transport '${body.type}' not implemented. Use 'ebics'"
)
}
return@post
}
/**
* Hello endpoint.
*/
get("/") {
call.respondText("Hello by nexus!\n")
return@get
}
}
}
logger.info("Up and running")
server.start(wait = true)
}