1use axum::extract::State;
9use axum::http::StatusCode;
10use axum::response::{IntoResponse, Response};
11use serde::Deserialize;
12use serde_json::{Map, Value, json};
13
14use crate::admin::{mfa, totp};
15use crate::sqlite::admin_user::AdminUser;
16use crate::webadmin::AdminState;
17use crate::webadmin::handlers::mfa::check_step_up;
18use crate::webadmin::pages::auth::{PageEnrolWrite, PageSession, PageSessionWrite};
19use crate::webadmin::pages::error::PageError;
20use crate::webadmin::pages::{chrome, respond, respond_fragment};
21use crate::webadmin::session::AdminClientIp;
22
23#[derive(Debug, Deserialize)]
24pub struct ConfirmForm {
25 pub code: String,
26}
27
28#[derive(Debug, Default, Deserialize)]
31pub struct StepUpForm {
32 #[serde(default)]
33 pub password: String,
34}
35
36async fn refuse_without_password(
46 state: &AdminState,
47 user: &AdminUser,
48 csrf_token: &str,
49 password: &str,
50 client: Option<std::net::IpAddr>,
51) -> Result<Option<Response>, PageError> {
52 let Err(error) = check_step_up(user, password, client, &state.logins) else {
53 return Ok(None);
54 };
55 let message = if error.status == StatusCode::UNAUTHORIZED {
61 "That password is not correct.".to_string()
62 } else {
63 error.message.clone()
64 };
65 let mut context = card_context(state, user, csrf_token).await?;
66 context.insert("flash".to_string(), super::flash_error(error.code, message));
67 Ok(Some(
68 (
69 error.status,
70 respond_fragment(state, "account/_mfa.html", context)?,
71 )
72 .into_response(),
73 ))
74}
75
76pub 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
89 Ok(respond(
90 &state,
91 session.hx,
92 "account/index.html",
93 "account/_mfa.html",
94 context,
95 )?
96 .into_response())
97}
98
99pub async fn begin_totp(
108 State(state): State<AdminState>,
109 AdminClientIp(client): AdminClientIp,
110 session: PageEnrolWrite,
111 axum::Form(body): axum::Form<StepUpForm>,
112) -> Result<Response, PageError> {
113 let mut user = session.enrol.user;
114 if let Some(refusal) = refuse_without_password(
115 &state,
116 &user,
117 &session.enrol.session.csrf_token,
118 &body.password,
119 client,
120 )
121 .await?
122 {
123 return Ok(refusal);
124 }
125
126 let enrolment = mfa::resume_or_begin_totp_enrolment(
127 &mut user,
128 &state.config.admin.base_url,
129 state.database.clone(),
130 )
131 .await?;
132
133 let mut context = Map::new();
134 context.insert(
135 "csrf_token".to_string(),
136 Value::String(session.enrol.session.csrf_token.clone()),
137 );
138 context.insert(
139 "enrolment".to_string(),
140 json!({
141 "secret": enrolment.secret_base32,
142 "uri": enrolment.uri,
143 "algorithm": "SHA1",
144 "digits": totp::DIGITS,
145 "period": totp::PERIOD_SECONDS,
146 }),
147 );
148
149 Ok(respond_fragment(&state, "account/_enrol.html", context)?.into_response())
150}
151
152pub async fn confirm_totp(
158 State(state): State<AdminState>,
159 session: PageEnrolWrite,
160 axum::Form(body): axum::Form<ConfirmForm>,
161) -> Result<Response, PageError> {
162 let mut user = session.enrol.user;
163 let keep = session.enrol.session.token_hash.clone();
164
165 let Some(codes) =
166 mfa::confirm_totp_enrolment(&mut user, &body.code, Some(&keep), state.database.clone())
167 .await?
168 else {
169 let enrolment = mfa::resume_or_begin_totp_enrolment(
172 &mut user,
173 &state.config.admin.base_url,
174 state.database.clone(),
175 )
176 .await?;
177
178 let mut context = Map::new();
179 context.insert(
180 "csrf_token".to_string(),
181 Value::String(session.enrol.session.csrf_token.clone()),
182 );
183 context.insert(
184 "enrolment".to_string(),
185 json!({
186 "secret": enrolment.secret_base32,
187 "uri": enrolment.uri,
188 "algorithm": "SHA1",
189 "digits": totp::DIGITS,
190 "period": totp::PERIOD_SECONDS,
191 }),
192 );
193 context.insert(
194 "flash".to_string(),
195 super::flash_error("bad_request", "That code did not match. Try the next one."),
196 );
197
198 return Ok((
199 StatusCode::BAD_REQUEST,
200 respond_fragment(&state, "account/_enrol.html", context)?,
201 )
202 .into_response());
203 };
204
205 let mut context = card_context(&state, &user, &session.enrol.session.csrf_token).await?;
206 context.insert("recovery_codes".to_string(), json!(codes));
207 Ok(respond_fragment(&state, "account/_codes.html", context)?.into_response())
208}
209
210pub async fn disable_totp(
215 State(state): State<AdminState>,
216 AdminClientIp(client): AdminClientIp,
217 session: PageSessionWrite,
218 axum::Form(body): axum::Form<StepUpForm>,
219) -> Result<Response, PageError> {
220 if state.config.admin.require_mfa {
221 let mut context =
223 card_context(&state, &session.auth.user, &session.auth.session.csrf_token).await?;
224 context.insert(
225 "flash".to_string(),
226 super::flash_error(
227 "mfa_required",
228 "This server requires a second factor of every operator.",
229 ),
230 );
231 return Ok((
232 StatusCode::CONFLICT,
233 respond_fragment(&state, "account/_mfa.html", context)?,
234 )
235 .into_response());
236 }
237
238 let mut user = session.auth.user;
239 if let Some(refusal) = refuse_without_password(
240 &state,
241 &user,
242 &session.auth.session.csrf_token,
243 &body.password,
244 client,
245 )
246 .await?
247 {
248 return Ok(refusal);
249 }
250
251 mfa::disable_totp(
252 &mut user,
253 Some(&session.auth.session.token_hash),
254 state.database.clone(),
255 )
256 .await?;
257
258 let mut context = card_context(&state, &user, &session.auth.session.csrf_token).await?;
259 context.insert(
260 "flash".to_string(),
261 super::flash(
262 "warn",
263 "Two-factor authentication is off. Your recovery codes were destroyed \
264 and every other session of yours was signed out.",
265 ),
266 );
267 Ok(respond_fragment(&state, "account/_mfa.html", context)?.into_response())
268}
269
270pub async fn regenerate_recovery_codes(
276 State(state): State<AdminState>,
277 AdminClientIp(client): AdminClientIp,
278 session: PageSessionWrite,
279 axum::Form(body): axum::Form<StepUpForm>,
280) -> Result<Response, PageError> {
281 if !session.auth.user.has_totp() {
282 let mut context =
283 card_context(&state, &session.auth.user, &session.auth.session.csrf_token).await?;
284 context.insert(
285 "flash".to_string(),
286 super::flash_error(
287 "mfa_not_enabled",
288 "There is no second factor for these codes to recover access to.",
289 ),
290 );
291 return Ok((
292 StatusCode::CONFLICT,
293 respond_fragment(&state, "account/_mfa.html", context)?,
294 )
295 .into_response());
296 }
297
298 if let Some(refusal) = refuse_without_password(
299 &state,
300 &session.auth.user,
301 &session.auth.session.csrf_token,
302 &body.password,
303 client,
304 )
305 .await?
306 {
307 return Ok(refusal);
308 }
309
310 let codes = mfa::regenerate_recovery_codes(&session.auth.user, state.database.clone()).await?;
311 let mut context =
312 card_context(&state, &session.auth.user, &session.auth.session.csrf_token).await?;
313 context.insert("recovery_codes".to_string(), json!(codes));
314
315 Ok(respond_fragment(&state, "account/_codes.html", context)?.into_response())
316}
317
318async fn status(state: &AdminState, user: &AdminUser) -> Result<Value, PageError> {
320 let remaining = mfa::recovery_codes_remaining(&user.id, state.database.clone()).await?;
321 Ok(json!({
322 "totpEnabled": user.has_totp(),
323 "enrolmentPending": user.has_pending_totp(),
324 "recoveryCodesRemaining": remaining,
325 }))
326}
327
328async fn card_context(
334 state: &AdminState,
335 user: &AdminUser,
336 csrf_token: &str,
337) -> Result<Map<String, Value>, PageError> {
338 let mut context = Map::new();
339 context.insert(
340 "csrf_token".to_string(),
341 Value::String(csrf_token.to_string()),
342 );
343 context.insert(
344 "user".to_string(),
345 crate::admin::render_admin_user_json(user),
346 );
347 context.insert("mfa".to_string(), status(state, user).await?);
348 context.insert(
349 "require_mfa".to_string(),
350 Value::Bool(state.config.admin.require_mfa),
351 );
352 context.insert("period".to_string(), json!(totp::PERIOD_SECONDS));
353 Ok(context)
354}
355
356