tan.rs (20142B)
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::{sync::Arc, time::Duration}; 21 22 use axum::{ 23 Json, Router, 24 extract::State, 25 http::StatusCode, 26 response::{IntoResponse, NoContent}, 27 routing::post, 28 }; 29 use compact_str::CompactString; 30 use jiff::Timestamp; 31 use serde::{Deserialize, Serialize}; 32 use taler_api::{ 33 error::{failure, failure_code}, 34 extract::{Path, Req}, 35 }; 36 use taler_common::{error_code::ErrorCode, types::time::TalerTimestamp}; 37 use tokio::{io::AsyncWriteExt as _, process::Command}; 38 use tracing::trace; 39 use uuid::Uuid; 40 41 use crate::{ 42 TanChannel, 43 api::BankState, 44 db::tan::{SendResult, SolveResult, mark_sent, send, solve}, 45 }; 46 47 pub const MAX_ACTIVE_CHALLENGES: u16 = 5; 48 49 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 50 pub struct ChallengeResponse { 51 pub challenges: Vec<Challenge>, 52 pub combi_and: bool, 53 } 54 55 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 56 pub struct Challenge { 57 pub challenge_id: String, 58 pub tan_channel: TanChannel, 59 pub tan_info: CompactString, 60 } 61 62 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 63 pub struct ChallengeRequestResponse { 64 pub solve_expiration: TalerTimestamp, 65 pub earliest_retransmission: TalerTimestamp, 66 } 67 68 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 69 pub struct ChallengeSolve { 70 pub tan: CompactString, 71 } 72 73 pub fn tan_api() -> Router<Arc<BankState>> { 74 Router::new().route( 75 "/accounts/{username}/challenge/{id}", 76 post( 77 async |State(state): State<Arc<BankState>>, 78 Path((_, id)): Path<((), Uuid)>| { 79 match send(&state.db, &id, &Timestamp::now(), MAX_ACTIVE_CHALLENGES).await? { 80 SendResult::NotFound => Err(failure_code(ErrorCode::BANK_CHALLENGE_NOT_FOUND)), 81 SendResult::Expired => Err(failure_code(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED)), 82 SendResult::TooMany => Err(failure_code(ErrorCode::BANK_TAN_RATE_LIMITED)), 83 SendResult::Solved => Ok(StatusCode::GONE.into_response()), 84 SendResult::Send { 85 info, 86 channel, 87 code, 88 expiration, 89 } => { 90 let Some((script, env) )= &state.cfg.tan_channels.get(&channel) else { 91 return Err(failure_code(ErrorCode::BANK_TAN_CHANNEL_NOT_SUPPORTED)) 92 }; 93 let msg = format!("T-{code} is your {} verification code", state.cfg.name); 94 trace!(target: "tan", "send {code} with {script}"); 95 let res = async { 96 let mut child = Command::new(script) 97 .arg(&info) 98 .stdin(std::process::Stdio::piped()) 99 .stdout(std::process::Stdio::piped()) 100 .stderr(std::process::Stdio::piped()) 101 .envs(env.iter()) 102 .spawn()?; 103 104 if let Some(mut stdin) = child.stdin.take() { 105 let _ = stdin.write_all(msg.as_bytes()).await; 106 } 107 108 child.wait_with_output().await 109 } 110 .await; 111 let output = match res { 112 Err(e) => { 113 tracing::error!(target: "tan", "{channel} {script} failed: {e}"); 114 return Err(failure( 115 ErrorCode::BANK_TAN_CHANNEL_SCRIPT_FAILED, 116 format_args!("TAN channel {channel} IO failure"), 117 )); 118 } 119 Ok(output) => output, 120 }; 121 122 let code = output.status.code().unwrap_or(-1); 123 state.metrics.register_tan_result(channel, code); 124 if code != 0 { 125 let out = String::from_utf8_lossy(&output.stdout); 126 tracing::error!(target: "tan", "{channel} {script}: {code} {out}"); 127 return Err(failure( 128 ErrorCode::BANK_TAN_CHANNEL_SCRIPT_FAILED, 129 format_args!("TAN channel {channel} failure with exit code"), 130 )); 131 } 132 133 let retransmission = Timestamp::now() + Duration::from_mins(3); 134 mark_sent(&state.db, &id, &retransmission).await?; 135 Ok(Json(ChallengeRequestResponse { 136 solve_expiration: expiration.into(), 137 earliest_retransmission: retransmission.into(), 138 }) 139 .into_response()) 140 } 141 SendResult::Success { 142 expiration, 143 retransmission, 144 } => Ok(Json(ChallengeRequestResponse { 145 solve_expiration: expiration.into(), 146 earliest_retransmission: retransmission.into(), 147 }) 148 .into_response()), 149 } 150 }, 151 ), 152 ).route( 153 "/accounts/{username}/challenge/{id}/confirm", 154 post( 155 async | 156 State(state): State<Arc<BankState>>, 157 Path((_, id)): Path<((), Uuid)>, 158 Req(req): Req<ChallengeSolve> 159 | { 160 let code = req.tan.strip_prefix("T-").unwrap_or(&req.tan); 161 match solve(&state.db, &id, code, &Timestamp::now()).await? { 162 SolveResult::NotFound => Err(failure_code(ErrorCode::BANK_CHALLENGE_NOT_FOUND)), 163 SolveResult::BadCode => Err(failure_code(ErrorCode::BANK_TAN_CHALLENGE_FAILED)), 164 SolveResult::NoRetry => Err(failure_code(ErrorCode::BANK_TAN_RATE_LIMITED)), 165 SolveResult::Expired => Err(failure_code(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED)), 166 SolveResult::Success { .. } => Ok(NoContent) 167 } 168 }, 169 ), 170 ) 171 } 172 173 #[cfg(test)] 174 pub mod test { 175 176 use sqlx::postgres::PgConnectOptions; 177 use taler_common::error_code::ErrorCode; 178 use taler_macros::db_test; 179 use taler_test_utils::{json, server::TestResponse}; 180 use uuid::Uuid; 181 182 use crate::{ 183 TanChannel, 184 api::{ 185 tan::{Challenge, ChallengeRequestResponse, ChallengeResponse, MAX_ACTIVE_CHALLENGES}, 186 test::{MfaRequest, bank_setup, bank_setup_conf, tan_code}, 187 }, 188 mfa::TALER_CHALLENGE_IDS, 189 }; 190 191 #[db_test(raw)] 192 async fn send(db: PgConnectOptions) { 193 let ctx = bank_setup(db).await; 194 195 let expect_mfa = async |res: TestResponse, and: bool, tans: &[(TanChannel, &str)]| { 196 res.assert_challenge_check(&ctx, async |res| { 197 assert_eq!( 198 tans, 199 res.challenges 200 .iter() 201 .map(|it| (it.tan_channel, it.tan_info.as_str())) 202 .collect::<Vec<_>>() 203 ); 204 assert_eq!(res.combi_and, and); 205 }) 206 .await 207 }; 208 209 let send = async |c: &Challenge| { 210 ctx.posta(format!("/accounts/merchant/challenge/{}", c.challenge_id)) 211 .await 212 .assert_ok(); 213 }; 214 215 macro_rules! patch { 216 ($($json:tt)+) => { 217 ctx.patcha("/accounts/merchant").json(json!($($json)+)).await 218 }; 219 } 220 221 // Set up 2fa 222 expect_mfa( 223 patch!({ 224 "contact_data": { 225 "phone": "+199", 226 "email": "email@example.com" 227 }, 228 "tan_channel": "sms" 229 }), 230 true, 231 &[(TanChannel::sms, "+199")], 232 ) 233 .await 234 .assert_no_content(); 235 236 // Update 2fa settings - first 2FA challenge then new tan channel check 237 expect_mfa( 238 patch!({ // Info change 239 "contact_data": { "phone": "+198" }, 240 }), 241 true, 242 &[(TanChannel::sms, "+199"), (TanChannel::sms, "+198")], 243 ) 244 .await 245 .assert_no_content(); 246 expect_mfa( 247 patch!({ // Channel change 248 "tan_channel": "email" 249 }), 250 true, 251 &[ 252 (TanChannel::sms, "+198"), 253 (TanChannel::email, "email@example.com"), 254 ], 255 ) 256 .await 257 .assert_no_content(); 258 expect_mfa( 259 patch!({ // Both change 260 "contact_data": { "phone": "+197" }, 261 "tan_channel": "sms" 262 }), 263 true, 264 &[ 265 (TanChannel::email, "email@example.com"), 266 (TanChannel::sms, "+197"), 267 ], 268 ) 269 .await 270 .assert_no_content(); 271 272 // Disable 2fa 273 expect_mfa( 274 patch!({ // Both change 275 "tan_channel": () 276 }), 277 true, 278 &[(TanChannel::sms, "+197")], 279 ) 280 .await 281 .assert_no_content(); 282 283 // Update mfa settings - first mfa challenge then new tan channel check 284 expect_mfa( 285 patch!({ // Both change 286 "tan_channels": ["sms", "email"] 287 }), 288 true, 289 &[ 290 (TanChannel::sms, "+197"), 291 (TanChannel::email, "email@example.com"), 292 ], 293 ) 294 .await 295 .assert_no_content(); 296 expect_mfa( 297 expect_mfa( 298 patch!({ 299 "contact_data": { 300 "phone": "+199", 301 "email": "email2@example.com" 302 } 303 }), 304 false, 305 &[ 306 (TanChannel::sms, "+197"), 307 (TanChannel::email, "email@example.com"), 308 ], 309 ) 310 .await, 311 true, 312 &[ 313 (TanChannel::sms, "+199"), 314 (TanChannel::email, "email2@example.com"), 315 ], 316 ) 317 .await 318 .assert_no_content(); 319 320 // Disable mfa 321 expect_mfa( 322 patch!({ "tan_channels": [] }), 323 false, 324 &[ 325 (TanChannel::sms, "+199"), 326 (TanChannel::email, "email2@example.com"), 327 ], 328 ) 329 .await 330 .assert_no_content(); 331 332 // Admin has no 2FA 333 ctx.patch_admin("/accounts/merchant") 334 .json(json!({ 335 "contact_data": { "phone": "+199" }, 336 "tan_channel": "sms" 337 })) 338 .await 339 .assert_no_content(); 340 ctx.patch_admin("/accounts/merchant") 341 .json(json!({ 342 "tan_channel": "email" 343 })) 344 .await 345 .assert_no_content(); 346 ctx.patch_admin("/accounts/merchant") 347 .json(json!({ 348 "tan_channel": () 349 })) 350 .await 351 .assert_no_content(); 352 353 // Check retry and invalidate 354 { 355 patch!({ 356 "contact_data": { "phone": "+188" }, 357 "tan_channel": "sms" 358 }) 359 .assert_challenge(&ctx) 360 .await 361 .assert_no_content(); 362 let res: ChallengeResponse = ctx 363 .patcha("/accounts/merchant") 364 .json(json!({ 365 "is_public": false 366 })) 367 .await 368 .assert_accepted_json(); 369 let challenge = &res.challenges[0]; 370 // Check ok 371 send(challenge).await; 372 let code = tan_code("+188").unwrap(); 373 // Check retry 374 send(challenge).await; 375 assert!(tan_code("+188").is_none()); 376 // Idempotent patch does nothing 377 patch!({ 378 "contact_data": { "phone": "+188" }, 379 "tan_channel": "sms" 380 }) 381 .assert_accepted(); 382 send(challenge).await; 383 assert!(tan_code("+88").is_none()); 384 385 // Change 2fa settings 386 patch!({ 387 "tan_channel": "email" 388 }) 389 .assert_challenge(&ctx) 390 .await 391 .assert_no_content(); 392 393 // Check invalidated 394 ctx.posta(format!( 395 "/accounts/merchant/challenge/{}/confirm", 396 challenge.challenge_id, 397 )) 398 .json(json!({"tan": code})) 399 .await 400 .assert_error(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED); 401 ctx.patcha("/accounts/merchant") 402 .header(TALER_CHALLENGE_IDS, challenge.challenge_id.to_string()) 403 .json(json!({"is_public": false})) 404 .await 405 .assert_challenge(&ctx) 406 .await 407 .assert_no_content(); 408 } 409 410 // Unknown challenge 411 ctx.posta(format!("/accounts/merchant/challenge/{}", Uuid::new_v4())) 412 .await 413 .assert_error(ErrorCode::BANK_CHALLENGE_NOT_FOUND); 414 415 // Unknown challenge 416 ctx.posta("/accounts/merchant/challenge/BAD") 417 .await 418 .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED); 419 } 420 421 #[db_test(raw)] 422 async fn rate_limited(db: PgConnectOptions) { 423 let ctx = bank_setup(db).await; 424 ctx.fill_tan_info("merchant").await; 425 426 // TODO need transaction API 427 let tx_challenge = async || { 428 ctx.posta("/accounts/merchant/transactions") 429 .json(json!({ 430 "payto_uri": format!("{}?message=tx&amount=KUDOS:0.1", ctx.customer_payto) 431 })) 432 .await 433 .assert_accepted_json::<ChallengeResponse>() 434 .challenges 435 .pop() 436 .unwrap() 437 }; 438 439 let submit = async |c: &Challenge| { 440 ctx.posta(format!("/accounts/merchant/challenge/{}", c.challenge_id)) 441 .await 442 .assert_ok_json::<ChallengeRequestResponse>() 443 }; 444 445 // Start a legitimate challenge and submit it 446 let old = tx_challenge().await; 447 submit(&old).await; 448 let code = tan_code(&old.tan_info); 449 450 // Challenge creation is not rate limited 451 for _ in 0..MAX_ACTIVE_CHALLENGES * 2 { 452 tx_challenge().await; 453 } 454 455 // Challenge submission is rate limited 456 for _ in 0..MAX_ACTIVE_CHALLENGES - 1 { 457 submit(&tx_challenge().await).await; 458 } 459 let c = tx_challenge().await; 460 ctx.posta(format!("/accounts/merchant/challenge/{}", c.challenge_id)) 461 .await 462 .assert_error(ErrorCode::BANK_TAN_RATE_LIMITED); 463 464 // Old already submitted challenge still works 465 submit(&old).await; 466 ctx.posta(format!( 467 "/accounts/merchant/challenge/{}/confirm", 468 old.challenge_id 469 )) 470 .json(json!({ "tan": code })) 471 .await 472 .assert_no_content(); 473 474 // Now an active challenge slot have been freed 475 submit(&c).await; 476 477 // We are still rate limited 478 let new = tx_challenge().await; 479 ctx.posta(format!("/accounts/merchant/challenge/{}", new.challenge_id)) 480 .await 481 .assert_error(ErrorCode::BANK_TAN_RATE_LIMITED); 482 } 483 484 #[db_test(raw)] 485 async fn tan_err(db: PgConnectOptions) { 486 let ctx = bank_setup_conf(db, "test_tan_err.conf").await; 487 ctx.fill_tan_info("merchant").await; 488 let res: ChallengeResponse = ctx 489 .patcha("/accounts/merchant") 490 .json(json!({ 491 "is_public": false 492 })) 493 .await 494 .assert_accepted_json(); 495 let challenge = &res.challenges[0]; 496 ctx.posta(format!( 497 "/accounts/merchant/challenge/{}", 498 challenge.challenge_id 499 )) 500 .await 501 .assert_error(ErrorCode::BANK_TAN_CHANNEL_SCRIPT_FAILED); 502 } 503 504 #[db_test(raw)] 505 async fn confirm(db: PgConnectOptions) { 506 let ctx = bank_setup(db).await; 507 ctx.fill_tan_info("merchant").await; 508 509 // Check simple case 510 { 511 let res: ChallengeResponse = ctx 512 .patcha("/accounts/merchant") 513 .json(json!({ "is_public": false })) 514 .await 515 .assert_accepted_json(); 516 let challenge = &res.challenges[0]; 517 let id = &challenge.challenge_id; 518 ctx.posta(format!("/accounts/merchant/challenge/{id}")) 519 .await 520 .assert_ok_json::<ChallengeRequestResponse>(); 521 let code = tan_code(&challenge.tan_info); 522 523 // Check bad TAN code 524 ctx.posta(format!("/accounts/merchant/challenge/{id}/confirm")) 525 .json(json!({ "tan": "nice-try" })) 526 .await 527 .assert_error(ErrorCode::BANK_TAN_CHALLENGE_FAILED); 528 529 // Check wrong account 530 ctx.posta(format!("/accounts/customer/challenge/{id}/confirm")) 531 .json(json!({ "tan": "nice-try" })) 532 .await 533 .assert_error(ErrorCode::BANK_TAN_CHALLENGE_FAILED); 534 535 // Check OK 536 ctx.posta(format!("/accounts/customer/challenge/{id}/confirm")) 537 .json(json!({ "tan": code })) 538 .await 539 .assert_no_content(); 540 // Check idempotence 541 ctx.posta(format!("/accounts/customer/challenge/{id}/confirm")) 542 .json(json!({ "tan": code })) 543 .await 544 .assert_no_content(); 545 546 // Unknown challenge 547 ctx.posta(format!( 548 "/accounts/customer/challenge/{}/confirm", 549 Uuid::new_v4() 550 )) 551 .json(json!({ "tan": code })) 552 .await 553 .assert_error(ErrorCode::BANK_CHALLENGE_NOT_FOUND); 554 } 555 556 // Check invalidation 557 { 558 let res: ChallengeResponse = ctx 559 .patcha("/accounts/merchant") 560 .json(json!({ "is_public": false })) 561 .await 562 .assert_accepted_json(); 563 let challenge = &res.challenges[0]; 564 let id = &challenge.challenge_id; 565 ctx.posta(format!("/accounts/merchant/challenge/{id}")) 566 .await 567 .assert_ok_json::<ChallengeRequestResponse>(); 568 569 // Check invalidated 570 ctx.fill_tan_info("merchant").await; 571 ctx.posta(format!("/accounts/customer/challenge/{id}/confirm")) 572 .json(json!({ "tan": tan_code(&challenge.tan_info) })) 573 .await 574 .assert_error(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED); 575 576 ctx.posta(format!("/accounts/customer/challenge/{id}")) 577 .await 578 .assert_error(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED); 579 } 580 } 581 }