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 }