Skip to main content

haste_server/auth_n/oidc/routes/interactions/
password_reset.rs

1use crate::auth_n::constant_time;
2use crate::auth_n::error::AuthError;
3use crate::{
4    auth_n::{
5        email::{Message, invitation_subject, send_password_reset_email},
6        invitations,
7        oidc::{hardcoded_clients::admin_app, utilities::set_user_password},
8    },
9    extract::{
10        csrf_token::CSRFToken,
11        path_tenant::{Project, ProjectIdentifier},
12    },
13    services::ServerState,
14    ui::{
15        components::TenantContext,
16        pages::{self, message::message_html},
17    },
18};
19use axum::{
20    Form,
21    extract::{OriginalUri, Query, State},
22};
23use axum_extra::{extract::Cached, routing::TypedPath};
24use haste_fhir_model::r4::generated::terminology::IssueType;
25use haste_fhir_operation_error::OperationOutcomeError;
26use haste_fhir_search::SearchEngine;
27use haste_fhir_terminology::FHIRTerminology;
28use haste_repository::{
29    Repository,
30    admin::{ProjectModelAdmin, TenantModelAdmin},
31    types::{
32        authorization_code::{AuthorizationCodeKind, CreateAuthorizationCode},
33        user::{AuthMethod, CreateUser, UserSearchClauses},
34    },
35};
36use maud::{Markup, html};
37use serde::Deserialize;
38use std::sync::Arc;
39
40use crate::auth_n::oidc::utilities::revoke_refresh_tokens;
41
42#[derive(TypedPath)]
43#[typed_path("/password-reset")]
44pub struct PasswordResetInitiate;
45
46pub async fn password_reset_initiate_get(
47    _: PasswordResetInitiate,
48    Cached(TenantContext { tenant, branding }): Cached<TenantContext>,
49    Cached(Project(project)): Cached<Project>,
50    CSRFToken(csrf_token): CSRFToken,
51    uri: OriginalUri,
52) -> Result<Markup, OperationOutcomeError> {
53    let response = pages::email_form::email_form_html(
54        &tenant,
55        Some(&project),
56        &csrf_token,
57        &pages::email_form::EmailInformation {
58            continue_url: uri.path().to_string(),
59        },
60        Some(&branding),
61    );
62
63    Ok(response)
64}
65
66#[allow(unused)]
67#[derive(Deserialize)]
68pub struct PasswordResetFormInitiate {
69    pub csrf_token: String,
70    pub email: String,
71}
72
73pub async fn password_reset_initiate_post<
74    Repo: Repository + Send + Sync + 'static,
75    Search: SearchEngine + Send + Sync + 'static,
76    Terminology: FHIRTerminology + Send + Sync + 'static,
77>(
78    _: PasswordResetInitiate,
79    Cached(TenantContext { tenant, branding }): Cached<TenantContext>,
80    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
81    project_resource: Project,
82    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
83    CSRFToken(csrf_token): CSRFToken,
84    form: axum::extract::Form<PasswordResetFormInitiate>,
85) -> Result<Markup, OperationOutcomeError> {
86    if !constant_time::str_eq(&form.csrf_token, &csrf_token) {
87        return Err(AuthError::InvalidCsrfToken.into());
88    }
89
90    let user_search_results = TenantModelAdmin::search(
91        &*state.repo,
92        &tenant,
93        &UserSearchClauses {
94            email: Some(form.email.clone()),
95            role: None,
96            method: Some(AuthMethod::EmailPassword),
97        },
98    )
99    .await?;
100
101    // The answer is the same whether or not the address belongs to a user, so
102    // this form cannot be used to find out which addresses have accounts. The
103    // email is sent off the request for the same reason: waiting for it would
104    // make a known address answer measurably slower than an unknown one.
105    if let Some(user) = user_search_results.into_iter().next() {
106        // Tell an invited user that setting a password accepts the invitation.
107        let message = if user.email_verified {
108            Message::default()
109        } else {
110            let tenant_name = branding.0.as_deref().unwrap_or(tenant.as_ref());
111            Message {
112                subject: Some(invitation_subject(tenant_name)),
113                body: Some(html! {
114                    span class="font-semibold" { (tenant_name) }
115                    " created an account for this address. Setting a password accepts the invitation. If you don't know this workspace, ignore this email and nothing will happen."
116                }),
117            }
118        };
119
120        let state = state.clone();
121        let tenant = tenant.clone();
122        let project = project.clone();
123
124        tokio::spawn(async move {
125            if let Err(error) =
126                send_password_reset_email(state.as_ref(), &tenant, &project, &user, message).await
127            {
128                tracing::error!(?error, "Failed to send password reset email");
129            }
130        });
131    } else {
132        tracing::info!("Password reset requested for an unknown email address");
133    }
134
135    Ok(message_html(
136        Some(&tenant),
137        Some(&project_resource.0),
138        &html! {"If an account exists for that email address, a message will arrive in the next few minutes with the next steps to reset your password."},
139        Some(&branding),
140    ))
141}
142
143#[derive(TypedPath)]
144#[typed_path("/password-reset-verify")]
145pub struct PasswordResetVerify;
146
147#[derive(Deserialize)]
148pub struct PasswordResetVerifyQuery {
149    code: String,
150}
151
152pub async fn password_reset_verify_get<
153    Repo: Repository + Send + Sync,
154    Search: SearchEngine + Send + Sync,
155    Terminology: FHIRTerminology + Send + Sync,
156>(
157    _: PasswordResetVerify,
158    uri: OriginalUri,
159    query: Query<PasswordResetVerifyQuery>,
160    Cached(TenantContext { tenant, branding }): Cached<TenantContext>,
161    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
162    Cached(Project(project_resource)): Cached<Project>,
163    CSRFToken(csrf_token): CSRFToken,
164    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
165) -> Result<Markup, OperationOutcomeError> {
166    if let Some(code) = ProjectModelAdmin::<CreateAuthorizationCode, _, _, _, _>::read(
167        &*state.repo,
168        &tenant,
169        &project,
170        &query.code,
171    )
172    .await?
173    {
174        if code.kind != AuthorizationCodeKind::PasswordReset {
175            return Err(AuthError::InvalidPasswordResetCode.into());
176        }
177        if code.is_expired.unwrap_or(true) {
178            return Err(AuthError::ExpiredPasswordResetCode.into());
179        }
180        Ok(message_html(
181            Some(&tenant),
182            Some(&project_resource),
183            &html! {
184                div {}
185                h1 class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl "{
186                    "Set your password"}
187                form class="space-y-4 md:space-y-6" action=(uri.path().to_string()) method="POST"{
188                    input type="hidden" id="code" name="code" value=(query.code) {}
189                    input type="hidden" name="csrf_token" value=(csrf_token) {}
190                    label for="password" class="block mb-2 text-sm font-medium text-gray-900"{"Enter your Password"}
191                    input type="password" id="password" placeholder="••••••••" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-brand-600 focus:border-brand-600 block w-full p-2.5" required="" name="password" {}
192                    label for="password_confirm" class="block mb-2 text-sm font-medium text-gray-900"  {"Confirm your Password"}
193                    input type="password" id="password_confirm" placeholder="••••••••" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-brand-600 focus:border-brand-600 block w-full p-2.5" required="" name="password_confirm" {}
194                    button type="submit" class="cursor-pointer w-full text-white bg-brand-600 hover:bg-brand-500 focus:ring-4 focus:outline-none focus:ring-brand-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"{"Continue"}
195                }
196            },
197            Some(&branding),
198        ))
199    } else {
200        Err(AuthError::InvalidPasswordResetCode)?
201    }
202}
203
204#[derive(Deserialize)]
205pub struct PasswordVerifyPOSTBODY {
206    csrf_token: String,
207    code: String,
208    password: String,
209    password_confirm: String,
210}
211
212pub async fn password_reset_verify_post<
213    Repo: Repository + Send + Sync,
214    Search: SearchEngine + Send + Sync,
215    Terminology: FHIRTerminology + Send + Sync,
216>(
217    _: PasswordResetVerify,
218    Cached(TenantContext { tenant, branding }): Cached<TenantContext>,
219    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
220    Cached(Project(project_resource)): Cached<Project>,
221    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
222    CSRFToken(csrf_token): CSRFToken,
223    Form(body): Form<PasswordVerifyPOSTBODY>,
224) -> Result<Markup, OperationOutcomeError> {
225    if !constant_time::str_eq(&body.csrf_token, &csrf_token) {
226        return Err(AuthError::InvalidCsrfToken.into());
227    }
228
229    if body.password != body.password_confirm {
230        return Err(OperationOutcomeError::error(
231            IssueType::invalid(),
232            "Passwords do not match.".to_string(),
233        ));
234    }
235
236    if let Some(code) = ProjectModelAdmin::<CreateAuthorizationCode, _, _, _, _>::read(
237        &*state.repo,
238        &tenant,
239        &project,
240        &body.code,
241    )
242    .await?
243    {
244        if code.kind != AuthorizationCodeKind::PasswordReset {
245            return Err(AuthError::InvalidPasswordResetCode.into());
246        }
247        ProjectModelAdmin::<CreateAuthorizationCode, _, _, _, _>::delete(
248            &*state.repo,
249            &tenant,
250            &project,
251            &body.code,
252        )
253        .await?;
254        if code.is_expired.unwrap_or(true) {
255            return Err(AuthError::ExpiredPasswordResetCode.into());
256        }
257
258        let Some(user) =
259            TenantModelAdmin::<CreateUser, _, _, _, _>::read(&*state.repo, &tenant, &code.user_id)
260                .await?
261        else {
262            return Err(OperationOutcomeError::error(
263                IssueType::not_found(),
264                "User not found.".to_string(),
265            ));
266        };
267
268        let email = user.email.as_ref().ok_or(AuthError::UserWithoutEmail)?;
269
270        set_user_password(&*state.repo, &tenant, email, &user.id, &body.password).await?;
271        // Whoever held a refresh token before the reset must not keep access.
272        revoke_refresh_tokens(&*state.repo, &tenant, &user.id).await?;
273
274        // The emailed link proves the address, so this accepts an invitation.
275        invitations::accept(state.as_ref(), user).await?;
276
277        let admin_app_url = admin_app::redirect_url(state.config.as_ref(), &tenant, &project);
278
279        Ok(message_html(
280            Some(&tenant),
281            Some(&project_resource),
282            &html! { span {
283                    "Password has been reset successfully. "
284                    @if let Some(admin_app_url) = admin_app_url {
285                        "Go to the Admin App "
286                        a class="hover:underline cursor-pointer text-brand-600" href=(admin_app_url) { "here" }
287                        "."
288                    }
289                }
290            },
291            Some(&branding),
292        ))
293    } else {
294        Err(AuthError::InvalidPasswordResetCode)?
295    }
296}