commit 5086e2514bc6f0881b630b20d07ce0737bc9fbea
parent 22f1484c6d4681a533e63ce678b36f53525b9f23
Author: Antoine A <>
Date: Tue, 17 Oct 2023 12:08:31 +0000
Improve and test accounts management endpoints
Diffstat:
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'")
}
}