token.rs (26153B)
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 response::{IntoResponse, NoContent}, 26 routing::{delete, get, post}, 27 }; 28 use jiff::Timestamp; 29 use serde::{Deserialize, Serialize}; 30 use taler_api::{ 31 error::{ApiResult, bad_request, failure}, 32 extract::{Path, Query}, 33 }; 34 use taler_common::{ 35 api::params::PageParams, 36 error_code::ErrorCode::{self}, 37 types::{ 38 base32::Base32, 39 time::{RelativeTime, TalerTimestamp}, 40 }, 41 }; 42 43 use crate::{ 44 api::BankState, 45 auth::{TOKEN_PREFIX, TokenScope, UserAuth, UserORWAuth, UserRAuth}, 46 db::{ 47 self, 48 token::{TokenCreationResult, access, create, refresh}, 49 }, 50 mfa::{MfaReq, TokenOp}, 51 }; 52 53 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 54 pub struct TokenInfo { 55 pub creation_time: TalerTimestamp, 56 pub expiration: TalerTimestamp, 57 pub scope: TokenScope, 58 pub refreshable: bool, 59 pub description: Option<String>, 60 pub last_access: TalerTimestamp, 61 pub row_id: u64, 62 pub token_id: u64, 63 } 64 65 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 66 pub struct TokenInfos { 67 pub tokens: Vec<TokenInfo>, 68 } 69 70 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 71 pub struct TokenRequest { 72 pub scope: TokenScope, 73 pub duration: Option<RelativeTime>, 74 pub description: Option<String>, 75 #[serde(default)] 76 pub refreshable: bool, 77 } 78 79 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 80 pub struct TokenSuccessResponse { 81 pub access_token: String, 82 pub expiration: TalerTimestamp, 83 pub token_id: u64, 84 } 85 86 pub const TOKEN_DEFAULT_DURATION: Duration = Duration::from_hours(24); 87 pub const TOKEN_REFRESH_OVERLAP: Duration = Duration::from_mins(5); 88 89 pub fn token_api() -> Router<Arc<BankState>> { 90 Router::new() 91 .route( 92 "/accounts/{username}/token", 93 post( 94 async |State(state): State<Arc<BankState>>, req: MfaReq<TokenOp>| { 95 let (mut auth, req, mfa) = req.solve(&state, &[]).await?; 96 if let Some(token) = &auth.token { 97 // This block checks permissions ONLY IF the call was authenticated with a token 98 let token = access(&state.db, token, &Timestamp::now()).await?; 99 let Some(token) = token else { 100 return Err(failure( 101 ErrorCode::BANK_UNMANAGED_EXCEPTION, 102 "Token used to auth not found in the database", 103 )); 104 }; 105 if !req.scope.logical().is_valid_scope(token.scope, true) { 106 return Err(failure( 107 ErrorCode::GENERIC_TOKEN_PERMISSION_INSUFFICIENT, 108 "Impossible to refresh a token with a larger scope", 109 )); 110 } 111 } 112 113 let new = Base32::<32>::secure_rand(); 114 let creation = Timestamp::now(); 115 let expiration = match req 116 .duration 117 .unwrap_or(RelativeTime::Duration(TOKEN_DEFAULT_DURATION)) 118 { 119 RelativeTime::Forever => TalerTimestamp::Never, 120 RelativeTime::Duration(duration) => { 121 TalerTimestamp::Timestamp(creation.checked_add(duration).map_err( 122 |e| bad_request(format_args!("Bad token duration: {}", e)), 123 )?) 124 } 125 }; 126 127 let res = if let Some(existing) = &auth.token { 128 refresh( 129 &state.db, 130 &auth.username, 131 existing, 132 new.as_ref(), 133 &creation, 134 &expiration, 135 &(creation + TOKEN_REFRESH_OVERLAP), 136 &req.scope, 137 req.refreshable, 138 req.description.as_deref(), 139 ) 140 .await? 141 } else { 142 create( 143 &state.db, 144 &auth.username, 145 new.as_ref(), 146 &creation, 147 &expiration, 148 &req.scope, 149 req.refreshable, 150 req.description.as_deref(), 151 mfa.is_2fa(), 152 ) 153 .await? 154 }; 155 match res { 156 TokenCreationResult::Success(token_id) => Ok(Json(TokenSuccessResponse { 157 access_token: format!("{TOKEN_PREFIX}{new}"), 158 expiration, 159 token_id, 160 }) 161 .into_response()), 162 TokenCreationResult::TanRequired => { 163 mfa.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await 164 } 165 } 166 }, 167 ) 168 .delete( 169 async |UserAuth { token, .. }: UserRAuth, State(state): State<Arc<BankState>>| { 170 if let Some(token) = token { 171 db::token::delete(&state.db, &token).await?; 172 ApiResult::Ok(NoContent) 173 } else { 174 ApiResult::Err(bad_request("Basic auth not supported here")) 175 } 176 }, 177 ), 178 ) 179 .route( 180 "/accounts/{username}/tokens/{id}", 181 delete( 182 async |UserAuth { username, .. }: UserORWAuth, 183 Path((_, id)): Path<((), u64)>, 184 State(state): State<Arc<BankState>>| { 185 if db::token::delete_by_id(&state.db, &username, id).await? { 186 ApiResult::Ok(NoContent) 187 } else { 188 ApiResult::Err(failure( 189 ErrorCode::BANK_TRANSACTION_NOT_FOUND, 190 format_args!("Token '{id}' not found"), 191 )) 192 } 193 }, 194 ), 195 ) 196 .route( 197 "/accounts/{username}/tokens", 198 get( 199 async |UserAuth { username, .. }: UserRAuth, 200 Query(params): Query<PageParams>, 201 State(state): State<Arc<BankState>>| { 202 let params = params.check()?; 203 let tokens = 204 db::token::page(&state.db, ¶ms, &username, &Timestamp::now()).await?; 205 if tokens.is_empty() { 206 ApiResult::Ok(NoContent.into_response()) 207 } else { 208 ApiResult::Ok(Json(TokenInfos { tokens }).into_response()) 209 } 210 }, 211 ), 212 ) 213 } 214 215 #[cfg(test)] 216 pub mod test { 217 218 use std::{str::FromStr, time::Duration}; 219 220 use axum::http::{Method, header::AUTHORIZATION}; 221 use jiff::{SignedDuration, Timestamp}; 222 use sqlx::postgres::PgConnectOptions; 223 use taler_api::db::TypeHelper; 224 use taler_common::{ 225 api::ErrorDetail, 226 error_code::ErrorCode, 227 types::{base32::Base32, time::TalerTimestamp}, 228 }; 229 use taler_macros::db_test; 230 use taler_test_utils::{json, server::TestServer}; 231 232 use crate::{ 233 api::{ 234 account::AccountData, 235 tan::ChallengeResponse, 236 test::{Auth, MfaRequest, bank_setup, tan_code}, 237 token::{TOKEN_DEFAULT_DURATION, TokenInfos, TokenSuccessResponse}, 238 }, 239 auth::TOKEN_PREFIX, 240 db::{account::MAX_TOKEN_CREATION_ATTEMPTS, token::access}, 241 mfa::TALER_CHALLENGE_IDS, 242 }; 243 244 #[db_test(raw)] 245 async fn create_and_delete(db: PgConnectOptions) { 246 let ctx = bank_setup(db).await; 247 248 ctx.auth_routine(Method::POST, "/accounts/merchant/token", Auth::Token) 249 .await; 250 ctx.auth_routine( 251 Method::DELETE, 252 "/accounts/merchant/tokens/1", 253 Auth::UserOnly, 254 ) 255 .await; 256 257 // Unknown account 258 ctx.post("/accounts/merchant/token") 259 .basic_auth("Unknown", "password") 260 .await 261 .assert_error(ErrorCode::GENERIC_UNAUTHORIZED); 262 263 // Wrong account 264 ctx.post("/accounts/merchant/token") 265 .basic_auth("merchant", "wrong-password") 266 .await 267 .assert_error(ErrorCode::GENERIC_UNAUTHORIZED); 268 269 // Wrong account 270 ctx.post("/accounts/merchant/token") 271 .basic_auth("exchange", "wrong-password") 272 .await 273 .assert_error(ErrorCode::GENERIC_UNAUTHORIZED); 274 275 // Default token duration 276 let res: TokenSuccessResponse = ctx 277 .postpw("/accounts/merchant/token") 278 .json(json!({ "scope": "readonly" })) 279 .await 280 .assert_ok_json(); 281 // Checking that the token lifetime defaulted to 24 hours 282 let token = access( 283 &ctx.state.db, 284 Base32::<32>::from_str(res.access_token.strip_prefix(TOKEN_PREFIX).unwrap()) 285 .unwrap() 286 .as_ref(), 287 &Timestamp::now(), 288 ) 289 .await 290 .unwrap() 291 .unwrap(); 292 match token.expiration { 293 TalerTimestamp::Never => unreachable!(), 294 TalerTimestamp::Timestamp(expiration) => { 295 let lifetime = token 296 .creation 297 .duration_until(expiration) 298 .max(SignedDuration::ZERO) 299 .unsigned_abs(); 300 assert_eq!(lifetime, TOKEN_DEFAULT_DURATION); 301 } 302 } 303 304 // Check valid refresh scope 305 for (from_scope, to_scope) in [ 306 ("readwrite", "readwrite"), 307 ("readonly", "readonly"), 308 ("revenue", "revenue"), 309 ("readwrite", "readonly"), 310 ("readwrite", "revenue"), 311 ("readonly", "revenue"), 312 ] { 313 let res: TokenSuccessResponse = ctx 314 .postpw("/accounts/merchant/token") 315 .json(json!({ "scope": from_scope, "refreshable": true })) 316 .await 317 .assert_ok_json(); 318 ctx.post("/accounts/merchant/token") 319 .header(AUTHORIZATION, format!("Bearer {}", res.access_token)) 320 .json(json!({ "scope": to_scope })) 321 .await 322 .assert_ok(); 323 } 324 325 // Check invalid refresh scope 326 for (from_scope, to_scope) in [ 327 ("readonly", "readwrite"), 328 ("revenue", "readonly"), 329 ("revenue", "readwrite"), 330 ] { 331 let res: TokenSuccessResponse = ctx 332 .postpw("/accounts/merchant/token") 333 .json(json!({ "scope": from_scope, "refreshable": true })) 334 .await 335 .assert_ok_json(); 336 ctx.post("/accounts/merchant/token") 337 .header(AUTHORIZATION, format!("Bearer {}", res.access_token)) 338 .json(json!({ "scope": to_scope })) 339 .await 340 .assert_error(ErrorCode::GENERIC_TOKEN_PERMISSION_INSUFFICIENT); 341 } 342 343 // Check no refreshable 344 let res: TokenSuccessResponse = ctx 345 .postpw("/accounts/merchant/token") 346 .json(json!({ "scope": "readonly" })) 347 .await 348 .assert_ok_json(); 349 ctx.post("/accounts/merchant/token") 350 .header(AUTHORIZATION, format!("Bearer {}", res.access_token)) 351 .json(json!({ "scope": "readonly" })) 352 .await 353 .assert_error(ErrorCode::GENERIC_TOKEN_PERMISSION_INSUFFICIENT); 354 355 // Check 'forever' case 356 let res: TokenSuccessResponse = ctx 357 .postpw("/accounts/merchant/token") 358 .json(json!({ 359 "scope": "readonly", 360 "duration": { 361 "d_us": "forever" 362 } 363 })) 364 .await 365 .assert_ok_json(); 366 assert_eq!(res.expiration, TalerTimestamp::Never); 367 368 // Check too big or invalid durations 369 ctx.postpw("/accounts/merchant/token") 370 .json(json!({ 371 "scope": "readonly", 372 "duration": { 373 "d_us": "invalid" 374 } 375 })) 376 .await 377 .assert_bad_request(); 378 379 ctx.postpw("/accounts/merchant/token") 380 .json(json!({ 381 "scope": "readonly", 382 "duration": { 383 "d_us": i64::MAX 384 } 385 })) 386 .await 387 .assert_bad_request(); 388 ctx.postpw("/accounts/merchant/token") 389 .json(json!({ 390 "scope": "readonly", 391 "duration": { 392 "d_us": -1 393 } 394 })) 395 .await 396 .assert_bad_request(); 397 398 // Delete current token 399 let res: TokenSuccessResponse = ctx 400 .postpw("/accounts/merchant/token") 401 .json(json!({ "scope": "readonly" })) 402 .await 403 .assert_ok_json(); 404 // Check OK 405 ctx.delete("/accounts/merchant/token") 406 .header(AUTHORIZATION, format!("Bearer {}", res.access_token)) 407 .json(json!({ "scope": "readonly" })) 408 .await 409 .assert_no_content(); 410 // Check token no longer work 411 ctx.delete("/accounts/merchant/token") 412 .header(AUTHORIZATION, format!("Bearer {}", res.access_token)) 413 .json(json!({ "scope": "readonly" })) 414 .await 415 .assert_error(ErrorCode::GENERIC_TOKEN_UNKNOWN); 416 417 // Delete by id 418 let res: TokenSuccessResponse = ctx 419 .postpw("/accounts/merchant/token") 420 .json(json!({ "scope": "readonly" })) 421 .await 422 .assert_ok_json(); 423 // Wrong account 424 ctx.deletea(format!("/accounts/customer/tokens/{}", res.token_id)) 425 .await 426 .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); 427 // Check OK 428 ctx.deletea(format!("/accounts/merchant/tokens/{}", res.token_id)) 429 .await 430 .assert_no_content(); 431 ctx.deletea(format!("/accounts/merchant/tokens/{}", res.token_id)) 432 .await 433 .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); 434 // Check token no longer work 435 ctx.post("/accounts/merchant/token") 436 .header(AUTHORIZATION, format!("Bearer {}", res.access_token)) 437 .json(json!({ "scope": "readonly" })) 438 .await 439 .assert_error(ErrorCode::GENERIC_TOKEN_UNKNOWN); 440 441 // Refresh overlap is fixed and non sliding 442 let TokenSuccessResponse { 443 access_token: source, 444 expiration: _, 445 token_id: source_id, 446 } = ctx 447 .postpw("/accounts/merchant/token") 448 .json(json!({ 449 "scope": "readonly", 450 "refreshable" : true, 451 "duration" : { "d_us" : 86_400_000_000u64 }, 452 "description" : "refresh source" 453 })) 454 .await 455 .assert_ok_json::<TokenSuccessResponse>(); 456 let before_refresh = Timestamp::now(); 457 for i in 0..2 { 458 ctx.post("/accounts/merchant/token") 459 .header(AUTHORIZATION, format!("Bearer {source}")) 460 .json(json!({ 461 "scope": "readonly", 462 "description": format!("replacement-{i}") 463 })) 464 .await 465 .assert_ok(); 466 } 467 let first_deadline: Timestamp = 468 sqlx::query("SELECT expiration_time FROM bearer_tokens WHERE bearer_token_id=$1") 469 .bind(source_id as i64) 470 .try_map(|it| it.try_get_timestamp(0)) 471 .fetch_one(&ctx.state.db) 472 .await 473 .unwrap(); 474 assert!(first_deadline > before_refresh + Duration::from_mins(5)); 475 assert!(first_deadline < Timestamp::now() + Duration::from_mins(5)); 476 // A retry after a lost response may create another replacement, but 477 // must not extend the original token's overlap deadline 478 ctx.post("/accounts/merchant/token") 479 .header(AUTHORIZATION, format!("Bearer {source}")) 480 .json(json!({ 481 "scope": "readonly", 482 "description": "replacement-lost-response-retry" 483 })) 484 .await 485 .assert_ok(); 486 487 assert!( 488 sqlx::query_scalar::<_, bool>( 489 "SELECT expiration_time=$2 FROM bearer_tokens WHERE bearer_token_id=$1" 490 ) 491 .bind(source_id as i64) 492 .bind(first_deadline.as_microsecond()) 493 .fetch_one(&ctx.state.db) 494 .await 495 .unwrap() 496 ); 497 498 // Kotlin compatibility 499 { 500 let TokenSuccessResponse { 501 access_token, 502 token_id, 503 .. 504 } = ctx 505 .postpw("/accounts/customer/token") 506 .json(json!({ 507 "scope": "readonly", 508 "refreshable" : true 509 })) 510 .await 511 .assert_ok_json(); 512 // Patch ID to use old sentinel value 513 sqlx::query( 514 "UPDATE bearer_tokens SET expiration_time=9223372036854775807 WHERE bearer_token_id=$1", 515 ) 516 .bind(token_id as i64) 517 .execute(&ctx.state.db) 518 .await 519 .unwrap(); 520 // Check token works 521 ctx.get("/accounts/customer/tokens") 522 .header(AUTHORIZATION, format!("Bearer {access_token}")) 523 .await 524 .assert_ok(); 525 // And can be refreshed 526 ctx.post("/accounts/customer/token") 527 .header(AUTHORIZATION, format!("Bearer {access_token}")) 528 .json(json!({ 529 "scope": "readonly" 530 })) 531 .await 532 .assert_ok(); 533 } 534 535 // 2FA 536 { 537 // Setup a known phone 2FA 538 ctx.patcha("/accounts/merchant") 539 .json(json!({ 540 "contact_data": { "phone": "+12345" }, 541 "tan_channel": "sms" 542 })) 543 .await 544 .assert_challenge(&ctx) 545 .await 546 .assert_no_content(); 547 // Check creating a token requires to solve an unauthenticated challenge 548 let challenge = ctx 549 .postpw("/accounts/merchant/token") 550 .json(json!({ "scope": "readonly" })) 551 .await 552 .assert_accepted_json::<ChallengeResponse>() 553 .challenges 554 .pop() 555 .unwrap(); 556 ctx.post(format!( 557 "/accounts/merchant/challenge/{}", 558 challenge.challenge_id 559 )) 560 .await 561 .assert_ok(); 562 assert_eq!("REDACTED", challenge.tan_info); // Check phone number is hidden 563 let code = tan_code("+12345"); 564 ctx.post(format!( 565 "/accounts/merchant/challenge/{}/confirm", 566 challenge.challenge_id 567 )) 568 .json(json!({ "tan": code })) 569 .await 570 .assert_no_content(); 571 ctx.postpw("/accounts/merchant/token") 572 .header(TALER_CHALLENGE_IDS, challenge.challenge_id) 573 .json(json!({ "scope": "readonly" })) 574 .await 575 .assert_ok_json::<TokenSuccessResponse>(); 576 } 577 578 // Locked 579 { 580 let lock_account = async || { 581 let mut counter = MAX_TOKEN_CREATION_ATTEMPTS + 1; 582 while counter > 0 { 583 let challenge = ctx 584 .postpw("/accounts/merchant/token") 585 .json(json!({ "scope": "readonly" })) 586 .await 587 .assert_accepted_json::<ChallengeResponse>() 588 .challenges 589 .pop() 590 .unwrap(); 591 ctx.post(format!( 592 "/accounts/merchant/challenge/{}", 593 challenge.challenge_id 594 )) 595 .await 596 .assert_ok(); 597 while counter > 0 { 598 let error: ErrorDetail = ctx 599 .post(format!( 600 "/accounts/merchant/challenge/{}/confirm", 601 challenge.challenge_id 602 )) 603 .json(json!({ "tan": "bad code" })) 604 .await 605 .json_parse(); 606 counter -= 1; 607 if error.code == ErrorCode::BANK_TAN_CHALLENGE_FAILED as u16 { 608 continue; 609 } else if error.code == ErrorCode::BANK_TAN_RATE_LIMITED as u16 610 || error.code == ErrorCode::BANK_TAN_CHALLENGE_EXPIRED as u16 611 { 612 break; 613 } else { 614 unreachable!("{error:?}") 615 } 616 } 617 } 618 ctx.postpw("/accounts/merchant/token") 619 .json(json!({ "scope": "readonly" })) 620 .await 621 .assert_error(ErrorCode::BANK_ACCOUNT_LOCKED); 622 }; 623 624 lock_account().await; 625 626 // Check token still works 627 assert!( 628 ctx.geta("/accounts/merchant") 629 .await 630 .assert_ok_json::<AccountData>() 631 .is_locked 632 ); 633 634 // Check admin can unlock 635 ctx.patch_admin("/accounts/merchant/auth") 636 .json(json!({ 637 "new_password": "merchant-password" 638 })) 639 .await 640 .assert_no_content(); 641 assert!( 642 !ctx.geta("/accounts/merchant") 643 .await 644 .assert_ok_json::<AccountData>() 645 .is_locked 646 ); 647 lock_account().await; 648 649 // Check token can unlock 650 ctx.patcha("/accounts/merchant/auth") 651 .json(json!({ 652 "old_password": "merchant-password", 653 "new_password": "merchant-password" 654 })) 655 .await 656 .assert_challenge(&ctx) 657 .await 658 .assert_no_content(); 659 assert!( 660 !ctx.geta("/accounts/merchant") 661 .await 662 .assert_ok_json::<AccountData>() 663 .is_locked 664 ); 665 } 666 } 667 668 #[db_test(raw)] 669 async fn get(db: PgConnectOptions) { 670 let ctx = bank_setup(db).await; 671 ctx.auth_routine(Method::GET, "/accounts/merchant/tokens", Auth::UserOrAdmin) 672 .await; 673 674 // Check OK 675 for account in ["merchant", "customer"] { 676 let res = ctx 677 .geta(format!("/accounts/{account}/tokens")) 678 .await 679 .assert_ok_json::<TokenInfos>(); 680 assert_eq!(res.tokens.len(), 1); 681 } 682 for scope in ["readonly", "readwrite"] { 683 ctx.postpw("/accounts/merchant/token") 684 .json(json!({ "scope": scope })) 685 .await 686 .assert_ok(); 687 } 688 ctx.postpw("/accounts/merchant/token") 689 .json(json!({ 690 "scope": "revenue", 691 "duration": { 692 "d_us": "forever" 693 } 694 })) 695 .await 696 .assert_ok(); 697 ctx.postpw("/accounts/merchant/token") 698 .json(json!({ 699 "scope": "revenue", 700 "duration": { 701 "d_us": 0 702 } 703 })) 704 .await 705 .assert_ok(); 706 ctx.postpw("/accounts/customer/token") 707 .json(json!({ 708 "scope": "readonly", 709 "description": "description" 710 })) 711 .await 712 .assert_ok(); 713 let res: TokenInfos = ctx.geta("/accounts/merchant/tokens").await.assert_ok_json(); 714 assert_eq!(res.tokens.len(), 4); 715 for token in res.tokens { 716 assert_eq!(token.description, None); 717 } 718 let res: TokenInfos = ctx.geta("/accounts/customer/tokens").await.assert_ok_json(); 719 assert_eq!(res.tokens.len(), 2); 720 assert_eq!(res.tokens[0].description.as_deref(), Some("description")); 721 } 722 }