1use axum::Form;
11use axum::extract::{Query, State};
12use axum::http::{HeaderMap, StatusCode, header};
13use axum::response::{IntoResponse, Response};
14use serde_json::{Map, Value};
15
16use crate::admin::mfa;
17use crate::webadmin::AdminState;
18use crate::webadmin::handlers::Caller;
19use crate::webadmin::handlers::session::apply_logout;
20use crate::webadmin::handlers::session::{
21 LoginRequest, LogoutQuery, MfaRequest, finish_enrolment, finish_mfa, sign_in,
22};
23use crate::webadmin::pages::auth::{PageMfaPending, PageMfaSubmit, PageSelfServiceWrite};
24use crate::webadmin::pages::error::{LOGIN_PATH, PageError, redirect};
25use crate::webadmin::pages::templates;
26use crate::webadmin::session::{AdminClientIp, MfaStep, PendingMfa, clearing_cookie};
27
28const PANEL_PATH: &str = "/ui/";
30
31pub(crate) const MFA_PATH: &str = "/ui/login/mfa";
33
34pub async fn get_login(State(state): State<AdminState>) -> Result<Response, PageError> {
41 Ok(page(&state, None, None)?.into_response())
42}
43
44pub async fn post_login(
49 State(state): State<AdminState>,
50 AdminClientIp(client): AdminClientIp,
51 headers: HeaderMap,
52 Form(credentials): Form<LoginRequest>,
53) -> Result<Response, PageError> {
54 match sign_in(&state, client, &headers, &credentials).await {
55 Ok(signed_in) => Ok((
58 StatusCode::SEE_OTHER,
59 [
60 (
61 header::LOCATION,
62 if signed_in.pending.is_some() {
63 MFA_PATH.to_string()
64 } else {
65 PANEL_PATH.to_string()
66 },
67 ),
68 (header::SET_COOKIE, signed_in.cookie),
69 ],
70 )
71 .into_response()),
72 Err(error) => {
78 let status = error.status;
79 let flash = super::flash_error(error.code, error.message);
80 Ok((
81 status,
82 page(&state, Some(flash), Some(&credentials.username))?,
83 )
84 .into_response())
85 }
86 }
87}
88
89pub async fn get_login_mfa(
96 State(state): State<AdminState>,
97 session: PageMfaPending,
98) -> Result<Response, PageError> {
99 Ok(challenge(&state, session.pending, None)
100 .await?
101 .into_response())
102}
103
104pub async fn post_login_mfa(
110 State(state): State<AdminState>,
111 AdminClientIp(client): AdminClientIp,
112 request_context: acme_proxy_core::audit::RequestContext,
113 session: PageMfaSubmit,
114 Form(body): Form<MfaRequest>,
115) -> Result<Response, PageError> {
116 if session.pending.step == MfaStep::Enrol {
120 return confirm_enrolment(
121 &state,
122 client,
123 &request_context,
124 session.pending,
125 &body.code,
126 )
127 .await;
128 }
129
130 let step = session.pending.step;
132 let expires_at = session.pending.session.expires_at;
133
134 match finish_mfa(&state, client, session.pending, &body.code).await {
135 Ok(signed_in) => Ok((
136 StatusCode::SEE_OTHER,
137 [
138 (header::LOCATION, PANEL_PATH.to_string()),
139 (header::SET_COOKIE, signed_in.cookie),
142 ],
143 )
144 .into_response()),
145 Err(error) => {
147 let status = error.status;
148 let flash = super::flash_error(error.code, error.message);
149 let mut context = Map::new();
150 context.insert("step".to_string(), Value::String(step.as_str().to_string()));
151 context.insert(
152 "expiresAt".to_string(),
153 Value::String(acme_proxy_core::datetime::rfc3339(expires_at)),
154 );
155 context.insert("flash".to_string(), flash);
156 Ok((status, render_challenge(&state, context)?).into_response())
157 }
158 }
159}
160
161async fn confirm_enrolment(
168 state: &AdminState,
169 client: Option<std::net::IpAddr>,
170 request_context: &acme_proxy_core::audit::RequestContext,
171 pending: PendingMfa,
172 code: &str,
173) -> Result<Response, PageError> {
174 let mut user = pending.user;
175 let keep = pending.session.token_hash.clone();
176
177 let Some(codes) =
178 mfa::confirm_totp_enrolment(&mut user, code, Some(&keep), state.database.clone()).await?
179 else {
180 let mut context = Map::new();
181 context.insert("step".to_string(), Value::String("enrol".to_string()));
182 context.insert(
183 "flash".to_string(),
184 super::flash_error("bad_request", "That code did not match. Try the next one."),
185 );
186 context.insert(
187 "enrolment".to_string(),
188 enrolment_context(state, &mut user).await?,
189 );
190 return Ok((StatusCode::UNAUTHORIZED, render_challenge(state, context)?).into_response());
191 };
192
193 state
197 .record_credential_change(
198 request_context,
199 &user.username,
200 &user,
201 crate::webadmin::CredentialChange::SecondFactorEnabled,
202 true,
203 client,
204 )
205 .await;
206
207 let (_, cookie) = finish_enrolment(
212 state,
213 client,
214 &mut user,
215 &pending.session.token_hash,
216 pending.session.user_agent.clone(),
217 )
218 .await?;
219
220 let mut context = Map::new();
221 context.insert("recovery_codes".to_string(), serde_json::json!(codes));
222 let body = templates::render(
223 &state.templates,
224 "mfa/enrolled.html",
225 minijinja::Value::from_serialize(Value::Object(context)),
226 )?;
227
228 let mut response = (StatusCode::OK, body).into_response();
229 if let Ok(value) = header::HeaderValue::from_str(&cookie) {
230 response.headers_mut().insert(header::SET_COOKIE, value);
231 }
232 Ok(response)
233}
234
235pub async fn post_logout(
237 State(state): State<AdminState>,
238 Query(query): Query<LogoutQuery>,
239 session: PageSelfServiceWrite,
240 request_context: acme_proxy_core::audit::RequestContext,
241) -> Result<Response, PageError> {
242 apply_logout(
243 &state,
244 &Caller::ui(&session.auth, &request_context),
245 query.all,
246 )
247 .await?;
248
249 let mut response = redirect(LOGIN_PATH, session.hx);
253 if let Ok(value) = header::HeaderValue::from_str(&clearing_cookie()) {
254 response.headers_mut().insert(header::SET_COOKIE, value);
255 }
256 Ok(response)
257}
258
259fn page(
261 state: &AdminState,
262 flash: Option<Value>,
263 username: Option<&str>,
264) -> Result<axum::response::Html<String>, PageError> {
265 let mut context = Map::new();
266 if let Some(flash) = flash {
267 context.insert("flash".to_string(), flash);
268 }
269 if let Some(username) = username {
270 context.insert("username".to_string(), Value::String(username.to_string()));
271 }
272 templates::render(
273 &state.templates,
274 "login.html",
275 minijinja::Value::from_serialize(Value::Object(context)),
276 )
277}
278
279async fn challenge(
285 state: &AdminState,
286 pending: PendingMfa,
287 flash: Option<Value>,
288) -> Result<axum::response::Html<String>, PageError> {
289 let mut context = Map::new();
290 context.insert(
291 "step".to_string(),
292 Value::String(pending.step.as_str().to_string()),
293 );
294 context.insert(
295 "expiresAt".to_string(),
296 Value::String(acme_proxy_core::datetime::rfc3339(
297 pending.session.expires_at,
298 )),
299 );
300 if let Some(flash) = flash {
301 context.insert("flash".to_string(), flash);
302 }
303 if pending.step == MfaStep::Enrol {
304 let mut user = pending.user;
305 context.insert(
306 "enrolment".to_string(),
307 enrolment_context(state, &mut user).await?,
308 );
309 }
310 render_challenge(state, context)
311}
312
313async fn enrolment_context(
315 state: &AdminState,
316 user: &mut acme_proxy_store::admin_user::AdminUser,
317) -> Result<Value, PageError> {
318 let enrolment = mfa::resume_or_begin_totp_enrolment(
319 user,
320 &state.config.admin.base_url,
321 state.database.clone(),
322 )
323 .await?;
324
325 Ok(serde_json::json!({
326 "secret": enrolment.secret_base32,
327 "uri": enrolment.uri,
328 "algorithm": "SHA1",
329 "digits": crate::admin::totp::DIGITS,
330 "period": crate::admin::totp::PERIOD_SECONDS,
331 }))
332}
333
334fn render_challenge(
335 state: &AdminState,
336 context: Map<String, Value>,
337) -> Result<axum::response::Html<String>, PageError> {
338 templates::render(
339 &state.templates,
340 "mfa/challenge.html",
341 minijinja::Value::from_serialize(Value::Object(context)),
342 )
343}