Skip to main content

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

1use crate::{
2    auth_n::{
3        email::{Message, send_password_reset_email},
4        oidc::{hardcoded_clients::admin_app, utilities::set_user_password},
5    },
6    extract::{
7        csrf_token::CSRFToken,
8        path_tenant::{Project, ProjectIdentifier},
9    },
10    services::ServerState,
11    ui::{
12        components::TenantContext,
13        pages::{self, message::message_html},
14    },
15};
16use axum::{
17    Form,
18    extract::{OriginalUri, Query, State},
19};
20use axum_extra::{extract::Cached, routing::TypedPath};
21use haste_fhir_model::r4::generated::terminology::IssueType;
22use haste_fhir_operation_error::OperationOutcomeError;
23use haste_fhir_search::SearchEngine;
24use haste_fhir_terminology::FHIRTerminology;
25use haste_repository::{
26    Repository,
27    admin::{ProjectModelAdmin, TenantModelAdmin},
28    types::{
29        authorization_code::{AuthorizationCodeKind, CreateAuthorizationCode},
30        user::{AuthMethod, CreateUser, UserSearchClauses},
31    },
32};
33use maud::{Markup, html};
34use serde::Deserialize;
35use std::sync::Arc;
36
37#[derive(TypedPath)]
38#[typed_path("/password-reset")]
39pub struct PasswordResetInitiate;
40
41pub async fn password_reset_initiate_get(
42    _: PasswordResetInitiate,
43    Cached(TenantContext { tenant, branding }): Cached<TenantContext>,
44    Cached(Project(project)): Cached<Project>,
45    CSRFToken(csrf_token): CSRFToken,
46    uri: OriginalUri,
47) -> Result<Markup, OperationOutcomeError> {
48    let response = pages::email_form::email_form_html(
49        &tenant,
50        Some(&project),
51        &csrf_token,
52        &pages::email_form::EmailInformation {
53            continue_url: uri.path().to_string(),
54        },
55        Some(&branding),
56    );
57
58    Ok(response)
59}
60
61#[allow(unused)]
62#[derive(Deserialize)]
63pub struct PasswordResetFormInitiate {
64    pub csrf_token: String,
65    pub email: String,
66}
67
68pub async fn password_reset_initiate_post<
69    Repo: Repository + Send + Sync,
70    Search: SearchEngine + Send + Sync,
71    Terminology: FHIRTerminology + Send + Sync,
72>(
73    _: PasswordResetInitiate,
74    Cached(TenantContext { tenant, branding }): Cached<TenantContext>,
75    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
76    project_resource: Project,
77    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
78    CSRFToken(csrf_token): CSRFToken,
79    form: axum::extract::Form<PasswordResetFormInitiate>,
80) -> Result<Markup, OperationOutcomeError> {
81    if form.csrf_token != csrf_token {
82        return Err(OperationOutcomeError::error(
83            IssueType::invalid(),
84            "Invalid CSRF Token".to_string(),
85        ));
86    }
87
88    let user_search_results = TenantModelAdmin::search(
89        &*state.repo,
90        &tenant,
91        &UserSearchClauses {
92            email: Some(form.email.clone()),
93            role: None,
94            method: Some(AuthMethod::EmailPassword),
95        },
96    )
97    .await?;
98
99    if let Some(user) = user_search_results.into_iter().next() {
100        send_password_reset_email(state.as_ref(), &tenant, &project, &user, Message::default())
101            .await?;
102
103        Ok(message_html(
104            Some(&tenant),
105            Some(&project_resource.0),
106            &html! {"An email will arrive in the next few minutes with the next steps to reset your password."},
107            Some(&branding),
108        ))
109    } else {
110        Err(OperationOutcomeError::error(
111            IssueType::not_found(),
112            "No user found with provided email address.".to_string(),
113        ))?
114    }
115}
116
117#[derive(TypedPath)]
118#[typed_path("/password-reset-verify")]
119pub struct PasswordResetVerify;
120
121#[derive(Deserialize)]
122pub struct PasswordResetVerifyQuery {
123    code: String,
124}
125
126pub async fn password_reset_verify_get<
127    Repo: Repository + Send + Sync,
128    Search: SearchEngine + Send + Sync,
129    Terminology: FHIRTerminology + Send + Sync,
130>(
131    _: PasswordResetVerify,
132    uri: OriginalUri,
133    query: Query<PasswordResetVerifyQuery>,
134    Cached(TenantContext { tenant, branding }): Cached<TenantContext>,
135    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
136    Cached(Project(project_resource)): Cached<Project>,
137    CSRFToken(csrf_token): CSRFToken,
138    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
139) -> Result<Markup, OperationOutcomeError> {
140    if let Some(code) = ProjectModelAdmin::<CreateAuthorizationCode, _, _, _, _>::read(
141        &*state.repo,
142        &tenant,
143        &project,
144        &query.code,
145    )
146    .await?
147    {
148        if code.kind != AuthorizationCodeKind::PasswordReset {
149            return Err(OperationOutcomeError::error(
150                IssueType::not_found(),
151                "Invalid Password reset code.".to_string(),
152            ));
153        }
154        if code.is_expired.unwrap_or(true) {
155            return Err(OperationOutcomeError::fatal(
156                IssueType::invalid(),
157                "Password reset code has expired.".to_string(),
158            ));
159        }
160        Ok(message_html(
161            Some(&tenant),
162            Some(&project_resource),
163            &html! {
164                div {}
165                h1 class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl "{
166                    "Set your password"}
167                form class="space-y-4 md:space-y-6" action=(uri.path().to_string()) method="POST"{
168                    input type="hidden" id="code" name="code" value=(query.code) {}
169                    input type="hidden" name="csrf_token" value=(csrf_token) {}
170                    label for="password" class="block mb-2 text-sm font-medium text-gray-900"{"Enter your Password"}
171                    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" {}
172                    label for="password_confirm" class="block mb-2 text-sm font-medium text-gray-900"  {"Confirm your Password"}
173                    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" {}
174                    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"}
175                }
176            },
177            Some(&branding),
178        ))
179    } else {
180        Err(OperationOutcomeError::error(
181            IssueType::not_found(),
182            "Invalid Password reset code.".to_string(),
183        ))?
184    }
185}
186
187#[derive(Deserialize)]
188pub struct PasswordVerifyPOSTBODY {
189    csrf_token: String,
190    code: String,
191    password: String,
192    password_confirm: String,
193}
194
195pub async fn password_reset_verify_post<
196    Repo: Repository + Send + Sync,
197    Search: SearchEngine + Send + Sync,
198    Terminology: FHIRTerminology + Send + Sync,
199>(
200    _: PasswordResetVerify,
201    Cached(TenantContext { tenant, branding }): Cached<TenantContext>,
202    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
203    Cached(Project(project_resource)): Cached<Project>,
204    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
205    CSRFToken(csrf_token): CSRFToken,
206    Form(body): Form<PasswordVerifyPOSTBODY>,
207) -> Result<Markup, OperationOutcomeError> {
208    if body.csrf_token != csrf_token {
209        return Err(OperationOutcomeError::error(
210            IssueType::invalid(),
211            "Invalid CSRF Token".to_string(),
212        ));
213    }
214
215    if body.password != body.password_confirm {
216        return Err(OperationOutcomeError::error(
217            IssueType::invalid(),
218            "Passwords do not match.".to_string(),
219        ));
220    }
221
222    if let Some(code) = ProjectModelAdmin::<CreateAuthorizationCode, _, _, _, _>::read(
223        &*state.repo,
224        &tenant,
225        &project,
226        &body.code,
227    )
228    .await?
229    {
230        if code.kind != AuthorizationCodeKind::PasswordReset {
231            return Err(OperationOutcomeError::error(
232                IssueType::not_found(),
233                "Invalid Password reset code.".to_string(),
234            ));
235        }
236        ProjectModelAdmin::<CreateAuthorizationCode, _, _, _, _>::delete(
237            &*state.repo,
238            &tenant,
239            &project,
240            &body.code,
241        )
242        .await?;
243        if code.is_expired.unwrap_or(true) {
244            return Err(OperationOutcomeError::fatal(
245                IssueType::invalid(),
246                "Password reset code has expired.".to_string(),
247            ));
248        }
249
250        let Some(user) =
251            TenantModelAdmin::<CreateUser, _, _, _, _>::read(&*state.repo, &tenant, &code.user_id)
252                .await?
253        else {
254            return Err(OperationOutcomeError::error(
255                IssueType::not_found(),
256                "User not found.".to_string(),
257            ));
258        };
259
260        let email = user.email.as_ref().ok_or_else(|| {
261            OperationOutcomeError::fatal(
262                IssueType::invalid(),
263                "User does not have an email associated.".to_string(),
264            )
265        })?;
266
267        set_user_password(&*state.repo, &tenant, email, &user.id, &body.password).await?;
268
269        let admin_app_url = admin_app::redirect_url(state.config.as_ref(), &tenant, &project);
270
271        Ok(message_html(
272            Some(&tenant),
273            Some(&project_resource),
274            &html! { span {
275                    "Password has been reset successfully. "
276                    @if let Some(admin_app_url) = admin_app_url {
277                        "Go to the Admin App "
278                        a class="hover:underline cursor-pointer text-brand-600" href=(admin_app_url) { "here" }
279                        "."
280                    }
281                }
282            },
283            Some(&branding),
284        ))
285    } else {
286        Err(OperationOutcomeError::error(
287            IssueType::not_found(),
288            "Invalid Password reset code.".to_string(),
289        ))?
290    }
291}