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