libeufin

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

mfa.rs (12105B)


      1 /*
      2 * This file is part of LibEuFin.
      3 * Copyright (C) 2026 Taler Systems S.A.
      4 
      5 * LibEuFin is free software; you can redistribute it and/or modify
      6 * it under the terms of the GNU Affero General Public License as
      7 * published by the Free Software Foundation; either version 3, or
      8 * (at your option) any later version.
      9 
     10 * LibEuFin is distributed in the hope that it will be useful, but
     11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13 * Public License for more details.
     14 
     15 * You should have received a copy of the GNU Affero General Public
     16 * License along with LibEuFin; see the file COPYING.  If not, see
     17 * <http://www.gnu.org/licenses/>
     18 */
     19 
     20 use std::{any::TypeId, marker::PhantomData, str::FromStr as _, sync::Arc, time::Duration};
     21 
     22 use aws_lc_rs::digest::SHA512;
     23 use axum::{
     24     Json,
     25     body::Bytes,
     26     extract::{FromRequest, FromRequestParts, Request},
     27     http::{HeaderMap, HeaderName, StatusCode},
     28     response::IntoResponse,
     29 };
     30 use compact_str::CompactString;
     31 use jiff::Timestamp;
     32 use rand::random_range;
     33 use serde::{Deserialize, Serialize, de::DeserializeOwned};
     34 use sqlx::PgPool;
     35 use taler_api::{
     36     error::{ApiError, ApiResult, failure, forbidden},
     37     extract::{Req, decompressed_strict_body},
     38 };
     39 use taler_common::{error_code::ErrorCode, types::base32::Base32};
     40 use taler_macros::EnumMeta;
     41 use uuid::Uuid;
     42 
     43 use crate::{
     44     TanChannel,
     45     api::{
     46         BankState,
     47         account::{AccountPasswordChange, AccountReconfiguration, TanInfo},
     48         cashout::CashoutRequest,
     49         tan::{Challenge, ChallengeResponse},
     50         token::TokenRequest,
     51         tx::TransactionCreateRequest,
     52         withdrawal::BankAccountConfirmWithdrawalRequest,
     53     },
     54     auth::{UserAuth, UserAuthScope, UserORWScope, UserRWScope, UserTokenScope},
     55     db::account::BankInfo,
     56     payto::PaytoCtx,
     57 };
     58 
     59 #[derive(
     60     sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumMeta, Serialize, Deserialize,
     61 )]
     62 #[sqlx(type_name = "op_enum")]
     63 #[enum_meta(Str)]
     64 #[allow(non_camel_case_types)]
     65 pub enum Operation {
     66     account_reconfig,
     67     account_delete,
     68     account_auth_reconfig,
     69     bank_transaction,
     70     cashout,
     71     withdrawal,
     72     create_token,
     73 }
     74 
     75 pub type Tans = Vec<(TanChannel, CompactString)>;
     76 
     77 pub trait MfaOp {
     78     const OP: Operation;
     79     type Scope: UserAuthScope;
     80     type Body: DeserializeOwned + Send + 'static;
     81 
     82     fn required_validation(_: &Self::Body, _: &BankInfo) -> ApiResult<Option<Tans>> {
     83         Ok(None)
     84     }
     85 }
     86 
     87 pub struct AccountReconfigOp;
     88 
     89 impl MfaOp for AccountReconfigOp {
     90     const OP: Operation = Operation::account_reconfig;
     91     type Scope = UserRWScope;
     92     type Body = AccountReconfiguration;
     93 
     94     fn required_validation(body: &Self::Body, info: &BankInfo) -> ApiResult<Option<Tans>> {
     95         Ok(Some(body.required_validation(info)?))
     96     }
     97 }
     98 
     99 pub struct BankTxOp;
    100 
    101 impl MfaOp for BankTxOp {
    102     const OP: Operation = Operation::bank_transaction;
    103     type Scope = UserORWScope;
    104     type Body = TransactionCreateRequest;
    105 }
    106 
    107 pub struct TokenOp;
    108 
    109 impl MfaOp for TokenOp {
    110     const OP: Operation = Operation::create_token;
    111     type Scope = UserTokenScope;
    112     type Body = TokenRequest;
    113 }
    114 
    115 pub struct AccountDeletionOp;
    116 
    117 impl MfaOp for AccountDeletionOp {
    118     const OP: Operation = Operation::account_delete;
    119     type Scope = UserRWScope;
    120     type Body = Empty;
    121 }
    122 
    123 pub struct AccountPasswordOp;
    124 
    125 impl MfaOp for AccountPasswordOp {
    126     const OP: Operation = Operation::account_auth_reconfig;
    127     type Scope = UserRWScope;
    128     type Body = AccountPasswordChange;
    129 }
    130 
    131 pub struct CashoutOp;
    132 
    133 impl MfaOp for CashoutOp {
    134     const OP: Operation = Operation::cashout;
    135     type Scope = UserORWScope;
    136     type Body = CashoutRequest;
    137 }
    138 
    139 pub struct WithdrawalOp;
    140 
    141 impl MfaOp for WithdrawalOp {
    142     const OP: Operation = Operation::withdrawal;
    143     type Scope = UserORWScope;
    144     type Body = BankAccountConfirmWithdrawalRequest;
    145 }
    146 
    147 fn mfa_body_hash(body: &[u8], username: &str, salt: &Base32<16>, ctx: &[u8]) -> Base32<64> {
    148     let mut digest = aws_lc_rs::digest::Context::new(&SHA512);
    149     digest.update(salt.as_ref());
    150     digest.update(username.as_bytes());
    151     digest.update(ctx);
    152     digest.update(body);
    153     Base32::try_from(digest.finish().as_ref()).unwrap()
    154 }
    155 
    156 #[derive(Debug)]
    157 enum Mfa {
    158     None,
    159     Challenged,
    160     Pending(Tans),
    161 }
    162 
    163 #[derive(Debug)]
    164 pub struct MfaCtx<'a, O: MfaOp> {
    165     body: Bytes,
    166     ctx: &'a [u8],
    167     mfa: Mfa,
    168     op: PhantomData<O>,
    169 }
    170 
    171 impl<'a, O: MfaOp> MfaCtx<'a, O> {
    172     async fn respond_challenges(
    173         &self,
    174         db: &PgPool,
    175         username: &str,
    176         tans: &[(TanChannel, CompactString)],
    177     ) -> ApiResult<Vec<Challenge>> {
    178         const TAN_RETRY_COUNTER: u16 = 3;
    179         const TAN_VALIDITY_PERIOD: Duration = Duration::from_mins(30);
    180 
    181         let salt = Base32::secure_rand();
    182         let hash = mfa_body_hash(&self.body, username, &salt, self.ctx);
    183         let mut challenges = Vec::new();
    184 
    185         for (channel, info) in tans {
    186             let code = gen_tan_code();
    187             let uuid = super::db::tan::new(
    188                 db,
    189                 username,
    190                 O::OP,
    191                 &hash,
    192                 &salt,
    193                 &code,
    194                 &Timestamp::now(),
    195                 TAN_RETRY_COUNTER,
    196                 TAN_VALIDITY_PERIOD,
    197                 *channel,
    198                 info,
    199             )
    200             .await?;
    201             // Create token is a public challenge
    202             let info = if O::OP == Operation::create_token {
    203                 CompactString::const_new("REDACTED")
    204             } else {
    205                 info.clone()
    206             };
    207             challenges.push(Challenge {
    208                 challenge_id: uuid.to_string(),
    209                 tan_channel: *channel,
    210                 tan_info: info,
    211             })
    212         }
    213         Ok(challenges)
    214     }
    215 
    216     /**
    217      * Generate a TAN challenge for an [op] request with [body] and
    218      * respond to the HTTP request with a TAN challenge.
    219      *
    220      * If [channel] and [info] are present, they will be used
    221      * to send the TAN code, otherwise defaults will be used.
    222      */
    223     pub async fn response_mfa(
    224         &self,
    225         auth: &mut UserAuth<O::Scope>,
    226         db: &PgPool,
    227         ctx: &PaytoCtx,
    228     ) -> ApiResult<axum::response::Response> {
    229         let info = auth.bank_info(db, ctx).await?;
    230         let challenges = self
    231             .respond_challenges(db, &info.username, &info.mfa())
    232             .await?;
    233         Ok((
    234             StatusCode::ACCEPTED,
    235             Json(ChallengeResponse {
    236                 challenges,
    237                 combi_and: false,
    238             }),
    239         )
    240             .into_response())
    241     }
    242 
    243     pub async fn response_validation(
    244         &self,
    245         auth: &UserAuth<O::Scope>,
    246         db: &PgPool,
    247         tans: &[(TanChannel, CompactString)],
    248     ) -> ApiResult<axum::response::Response> {
    249         let challenges = self.respond_challenges(db, &auth.username, tans).await?;
    250         Ok((
    251             StatusCode::ACCEPTED,
    252             Json(ChallengeResponse {
    253                 challenges,
    254                 combi_and: true,
    255             }),
    256         )
    257             .into_response())
    258     }
    259 
    260     pub fn pending_mfa(&self) -> Option<&[(TanChannel, CompactString)]> {
    261         match &self.mfa {
    262             Mfa::None => None,
    263             Mfa::Challenged => None,
    264             Mfa::Pending(items) => Some(items),
    265         }
    266     }
    267 
    268     pub fn is_2fa(&self) -> bool {
    269         matches!(self.mfa, Mfa::Challenged)
    270     }
    271 }
    272 
    273 pub const TALER_CHALLENGE_IDS: HeaderName = HeaderName::from_static("taler-challenge-ids");
    274 
    275 #[must_use]
    276 pub struct MfaReq<O: MfaOp> {
    277     auth: UserAuth<O::Scope>,
    278     body: Bytes,
    279     req: O::Body,
    280     headers: HeaderMap,
    281 }
    282 
    283 impl<O: MfaOp> MfaReq<O> {
    284     pub async fn solve<'a>(
    285         mut self,
    286         state: &Arc<BankState>,
    287         ctx: &'a [u8],
    288     ) -> ApiResult<(UserAuth<O::Scope>, O::Body, MfaCtx<'a, O>)> {
    289         // Check if challenges are used
    290         let mfa = match self.headers.get(&TALER_CHALLENGE_IDS) {
    291             Some(header) => {
    292                 let uuids: Option<Vec<Uuid>> = header.to_str().ok().and_then(|s| {
    293                     s.split(',')
    294                         .map(|s| Uuid::from_str(s.trim()).ok())
    295                         .collect()
    296                 });
    297                 let Some(uuids) = uuids else {
    298                     return Err(failure(
    299                         ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED,
    300                         format_args!("{TALER_CHALLENGE_IDS} does not contains valid challenge ids"),
    301                     )
    302                     .with_path(TALER_CHALLENGE_IDS));
    303                 };
    304                 let challenges = super::db::tan::challenge(&state.db, &uuids).await?;
    305                 let mut validated = Vec::new();
    306                 for challenge in challenges {
    307                     if challenge.op != O::OP {
    308                         return Err(forbidden(format_args!(
    309                             "Challenge '{}' is for a different operation",
    310                             challenge.id
    311                         )));
    312                     } else if mfa_body_hash(&self.body, &self.auth.username, &challenge.salt, ctx)
    313                         != challenge.hash
    314                     {
    315                         return Err(forbidden(format_args!(
    316                             "Challenge '{}' is for a different request",
    317                             challenge.id
    318                         )));
    319                     } else if challenge.confirmed {
    320                         validated.push((challenge.channel, challenge.info));
    321                     }
    322                 }
    323 
    324                 if !validated.is_empty() {
    325                     // Check if challenges are solved
    326                     let info = self.auth.bank_info(&state.db, &state.cfg.ctx).await?;
    327 
    328                     if let Some(validation) = O::required_validation(&self.req, info)? {
    329                         // Check mfa & new TAN validation
    330                         if validation.iter().all(|it| validated.contains(it)) {
    331                             Mfa::Challenged
    332                         } else if info.mfa().iter().any(|it| validated.contains(it)) {
    333                             Mfa::Pending(validation)
    334                         } else {
    335                             Mfa::None
    336                         }
    337                     } else {
    338                         // Check mfa
    339                         if info.mfa().iter().any(|it| validated.contains(it)) {
    340                             Mfa::Challenged
    341                         } else {
    342                             Mfa::None
    343                         }
    344                     }
    345                 } else {
    346                     Mfa::None
    347                 }
    348             }
    349             None => Mfa::None,
    350         };
    351         Ok((
    352             self.auth,
    353             self.req,
    354             MfaCtx {
    355                 mfa,
    356                 op: PhantomData,
    357                 body: self.body,
    358                 ctx,
    359             },
    360         ))
    361     }
    362 }
    363 
    364 impl<O: MfaOp> FromRequest<Arc<BankState>> for MfaReq<O> {
    365     type Rejection = ApiError;
    366 
    367     async fn from_request(req: Request, state: &Arc<BankState>) -> Result<Self, Self::Rejection> {
    368         let (mut parts, body) = req.into_parts();
    369         let auth = UserAuth::from_request_parts(&mut parts, state).await?;
    370         let body = if TypeId::of::<O::Body>() == TypeId::of::<Empty>() {
    371             Bytes::default()
    372         } else {
    373             decompressed_strict_body(&parts.headers, body).await?
    374         };
    375         let Req(req) = Req::<O::Body>::try_from(&body)?;
    376         Ok(Self {
    377             auth,
    378             body,
    379             req,
    380             headers: parts.headers,
    381         })
    382     }
    383 }
    384 
    385 /// Generate a secure random TAN code
    386 pub fn gen_tan_code() -> String {
    387     // TODO do we need a more secure rng here ?
    388     // Generate a random number between 0 and 99,999,999
    389     let rand_val: u32 = random_range(0..100000000);
    390     format!("{:08}", rand_val)
    391 }
    392 
    393 pub struct Empty;
    394 
    395 impl<'de> Deserialize<'de> for Empty {
    396     fn deserialize<D>(_: D) -> Result<Self, D::Error>
    397     where
    398         D: serde::Deserializer<'de>,
    399     {
    400         Ok(Self)
    401     }
    402 }