diff options
author | Florian Dold <florian@dold.me> | 2023-09-24 13:19:20 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2023-09-24 13:19:20 +0200 |
commit | 1ce3c4d4251015283d61654be5196c1a9734ca40 (patch) | |
tree | cc3ff99dd6c5b943c772fe5190bf02bd82114dc3 | |
parent | 236b29f957795aad279bc62081de06861bfe0821 (diff) | |
download | libeufin-1ce3c4d4251015283d61654be5196c1a9734ca40.tar.gz libeufin-1ce3c4d4251015283d61654be5196c1a9734ca40.tar.bz2 libeufin-1ce3c4d4251015283d61654be5196c1a9734ca40.zip |
refactor file structure
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt | 494 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt (renamed from bank/src/main/kotlin/tech/libeufin/bank/talerIntegrationHandlers.kt) | 0 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/Main.kt | 3 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt (renamed from bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt) | 0 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt | 168 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt | 177 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt | 105 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt | 136 | ||||
m--------- | contrib/wallet-core | 0 |
9 files changed, 494 insertions, 589 deletions
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt new file mode 100644 index 00000000..49421402 --- /dev/null +++ b/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt @@ -0,0 +1,494 @@ +package tech.libeufin.bank + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import net.taler.common.errorcodes.TalerErrorCode +import net.taler.wallet.crypto.Base32Crockford +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import tech.libeufin.util.* +import java.util.* + +private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers") + +/** + * This function collects all the /accounts handlers that + * create, update, delete, show bank accounts. No histories + * and wire transfers should belong here. + */ +fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { + + delete("/accounts/{USERNAME}/token") { + throw internalServerError("Token deletion not implemented.") + } + + post("/accounts/{USERNAME}/token") { + val customer = call.myAuth(db, TokenScope.refreshable) ?: throw unauthorized("Authentication failed") + val endpointOwner = call.maybeUriComponent("USERNAME") + if (customer.login != endpointOwner) + throw forbidden( + "User has no rights on this enpoint", + TalerErrorCode.TALER_EC_END // FIXME: need generic forbidden + ) + val maybeAuthToken = call.getAuthToken() + val req = call.receive<TokenRequest>() + /** + * This block checks permissions ONLY IF the call was authenticated + * with a token. Basic auth gets always granted. + */ + if (maybeAuthToken != null) { + val tokenBytes = Base32Crockford.decode(maybeAuthToken) + val refreshingToken = db.bearerTokenGet(tokenBytes) ?: throw internalServerError( + "Token used to auth not found in the database!" + ) + if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite) + throw forbidden( + "Cannot generate RW token from RO", + TalerErrorCode.TALER_EC_GENERIC_TOKEN_PERMISSION_INSUFFICIENT + ) + } + val tokenBytes = ByteArray(32).apply { + java.util.Random().nextBytes(this) + } + val maxDurationTime: Long = ctx.maxAuthTokenDurationUs + if (req.duration != null && req.duration.d_us > maxDurationTime) + throw forbidden( + "Token duration bigger than bank's limit", + // FIXME: define new EC for this case. + TalerErrorCode.TALER_EC_END + ) + val tokenDurationUs = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION_US + val customerDbRow = customer.dbRowId ?: throw internalServerError( + "Could not get customer '${customer.login}' database row ID" + ) + val expirationTimestampUs: Long = getNowUs() + tokenDurationUs + if (expirationTimestampUs < tokenDurationUs) + throw badRequest( + "Token duration caused arithmetic overflow", + // FIXME: need dedicate EC (?) + talerErrorCode = TalerErrorCode.TALER_EC_END + ) + val token = BearerToken( + bankCustomer = customerDbRow, + content = tokenBytes, + creationTime = expirationTimestampUs, + expirationTime = expirationTimestampUs, + scope = req.scope, + isRefreshable = req.refreshable + ) + if (!db.bearerTokenCreate(token)) + throw internalServerError("Failed at inserting new token in the database") + call.respond( + TokenSuccessResponse( + access_token = Base32Crockford.encode(tokenBytes), + expiration = Timestamp( + t_s = expirationTimestampUs / 1000000L + ) + ) + ) + return@post + } + + post("/accounts") { + // check if only admin is allowed to create new accounts + if (ctx.restrictRegistration) { + val customer: Customer? = call.myAuth(db, TokenScope.readwrite) + if (customer == null || customer.login != "admin") + throw LibeufinBankException( + httpStatus = HttpStatusCode.Unauthorized, + talerError = TalerError( + code = TalerErrorCode.TALER_EC_BANK_LOGIN_FAILED.code, + hint = "Either 'admin' not authenticated or an ordinary user tried this operation." + ) + ) + } + // auth passed, proceed with activity. + val req = call.receive<RegisterAccountRequest>() + // Prohibit reserved usernames: + if (req.username == "admin" || req.username == "bank") + throw LibeufinBankException( + httpStatus = HttpStatusCode.Conflict, + talerError = TalerError( + code = GENERIC_UNDEFINED, // FIXME: this waits GANA. + hint = "Username '${req.username}' is reserved." + ) + ) + // Checking idempotency. + val maybeCustomerExists = db.customerGetFromLogin(req.username) + // Can be null if previous call crashed before completion. + val maybeHasBankAccount = maybeCustomerExists.run { + if (this == null) return@run null + db.bankAccountGetFromOwnerId(this.expectRowId()) + } + if (maybeCustomerExists != null && maybeHasBankAccount != null) { + tech.libeufin.bank.logger.debug("Registering username was found: ${maybeCustomerExists.login}") + // Checking _all_ the details are the same. + val isIdentic = + maybeCustomerExists.name == req.name && + maybeCustomerExists.email == req.challenge_contact_data?.email && + maybeCustomerExists.phone == req.challenge_contact_data?.phone && + maybeCustomerExists.cashoutPayto == req.cashout_payto_uri && + CryptoUtil.checkpw(req.password, maybeCustomerExists.passwordHash) && + maybeHasBankAccount.isPublic == req.is_public && + maybeHasBankAccount.isTalerExchange == req.is_taler_exchange && + maybeHasBankAccount.internalPaytoUri == req.internal_payto_uri + if (isIdentic) { + call.respond(HttpStatusCode.Created) + return@post + } + throw LibeufinBankException( + httpStatus = HttpStatusCode.Conflict, + talerError = TalerError( + code = GENERIC_UNDEFINED, // GANA needs this. + hint = "Idempotency check failed." + ) + ) + } + // From here: fresh user being added. + val newCustomer = Customer( + login = req.username, + name = req.name, + email = req.challenge_contact_data?.email, + phone = req.challenge_contact_data?.phone, + cashoutPayto = req.cashout_payto_uri, + // Following could be gone, if included in cashout_payto_uri + cashoutCurrency = ctx.cashoutCurrency, + passwordHash = CryptoUtil.hashpw(req.password), + ) + val newCustomerRowId = db.customerCreate(newCustomer) + ?: throw internalServerError("New customer INSERT failed despite the previous checks") + /* Crashing here won't break data consistency between customers + * and bank accounts, because of the idempotency. Client will + * just have to retry. */ + val maxDebt = ctx.defaultCustomerDebtLimit + val newBankAccount = BankAccount( + hasDebt = false, + internalPaytoUri = req.internal_payto_uri ?: genIbanPaytoUri(), + owningCustomerId = newCustomerRowId, + isPublic = req.is_public, + isTalerExchange = req.is_taler_exchange, + maxDebt = maxDebt + ) + val newBankAccountId = db.bankAccountCreate(newBankAccount) + ?: throw internalServerError("Could not INSERT bank account despite all the checks.") + + /** + * The new account got created, now optionally award the registration + * bonus to it. The configuration gets either a Taler amount (of the + * bonus), or null if no bonus is meant to be awarded. + */ + val bonusAmount = if (ctx.registrationBonusEnabled) ctx.registrationBonus else null + if (bonusAmount != null) { + val adminCustomer = db.customerGetFromLogin("admin") + ?: throw internalServerError("Admin customer not found") + val adminBankAccount = db.bankAccountGetFromOwnerId(adminCustomer.expectRowId()) + ?: throw internalServerError("Admin bank account not found") + val adminPaysBonus = BankInternalTransaction( + creditorAccountId = newBankAccountId, + debtorAccountId = adminBankAccount.expectRowId(), + amount = bonusAmount, + subject = "Registration bonus.", + transactionDate = getNowUs() + ) + when(db.bankTransactionCreate(adminPaysBonus)) { + Database.BankTransactionResult.NO_CREDITOR -> + throw internalServerError("Bonus impossible: creditor not found, despite its recent creation.") + Database.BankTransactionResult.NO_DEBTOR -> + throw internalServerError("Bonus impossible: admin not found.") + Database.BankTransactionResult.CONFLICT -> + throw internalServerError("Bonus impossible: admin has insufficient balance.") + Database.BankTransactionResult.SUCCESS -> {/* continue the execution */} + } + } + call.respond(HttpStatusCode.Created) + return@post + } + get("/accounts/{USERNAME}") { + val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized("Login failed") + val resourceName = call.maybeUriComponent("USERNAME") ?: throw badRequest( + hint = "No username found in the URI", + talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MISSING + ) + // Checking resource name only if Basic auth was used. + // Successful tokens do not need this check, they just pass. + if ( + ((c.login != resourceName) + && (c.login != "admin")) + && (call.getAuthToken() == null) + ) + throw forbidden("No rights on the resource.") + val customerData = db.customerGetFromLogin(c.login) ?: throw internalServerError("Customer '${c.login} despite being authenticated.'") + val customerInternalId = customerData.dbRowId ?: throw internalServerError("Customer '${c.login} had no row ID despite it was found in the database.'") + val bankAccountData = db.bankAccountGetFromOwnerId(customerInternalId) ?: throw internalServerError("Customer '${c.login} had no bank account despite they are customer.'") + call.respond(AccountData( + name = customerData.name, + balance = bankAccountData.balance.toString(), + debit_threshold = bankAccountData.maxDebt.toString(), + payto_uri = bankAccountData.internalPaytoUri, + contact_data = ChallengeContactData( + email = customerData.email, + phone = customerData.phone + ), + cashout_payto_uri = customerData.cashoutPayto, + has_debit = bankAccountData.hasDebt + )) + return@get + } + + post("/accounts/{USERNAME}/withdrawals") { + val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() + // Admin not allowed to withdraw in the name of customers: + val accountName = call.expectUriComponent("USERNAME") + if (c.login != accountName) + throw unauthorized("User ${c.login} not allowed to withdraw for account '${accountName}'") + val req = call.receive<BankAccountCreateWithdrawalRequest>() + // Checking that the user has enough funds. + val b = db.bankAccountGetFromOwnerId(c.expectRowId()) + ?: throw internalServerError("Customer '${c.login}' lacks bank account.") + val withdrawalAmount = parseTalerAmount(req.amount) + if ( + !isBalanceEnough( + balance = b.expectBalance(), + due = withdrawalAmount, + maxDebt = b.maxDebt, + hasBalanceDebt = b.hasDebt + )) + throw forbidden( + hint = "Insufficient funds to withdraw with Taler", + talerErrorCode = TalerErrorCode.TALER_EC_NONE // FIXME: need EC. + ) + // Auth and funds passed, create the operation now! + val opId = UUID.randomUUID() + if( + !db.talerWithdrawalCreate( + opId, + b.expectRowId(), + withdrawalAmount + ) + ) + throw internalServerError("Bank failed at creating the withdraw operation.") + + val bankBaseUrl = call.request.getBaseUrl() + ?: throw internalServerError("Bank could not find its own base URL") + call.respond(BankAccountCreateWithdrawalResponse( + withdrawal_id = opId.toString(), + taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString()) + )) + return@post + } + get("/accounts/{USERNAME}/withdrawals/{withdrawal_id}") { + val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() + val accountName = call.expectUriComponent("USERNAME") + // Admin allowed to see the details + if (c.login != accountName && c.login != "admin") throw forbidden() + // Permissions passed, get the information. + val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) + call.respond(BankAccountGetWithdrawalResponse( + amount = op.amount.toString(), + aborted = op.aborted, + confirmation_done = op.confirmationDone, + selection_done = op.selectionDone, + selected_exchange_account = op.selectedExchangePayto, + selected_reserve_pub = op.reservePub + )) + return@get + } + post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") { + val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() + // Admin allowed to abort. + if (!call.getResourceName("USERNAME").canI(c)) throw forbidden() + val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) + // Idempotency: + if (op.aborted) { + call.respondText("{}", ContentType.Application.Json) + return@post + } + // Op is found, it'll now fail only if previously confirmed (DB checks). + if (!db.talerWithdrawalAbort(op.withdrawalUuid)) throw conflict( + hint = "Cannot abort confirmed withdrawal", + talerEc = TalerErrorCode.TALER_EC_END + ) + call.respondText("{}", ContentType.Application.Json) + return@post + } + post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") { + val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() + // No admin allowed. + if(!call.getResourceName("USERNAME").canI(c, withAdmin = false)) throw forbidden() + val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) + // Checking idempotency: + if (op.confirmationDone) { + call.respondText("{}", ContentType.Application.Json) + return@post + } + if (op.aborted) + throw conflict( + hint = "Cannot confirm an aborted withdrawal", + talerEc = TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT + ) + // Checking that reserve GOT indeed selected. + if (!op.selectionDone) + throw LibeufinBankException( + httpStatus = HttpStatusCode.UnprocessableEntity, + talerError = TalerError( + hint = "Cannot confirm an unselected withdrawal", + code = TalerErrorCode.TALER_EC_END.code + )) + /* Confirmation conditions are all met, now put the operation + * to the selected state _and_ wire the funds to the exchange. + * Note: 'when' helps not to omit more result codes, should more + * be added. + */ + when (db.talerWithdrawalConfirm(op.withdrawalUuid, getNowUs())) { + WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> + throw conflict( + "Insufficient funds", + TalerErrorCode.TALER_EC_END // FIXME: define EC for this. + ) + WithdrawalConfirmationResult.OP_NOT_FOUND -> + /** + * Despite previous checks, the database _still_ did not + * find the withdrawal operation, that's on the bank. + */ + throw internalServerError("Withdrawal operation (${op.withdrawalUuid}) not found") + WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND -> + /** + * That can happen because the bank did not check the exchange + * exists when POST /withdrawals happened, or because the exchange + * bank account got removed before this confirmation. + */ + throw conflict( + hint = "Exchange to withdraw from not found", + talerEc = TalerErrorCode.TALER_EC_END // FIXME + ) + WithdrawalConfirmationResult.CONFLICT -> + throw internalServerError("Bank didn't check for idempotency") + WithdrawalConfirmationResult.SUCCESS -> + call.respondText( + "{}", + ContentType.Application.Json + ) + } + return@post + } + + get("/accounts/{USERNAME}/transactions") { + val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() + val resourceName = call.expectUriComponent("USERNAME") + if (c.login != resourceName && c.login != "admin") throw forbidden() + // Collecting params. + val historyParams = getHistoryParams(call.request) + // Making the query. + val bankAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) + ?: throw internalServerError("Customer '${c.login}' lacks bank account.") + val bankAccountId = bankAccount.expectRowId() + val history: List<BankAccountTransaction> = db.bankTransactionGetHistory( + start = historyParams.start, + delta = historyParams.delta, + bankAccountId = bankAccountId + ) + val res = BankAccountTransactionsResponse(transactions = mutableListOf()) + history.forEach { + res.transactions.add(BankAccountTransactionInfo( + debtor_payto_uri = it.debtorPaytoUri, + creditor_payto_uri = it.creditorPaytoUri, + subject = it.subject, + amount = it.amount.toString(), + direction = it.direction, + date = it.transactionDate, + row_id = it.dbRowId ?: throw internalServerError( + "Transaction timestamped with '${it.transactionDate}' did not have row ID" + ) + )) + } + call.respond(res) + return@get + } + // Creates a bank transaction. + post("/accounts/{USERNAME}/transactions") { + val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() + val resourceName = call.expectUriComponent("USERNAME") + // admin has no rights here. + if ((c.login != resourceName) && (call.getAuthToken() == null)) + throw forbidden() + val txData = call.receive<BankAccountTransactionCreate>() + // FIXME: make payto parser IBAN-agnostic? + val payto = parsePayto(txData.payto_uri) ?: throw badRequest("Invalid creditor Payto") + val paytoWithoutParams = "payto://iban/${payto.bic}/${payto.iban}" + val subject = payto.message ?: throw badRequest("Wire transfer lacks subject") + val debtorId = c.dbRowId ?: throw internalServerError("Debtor database ID not found") + // This performs already a SELECT on the bank account, + // like the wire transfer will do as well later! + val creditorCustomerData = db.bankAccountGetFromInternalPayto(paytoWithoutParams) + ?: throw notFound( + "Creditor account not found", + TalerErrorCode.TALER_EC_END // FIXME: define this EC. + ) + val amount = parseTalerAmount(txData.amount) + if (amount.currency != ctx.currency) + throw badRequest( + "Wrong currency: ${amount.currency}", + talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH + ) + val dbInstructions = BankInternalTransaction( + debtorAccountId = debtorId, + creditorAccountId = creditorCustomerData.owningCustomerId, + subject = subject, + amount = amount, + transactionDate = getNowUs() + ) + val res = db.bankTransactionCreate(dbInstructions) + when(res) { + Database.BankTransactionResult.CONFLICT -> + throw conflict( + "Insufficient funds", + TalerErrorCode.TALER_EC_END // FIXME: need bank 'insufficient funds' EC. + ) + Database.BankTransactionResult.NO_CREDITOR -> + throw internalServerError("Creditor not found despite previous checks.") + Database.BankTransactionResult.NO_DEBTOR -> + throw internalServerError("Debtor not found despite the request was authenticated.") + Database.BankTransactionResult.SUCCESS -> call.respond(HttpStatusCode.OK) + } + return@post + } + get("/accounts/{USERNAME}/transactions/{T_ID}") { + val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() + val accountOwner = call.expectUriComponent("USERNAME") + // auth ok, check rights. + if (c.login != "admin" && c.login != accountOwner) + throw forbidden() + // rights ok, check tx exists. + val tId = call.expectUriComponent("T_ID") + val txRowId = try { + tId.toLong() + } catch (e: Exception) { + logger.error(e.message) + throw badRequest("TRANSACTION_ID is not a number: ${tId}") + } + val customerRowId = c.dbRowId ?: throw internalServerError("Authenticated client lacks database entry") + val tx = db.bankTransactionGetFromInternalId(txRowId) + ?: throw notFound( + "Bank transaction '$tId' not found", + TalerErrorCode.TALER_EC_NONE // FIXME: need def. + ) + val customerBankAccount = db.bankAccountGetFromOwnerId(customerRowId) + ?: throw internalServerError("Customer '${c.login}' lacks bank account.") + if (tx.bankAccountId != customerBankAccount.bankAccountId) + throw forbidden("Client has no rights over the bank transaction: $tId") + // auth and rights, respond. + call.respond(BankAccountTransactionInfo( + amount = "${tx.amount.currency}:${tx.amount.value}.${tx.amount.frac}", + creditor_payto_uri = tx.creditorPaytoUri, + debtor_payto_uri = tx.debtorPaytoUri, + date = tx.transactionDate, + direction = tx.direction, + subject = tx.subject, + row_id = txRowId + )) + return@get + } +}
\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/talerIntegrationHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt index 502d8746..502d8746 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/talerIntegrationHandlers.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt index 61ea1227..c01fd67f 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -311,9 +311,6 @@ fun Application.corebankWebApp(db: Database, ctx: BankApplicationContext) { return@get } this.accountsMgmtHandlers(db, ctx) - this.tokenHandlers(db, ctx) - this.transactionsHandlers(db, ctx) - this.talerWebHandlers(db) this.talerIntegrationHandlers(db, ctx) this.talerWireGatewayHandlers(db, ctx) } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt index 619e5fc8..619e5fc8 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt deleted file mode 100644 index ef6d66e3..00000000 --- a/bank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt +++ /dev/null @@ -1,168 +0,0 @@ -package tech.libeufin.bank - -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import net.taler.common.errorcodes.TalerErrorCode -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.util.CryptoUtil -import tech.libeufin.util.getNowUs -import tech.libeufin.util.maybeUriComponent - -private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers") - -/** - * This function collects all the /accounts handlers that - * create, update, delete, show bank accounts. No histories - * and wire transfers should belong here. - */ -fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { - post("/accounts") { - // check if only admin is allowed to create new accounts - if (ctx.restrictRegistration) { - val customer: Customer? = call.myAuth(db, TokenScope.readwrite) - if (customer == null || customer.login != "admin") - throw LibeufinBankException( - httpStatus = HttpStatusCode.Unauthorized, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_BANK_LOGIN_FAILED.code, - hint = "Either 'admin' not authenticated or an ordinary user tried this operation." - ) - ) - } - // auth passed, proceed with activity. - val req = call.receive<RegisterAccountRequest>() - // Prohibit reserved usernames: - if (req.username == "admin" || req.username == "bank") - throw LibeufinBankException( - httpStatus = HttpStatusCode.Conflict, - talerError = TalerError( - code = GENERIC_UNDEFINED, // FIXME: this waits GANA. - hint = "Username '${req.username}' is reserved." - ) - ) - // Checking idempotency. - val maybeCustomerExists = db.customerGetFromLogin(req.username) - // Can be null if previous call crashed before completion. - val maybeHasBankAccount = maybeCustomerExists.run { - if (this == null) return@run null - db.bankAccountGetFromOwnerId(this.expectRowId()) - } - if (maybeCustomerExists != null && maybeHasBankAccount != null) { - tech.libeufin.bank.logger.debug("Registering username was found: ${maybeCustomerExists.login}") - // Checking _all_ the details are the same. - val isIdentic = - maybeCustomerExists.name == req.name && - maybeCustomerExists.email == req.challenge_contact_data?.email && - maybeCustomerExists.phone == req.challenge_contact_data?.phone && - maybeCustomerExists.cashoutPayto == req.cashout_payto_uri && - CryptoUtil.checkpw(req.password, maybeCustomerExists.passwordHash) && - maybeHasBankAccount.isPublic == req.is_public && - maybeHasBankAccount.isTalerExchange == req.is_taler_exchange && - maybeHasBankAccount.internalPaytoUri == req.internal_payto_uri - if (isIdentic) { - call.respond(HttpStatusCode.Created) - return@post - } - throw LibeufinBankException( - httpStatus = HttpStatusCode.Conflict, - talerError = TalerError( - code = GENERIC_UNDEFINED, // GANA needs this. - hint = "Idempotency check failed." - ) - ) - } - // From here: fresh user being added. - val newCustomer = Customer( - login = req.username, - name = req.name, - email = req.challenge_contact_data?.email, - phone = req.challenge_contact_data?.phone, - cashoutPayto = req.cashout_payto_uri, - // Following could be gone, if included in cashout_payto_uri - cashoutCurrency = ctx.cashoutCurrency, - passwordHash = CryptoUtil.hashpw(req.password), - ) - val newCustomerRowId = db.customerCreate(newCustomer) - ?: throw internalServerError("New customer INSERT failed despite the previous checks") - /* Crashing here won't break data consistency between customers - * and bank accounts, because of the idempotency. Client will - * just have to retry. */ - val maxDebt = ctx.defaultCustomerDebtLimit - val newBankAccount = BankAccount( - hasDebt = false, - internalPaytoUri = req.internal_payto_uri ?: genIbanPaytoUri(), - owningCustomerId = newCustomerRowId, - isPublic = req.is_public, - isTalerExchange = req.is_taler_exchange, - maxDebt = maxDebt - ) - val newBankAccountId = db.bankAccountCreate(newBankAccount) - ?: throw internalServerError("Could not INSERT bank account despite all the checks.") - - /** - * The new account got created, now optionally award the registration - * bonus to it. The configuration gets either a Taler amount (of the - * bonus), or null if no bonus is meant to be awarded. - */ - val bonusAmount = if (ctx.registrationBonusEnabled) ctx.registrationBonus else null - if (bonusAmount != null) { - val adminCustomer = db.customerGetFromLogin("admin") - ?: throw internalServerError("Admin customer not found") - val adminBankAccount = db.bankAccountGetFromOwnerId(adminCustomer.expectRowId()) - ?: throw internalServerError("Admin bank account not found") - val adminPaysBonus = BankInternalTransaction( - creditorAccountId = newBankAccountId, - debtorAccountId = adminBankAccount.expectRowId(), - amount = bonusAmount, - subject = "Registration bonus.", - transactionDate = getNowUs() - ) - when(db.bankTransactionCreate(adminPaysBonus)) { - Database.BankTransactionResult.NO_CREDITOR -> - throw internalServerError("Bonus impossible: creditor not found, despite its recent creation.") - Database.BankTransactionResult.NO_DEBTOR -> - throw internalServerError("Bonus impossible: admin not found.") - Database.BankTransactionResult.CONFLICT -> - throw internalServerError("Bonus impossible: admin has insufficient balance.") - Database.BankTransactionResult.SUCCESS -> {/* continue the execution */} - } - } - call.respond(HttpStatusCode.Created) - return@post - } - get("/accounts/{USERNAME}") { - val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized("Login failed") - val resourceName = call.maybeUriComponent("USERNAME") ?: throw badRequest( - hint = "No username found in the URI", - talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MISSING - ) - // Checking resource name only if Basic auth was used. - // Successful tokens do not need this check, they just pass. - if ( - ((c.login != resourceName) - && (c.login != "admin")) - && (call.getAuthToken() == null) - ) - throw forbidden("No rights on the resource.") - val customerData = db.customerGetFromLogin(c.login) ?: throw internalServerError("Customer '${c.login} despite being authenticated.'") - val customerInternalId = customerData.dbRowId ?: throw internalServerError("Customer '${c.login} had no row ID despite it was found in the database.'") - val bankAccountData = db.bankAccountGetFromOwnerId(customerInternalId) ?: throw internalServerError("Customer '${c.login} had no bank account despite they are customer.'") - call.respond(AccountData( - name = customerData.name, - balance = bankAccountData.balance.toString(), - debit_threshold = bankAccountData.maxDebt.toString(), - payto_uri = bankAccountData.internalPaytoUri, - contact_data = ChallengeContactData( - email = customerData.email, - phone = customerData.phone - ), - cashout_payto_uri = customerData.cashoutPayto, - has_debit = bankAccountData.hasDebt - )) - return@get - } -}
\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt deleted file mode 100644 index 258bc005..00000000 --- a/bank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt +++ /dev/null @@ -1,177 +0,0 @@ -/* - * 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 - * <http://www.gnu.org/licenses/> - */ - -/* This file contains all the Taler handlers that do NOT - * communicate with wallets, therefore any handler that serves - * to SPAs or CLI HTTP clients. - */ - -package tech.libeufin.bank - -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.plugins.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import net.taler.common.errorcodes.TalerErrorCode -import net.taler.wallet.crypto.Base32Crockford -import tech.libeufin.util.getBaseUrl -import tech.libeufin.util.getNowUs -import java.util.* - -fun Routing.talerWebHandlers(db: Database) { - post("/accounts/{USERNAME}/withdrawals") { - val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() - // Admin not allowed to withdraw in the name of customers: - val accountName = call.expectUriComponent("USERNAME") - if (c.login != accountName) - throw unauthorized("User ${c.login} not allowed to withdraw for account '${accountName}'") - val req = call.receive<BankAccountCreateWithdrawalRequest>() - // Checking that the user has enough funds. - val b = db.bankAccountGetFromOwnerId(c.expectRowId()) - ?: throw internalServerError("Customer '${c.login}' lacks bank account.") - val withdrawalAmount = parseTalerAmount(req.amount) - if ( - !isBalanceEnough( - balance = b.expectBalance(), - due = withdrawalAmount, - maxDebt = b.maxDebt, - hasBalanceDebt = b.hasDebt - )) - throw forbidden( - hint = "Insufficient funds to withdraw with Taler", - talerErrorCode = TalerErrorCode.TALER_EC_NONE // FIXME: need EC. - ) - // Auth and funds passed, create the operation now! - val opId = UUID.randomUUID() - if( - !db.talerWithdrawalCreate( - opId, - b.expectRowId(), - withdrawalAmount - ) - ) - throw internalServerError("Bank failed at creating the withdraw operation.") - - val bankBaseUrl = call.request.getBaseUrl() - ?: throw internalServerError("Bank could not find its own base URL") - call.respond(BankAccountCreateWithdrawalResponse( - withdrawal_id = opId.toString(), - taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString()) - )) - return@post - } - get("/accounts/{USERNAME}/withdrawals/{withdrawal_id}") { - val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() - val accountName = call.expectUriComponent("USERNAME") - // Admin allowed to see the details - if (c.login != accountName && c.login != "admin") throw forbidden() - // Permissions passed, get the information. - val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) - call.respond(BankAccountGetWithdrawalResponse( - amount = op.amount.toString(), - aborted = op.aborted, - confirmation_done = op.confirmationDone, - selection_done = op.selectionDone, - selected_exchange_account = op.selectedExchangePayto, - selected_reserve_pub = op.reservePub - )) - return@get - } - post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") { - val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() - // Admin allowed to abort. - if (!call.getResourceName("USERNAME").canI(c)) throw forbidden() - val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) - // Idempotency: - if (op.aborted) { - call.respondText("{}", ContentType.Application.Json) - return@post - } - // Op is found, it'll now fail only if previously confirmed (DB checks). - if (!db.talerWithdrawalAbort(op.withdrawalUuid)) throw conflict( - hint = "Cannot abort confirmed withdrawal", - talerEc = TalerErrorCode.TALER_EC_END - ) - call.respondText("{}", ContentType.Application.Json) - return@post - } - post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") { - val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() - // No admin allowed. - if(!call.getResourceName("USERNAME").canI(c, withAdmin = false)) throw forbidden() - val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) - // Checking idempotency: - if (op.confirmationDone) { - call.respondText("{}", ContentType.Application.Json) - return@post - } - if (op.aborted) - throw conflict( - hint = "Cannot confirm an aborted withdrawal", - talerEc = TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT - ) - // Checking that reserve GOT indeed selected. - if (!op.selectionDone) - throw LibeufinBankException( - httpStatus = HttpStatusCode.UnprocessableEntity, - talerError = TalerError( - hint = "Cannot confirm an unselected withdrawal", - code = TalerErrorCode.TALER_EC_END.code - )) - /* Confirmation conditions are all met, now put the operation - * to the selected state _and_ wire the funds to the exchange. - * Note: 'when' helps not to omit more result codes, should more - * be added. - */ - when (db.talerWithdrawalConfirm(op.withdrawalUuid, getNowUs())) { - WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> - throw conflict( - "Insufficient funds", - TalerErrorCode.TALER_EC_END // FIXME: define EC for this. - ) - WithdrawalConfirmationResult.OP_NOT_FOUND -> - /** - * Despite previous checks, the database _still_ did not - * find the withdrawal operation, that's on the bank. - */ - throw internalServerError("Withdrawal operation (${op.withdrawalUuid}) not found") - WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND -> - /** - * That can happen because the bank did not check the exchange - * exists when POST /withdrawals happened, or because the exchange - * bank account got removed before this confirmation. - */ - throw conflict( - hint = "Exchange to withdraw from not found", - talerEc = TalerErrorCode.TALER_EC_END // FIXME - ) - WithdrawalConfirmationResult.CONFLICT -> - throw internalServerError("Bank didn't check for idempotency") - WithdrawalConfirmationResult.SUCCESS -> - call.respondText( - "{}", - ContentType.Application.Json - ) - } - return@post - } -} - diff --git a/bank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt deleted file mode 100644 index 218651d3..00000000 --- a/bank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * 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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.bank - -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import net.taler.common.errorcodes.TalerErrorCode -import net.taler.wallet.crypto.Base32Crockford -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.util.maybeUriComponent -import tech.libeufin.util.getNowUs - -private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers") - -fun Routing.tokenHandlers(db: Database, ctx: BankApplicationContext) { - delete("/accounts/{USERNAME}/token") { - throw internalServerError("Token deletion not implemented.") - } - post("/accounts/{USERNAME}/token") { - val customer = call.myAuth(db, TokenScope.refreshable) ?: throw unauthorized("Authentication failed") - val endpointOwner = call.maybeUriComponent("USERNAME") - if (customer.login != endpointOwner) - throw forbidden( - "User has no rights on this enpoint", - TalerErrorCode.TALER_EC_END // FIXME: need generic forbidden - ) - val maybeAuthToken = call.getAuthToken() - val req = call.receive<TokenRequest>() - /** - * This block checks permissions ONLY IF the call was authenticated - * with a token. Basic auth gets always granted. - */ - if (maybeAuthToken != null) { - val tokenBytes = Base32Crockford.decode(maybeAuthToken) - val refreshingToken = db.bearerTokenGet(tokenBytes) ?: throw internalServerError( - "Token used to auth not found in the database!" - ) - if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite) - throw forbidden( - "Cannot generate RW token from RO", - TalerErrorCode.TALER_EC_GENERIC_TOKEN_PERMISSION_INSUFFICIENT - ) - } - val tokenBytes = ByteArray(32).apply { - java.util.Random().nextBytes(this) - } - val maxDurationTime: Long = ctx.maxAuthTokenDurationUs - if (req.duration != null && req.duration.d_us > maxDurationTime) - throw forbidden( - "Token duration bigger than bank's limit", - // FIXME: define new EC for this case. - TalerErrorCode.TALER_EC_END - ) - val tokenDurationUs = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION_US - val customerDbRow = customer.dbRowId ?: throw internalServerError( - "Could not get customer '${customer.login}' database row ID" - ) - val expirationTimestampUs: Long = getNowUs() + tokenDurationUs - if (expirationTimestampUs < tokenDurationUs) - throw badRequest( - "Token duration caused arithmetic overflow", - // FIXME: need dedicate EC (?) - talerErrorCode = TalerErrorCode.TALER_EC_END - ) - val token = BearerToken( - bankCustomer = customerDbRow, - content = tokenBytes, - creationTime = expirationTimestampUs, - expirationTime = expirationTimestampUs, - scope = req.scope, - isRefreshable = req.refreshable - ) - if (!db.bearerTokenCreate(token)) - throw internalServerError("Failed at inserting new token in the database") - call.respond( - TokenSuccessResponse( - access_token = Base32Crockford.encode(tokenBytes), - expiration = Timestamp( - t_s = expirationTimestampUs / 1000000L - ) - ) - ) - return@post - } -}
\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt deleted file mode 100644 index 64266110..00000000 --- a/bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt +++ /dev/null @@ -1,136 +0,0 @@ -package tech.libeufin.bank - -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.plugins.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import net.taler.common.errorcodes.TalerErrorCode -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.util.getNowUs -import tech.libeufin.util.parsePayto -import kotlin.math.abs - - -private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.transactionHandlers") - -fun Routing.transactionsHandlers(db: Database, ctx: BankApplicationContext) { - get("/accounts/{USERNAME}/transactions") { - val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() - val resourceName = call.expectUriComponent("USERNAME") - if (c.login != resourceName && c.login != "admin") throw forbidden() - // Collecting params. - val historyParams = getHistoryParams(call.request) - // Making the query. - val bankAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) - ?: throw internalServerError("Customer '${c.login}' lacks bank account.") - val bankAccountId = bankAccount.expectRowId() - val history: List<BankAccountTransaction> = db.bankTransactionGetHistory( - start = historyParams.start, - delta = historyParams.delta, - bankAccountId = bankAccountId - ) - val res = BankAccountTransactionsResponse(transactions = mutableListOf()) - history.forEach { - res.transactions.add(BankAccountTransactionInfo( - debtor_payto_uri = it.debtorPaytoUri, - creditor_payto_uri = it.creditorPaytoUri, - subject = it.subject, - amount = it.amount.toString(), - direction = it.direction, - date = it.transactionDate, - row_id = it.dbRowId ?: throw internalServerError( - "Transaction timestamped with '${it.transactionDate}' did not have row ID" - ) - )) - } - call.respond(res) - return@get - } - // Creates a bank transaction. - post("/accounts/{USERNAME}/transactions") { - val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() - val resourceName = call.expectUriComponent("USERNAME") - // admin has no rights here. - if ((c.login != resourceName) && (call.getAuthToken() == null)) - throw forbidden() - val txData = call.receive<BankAccountTransactionCreate>() - // FIXME: make payto parser IBAN-agnostic? - val payto = parsePayto(txData.payto_uri) ?: throw badRequest("Invalid creditor Payto") - val paytoWithoutParams = "payto://iban/${payto.bic}/${payto.iban}" - val subject = payto.message ?: throw badRequest("Wire transfer lacks subject") - val debtorId = c.dbRowId ?: throw internalServerError("Debtor database ID not found") - // This performs already a SELECT on the bank account, - // like the wire transfer will do as well later! - val creditorCustomerData = db.bankAccountGetFromInternalPayto(paytoWithoutParams) - ?: throw notFound( - "Creditor account not found", - TalerErrorCode.TALER_EC_END // FIXME: define this EC. - ) - val amount = parseTalerAmount(txData.amount) - if (amount.currency != ctx.currency) - throw badRequest( - "Wrong currency: ${amount.currency}", - talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH - ) - val dbInstructions = BankInternalTransaction( - debtorAccountId = debtorId, - creditorAccountId = creditorCustomerData.owningCustomerId, - subject = subject, - amount = amount, - transactionDate = getNowUs() - ) - val res = db.bankTransactionCreate(dbInstructions) - when(res) { - Database.BankTransactionResult.CONFLICT -> - throw conflict( - "Insufficient funds", - TalerErrorCode.TALER_EC_END // FIXME: need bank 'insufficient funds' EC. - ) - Database.BankTransactionResult.NO_CREDITOR -> - throw internalServerError("Creditor not found despite previous checks.") - Database.BankTransactionResult.NO_DEBTOR -> - throw internalServerError("Debtor not found despite the request was authenticated.") - Database.BankTransactionResult.SUCCESS -> call.respond(HttpStatusCode.OK) - } - return@post - } - get("/accounts/{USERNAME}/transactions/{T_ID}") { - val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() - val accountOwner = call.expectUriComponent("USERNAME") - // auth ok, check rights. - if (c.login != "admin" && c.login != accountOwner) - throw forbidden() - // rights ok, check tx exists. - val tId = call.expectUriComponent("T_ID") - val txRowId = try { - tId.toLong() - } catch (e: Exception) { - logger.error(e.message) - throw badRequest("TRANSACTION_ID is not a number: ${tId}") - } - val customerRowId = c.dbRowId ?: throw internalServerError("Authenticated client lacks database entry") - val tx = db.bankTransactionGetFromInternalId(txRowId) - ?: throw notFound( - "Bank transaction '$tId' not found", - TalerErrorCode.TALER_EC_NONE // FIXME: need def. - ) - val customerBankAccount = db.bankAccountGetFromOwnerId(customerRowId) - ?: throw internalServerError("Customer '${c.login}' lacks bank account.") - if (tx.bankAccountId != customerBankAccount.bankAccountId) - throw forbidden("Client has no rights over the bank transaction: $tId") - // auth and rights, respond. - call.respond(BankAccountTransactionInfo( - amount = "${tx.amount.currency}:${tx.amount.value}.${tx.amount.frac}", - creditor_payto_uri = tx.creditorPaytoUri, - debtor_payto_uri = tx.debtorPaytoUri, - date = tx.transactionDate, - direction = tx.direction, - subject = tx.subject, - row_id = txRowId - )) - return@get - } -}
\ No newline at end of file diff --git a/contrib/wallet-core b/contrib/wallet-core -Subproject 7079bce1ad2640e44561f56b46d5f00758df8e5 +Subproject 9e2d95b39723a038eb714d723ac0910a5bf596e |