1use axum::extract::{Path, State};
14use axum::http::StatusCode;
15use axum::response::{IntoResponse, Response};
16use serde::Deserialize;
17use serde_json::{Map, Value, json};
18
19use crate::admin;
20use crate::admin::{mfa, totp};
21use crate::webadmin::AdminState;
22use crate::webadmin::error::AdminError;
23use crate::webadmin::handlers::Caller;
24use crate::webadmin::handlers::account::apply_revoke_own_session;
25use crate::webadmin::handlers::mfa::verify_current_password;
26use crate::webadmin::handlers::paging::PageParams;
27use crate::webadmin::pages::auth::{PageEnrolWrite, PageSelfServiceWrite, PageSession};
28use crate::webadmin::pages::error::PageError;
29use crate::webadmin::pages::{chrome, respond, respond_fragment};
30use crate::webadmin::session::{AdminClientIp, clearing_cookie};
31use acme_proxy_store::admin_session::AdminSession;
32use acme_proxy_store::admin_user::AdminUser;
33
34#[derive(Debug, Deserialize)]
37pub struct ConfirmForm {
38 pub code: String,
39}
40
41#[derive(Debug, Default, Deserialize)]
44pub struct StepUpForm {
45 #[serde(default)]
46 pub password: String,
47}
48
49async fn refuse_on_the_card(
59 state: &AdminState,
60 user: &AdminUser,
61 csrf_token: &str,
62 error: &AdminError,
63) -> Result<Response, PageError> {
64 let context = card_context(state, user, csrf_token).await?;
72 super::refuse_with_card(state, "account/_mfa.html", context, error)
73}
74
75pub async fn get_account(
78 State(state): State<AdminState>,
79 session: PageSession,
80) -> Result<Response, PageError> {
81 let mut context = chrome(&session, "account", "Your account");
82 context.insert("mfa".to_string(), status(&state, &session.auth.user).await?);
83 context.insert(
84 "require_mfa".to_string(),
85 Value::Bool(state.config.admin.require_mfa),
86 );
87 context.insert("period".to_string(), json!(totp::PERIOD_SECONDS));
88 context.insert(
89 "min_password_length".to_string(),
90 json!(crate::admin::password::MIN_PASSWORD_LEN),
91 );
92 insert_own_sessions(&mut context, &state, &session.auth).await?;
93
94 Ok(respond(
95 &state,
96 session.hx,
97 "account/index.html",
98 "account/_mfa.html",
99 context,
100 )?
101 .into_response())
102}
103
104pub async fn begin_totp(
114 State(state): State<AdminState>,
115 AdminClientIp(client): AdminClientIp,
116 session: PageEnrolWrite,
117 axum::Form(body): axum::Form<StepUpForm>,
118) -> Result<Response, PageError> {
119 let mut user = session.enrol.user;
120 let enrolment = match crate::webadmin::handlers::mfa::begin_totp_for(
121 &state,
122 &mut user,
123 &body.password,
124 client,
125 )
126 .await
127 {
128 Ok(enrolment) => enrolment,
129 Err(error) => {
130 return refuse_on_the_card(&state, &user, &session.enrol.session.csrf_token, &error)
131 .await;
132 }
133 };
134
135 let mut context = Map::new();
136 context.insert(
137 "csrf_token".to_string(),
138 Value::String(session.enrol.session.csrf_token.clone()),
139 );
140 context.insert(
141 "enrolment".to_string(),
142 json!({
143 "secret": enrolment.secret_base32,
144 "uri": enrolment.uri,
145 "algorithm": "SHA1",
146 "digits": totp::DIGITS,
147 "period": totp::PERIOD_SECONDS,
148 }),
149 );
150
151 Ok(respond_fragment(&state, "account/_enrol.html", context)?.into_response())
152}
153
154pub async fn confirm_totp(
160 State(state): State<AdminState>,
161 AdminClientIp(client): AdminClientIp,
162 session: PageEnrolWrite,
163 request_context: acme_proxy_core::audit::RequestContext,
164 axum::Form(body): axum::Form<ConfirmForm>,
165) -> Result<Response, PageError> {
166 let mut user = session.enrol.user;
167 let keep = session.enrol.session.token_hash.clone();
168
169 let Some(codes) = crate::webadmin::handlers::mfa::confirm_totp_for(
170 &state,
171 &request_context,
172 &mut user,
173 &body.code,
174 &keep,
175 client,
176 )
177 .await?
178 else {
179 let enrolment = mfa::resume_or_begin_totp_enrolment(
182 &mut user,
183 &state.config.admin.base_url,
184 state.database.clone(),
185 )
186 .await?;
187
188 let mut context = Map::new();
189 context.insert(
190 "csrf_token".to_string(),
191 Value::String(session.enrol.session.csrf_token.clone()),
192 );
193 context.insert(
194 "enrolment".to_string(),
195 json!({
196 "secret": enrolment.secret_base32,
197 "uri": enrolment.uri,
198 "algorithm": "SHA1",
199 "digits": totp::DIGITS,
200 "period": totp::PERIOD_SECONDS,
201 }),
202 );
203 context.insert(
204 "flash".to_string(),
205 super::flash_error("bad_request", "That code did not match. Try the next one."),
206 );
207
208 return Ok((
209 StatusCode::BAD_REQUEST,
210 respond_fragment(&state, "account/_enrol.html", context)?,
211 )
212 .into_response());
213 };
214
215 let mut context = card_context(&state, &user, &session.enrol.session.csrf_token).await?;
216 context.insert("recovery_codes".to_string(), json!(codes));
217 Ok(respond_fragment(&state, "account/_codes.html", context)?.into_response())
218}
219
220pub async fn disable_totp(
225 State(state): State<AdminState>,
226 AdminClientIp(client): AdminClientIp,
227 session: PageSelfServiceWrite,
228 request_context: acme_proxy_core::audit::RequestContext,
229 axum::Form(body): axum::Form<StepUpForm>,
230) -> Result<Response, PageError> {
231 let mut user = session.auth.user;
232 if let Err(error) = crate::webadmin::handlers::mfa::disable_totp_for(
235 &state,
236 &request_context,
237 &mut user,
238 &body.password,
239 &session.auth.session.token_hash,
240 client,
241 )
242 .await
243 {
244 return refuse_on_the_card(&state, &user, &session.auth.session.csrf_token, &error).await;
245 }
246
247 let mut context = card_context(&state, &user, &session.auth.session.csrf_token).await?;
248 context.insert(
249 "flash".to_string(),
250 super::flash(
251 "warn",
252 "Two-factor authentication is off. Your recovery codes were destroyed \
253 and every other session of yours was signed out.",
254 ),
255 );
256 Ok(respond_fragment(&state, "account/_mfa.html", context)?.into_response())
257}
258
259pub async fn regenerate_recovery_codes(
265 State(state): State<AdminState>,
266 AdminClientIp(client): AdminClientIp,
267 session: PageSelfServiceWrite,
268 request_context: acme_proxy_core::audit::RequestContext,
269 axum::Form(body): axum::Form<StepUpForm>,
270) -> Result<Response, PageError> {
271 let codes = match crate::webadmin::handlers::mfa::regenerate_recovery_codes_for(
272 &state,
273 &request_context,
274 &session.auth.user,
275 &body.password,
276 client,
277 )
278 .await
279 {
280 Ok(codes) => codes,
281 Err(error) => {
282 return refuse_on_the_card(
283 &state,
284 &session.auth.user,
285 &session.auth.session.csrf_token,
286 &error,
287 )
288 .await;
289 }
290 };
291
292 let mut context =
293 card_context(&state, &session.auth.user, &session.auth.session.csrf_token).await?;
294 context.insert("recovery_codes".to_string(), json!(codes));
295
296 Ok(respond_fragment(&state, "account/_codes.html", context)?.into_response())
297}
298
299#[derive(Debug, Deserialize)]
302pub struct ChangePasswordForm {
303 pub current_password: String,
304 pub new_password: String,
305}
306
307#[derive(Debug, Default, Deserialize)]
309pub struct ContactForm {
310 #[serde(default)]
311 pub current_password: String,
312 #[serde(default)]
313 pub contact: String,
314}
315
316fn contact_card_context(
321 csrf_token: &str,
322 user: &AdminUser,
323 typed: Option<&str>,
324) -> Map<String, Value> {
325 let mut context = Map::new();
326 context.insert(
327 "csrf_token".to_string(),
328 Value::String(csrf_token.to_string()),
329 );
330 context.insert(
331 "user".to_string(),
332 crate::admin::render_admin_user_json(user),
333 );
334 if let Some(typed) = typed {
335 context.insert(
336 "contact_input".to_string(),
337 Value::String(typed.to_string()),
338 );
339 }
340 context
341}
342
343pub async fn change_contact(
350 State(state): State<AdminState>,
351 AdminClientIp(client): AdminClientIp,
352 session: PageSelfServiceWrite,
353 request_context: acme_proxy_core::audit::RequestContext,
354 axum::Form(form): axum::Form<ContactForm>,
355) -> Result<Response, PageError> {
356 let caller = session.auth.user.clone();
357 let csrf_token = session.auth.session.csrf_token.clone();
358
359 if let Err(error) =
360 verify_current_password(&caller, &form.current_password, client, &state.logins).await
361 {
362 return super::refuse_with_card(
363 &state,
364 "account/_contact.html",
365 contact_card_context(&csrf_token, &caller, Some(&form.contact)),
366 &error,
367 );
368 }
369
370 let mut target = caller.clone();
371 if let Err(error) = crate::webadmin::handlers::operators::apply_contact_change(
372 &state,
373 &caller,
374 &mut target,
375 Some(form.contact.as_str()),
376 client,
377 &request_context,
378 "ui",
379 )
380 .await
381 {
382 if error.status.is_server_error() {
383 return Err(error.into());
384 }
385 return super::refuse_with_card(
386 &state,
387 "account/_contact.html",
388 contact_card_context(&csrf_token, &caller, Some(&form.contact)),
389 &error,
390 );
391 }
392
393 let mut context = contact_card_context(&csrf_token, &target, None);
394 let banner = match &target.contact_email {
395 Some(address) => super::flash("ok", format!("Security notifications now go to {address}.")),
396 None => super::flash(
397 "warn",
398 "No address is on file: security notifications cannot reach you.",
399 ),
400 };
401 context.insert("flash".to_string(), banner);
402 Ok(respond_fragment(&state, "account/_contact.html", context)?.into_response())
403}
404
405fn password_card_context(csrf_token: &str) -> Map<String, Value> {
410 let mut context = Map::new();
411 context.insert(
412 "csrf_token".to_string(),
413 Value::String(csrf_token.to_string()),
414 );
415 context.insert(
419 "min_password_length".to_string(),
420 json!(crate::admin::password::MIN_PASSWORD_LEN),
421 );
422 context
423}
424
425pub async fn change_password(
432 State(state): State<AdminState>,
433 AdminClientIp(client): AdminClientIp,
434 session: PageSelfServiceWrite,
435 request_context: acme_proxy_core::audit::RequestContext,
436 axum::Form(body): axum::Form<ChangePasswordForm>,
437) -> Result<Response, PageError> {
438 let mut user = session.auth.user;
439 let csrf_token = session.auth.session.csrf_token.clone();
440 let mut fragment_context = password_card_context(&csrf_token);
441
442 if let Err(error) = crate::webadmin::handlers::account::change_own_password_for(
445 &state,
446 &request_context,
447 &mut user,
448 &body.current_password,
449 &body.new_password,
450 &session.auth.session.token_hash,
451 client,
452 )
453 .await
454 {
455 return super::refuse_with_card(&state, "account/_password.html", fragment_context, &error);
456 }
457
458 fragment_context.insert(
459 "flash".to_string(),
460 super::flash(
461 "ok",
462 "Your password was changed. Every other session of yours was signed out.",
463 ),
464 );
465 Ok(respond_fragment(&state, "account/_password.html", fragment_context)?.into_response())
466}
467
468pub async fn revoke_own_session(
478 State(state): State<AdminState>,
479 Path(id): Path<String>,
480 session: PageSelfServiceWrite,
481 request_context: acme_proxy_core::audit::RequestContext,
482) -> Result<Response, PageError> {
483 let was_current =
484 apply_revoke_own_session(&state, &Caller::ui(&session.auth, &request_context), &id).await?;
485
486 if was_current {
487 let mut response = crate::webadmin::pages::error::redirect(
488 crate::webadmin::pages::error::LOGIN_PATH,
489 session.hx,
490 );
491 if let Ok(value) = axum::http::HeaderValue::from_str(&clearing_cookie()) {
492 response
493 .headers_mut()
494 .insert(axum::http::header::SET_COOKIE, value);
495 }
496 return Ok(response);
497 }
498
499 let mut context = Map::new();
500 context.insert(
501 "csrf_token".to_string(),
502 Value::String(session.auth.session.csrf_token.clone()),
503 );
504 insert_own_sessions(&mut context, &state, &session.auth).await?;
505 context.insert("flash".to_string(), super::flash("ok", "Session revoked."));
506 Ok(respond_fragment(&state, "account/_sessions.html", context)?.into_response())
507}
508
509async fn insert_own_sessions(
519 context: &mut Map<String, Value>,
520 state: &AdminState,
521 auth: &crate::webadmin::session::Authenticated,
522) -> Result<(), PageError> {
523 let page = PageParams::default().resolve(&state.config);
524 let (sessions, _total) =
525 AdminSession::search(Some(auth.user.id), page.limit, page.offset, &state.database).await?;
526 let rows: Vec<Value> = sessions
527 .iter()
528 .map(|s| admin::render_admin_session_detail_json(s, &auth.session.token_hash))
529 .collect();
530
531 context.insert("sessions".to_string(), Value::Array(rows));
532 context.insert(
533 "sessions_revoke_prefix".to_string(),
534 Value::String("/ui/account/sessions".to_string()),
535 );
536 context.insert(
537 "sessions_target".to_string(),
538 Value::String("#account-sessions".to_string()),
539 );
540 Ok(())
541}
542
543async fn status(state: &AdminState, user: &AdminUser) -> Result<Value, PageError> {
545 let remaining = mfa::recovery_codes_remaining(user.id, state.database.clone()).await?;
546 Ok(json!({
547 "totpEnabled": user.has_totp(),
548 "enrolmentPending": user.has_pending_totp(),
549 "recoveryCodesRemaining": remaining,
550 }))
551}
552
553async fn card_context(
559 state: &AdminState,
560 user: &AdminUser,
561 csrf_token: &str,
562) -> Result<Map<String, Value>, PageError> {
563 let mut context = Map::new();
564 context.insert(
565 "csrf_token".to_string(),
566 Value::String(csrf_token.to_string()),
567 );
568 context.insert(
569 "user".to_string(),
570 crate::admin::render_admin_user_json(user),
571 );
572 context.insert("mfa".to_string(), status(state, user).await?);
573 context.insert(
574 "require_mfa".to_string(),
575 Value::Bool(state.config.admin.require_mfa),
576 );
577 context.insert("period".to_string(), json!(totp::PERIOD_SECONDS));
578 Ok(context)
579}
580
581