Skip to main content

haste_server/auth_n/mfa/routes/
totp_verification.rs

1use crate::{
2    auth_n::{
3        mfa::utilities::user_mfa_to_totp,
4        oidc::routes::route_string::tenant_route_string,
5        session::{self, user::SessionAuthorizationState},
6    },
7    extract::csrf_token::CSRFToken,
8    services::ServerState,
9    ui::{components::TenantContext, pages::mfa},
10};
11use axum::{
12    Form,
13    extract::{OriginalUri, Query, State},
14    response::{IntoResponse, Redirect, Response},
15};
16use axum_extra::{extract::Cached, routing::TypedPath};
17use haste_fhir_model::r4::generated::terminology::IssueType;
18use haste_fhir_operation_error::OperationOutcomeError;
19use haste_fhir_search::SearchEngine;
20use haste_fhir_terminology::FHIRTerminology;
21use haste_jwt::TenantId;
22use haste_repository::{
23    Repository,
24    admin::TenantModelAdmin,
25    types::{
26        mfa::{UserMFACredential, UserMFACredentialCreate, UserMFASearchClaims},
27        scope::UserId,
28        user::User,
29    },
30};
31use serde::Deserialize;
32use std::sync::Arc;
33use tower_sessions::Session;
34use url::form_urlencoded;
35
36fn is_safe_local_redirect_path(path: &str) -> bool {
37    path.starts_with('/') && !path.starts_with("//")
38}
39
40pub fn totp_verification_route(tenant: &TenantId, redirect_to: &str) -> String {
41    let route = tenant_route_string(tenant)
42        .join("mfa")
43        .join("totp-verification");
44
45    let query = form_urlencoded::Serializer::new(String::new())
46        .append_pair("redirect_to", redirect_to)
47        .finish();
48
49    format!("{}?{}", route.to_string_lossy(), query)
50}
51
52async fn get_required_totp_credentials<
53    Repo: Repository + Send + Sync,
54    Search: SearchEngine + Send + Sync,
55    Terminology: FHIRTerminology + Send + Sync,
56>(
57    state: &ServerState<Repo, Search, Terminology>,
58    tenant: &TenantId,
59    current_session: &Session,
60) -> Result<(User, Vec<UserMFACredential>), OperationOutcomeError> {
61    let user = match session::user::get_authorization_state(current_session).await? {
62        Some(SessionAuthorizationState::MFARequired { user }) => user,
63        _ => {
64            return Err(OperationOutcomeError::error(
65                IssueType::security(),
66                "MFA verification is not required.".to_string(),
67            ));
68        }
69    };
70
71    let credentials = TenantModelAdmin::<UserMFACredentialCreate, _, _, _, _>::search(
72        state.repo.as_ref(),
73        tenant,
74        &UserMFASearchClaims {
75            tenant: tenant.clone(),
76            user_id: UserId::new(user.id.clone()),
77            is_active: Some(true),
78        },
79    )
80    .await?
81    .into_iter()
82    .filter(|credential| credential.credential_type == "totp")
83    .collect::<Vec<_>>();
84
85    if credentials.is_empty() {
86        return Err(OperationOutcomeError::error(
87            IssueType::not_found(),
88            "No active TOTP credential found for this user.".to_string(),
89        ));
90    }
91
92    Ok((user, credentials))
93}
94
95#[derive(Deserialize)]
96pub struct TOTPVerificationPOSTBody {
97    pub csrf_token: String,
98    pub otp_code: String,
99}
100
101#[derive(Deserialize)]
102pub struct TOTPVerificationQuery {
103    pub redirect_to: String,
104}
105
106#[derive(TypedPath, Deserialize)]
107#[typed_path("/totp-verification")]
108pub struct TOTPVerificationGET;
109
110#[derive(TypedPath, Deserialize)]
111#[typed_path("/totp-verification")]
112pub struct TOTPVerificationPOST;
113
114pub async fn totp_verification_get<
115    Repo: Repository + Send + Sync,
116    Search: SearchEngine + Send + Sync,
117    Terminology: FHIRTerminology + Send + Sync,
118>(
119    _: TOTPVerificationGET,
120    Query(query): Query<TOTPVerificationQuery>,
121    uri: OriginalUri,
122    CSRFToken(csrf_token): CSRFToken,
123    Cached(TenantContext { tenant, branding }): Cached<TenantContext>,
124    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
125    Cached(current_session): Cached<Session>,
126) -> Result<Response, OperationOutcomeError> {
127    if !is_safe_local_redirect_path(&query.redirect_to) {
128        return Err(OperationOutcomeError::error(
129            IssueType::security(),
130            "Invalid MFA redirect target.".to_string(),
131        ));
132    }
133
134    let (_, credentials) =
135        get_required_totp_credentials(state.as_ref(), &tenant, &current_session).await?;
136
137    Ok(mfa::totp_verification::totp_entry_html(
138        &tenant,
139        &csrf_token,
140        credentials[0].totp_digits as usize,
141        &uri.to_string(),
142        None,
143        Some(&branding),
144    )
145    .into_response())
146}
147
148pub async fn totp_verification_post<
149    Repo: Repository + Send + Sync,
150    Search: SearchEngine + Send + Sync,
151    Terminology: FHIRTerminology + Send + Sync,
152>(
153    _: TOTPVerificationPOST,
154    Query(query): Query<TOTPVerificationQuery>,
155    uri: OriginalUri,
156    CSRFToken(csrf_token): CSRFToken,
157    Cached(TenantContext { tenant, branding }): Cached<TenantContext>,
158    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
159    Cached(current_session): Cached<Session>,
160    Form(form_data): Form<TOTPVerificationPOSTBody>,
161) -> Result<Response, OperationOutcomeError> {
162    if form_data.csrf_token != csrf_token {
163        return Err(OperationOutcomeError::error(
164            IssueType::security(),
165            "Invalid CSRF token.".to_string(),
166        ));
167    }
168
169    if !is_safe_local_redirect_path(&query.redirect_to) {
170        return Err(OperationOutcomeError::error(
171            IssueType::security(),
172            "Invalid MFA redirect target.".to_string(),
173        ));
174    }
175
176    let (user, credentials) =
177        get_required_totp_credentials(state.as_ref(), &tenant, &current_session).await?;
178    let digits = credentials[0].totp_digits as usize;
179
180    let mut is_otp_valid = false;
181
182    for credential in credentials {
183        let totp = user_mfa_to_totp(
184            state.secret_provider.as_ref(),
185            &state.config,
186            &user,
187            credential,
188        )
189        .await?;
190
191        if totp.check_current(&form_data.otp_code).map_err(|_e| {
192            OperationOutcomeError::error(
193                IssueType::security(),
194                "Invalid verification code.".to_string(),
195            )
196        })? {
197            is_otp_valid = true;
198            break;
199        }
200    }
201
202    if !is_otp_valid {
203        return Ok(mfa::totp_verification::totp_entry_html(
204            &tenant,
205            &csrf_token,
206            digits,
207            &uri.to_string(),
208            Some(vec![
209                "Invalid verification code. Please try again.".to_string(),
210            ]),
211            Some(&branding),
212        )
213        .into_response());
214    }
215
216    session::user::set_completed_authorization_state(&current_session, user).await?;
217
218    Ok(Redirect::to(&query.redirect_to).into_response())
219}