libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 5086e2514bc6f0881b630b20d07ce0737bc9fbea
parent 22f1484c6d4681a533e63ce678b36f53525b9f23
Author: Antoine A <>
Date:   Tue, 17 Oct 2023 12:08:31 +0000

Improve and test accounts management endpoints

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Authentication.kt | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 447+++++++++++++++++++++++++++++++++++++------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 24------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt | 20++++++++++----------
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 131++++++++++++++++---------------------------------------------------------------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 717+++++++++++++++++++++++++++++--------------------------------------------------
Mbank/src/test/kotlin/DatabaseTest.kt | 25-------------------------
Mbank/src/test/kotlin/helpers.kt | 19+++++++++++++++++--
Mutil/src/main/kotlin/CryptoUtil.kt | 32++++++++++++++------------------
10 files changed, 671 insertions(+), 877 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Authentication.kt b/bank/src/main/kotlin/tech/libeufin/bank/Authentication.kt @@ -3,8 +3,9 @@ package tech.libeufin.bank import io.ktor.http.* import io.ktor.server.application.* import net.taler.common.errorcodes.TalerErrorCode -import tech.libeufin.util.getAuthorizationDetails -import tech.libeufin.util.getAuthorizationRawHeader +import tech.libeufin.util.* +import net.taler.wallet.crypto.Base32Crockford +import java.time.Instant /** * This function tries to authenticate the call according @@ -36,4 +37,130 @@ suspend fun ApplicationCall.authenticateBankRequest(db: Database, requiredScope: ) ) } +} + +/** Authenticate admin */ +suspend fun ApplicationCall.authAdmin(db: Database, scope: TokenScope) { + // TODO when all endpoints use this function we can use an optimized database request that only query the customer login + val c = authenticateBankRequest(db, scope) ?: throw unauthorized("Bad login") + if (c.login != "admin") { + throw unauthorized("Only administrator allowed") + } +} + +/** Authenticate and check access rights */ +suspend fun ApplicationCall.authCheck(db: Database, scope: TokenScope, withAdmin: Boolean = true, requireAdmin: Boolean = false): Pair<String, Boolean> { + // TODO when all endpoints use this function we can use an optimized database request that only query the customer login + val c = authenticateBankRequest(db, scope) ?: throw unauthorized("Bad login") + val login = accountLogin() + if (requireAdmin && c.login != "admin") { + if (c.login != "admin") { + throw unauthorized("Only administrator allowed") + } + } else { + val hasRight = c.login == login || (withAdmin && c.login == "admin"); + if (!hasRight) { + throw unauthorized("No right on $login account") + } + } + return Pair(login, c.login == "admin") +} + +// Get the auth token (stripped of the bearer-token:-prefix) +// IF the call was authenticated with it. +fun ApplicationCall.getAuthToken(): String? { + val h = getAuthorizationRawHeader(this.request) ?: return null + val authDetails = getAuthorizationDetails(h) ?: throw badRequest( + "Authorization header is malformed.", TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED + ) + if (authDetails.scheme == "Bearer") return splitBearerToken(authDetails.content) ?: throw throw badRequest( + "Authorization header is malformed (could not strip the prefix from Bearer token).", + TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED + ) + return null // Not a Bearer token case. +} + + +/** + * Performs the HTTP basic authentication. Returns the + * authenticated customer on success, or null otherwise. + */ +private suspend fun doBasicAuth(db: Database, encodedCredentials: String): Customer? { + val plainUserAndPass = String(base64ToBytes(encodedCredentials), Charsets.UTF_8) // :-separated + val userAndPassSplit = plainUserAndPass.split( + ":", + /** + * this parameter allows colons to occur in passwords. + * Without this, passwords that have colons would be split + * and become meaningless. + */ + limit = 2 + ) + if (userAndPassSplit.size != 2) throw LibeufinBankException( + httpStatus = HttpStatusCode.BadRequest, talerError = TalerError( + code = TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED.code, + "Malformed Basic auth credentials found in the Authorization header." + ) + ) + val login = userAndPassSplit[0] + val plainPassword = userAndPassSplit[1] + val maybeCustomer = db.customerGetFromLogin(login) ?: throw unauthorized() + if (!CryptoUtil.checkpw(plainPassword, maybeCustomer.passwordHash)) return null + return maybeCustomer +} + +/** + * This function takes a prefixed Bearer token, removes the + * secret-token:-prefix and returns it. Returns null, if the + * input is invalid. + */ +private fun splitBearerToken(tok: String): String? { + val tokenSplit = tok.split(":", limit = 2) + if (tokenSplit.size != 2) return null + if (tokenSplit[0] != "secret-token") return null + return tokenSplit[1] +} + +/* Performs the secret-token authentication. Returns the + * authenticated customer on success, null otherwise. */ +private suspend fun doTokenAuth( + db: Database, + token: String, + requiredScope: TokenScope, +): Customer? { + val bareToken = splitBearerToken(token) ?: throw badRequest( + "Bearer token malformed", + talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED + ) + val tokenBytes = try { + Base32Crockford.decode(bareToken) + } catch (e: Exception) { + throw badRequest( + e.message, TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED + ) + } + val maybeToken: BearerToken? = db.bearerTokenGet(tokenBytes) + if (maybeToken == null) { + logger.error("Auth token not found") + return null + } + if (maybeToken.expirationTime.isBefore(Instant.now())) { + logger.error("Auth token is expired") + return null + } + if (maybeToken.scope == TokenScope.readonly && requiredScope == TokenScope.readwrite) { + logger.error("Auth token has insufficient scope") + return null + } + if (!maybeToken.isRefreshable && requiredScope == TokenScope.refreshable) { + logger.error("Could not refresh unrefreshable token") + return null + } + // Getting the related username. + return db.customerGetFromRowId(maybeToken.bankCustomer) ?: throw LibeufinBankException( + httpStatus = HttpStatusCode.InternalServerError, talerError = TalerError( + code = TalerErrorCode.TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE.code, + hint = "Customer not found, despite token mentions it.", + ) + ) } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -24,7 +24,6 @@ private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.account * and wire transfers should belong here. */ fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) { - // TOKEN ENDPOINTS delete("/accounts/{USERNAME}/token") { val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() @@ -112,51 +111,128 @@ fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) { ) return@post } - // ACCOUNT ENDPOINTS - get("/public-accounts") { - // no authentication here. - val publicAccounts = db.accountsGetPublic(ctx.currency) - if (publicAccounts.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(PublicAccountsResponse(publicAccounts)) - } + // WITHDRAWAL ENDPOINTS + post("/accounts/{USERNAME}/withdrawals") { + val c = call.authenticateBankRequest(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. + if(req.amount.currency != ctx.currency) + throw badRequest("Wrong currency: ${req.amount.currency}") + val b = db.bankAccountGetFromOwnerId(c.expectRowId()) + ?: throw internalServerError("Customer '${c.login}' lacks bank account.") + if (!isBalanceEnough( + balance = b.expectBalance(), due = req.amount, maxDebt = b.maxDebt, hasBalanceDebt = b.hasDebt + ) + ) throw forbidden( + hint = "Insufficient funds to withdraw with Taler", + talerErrorCode = TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT + ) // Auth and funds passed, create the operation now! + val opId = UUID.randomUUID() + if (!db.talerWithdrawalCreate( + opId, b.expectRowId(), req.amount + ) + ) 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") { - val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() - if (c.login != "admin") throw forbidden("Only admin allowed.") - // Get optional param. - val maybeFilter: String? = call.request.queryParameters["filter_name"] - logger.debug("Filtering on '${maybeFilter}'") - val queryParam = if (maybeFilter != null) { - "%${maybeFilter}%" - } else "%" - val accounts = db.accountsGetForAdmin(queryParam) - if (accounts.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(ListBankAccountsResponse(accounts)) - } + get("/withdrawals/{withdrawal_id}") { + val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) + call.respond( + BankAccountGetWithdrawalResponse( + amount = op.amount, + 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") { // check if only admin is allowed to create new accounts - if (ctx.restrictRegistration) { - val customer: Customer? = call.authenticateBankRequest(db, TokenScope.readwrite) - if (customer == null || customer.login != "admin") throw LibeufinBankException( - httpStatus = HttpStatusCode.Unauthorized, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_GENERIC_UNAUTHORIZED.code, - hint = "Either 'admin' not authenticated or an ordinary user tried this operation." + post("/withdrawals/{withdrawal_id}/abort") { + 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("/withdrawals/{withdrawal_id}/confirm") { + 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, Instant.now())) { + WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> + throw conflict( + "Insufficient funds", + TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT ) + 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_BANK_UNKNOWN_ACCOUNT + ) + + WithdrawalConfirmationResult.CONFLICT -> throw internalServerError("Bank didn't check for idempotency") + + WithdrawalConfirmationResult.SUCCESS -> call.respondText( + "{}", ContentType.Application.Json ) + } + return@post + } +} + +fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankApplicationContext) { + post("/accounts") { + // check if only admin is allowed to create new accounts + if (ctx.restrictRegistration) { + call.authAdmin(db, TokenScope.readwrite) } // 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 = TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT.code, - hint = "Username '${req.username}' is reserved." - ) - ) // Checking idempotency. + val req = call.receive<RegisterAccountRequest>() + // Prohibit reserved usernames: + if (reservedAccounts.contains(req.username)) throw conflict( + "Username '${req.username}' is reserved.", + TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT + ) // TODO conflict or forbidden ? + // Checking idempotency. val maybeCustomerExists = db.customerGetFromLogin(req.username) // Can be null if previous call crashed before completion. val maybeHasBankAccount = maybeCustomerExists.run { @@ -179,11 +255,9 @@ fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) { call.respond(HttpStatusCode.Created) return@post } - throw LibeufinBankException( - httpStatus = HttpStatusCode.Conflict, talerError = TalerError( - code = GENERIC_UNDEFINED, // FIXME: provide appropriate EC. - hint = "Idempotency check failed." - ) + throw conflict( + "Idempotency check failed.", + TalerErrorCode.TALER_EC_END // FIXME: provide appropriate EC. ) } @@ -231,98 +305,38 @@ fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) { BankTransactionResult.NO_DEBTOR -> throw internalServerError("Bonus impossible: admin not found.") BankTransactionResult.BALANCE_INSUFFICIENT -> throw internalServerError("Bonus impossible: admin has insufficient balance.") BankTransactionResult.SAME_ACCOUNT -> throw internalServerError("Bonus impossible: admin should not be creditor.") - BankTransactionResult.SUCCESS -> {/* continue the execution */ - } + BankTransactionResult.SUCCESS -> { /* continue the execution */ } } } call.respond(HttpStatusCode.Created) - return@post - } - get("/accounts/{USERNAME}") { - val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized("Login failed") - val resourceName = call.expectUriComponent("USERNAME") - if (!resourceName.canI(c, withAdmin = true)) throw forbidden() - val customerData = db.customerGetFromLogin(resourceName) ?: throw notFound( - "Customer '$resourceName' not found in the database.", - talerEc = TalerErrorCode.TALER_EC_END - ) - val bankAccountData = db.bankAccountGetFromOwnerId(customerData.expectRowId()) - ?: throw internalServerError("Customer '$resourceName' had no bank account despite they are customer.'") - val balance = Balance( - amount = bankAccountData.balance ?: throw internalServerError("Account '${customerData.login}' lacks balance!"), - credit_debit_indicator = if (bankAccountData.hasDebt) { - CorebankCreditDebitInfo.debit - } else { - CorebankCreditDebitInfo.credit - } - ) - call.respond( - AccountData( - name = customerData.name, - balance = balance, - debit_threshold = bankAccountData.maxDebt, - payto_uri = bankAccountData.internalPaytoUri, - contact_data = ChallengeContactData( - email = customerData.email, phone = customerData.phone - ), - cashout_payto_uri = customerData.cashoutPayto, - ) - ) - return@get } delete("/accounts/{USERNAME}") { - val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() - val resourceName = call.expectUriComponent("USERNAME") - // Checking rights. - if (c.login != "admin" && ctx.restrictAccountDeletion) - throw forbidden("Only admin allowed.") - if (!resourceName.canI(c, withAdmin = true)) - throw forbidden("Insufficient rights on this account.") + val (login, _) = call.authCheck(db, TokenScope.readwrite, requireAdmin = ctx.restrictAccountDeletion) // Not deleting reserved names. - if (resourceName == "bank" || resourceName == "admin") - throw forbidden("Cannot delete reserved accounts.") - val res = db.customerDeleteIfBalanceIsZero(resourceName) - when (res) { - CustomerDeletionResult.CUSTOMER_NOT_FOUND -> - throw notFound( - "Customer '$resourceName' not found", - talerEc = TalerErrorCode.TALER_EC_NONE // FIXME: need EC. - ) - CustomerDeletionResult.BALANCE_NOT_ZERO -> - throw LibeufinBankException( - httpStatus = HttpStatusCode.PreconditionFailed, - talerError = TalerError( - hint = "Balance is not zero.", - code = TalerErrorCode.TALER_EC_NONE.code // FIXME: need EC. - ) + if (reservedAccounts.contains(login)) throw conflict( + "Cannot delete reserved accounts", + TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT + ) // TODO conflict or forbidden ? + + when (db.customerDeleteIfBalanceIsZero(login)) { + CustomerDeletionResult.CUSTOMER_NOT_FOUND -> throw notFound( + "Customer '$login' not found", + talerEc = TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + CustomerDeletionResult.BALANCE_NOT_ZERO -> throw LibeufinBankException( + httpStatus = HttpStatusCode.PreconditionFailed, // PreconditionFailed or conflict ? + talerError = TalerError( + hint = "Balance is not zero.", + code = TalerErrorCode.TALER_EC_NONE.code // FIXME: need EC. ) + ) CustomerDeletionResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) } - return@delete - } - patch("/accounts/{USERNAME}/auth") { - val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() - val accountName = call.getResourceName("USERNAME") - if (!accountName.canI(c, withAdmin = true)) throw forbidden() - val req = call.receive<AccountPasswordChange>() - val hashedPassword = CryptoUtil.hashpw(req.new_password) - if (!db.customerChangePassword( - accountName, - hashedPassword - )) throw notFound( - "Account '$accountName' not found (despite it being authenticated by this call)", - talerEc = TalerErrorCode.TALER_EC_END // FIXME: need at least GENERIC_NOT_FOUND. - ) - call.respond(HttpStatusCode.NoContent) - return@patch } patch("/accounts/{USERNAME}") { - val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() - val accountName = call.getResourceName("USERNAME") - // preventing user non-admin X trying on resource Y. - if (!accountName.canI(c, withAdmin = true)) throw forbidden() + val (login, isAdmin) = call.authCheck(db, TokenScope.readwrite) // admin is not allowed itself to change its own details. - if (accountName == "admin") throw forbidden("admin account not patchable") + if (login == "admin") throw forbidden("admin account not patchable") // authentication OK, go on. val req = call.receive<AccountReconfiguration>() /** @@ -330,12 +344,12 @@ fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) { * by this operation, as it MAY differ from the one being authenticated. * This typically happens when admin did the request. */ - val accountCustomer = db.customerGetFromLogin(accountName) ?: throw notFound( - "Account $accountName not found", + val accountCustomer = db.customerGetFromLogin(login) ?: throw notFound( + "Account $login not found", talerEc = TalerErrorCode.TALER_EC_END // FIXME, define EC. ) // Check if a non-admin user tried to change their legal name - if ((c.login != "admin") && (req.name != null) && (req.name != accountCustomer.name)) + if (!isAdmin && (req.name != null) && (req.name != accountCustomer.name)) throw forbidden("non-admin user cannot change their legal name") // Preventing identical data to be overridden. val bankAccount = db.bankAccountGetFromOwnerId(accountCustomer.expectRowId()) @@ -343,7 +357,7 @@ fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) { if ( (req.is_exchange == bankAccount.isTalerExchange) && (req.cashout_address == accountCustomer.cashoutPayto) && - (req.name == c.name) && + (req.name == accountCustomer.name) && (req.challenge_contact_data?.phone == accountCustomer.phone) && (req.challenge_contact_data?.email == accountCustomer.email) ) { @@ -370,120 +384,78 @@ fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) { throw internalServerError("Customer '${accountCustomer.login}' lacks bank account") } } - return@patch - } - // WITHDRAWAL ENDPOINTS - post("/accounts/{USERNAME}/withdrawals") { - val c = call.authenticateBankRequest(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. - if(req.amount.currency != ctx.currency) - throw badRequest("Wrong currency: ${req.amount.currency}") - val b = db.bankAccountGetFromOwnerId(c.expectRowId()) - ?: throw internalServerError("Customer '${c.login}' lacks bank account.") - if (!isBalanceEnough( - balance = b.expectBalance(), due = req.amount, maxDebt = b.maxDebt, hasBalanceDebt = b.hasDebt - ) - ) throw forbidden( - hint = "Insufficient funds to withdraw with Taler", - talerErrorCode = TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT - ) // Auth and funds passed, create the operation now! - val opId = UUID.randomUUID() - if (!db.talerWithdrawalCreate( - opId, b.expectRowId(), req.amount - ) - ) 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("/withdrawals/{withdrawal_id}") { - val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) - call.respond( - BankAccountGetWithdrawalResponse( - amount = op.amount, - aborted = op.aborted, - confirmation_done = op.confirmationDone, - selection_done = op.selectionDone, - selected_exchange_account = op.selectedExchangePayto, - selected_reserve_pub = op.reservePub - ) + patch("/accounts/{USERNAME}/auth") { + val (login, _) = call.authCheck(db, TokenScope.readwrite) + val req = call.receive<AccountPasswordChange>() + val hashedPassword = CryptoUtil.hashpw(req.new_password) + if (!db.customerChangePassword( + login, + hashedPassword + )) throw notFound( + "Account '$login' not found (despite it being authenticated by this call)", + talerEc = TalerErrorCode.TALER_EC_END // FIXME: need at least GENERIC_NOT_FOUND. ) - return@get + call.respond(HttpStatusCode.NoContent) } - post("/withdrawals/{withdrawal_id}/abort") { - 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 + get("/public-accounts") { + // no authentication here. + val publicAccounts = db.accountsGetPublic(ctx.currency) + if (publicAccounts.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(PublicAccountsResponse(publicAccounts)) + } } - post("/withdrawals/{withdrawal_id}/confirm") { - val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Checking idempotency: - if (op.confirmationDone) { - call.respondText("{}", ContentType.Application.Json) - return@post + get("/accounts") { + call.authAdmin(db, TokenScope.readonly) + // Get optional param. + val maybeFilter: String? = call.request.queryParameters["filter_name"] + logger.debug("Filtering on '${maybeFilter}'") + val queryParam = if (maybeFilter != null) { + "%${maybeFilter}%" + } else "%" + val accounts = db.accountsGetForAdmin(queryParam) + if (accounts.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(ListBankAccountsResponse(accounts)) } - 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, Instant.now())) { - WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> - throw conflict( - "Insufficient funds", - TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT - ) - 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_BANK_UNKNOWN_ACCOUNT - ) - - WithdrawalConfirmationResult.CONFLICT -> throw internalServerError("Bank didn't check for idempotency") - - WithdrawalConfirmationResult.SUCCESS -> call.respondText( - "{}", ContentType.Application.Json + } + get("/accounts/{USERNAME}") { + val (login, _) = call.authCheck(db, TokenScope.readonly) + val customerData = db.customerGetFromLogin(login) ?: throw notFound( + "Customer '$login' not found in the database.", + talerEc = TalerErrorCode.TALER_EC_END + ) + val bankAccountData = db.bankAccountGetFromOwnerId(customerData.expectRowId()) + ?: throw internalServerError("Customer '$login' had no bank account despite they are customer.'") + val balance = Balance( + amount = bankAccountData.balance ?: throw internalServerError("Account '${customerData.login}' lacks balance!"), + credit_debit_indicator = if (bankAccountData.hasDebt) { + CorebankCreditDebitInfo.debit + } else { + CorebankCreditDebitInfo.credit + } + ) + call.respond( + AccountData( + name = customerData.name, + balance = balance, + debit_threshold = bankAccountData.maxDebt, + payto_uri = bankAccountData.internalPaytoUri, + contact_data = ChallengeContactData( + email = customerData.email, phone = customerData.phone + ), + cashout_payto_uri = customerData.cashoutPayto, ) - } - return@post + ) } } fun Routing.coreBankTransactionsApi(db: Database, ctx: BankApplicationContext) { get("/accounts/{USERNAME}/transactions") { - call.authCheck(db, TokenScope.readonly, true) + call.authCheck(db, TokenScope.readonly) val params = getHistoryParams(call.request.queryParameters) val bankAccount = call.bankAccount(db) @@ -491,7 +463,7 @@ fun Routing.coreBankTransactionsApi(db: Database, ctx: BankApplicationContext) { call.respond(BankAccountTransactionsResponse(history)) } get("/accounts/{USERNAME}/transactions/{T_ID}") { - call.authCheck(db, TokenScope.readonly, true) + call.authCheck(db, TokenScope.readonly) val tId = call.expectUriComponent("T_ID") val txRowId = try { tId.toLong() @@ -521,7 +493,7 @@ fun Routing.coreBankTransactionsApi(db: Database, ctx: BankApplicationContext) { ) } post("/accounts/{USERNAME}/transactions") { - val username = call.authCheck(db, TokenScope.readonly, false) + val (login, _ ) = call.authCheck(db, TokenScope.readwrite, withAdmin = false) val tx = call.receive<BankAccountTransactionCreate>() val subject = tx.payto_uri.message ?: throw badRequest("Wire transfer lacks subject") @@ -530,10 +502,9 @@ fun Routing.coreBankTransactionsApi(db: Database, ctx: BankApplicationContext) { "Wrong currency: ${amount.currency}", talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH ) - val result = db.bankTransaction( creditAccountPayto = tx.payto_uri, - debitAccountUsername = username, + debitAccountUsername = login, subject = subject, amount = amount, timestamp = Instant.now(), @@ -548,7 +519,7 @@ fun Routing.coreBankTransactionsApi(db: Database, ctx: BankApplicationContext) { TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT ) BankTransactionResult.NO_DEBTOR -> throw notFound( - "Customer $username not found", + "Customer $login not found", TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT ) BankTransactionResult.NO_CREDITOR -> throw notFound( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -66,7 +66,6 @@ import kotlin.system.exitProcess // GLOBALS private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Main") -const val GENERIC_UNDEFINED = -1 // Filler for ECs that don't exist yet. val TOKEN_DEFAULT_DURATION: java.time.Duration = Duration.ofDays(1L) /** @@ -260,6 +259,7 @@ fun Application.corebankWebApp(db: Database, ctx: BankApplicationContext) { return@get } this.accountsMgmtApi(db, ctx) + this.coreBankAccountsMgmtApi(db, ctx) this.coreBankTransactionsApi(db, ctx) this.accountsMgmtApi(db, ctx) this.bankIntegrationApi(db, ctx) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -403,30 +403,6 @@ data class BankAccountGetWithdrawalResponse( val selected_exchange_account: IbanPayTo? = null ) -typealias ResourceName = String - -/** - * Checks if the input Customer has the rights over ResourceName. - */ -fun ResourceName.canI(c: Customer, withAdmin: Boolean = true): Boolean { - if (c.login == this) return true - if (c.login == "admin" && withAdmin) return true - return false -} - -/** - * Factors out the retrieval of the resource name from - * the URI. The resource looked for defaults to "USERNAME" - * as this is frequently mentioned resource along the endpoints. - * - * This helper is recommended because it returns a ResourceName - * type that then offers the ".canI()" helper to check if the user - * has the rights on the resource. - */ -fun ApplicationCall.getResourceName(param: String): ResourceName = - this.expectUriComponent(param) - - // GET /config response from the Taler Integration API. @Serializable data class TalerIntegrationConfigResponse( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt @@ -43,7 +43,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankApplicationContext) { } post("/accounts/{USERNAME}/taler-wire-gateway/transfer") { - val username = call.authCheck(db, TokenScope.readwrite, true) + val (login, _) = call.authCheck(db, TokenScope.readwrite, withAdmin = false) val req = call.receive<TransferRequest>() if (req.amount.currency != ctx.currency) throw badRequest( @@ -52,16 +52,16 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankApplicationContext) { ) val dbRes = db.talerTransferCreate( req = req, - username = username, + username = login, timestamp = Instant.now() ) when (dbRes.txResult) { TalerTransferResult.NO_DEBITOR -> throw notFound( - "Customer $username not found", + "Customer $login not found", TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT ) TalerTransferResult.NOT_EXCHANGE -> throw conflict( - "$username is not an exchange account.", + "$login is not an exchange account.", TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT ) TalerTransferResult.NO_CREDITOR -> throw notFound( @@ -98,13 +98,13 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankApplicationContext) { reduce: (List<T>, String) -> Any, dbLambda: suspend Database.(HistoryParams, Long) -> List<T> ) { - val username = call.authCheck(db, TokenScope.readonly, true) + val (login, _) = call.authCheck(db, TokenScope.readonly) val params = getHistoryParams(call.request.queryParameters) val bankAccount = call.bankAccount(db) if (!bankAccount.isTalerExchange) throw conflict( - "$username is not an exchange account.", + "$login is not an exchange account.", TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT ) @@ -126,7 +126,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankApplicationContext) { } post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { - val username = call.authCheck(db, TokenScope.readwrite, false) + val (login, _) = call.authCheck(db, TokenScope.readwrite, withAdmin = false) val req = call.receive<AddIncomingRequest>() if (req.amount.currency != ctx.currency) throw badRequest( @@ -136,16 +136,16 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankApplicationContext) { val timestamp = Instant.now() val dbRes = db.talerAddIncomingCreate( req = req, - username = username, + username = login, timestamp = timestamp ) when (dbRes.txResult) { TalerAddIncomingResult.NO_CREDITOR -> throw notFound( - "Customer $username not found", + "Customer $login not found", TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT ) TalerAddIncomingResult.NOT_EXCHANGE -> throw conflict( - "$username is not an exchange account.", + "$login is not an exchange account.", TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT ) TalerAddIncomingResult.NO_DEBITOR -> throw notFound( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -34,124 +34,46 @@ import java.time.Instant import java.util.* private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.helpers") +val reservedAccounts = setOf("admin", "bank") fun ApplicationCall.expectUriComponent(componentName: String) = this.maybeUriComponent(componentName) ?: throw badRequest( hint = "No username found in the URI", talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MISSING ) -// Get the auth token (stripped of the bearer-token:-prefix) -// IF the call was authenticated with it. -fun ApplicationCall.getAuthToken(): String? { - val h = getAuthorizationRawHeader(this.request) ?: return null - val authDetails = getAuthorizationDetails(h) ?: throw badRequest( - "Authorization header is malformed.", TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED - ) - if (authDetails.scheme == "Bearer") return splitBearerToken(authDetails.content) ?: throw throw badRequest( - "Authorization header is malformed (could not strip the prefix from Bearer token).", - TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED - ) - return null // Not a Bearer token case. -} - -/** Authenticate and check access rights */ -suspend fun ApplicationCall.authCheck(db: Database, scope: TokenScope, withAdmin: Boolean): String { - val authCustomer = authenticateBankRequest(db, scope) ?: throw unauthorized("Bad login") - val username = getResourceName("USERNAME") - if (!username.canI(authCustomer, withAdmin)) throw unauthorized("No right on $username account") - return username -} - -/** Retrieve the bank account info for the selected username*/ -suspend fun ApplicationCall.bankAccount(db: Database): Database.BankInfo { - val username = getResourceName("USERNAME") - return db.bankAccountInfoFromCustomerLogin(username) ?: throw notFound( - hint = "Customer $username not found", - talerEc = TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) -} +typealias ResourceName = String /** - * Performs the HTTP basic authentication. Returns the - * authenticated customer on success, or null otherwise. + * Checks if the input Customer has the rights over ResourceName. */ -suspend fun doBasicAuth(db: Database, encodedCredentials: String): Customer? { - val plainUserAndPass = String(base64ToBytes(encodedCredentials), Charsets.UTF_8) // :-separated - val userAndPassSplit = plainUserAndPass.split( - ":", - /** - * this parameter allows colons to occur in passwords. - * Without this, passwords that have colons would be split - * and become meaningless. - */ - limit = 2 - ) - if (userAndPassSplit.size != 2) throw LibeufinBankException( - httpStatus = HttpStatusCode.BadRequest, talerError = TalerError( - code = TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED.code, - "Malformed Basic auth credentials found in the Authorization header." - ) - ) - val login = userAndPassSplit[0] - val plainPassword = userAndPassSplit[1] - val maybeCustomer = db.customerGetFromLogin(login) ?: throw unauthorized() - if (!CryptoUtil.checkpw(plainPassword, maybeCustomer.passwordHash)) return null - return maybeCustomer +fun ResourceName.canI(c: Customer, withAdmin: Boolean = true): Boolean { + if (c.login == this) return true + if (c.login == "admin" && withAdmin) return true + return false } /** - * This function takes a prefixed Bearer token, removes the - * secret-token:-prefix and returns it. Returns null, if the - * input is invalid. + * Factors out the retrieval of the resource name from + * the URI. The resource looked for defaults to "USERNAME" + * as this is frequently mentioned resource along the endpoints. + * + * This helper is recommended because it returns a ResourceName + * type that then offers the ".canI()" helper to check if the user + * has the rights on the resource. */ -private fun splitBearerToken(tok: String): String? { - val tokenSplit = tok.split(":", limit = 2) - if (tokenSplit.size != 2) return null - if (tokenSplit[0] != "secret-token") return null - return tokenSplit[1] -} +fun ApplicationCall.getResourceName(param: String): ResourceName = + this.expectUriComponent(param) + -/* Performs the secret-token authentication. Returns the - * authenticated customer on success, null otherwise. */ -suspend fun doTokenAuth( - db: Database, - token: String, - requiredScope: TokenScope, -): Customer? { - val bareToken = splitBearerToken(token) ?: throw badRequest( - "Bearer token malformed", - talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED - ) - val tokenBytes = try { - Base32Crockford.decode(bareToken) - } catch (e: Exception) { - throw badRequest( - e.message, TalerErrorCode.TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED - ) - } - val maybeToken: BearerToken? = db.bearerTokenGet(tokenBytes) - if (maybeToken == null) { - logger.error("Auth token not found") - return null - } - if (maybeToken.expirationTime.isBefore(Instant.now())) { - logger.error("Auth token is expired") - return null - } - if (maybeToken.scope == TokenScope.readonly && requiredScope == TokenScope.readwrite) { - logger.error("Auth token has insufficient scope") - return null - } - if (!maybeToken.isRefreshable && requiredScope == TokenScope.refreshable) { - logger.error("Could not refresh unrefreshable token") - return null - } - // Getting the related username. - return db.customerGetFromRowId(maybeToken.bankCustomer) ?: throw LibeufinBankException( - httpStatus = HttpStatusCode.InternalServerError, talerError = TalerError( - code = TalerErrorCode.TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE.code, - hint = "Customer not found, despite token mentions it.", - ) +/** Get account login from path */ +suspend fun ApplicationCall.accountLogin(): String = getResourceName("USERNAME") + +/** Retrieve the bank account info for the selected username*/ +suspend fun ApplicationCall.bankAccount(db: Database): Database.BankInfo { + val login = accountLogin() + return db.bankAccountInfoFromCustomerLogin(login) ?: throw notFound( + hint = "Customer $login not found", + talerEc = TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT ) } @@ -176,7 +98,6 @@ fun internalServerError(hint: String?): LibeufinBankException = LibeufinBankExce ) ) - fun notFound( hint: String?, talerEc: TalerErrorCode diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -21,6 +21,270 @@ import kotlin.random.Random import kotlin.test.* import kotlinx.coroutines.* +class CoreBankAccountsMgmtApiTest { + // Testing the account creation and its idempotency + @Test + fun createAccountTest() = bankSetup { _ -> + val ibanPayto = genIbanPaytoUri() + val req = json { + "username" to "foo" + "password" to "password" + "name" to "Jane" + "is_public" to true + "internal_payto_uri" to ibanPayto + } + // Check Ok + client.post("/accounts") { + jsonBody(req) + }.assertCreated() + // Testing idempotency. + client.post("/accounts") { + jsonBody(req) + }.assertCreated() + + // Test generate payto_uri + client.post("/accounts") { + jsonBody(json { + "username" to "jor" + "password" to "password" + "name" to "Joe" + }) + }.assertCreated() + + // Reserved account + reservedAccounts.forEach { + client.post("/accounts") { + jsonBody(json { + "username" to it + "password" to "password" + "name" to "John Smith" + }) + }.assertStatus(HttpStatusCode.Conflict) + } + } + + // Test admin-only account creation + @Test + fun createAccountRestrictedTest() = bankSetup(conf = "test_restrict.conf") { _ -> + val req = json { + "username" to "baz" + "password" to "xyz" + "name" to "Mallory" + } + + // Ordinary user + client.post("/accounts") { + basicAuth("merchant", "merchant-password") + jsonBody(req) + }.assertUnauthorized() + // Administrator + client.post("/accounts") { + basicAuth("admin", "admin-password") + jsonBody(req) + }.assertCreated() + } + + // DELETE /accounts/USERNAME + @Test + fun deleteAccount() = bankSetup { _ -> + // Unknown account + client.delete("/accounts/unknown") { + basicAuth("admin", "admin-password") + }.assertStatus(HttpStatusCode.NotFound) + + // Reserved account + reservedAccounts.forEach { + client.delete("/accounts/$it") { + basicAuth("admin", "admin-password") + expectSuccess = false + }.assertStatus(HttpStatusCode.Conflict) + } + + // successful deletion + client.post("/accounts") { + jsonBody(json { + "username" to "john" + "password" to "password" + "name" to "John Smith" + }) + }.assertCreated() + client.delete("/accounts/john") { + basicAuth("admin", "admin-password") + }.assertNoContent() + // Trying again must yield 404 + client.delete("/accounts/john") { + basicAuth("admin", "admin-password") + }.assertStatus(HttpStatusCode.NotFound) + + + // fail to delete, due to a non-zero balance. + client.post("/accounts/exchange/transactions") { + basicAuth("exchange", "exchange-password") + jsonBody(json { + "payto_uri" to "payto://iban/MERCHANT-IBAN-XYZ?message=payout&amount=KUDOS:1" + }) + }.assertOk() + client.delete("/accounts/merchant") { + basicAuth("admin", "admin-password") + }.assertStatus(HttpStatusCode.PreconditionFailed) + client.post("/accounts/merchant/transactions") { + basicAuth("merchant", "merchant-password") + jsonBody(json { + "payto_uri" to "payto://iban/EXCHANGE-IBAN-XYZ?message=payout&amount=KUDOS:1" + }) + }.assertOk() + client.delete("/accounts/merchant") { + basicAuth("admin", "admin-password") + }.assertNoContent() + } + + // PATCH /accounts/USERNAME + @Test + fun accountReconfig() = bankSetup { db -> + // Successful attempt now. + val req = json { + "cashout_address" to "payto://new-cashout-address" + "challenge_contact_data" to json { + "email" to "new@example.com" + "phone" to "+987" + } + "is_exchange" to true + } + client.patch("/accounts/merchant") { + basicAuth("merchant", "merchant-password") + jsonBody(req) + }.assertNoContent() + // Checking idempotence. + client.patch("/accounts/merchant") { + basicAuth("merchant", "merchant-password") + jsonBody(req) + }.assertNoContent() + + val nameReq = json { + "name" to "Another Foo" + } + // Checking ordinary user doesn't get to patch their name. + client.patch("/accounts/merchant") { + basicAuth("merchant", "merchant-password") + jsonBody(nameReq) + }.assertStatus(HttpStatusCode.Forbidden) + // Finally checking that admin does get to patch foo's name. + client.patch("/accounts/merchant") { + basicAuth("admin", "admin-password") + jsonBody(nameReq) + }.assertNoContent() + + val fooFromDb = db.customerGetFromLogin("merchant") + assertEquals("Another Foo", fooFromDb?.name) + } + + // PATCH /accounts/USERNAME/auth + @Test + fun passwordChangeTest() = bankSetup { _ -> + // Changing the password. + client.patch("/accounts/merchant/auth") { + basicAuth("merchant", "merchant-password") + jsonBody(json { + "new_password" to "new-password" + }) + }.assertNoContent() + // Previous password should fail. + client.patch("/accounts/merchant/auth") { + basicAuth("merchant", "merchant-password") + }.assertUnauthorized() + // New password should succeed. + client.patch("/accounts/merchant/auth") { + basicAuth("merchant", "new-password") + jsonBody(json { + "new_password" to "merchant-password" + }) + }.assertNoContent() + } + + // GET /public-accounts and GET /accounts + @Test + fun accountsListTest() = bankSetup { _ -> + // Remove default accounts + listOf("merchant", "exchange").forEach { + client.delete("/accounts/$it") { + basicAuth("admin", "admin-password") + }.assertNoContent() + } + // Check error when no public accounts + client.get("/public-accounts").assertNoContent() + client.get("/accounts") { + basicAuth("admin", "admin-password") + }.assertOk() + + // Gen some public and private accounts + repeat(5) { + client.post("/accounts") { + jsonBody(json { + "username" to "$it" + "password" to "password" + "name" to "Mr $it" + "is_public" to (it%2 == 0) + }) + }.assertCreated() + } + // All public + client.get("/public-accounts").run { + assertOk() + val obj = Json.decodeFromString<PublicAccountsResponse>(bodyAsText()) + assertEquals(3, obj.public_accounts.size) + obj.public_accounts.forEach { + assertEquals(0, it.account_name.toInt() % 2) + } + } + // All accounts + client.get("/accounts"){ + basicAuth("admin", "admin-password") + }.run { + assertOk() + val obj = Json.decodeFromString<ListBankAccountsResponse>(bodyAsText()) + assertEquals(6, obj.accounts.size) + obj.accounts.forEachIndexed { idx, it -> + if (idx == 0) { + assertEquals("admin", it.username) + } else { + assertEquals(idx - 1, it.username.toInt()) + } + } + } + // Filtering + client.get("/accounts?filter_name=3"){ + basicAuth("admin", "admin-password") + }.run { + assertOk() + val obj = Json.decodeFromString<ListBankAccountsResponse>(bodyAsText()) + assertEquals(1, obj.accounts.size) + assertEquals("3", obj.accounts[0].username) + } + } + + // GET /accounts/USERNAME + @Test + fun getAccountTest() = bankSetup { db -> + // Check ok + client.get("/accounts/merchant") { + basicAuth("merchant", "merchant-password") + }.assertOk().run { + val obj: AccountData = Json.decodeFromString(bodyAsText()) + assertEquals("Merchant", obj.name) + } + + // Check admin ok + client.get("/accounts/merchant") { + basicAuth("admin", "admin-password") + }.assertOk() + + // Check wrong user + client.get("/accounts/exchange") { + basicAuth("merchanr", "merchanr-password") + }.assertStatus(HttpStatusCode.Unauthorized) + } +} + class CoreBankTransactionsApiTest { // Test endpoint is correctly authenticated suspend fun ApplicationTestBuilder.authRoutine(path: String, withAdmin: Boolean = true, method: HttpMethod = HttpMethod.Post) { @@ -299,7 +563,6 @@ class CoreBankTransactionsApiTest { }) }.assertStatus(HttpStatusCode.Conflict) } - } class LibeuFinApiTest { @@ -337,38 +600,7 @@ class LibeuFinApiTest { println(r.bodyAsText()) } - @Test - fun passwordChangeTest() = setup { db, ctx -> - assert(db.customerCreate(customerFoo) != null) - testApplication { - application { - corebankWebApp(db, ctx) - } - // Changing the password. - client.patch("/accounts/foo/auth") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth("foo", "pw") - setBody("""{"new_password": "bar"}""") - } - // Previous password should fail. - client.patch("/accounts/foo/auth") { - expectSuccess = false - contentType(ContentType.Application.Json) - basicAuth("foo", "pw") - setBody("""{"new_password": "not-even-parsed"}""") - }.apply { - assert(this.status == HttpStatusCode.Unauthorized) - } - // New password should succeed. - client.patch("/accounts/foo/auth") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth("foo", "bar") - setBody("""{"new_password": "not-used"}""") - } - } - } + @Test fun tokenDeletionTest() = setup { db, ctx -> assert(db.customerCreate(customerFoo) != null) @@ -417,39 +649,6 @@ class LibeuFinApiTest { } } - @Test - fun publicAccountsTest() = setup { db, ctx -> - testApplication { - application { - corebankWebApp(db, ctx) - } - client.get("/public-accounts").apply { - assert(this.status == HttpStatusCode.NoContent) - } - // Make one public account. - db.customerCreate(customerBar).apply { - assert(this != null) - assert( - db.bankAccountCreate( - BankAccount( - isPublic = true, - internalPaytoUri = IbanPayTo("payto://iban/non-used"), - lastNexusFetchRowId = 1L, - owningCustomerId = this!!, - hasDebt = false, - maxDebt = TalerAmount(10, 1, "KUDOS") - ) - ) != null - ) - } - client.get("/public-accounts").apply { - assert(this.status == HttpStatusCode.OK) - val obj = Json.decodeFromString<PublicAccountsResponse>(this.bodyAsText()) - assert(obj.public_accounts.size == 1) - assert(obj.public_accounts[0].account_name == "bar") - } - } - } // Creating token with "forever" duration. @Test fun tokenForeverTest() = setup { db, ctx -> @@ -575,390 +774,4 @@ class LibeuFinApiTest { assert(never.expiration.t_s == Instant.MAX) } } - - /** - * Testing the retrieval of account information. - * The tested logic is the one usually needed by SPAs - * to show customers their status. - */ - @Test - fun getAccountTest() = setup { db, ctx -> - // Artificially insert a customer and bank account in the database. - val customerRowId = db.customerCreate( - Customer( - "foo", - CryptoUtil.hashpw("pw"), - "Foo" - ) - ) - assert(customerRowId != null) - assert( - db.bankAccountCreate( - BankAccount( - hasDebt = false, - internalPaytoUri = IbanPayTo("payto://iban/DE1234"), - maxDebt = TalerAmount(100, 0, "KUDOS"), - owningCustomerId = customerRowId!! - ) - ) != null - ) - testApplication { - application { - corebankWebApp(db, ctx) - } - val r = client.get("/accounts/foo") { - expectSuccess = true - basicAuth("foo", "pw") - } - val obj: AccountData = Json.decodeFromString(r.bodyAsText()) - assert(obj.name == "Foo") - // Checking admin can. - val adminRowId = db.customerCreate( - Customer( - "admin", - CryptoUtil.hashpw("admin"), - "Admin" - ) - ) - assert(adminRowId != null) - assert( - db.bankAccountCreate( - BankAccount( - hasDebt = false, - internalPaytoUri = IbanPayTo("payto://iban/SANDBOXX/ADMIN-IBAN"), - maxDebt = TalerAmount(100, 0, "KUDOS"), - owningCustomerId = adminRowId!! - ) - ) != null - ) - client.get("/accounts/foo") { - expectSuccess = true - basicAuth("admin", "admin") - } - val shouldNot = client.get("/accounts/foo") { - basicAuth("not", "not") - expectSuccess = false - } - assert(shouldNot.status == HttpStatusCode.Unauthorized) - } - } - - /** - * Testing the account creation and its idempotency - */ - @Test - fun createAccountTest() = setup { db, ctx -> - testApplication { - val ibanPayto = genIbanPaytoUri() - application { - corebankWebApp(db, ctx) - } - var resp = client.post("/accounts") { - expectSuccess = false - contentType(ContentType.Application.Json) - setBody( - """{ - "username": "foo", - "password": "bar", - "name": "Jane", - "is_public": true, - "internal_payto_uri": "$ibanPayto" - }""".trimIndent() - ) - } - assert(resp.status == HttpStatusCode.Created) - // Testing idempotency. - resp = client.post("/accounts") { - expectSuccess = false - contentType(ContentType.Application.Json) - setBody( - """{ - "username": "foo", - "password": "bar", - "name": "Jane", - "is_public": true, - "internal_payto_uri": "$ibanPayto" - }""".trimIndent() - ) - } - assert(resp.status == HttpStatusCode.Created) - } - } - - /** - * Testing the account creation and its idempotency - */ - @Test - fun createTwoAccountsTest() = setup { db, ctx -> - testApplication { - application { - corebankWebApp(db, ctx) - } - var resp = client.post("/accounts") { - expectSuccess = false - contentType(ContentType.Application.Json) - setBody( - """{ - "username": "foo", - "password": "bar", - "name": "Jane" - }""".trimIndent() - ) - } - assert(resp.status == HttpStatusCode.Created) - // Test creating another account. - resp = client.post("/accounts") { - expectSuccess = false - contentType(ContentType.Application.Json) - setBody( - """{ - "username": "joe", - "password": "bar", - "name": "Joe" - }""".trimIndent() - ) - } - assert(resp.status == HttpStatusCode.Created) - } - } - - /** - * Test admin-only account creation - */ - @Test - fun createAccountRestrictedTest() = setup(conf = "test_restrict.conf") { db, ctx -> - testApplication { - application { - corebankWebApp(db, ctx) - } - - // Ordinary user tries, should fail. - var resp = client.post("/accounts") { - expectSuccess = false - basicAuth("foo", "bar") - contentType(ContentType.Application.Json) - setBody( - """{ - "username": "baz", - "password": "xyz", - "name": "Mallory" - }""".trimIndent() - ) - } - assert(resp.status == HttpStatusCode.Unauthorized) - // Creating the administrator. - assert( - db.customerCreate( - Customer( - "admin", - CryptoUtil.hashpw("pass"), - "CFO" - ) - ) != null - ) - // customer exists, this makes only the bank account: - assert(maybeCreateAdminAccount(db, ctx)) - resp = client.post("/accounts") { - expectSuccess = false - basicAuth("admin", "pass") - contentType(ContentType.Application.Json) - setBody( - """{ - "username": "baz", - "password": "xyz", - "name": "Mallory" - }""".trimIndent() - ) - } - assert(resp.status == HttpStatusCode.Created) - } - } - - /** - * Tests DELETE /accounts/foo - */ - @Test - fun deleteAccount() = setup { db, ctx -> - val adminCustomer = Customer( - "admin", - CryptoUtil.hashpw("pass"), - "CFO" - ) - db.customerCreate(adminCustomer) - testApplication { - application { - corebankWebApp(db, ctx) - } - // account to delete doesn't exist. - client.delete("/accounts/foo") { - basicAuth("admin", "pass") - expectSuccess = false - }.apply { - assert(this.status == HttpStatusCode.NotFound) - } - // account to delete is reserved. - client.delete("/accounts/admin") { - basicAuth("admin", "pass") - expectSuccess = false - }.apply { - assert(this.status == HttpStatusCode.Forbidden) - } - // successful deletion - db.customerCreate(customerFoo).apply { - assert(this != null) - assert(db.bankAccountCreate(genBankAccount(this!!)) != null) - } - client.delete("/accounts/foo") { - basicAuth("admin", "pass") - expectSuccess = true - }.apply { - assert(this.status == HttpStatusCode.NoContent) - } - // Trying again must yield 404 - client.delete("/accounts/foo") { - basicAuth("admin", "pass") - expectSuccess = false - }.apply { - assert(this.status == HttpStatusCode.NotFound) - } - // fail to delete, due to a non-zero balance. - db.customerCreate(customerBar).apply { - assert(this != null) - db.bankAccountCreate(genBankAccount(this!!)).apply { - assert(this != null) - val conn = DriverManager.getConnection("jdbc:postgresql:///libeufincheck").unwrap(PgConnection::class.java) - conn.execSQLUpdate("UPDATE libeufin_bank.bank_accounts SET balance.val = 1 WHERE bank_account_id = $this") - } - } - client.delete("/accounts/bar") { - basicAuth("admin", "pass") - expectSuccess = false - }.apply { - assert(this.status == HttpStatusCode.PreconditionFailed) - } - } - } - - /** - * Tests reconfiguration of account data. - */ - @Test - fun accountReconfig() = setup { db, ctx -> - testApplication { - application { - corebankWebApp(db, ctx) - } - assertNotNull(db.customerCreate(customerFoo)) - // First call expects 500, because foo lacks a bank account - client.patch("/accounts/foo") { - basicAuth("foo", "pw") - jsonBody(json { - "is_exchange" to true - }) - }.assertStatus(HttpStatusCode.InternalServerError) - // Creating foo's bank account. - assertNotNull(db.bankAccountCreate(genBankAccount(1L))) - // Successful attempt now. - val validReq = AccountReconfiguration( - cashout_address = "payto://new-cashout-address", - challenge_contact_data = ChallengeContactData( - email = "new@example.com", - phone = "+987" - ), - is_exchange = true, - name = null - ) - client.patch("/accounts/foo") { - basicAuth("foo", "pw") - jsonBody(validReq) - }.assertStatus(HttpStatusCode.NoContent) - // Checking idempotence. - client.patch("/accounts/foo") { - basicAuth("foo", "pw") - jsonBody(validReq) - }.assertStatus(HttpStatusCode.NoContent) - // Checking ordinary user doesn't get to patch their name. - client.patch("/accounts/foo") { - basicAuth("foo", "pw") - jsonBody(json { - "name" to "Another Foo" - }) - }.assertStatus(HttpStatusCode.Forbidden) - // Finally checking that admin does get to patch foo's name. - assertNotNull(db.customerCreate(Customer( - login = "admin", - passwordHash = CryptoUtil.hashpw("secret"), - name = "CFO" - ))) - client.patch("/accounts/foo") { - basicAuth("admin", "secret") - jsonBody(json { - "name" to "Another Foo" - }) - }.assertStatus(HttpStatusCode.NoContent) - val fooFromDb = db.customerGetFromLogin("foo") - assertNotNull(fooFromDb) - assertEquals("Another Foo", fooFromDb.name) - } - } - - /** - * Tests the GET /accounts endpoint. - */ - @Test - fun getAccountsList() = setup { db, ctx -> - val adminCustomer = Customer( - "admin", - CryptoUtil.hashpw("pass"), - "CFO" - ) - assert(db.customerCreate(adminCustomer) != null) - testApplication { - application { - corebankWebApp(db, ctx) - } - // No users registered, expect no data. - client.get("/accounts") { - basicAuth("admin", "pass") - expectSuccess = true - }.apply { - assert(this.status == HttpStatusCode.NoContent) - } - // foo account - db.customerCreate(customerFoo).apply { - assert(this != null) - assert(db.bankAccountCreate(genBankAccount(this!!)) != null) - } - // bar account - db.customerCreate(customerBar).apply { - assert(this != null) - assert(db.bankAccountCreate(genBankAccount(this!!)) != null) - } - // Two users registered, requesting all of them. - client.get("/accounts") { - basicAuth("admin", "pass") - expectSuccess = true - }.apply { - println(this.bodyAsText()) - assert(this.status == HttpStatusCode.OK) - val obj = Json.decodeFromString<ListBankAccountsResponse>(this.bodyAsText()) - assert(obj.accounts.size == 2) - // Order unreliable, just checking they're different. - assert(obj.accounts[0].username != obj.accounts[1].username) - } - // Filtering on bar. - client.get("/accounts?filter_name=ar") { - basicAuth("admin", "pass") - expectSuccess = true - }.apply { - assert(this.status == HttpStatusCode.OK) - val obj = Json.decodeFromString<ListBankAccountsResponse>(this.bodyAsText()) - assert(obj.accounts.size == 1) { - println("Wrong size of filtered query: ${obj.accounts.size}") - } - assert(obj.accounts[0].username == "bar") - } - } - } - } diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -255,31 +255,6 @@ class DatabaseTest { assert(barAccount?.balance?.equals(TalerAmount(10, 0, "KUDOS")) == true) } - // Testing customer(+bank account) deletion logic. - @Test - fun customerDeletionTest() = setupDb { db -> - // asserting false, as foo doesn't exist yet. - assert(db.customerDeleteIfBalanceIsZero("foo") == CustomerDeletionResult.CUSTOMER_NOT_FOUND) - // Creating foo. - db.customerCreate(customerFoo).apply { - assert(this != null) - assert(db.bankAccountCreate(bankAccountFoo) != null) - } - // foo has zero balance, deletion should succeed. - assert(db.customerDeleteIfBalanceIsZero("foo") == CustomerDeletionResult.SUCCESS) - - // Creating foo again, artificially setting its balance != zero. - db.customerCreate(customerFoo).apply { - assert(this != null) - db.bankAccountCreate(bankAccountFoo).apply { - assert(this != null) - val conn = DriverManager.getConnection("jdbc:postgresql:///libeufincheck").unwrap(PgConnection::class.java) - conn.execSQLUpdate("UPDATE libeufin_bank.bank_accounts SET balance.frac = 1 WHERE bank_account_id = $this") - } - } - assert(db.customerDeleteIfBalanceIsZero("foo") == CustomerDeletionResult.BALANCE_NOT_ZERO) - } - @Test fun customerCreationTest() = setupDb { db -> assert(db.customerGetFromLogin("foo") == null) diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -47,13 +47,25 @@ val bankAccountExchange = BankAccount( isTalerExchange = true ) -fun bankSetup(lambda: suspend ApplicationTestBuilder.(Database) -> Unit) { - setup { db, ctx -> +fun bankSetup( + conf: String = "test.conf", + lambda: suspend ApplicationTestBuilder.(Database) -> Unit +) { + setup(conf) { db, ctx -> // Creating the exchange and merchant accounts first. assertNotNull(db.customerCreate(customerMerchant)) assertNotNull(db.bankAccountCreate(bankAccountMerchant)) assertNotNull(db.customerCreate(customerExchange)) assertNotNull(db.bankAccountCreate(bankAccountExchange)) + // Create admin account + assertNotNull(db.customerCreate( + Customer( + "admin", + CryptoUtil.hashpw("admin-password"), + "CFO" + ) + )) + assert(maybeCreateAdminAccount(db, ctx)) testApplication { application { corebankWebApp(db, ctx) @@ -90,6 +102,9 @@ fun HttpResponse.assertStatus(status: HttpStatusCode): HttpResponse { return this } fun HttpResponse.assertOk(): HttpResponse = assertStatus(HttpStatusCode.OK) +fun HttpResponse.assertCreated(): HttpResponse = assertStatus(HttpStatusCode.Created) +fun HttpResponse.assertNoContent(): HttpResponse = assertStatus(HttpStatusCode.NoContent) +fun HttpResponse.assertUnauthorized(): HttpResponse = assertStatus(HttpStatusCode.Unauthorized) fun HttpResponse.assertBadRequest(): HttpResponse = assertStatus(HttpStatusCode.BadRequest) fun BankTransactionResult.assertSuccess() { diff --git a/util/src/main/kotlin/CryptoUtil.kt b/util/src/main/kotlin/CryptoUtil.kt @@ -310,25 +310,21 @@ object CryptoUtil { fun checkpw(pw: String, storedPwHash: String): Boolean { val components = storedPwHash.split('$') - if (components.size < 2) { - throw Exception("bad password hash") - } - val algo = components[0] - // Support legacy unsalted passwords - if (algo == "sha256") { - val hash = components[1] - val pwh = bytesToBase64(CryptoUtil.hashStringSHA256(pw)) - return pwh == hash - } - if (algo == "sha256-salted") { - if (components.size != 3) { - throw Exception("bad password hash") + when (val algo = components[0]) { + "sha256" -> { // Support legacy unsalted passwords + if (components.size != 2) throw Exception("bad password hash") + val hash = components[1] + val pwh = bytesToBase64(CryptoUtil.hashStringSHA256(pw)) + return pwh == hash + } + "sha256-salted" -> { + if (components.size != 3) throw Exception("bad password hash") + val salt = components[1] + val hash = components[2] + val pwh = bytesToBase64(CryptoUtil.hashStringSHA256("$salt|$pw")) + return pwh == hash } - val salt = components[1] - val hash = components[2] - val pwh = bytesToBase64(CryptoUtil.hashStringSHA256("$salt|$pw")) - return pwh == hash + else -> throw Exception("unsupported hash algo: '$algo'") } - throw Exception("unsupported hash algo: '$algo'") } }