libeufin

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

tan.rs (7490B)


      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 //! Data access logic for tan challenged
     21 
     22 use std::time::Duration;
     23 
     24 use compact_str::CompactString;
     25 use jiff::Timestamp;
     26 use sqlx::{PgPool, Row, postgres::PgRow};
     27 use taler_api::{
     28     db::{BindHelper, TypeHelper},
     29     serialized,
     30 };
     31 use taler_common::types::base32::Base32;
     32 use uuid::Uuid;
     33 
     34 use crate::{TanChannel, mfa::Operation};
     35 
     36 /** Create a new challenge */
     37 pub async fn new(
     38     db: &PgPool,
     39     username: &str,
     40     op: Operation,
     41     hash: &Base32<64>,
     42     salt: &Base32<16>,
     43     code: &str,
     44     now: &Timestamp,
     45     retry_counter: u16,
     46     validity: Duration,
     47     channel: TanChannel,
     48     info: &str,
     49 ) -> sqlx::Result<Uuid> {
     50     serialized!(
     51         sqlx::query_scalar(
     52             "
     53         INSERT INTO tan_challenges (
     54             hbody,
     55             salt,
     56             op,
     57             code,
     58             creation_date,
     59             expiration_date,
     60             retry_counter,
     61             customer,
     62             tan_channel,
     63             tan_info,
     64             uuid
     65         ) VALUES (
     66             $1,
     67             $2,
     68             $3,
     69             $4,
     70             $5,
     71             $6,
     72             $7,
     73             (SELECT customer_id FROM customers WHERE username = $8 AND deleted_at IS NULL),
     74             $9,
     75             $10,
     76             gen_random_uuid()
     77         ) RETURNING uuid
     78         ",
     79         )
     80         .bind(hash)
     81         .bind(salt)
     82         .bind(op)
     83         .bind(code)
     84         .bind_timestamp(now)
     85         .bind_timestamp(&(*now + validity))
     86         .bind(retry_counter as i16)
     87         .bind(username)
     88         .bind(channel)
     89         .bind(info)
     90         .fetch_one(db)
     91     )
     92 }
     93 
     94 /** Result of TAN challenge transmission */
     95 #[derive(Debug, Clone, PartialEq, Eq)]
     96 pub enum SendResult {
     97     Send {
     98         info: CompactString,
     99         channel: TanChannel,
    100         code: CompactString,
    101         expiration: Timestamp,
    102     },
    103     Success {
    104         expiration: Timestamp,
    105         retransmission: Timestamp,
    106     },
    107     Expired,
    108     Solved,
    109     NotFound,
    110     TooMany,
    111 }
    112 
    113 /** Request TAN challenge transmission */
    114 pub async fn send(
    115     db: &PgPool,
    116     uuid: &Uuid,
    117     now: &Timestamp,
    118     max_active: u16,
    119 ) -> sqlx::Result<SendResult> {
    120     Ok(serialized!(
    121         sqlx::query("
    122     SELECT
    123        (confirmation_date IS NOT NULL) as solved
    124       ,retransmission_date
    125       ,expiration_date
    126       ,code
    127       ,tan_channel
    128       ,tan_info
    129       ,retry_counter
    130       -- If this is the first time we submit this challenge check there is not too many active challenges
    131       ,(retransmission_date = 0 AND (
    132         SELECT count(*) >= $1
    133         FROM tan_challenges as o
    134         WHERE c.customer = o.customer
    135           AND retransmission_date != 0
    136           AND confirmation_date IS NULL
    137           AND expiration_date >= $2
    138       )) AS too_many
    139     FROM tan_challenges as c
    140     WHERE uuid = $3
    141     ")
    142     .bind(max_active as i16)
    143     .bind_timestamp(now)
    144     .bind(uuid)
    145     .try_map(|r: PgRow| Ok(
    146             if r.try_get("solved")? {
    147                 SendResult::Solved
    148             } else if r.try_get::<i32, _>("retry_counter")? <= 0 {
    149                 SendResult::Expired
    150             } else if r.try_get("too_many")? {
    151                 SendResult::TooMany
    152             } else {
    153                 let retransmission = r.try_get_timestamp("retransmission_date")?;
    154                 let expiration = r.try_get_timestamp("expiration_date")?;
    155                 if expiration <= *now {
    156                     SendResult::Expired
    157                 } else if retransmission <= *now {
    158                     SendResult::Send {
    159                         info: r.try_get("tan_info")?,
    160                         channel: r.try_get("tan_channel")?,
    161                         code: r.try_get("code")?,
    162                         expiration
    163                     }
    164                 } else {
    165                     SendResult::Success {
    166                         expiration,
    167                         retransmission
    168                     }
    169                 }
    170             }
    171         ))
    172     .fetch_optional(db)
    173     )?.unwrap_or(SendResult::NotFound))
    174 }
    175 
    176 /** Mark TAN challenge transmission */
    177 pub async fn mark_sent(db: &PgPool, uuid: &Uuid, retransmission: &Timestamp) -> sqlx::Result<()> {
    178     serialized!(
    179         sqlx::query("UPDATE tan_challenges SET retransmission_date = $1 WHERE uuid = $2")
    180             .bind_timestamp(retransmission)
    181             .bind(uuid)
    182             .execute(db)
    183     )?;
    184     Ok(())
    185 }
    186 
    187 /** Result of TAN challenge solution */
    188 #[derive(Debug, Clone, PartialEq, Eq)]
    189 pub enum SolveResult {
    190     Success {
    191         op: Operation,
    192         channel: Option<TanChannel>,
    193         info: Option<CompactString>,
    194     },
    195     NotFound,
    196     NoRetry,
    197     Expired,
    198     BadCode,
    199 }
    200 
    201 /** Solve TAN challenge */
    202 pub async fn solve(
    203     db: &PgPool,
    204     uuid: &Uuid,
    205     code: &str,
    206     timestamp: &Timestamp,
    207 ) -> sqlx::Result<SolveResult> {
    208     serialized!(
    209         sqlx::query(
    210             "
    211         SELECT
    212             out_ok, out_no_op, out_no_retry, out_expired,
    213             out_op, out_channel, out_info
    214         FROM tan_challenge_try($1,$2,$3)
    215         ",
    216         )
    217         .bind(uuid)
    218         .bind(code)
    219         .bind_timestamp(timestamp)
    220         .try_map(|r: PgRow| {
    221             Ok(if r.try_get_flag("out_ok")? {
    222                 SolveResult::Success {
    223                     op: r.try_get("out_op")?,
    224                     channel: r.try_get("out_channel")?,
    225                     info: r.try_get("out_info")?,
    226                 }
    227             } else if r.try_get_flag("out_no_op")? {
    228                 SolveResult::NotFound
    229             } else if r.try_get_flag("out_no_retry")? {
    230                 SolveResult::NoRetry
    231             } else if r.try_get_flag("out_expired")? {
    232                 SolveResult::Expired
    233             } else {
    234                 SolveResult::BadCode
    235             })
    236         })
    237         .fetch_one(db)
    238     )
    239 }
    240 
    241 #[derive(Debug)]
    242 pub struct Challenge {
    243     pub id: Uuid,
    244     pub salt: Base32<16>,
    245     pub hash: Base32<64>,
    246     pub channel: TanChannel,
    247     pub info: CompactString,
    248     pub confirmed: bool,
    249     pub op: Operation,
    250 }
    251 
    252 pub async fn challenge(db: &PgPool, uuids: &[Uuid]) -> sqlx::Result<Vec<Challenge>> {
    253     serialized!(
    254         sqlx::query(
    255             "
    256         SELECT uuid, salt, hbody, tan_channel, tan_info, op, (confirmation_date IS NOT NULL) as confirmed
    257         FROM tan_challenges
    258         WHERE uuid = ANY($1)
    259         ",
    260         )
    261         .bind(uuids)
    262         .try_map(|r: PgRow| {
    263             Ok(Challenge {
    264                 id: r.try_get("uuid")?,
    265                 salt: r.try_get("salt")?,
    266                 hash: r.try_get("hbody")?,
    267                 channel: r.try_get("tan_channel")?,
    268                 info: r.try_get("tan_info")?,
    269                 confirmed: r.try_get("confirmed")?,
    270                 op: r.try_get("op")?,
    271             })
    272         })
    273         .fetch_all(db)
    274     )
    275 }