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 if let Some(user) = user_search_results.into_iter().next() {
106 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 revoke_refresh_tokens(&*state.repo, &tenant, &user.id).await?;
273
274 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}