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 }