1use anyhow::Result;
2use axum::debug_handler;
3use axum::extract::State;
4use axum::response::{IntoResponse, Redirect, Response};
5use axum_extra::extract::CookieJar;
6use axum_extra::extract::cookie::Cookie;
7use axum_typed_multipart::TryFromMultipart;
8use maplit::btreemap;
9use serde::{Deserialize, Serialize};
10
11use crate::io::webui::flexible_form::FlexibleForm;
12use crate::io::webui::session_auth::Session;
13use crate::io::webui::{
14 AppState, RequestState, error_404, recoverable_error, respond_dialog,
15};
16use crate::io::webui::controllers::base::{redirect_temporary};
17use crate::storage::pc_settings::get_bool_setting;
18use crate::storage::user::{User, UserPublicInfo};
19use crate::utilities::feature;
20use crate::utilities::password::Password;
21use crate::{debug, error, info, json_value};
22
23fn login_disabled(
24 State(state): State<AppState>,
25 req: RequestState,
26) -> Response {
27 let kiosk_mode = get_bool_setting("kiosk_mode");
28 respond_dialog(
29 &state,
30 req,
31 "login_disabled",
32 &btreemap! { "kiosk_mode".to_string() => kiosk_mode },
33 )
34}
35
36fn registration_disabled(
37 State(state): State<AppState>,
38 req: RequestState,
39) -> Response {
40 let kiosk_mode = get_bool_setting("kiosk_mode");
41 respond_dialog(
42 &state,
43 req,
44 "registration_disabled",
45 &btreemap! { "kiosk_mode".to_string() => kiosk_mode },
46 )
47}
48
49pub async fn get_login(
50 State(state): State<AppState>,
51 req: RequestState,
52) -> Response {
53 if (!feature("login")) {
54 return login_disabled(State(state), req);
55 }
56
57 respond_dialog(&state, req, "login", &json_value!({}))
58}
59
60#[derive(TryFromMultipart, Serialize, Deserialize)]
61#[try_from_multipart(strict)]
62pub struct LoginForm {
63 username: String,
64}
65
66pub async fn post_login(
67 State(state): State<AppState>,
68 req: RequestState,
69 FlexibleForm(input): FlexibleForm<LoginForm>,
70) -> Response {
71 if (!feature("login")) {
72 return login_disabled(State(state), req);
73 }
74
75 let username = input.username;
76
77 let local_exists = local_account_exists(&username);
78 let remote_exists = remote_account_exists(&username);
79
80 respond_dialog(
96 &state,
97 req,
98 "login_password",
99 &btreemap! { "username".to_string() => username.clone() },
100 )
101}
102
103#[derive(TryFromMultipart, Serialize, Deserialize)]
104#[try_from_multipart(strict)]
105pub struct LoginPasswordForm {
106 username: String,
107 password: String, }
109pub async fn post_login_password(
110 State(state): State<AppState>,
111 req: RequestState,
112 jar: CookieJar,
113 FlexibleForm(input): FlexibleForm<LoginPasswordForm>,
114) -> impl IntoResponse {
115 if (!feature("login")) {
116 return (jar, login_disabled(State(state), req));
117 }
118
119 let username = input.username;
120 let password = Password {
121 password: input.password.as_bytes().to_vec(),
122 };
123 debug!(format!(
124 "post_login_password: username={}, password.len={}",
125 username.clone(),
126 password.password.len()
127 ));
128 let user_public_info = UserPublicInfo::get_by_name(&username);
129 let Ok(Some(user_public_info)) = user_public_info else {
130 return (
131 jar,
132 error_404(
133 &state,
134 &req,
135 "The account seems to have disappeared or been removed? This may indicate a bug",
136 ),
137 );
138 };
139
140 let logged_in = User::login(user_public_info, &password);
141 let Ok(logged_in) = logged_in else {
142 return (
143 jar,
144 recoverable_error(
145 &state,
146 req,
147 "The password is most likely incorrect, or perhaps there was an error looking up the user.",
148 ),
149 );
150 };
151
152 let session = Session::new(&mut state.clone(), logged_in).await;
154 let updated_jar = jar.add(Cookie::new("session", session.id()));
155
156 (updated_jar, redirect_temporary(req.is_js_request, "/home"))
157}
158
159#[derive(TryFromMultipart, Serialize, Deserialize)]
160#[try_from_multipart(strict)]
161pub struct RegistrationForm {
162 username: String,
163 password: String,
164 password_confirm: String,
165}
166#[debug_handler]
169pub async fn post_registration(
170 State(state): State<AppState>,
171 req: RequestState,
172 jar: CookieJar,
173 FlexibleForm(input): FlexibleForm<RegistrationForm>,
174) -> impl IntoResponse {
175 if (!feature("registration")) {
176 return registration_disabled(State(state), req);
177 }
178
179 debug!("post_registration: received registration request");
180 info!("post_registration: received registration request");
181
182 let username = input.username.clone();
183 let password = Password {
184 password: input.password.as_bytes().to_vec(),
185 };
186 let password_confirm = Password {
187 password: input.password_confirm.as_bytes().to_vec(),
188 };
189
190 if password != password_confirm {
191 error!(
192 "post_registration: passwords did not match for user '{}'",
193 username
194 );
195 return recoverable_error(
196 &state,
197 req,
198 "Passwords did not match".to_string(),
199 );
200 }
201
202 error!("post_registration: creating user '{}'", &username);
203 let user = match User::create(&username, &password) {
204 Ok(user) => user,
205 Err(e) => {
206 error!(
207 "post_registration: failed to create user '{}': {:?}",
208 username, &e
209 );
210 return recoverable_error(
211 &state,
212 req,
213 format!("Failed to create user: {e:?}"),
214 );
215 }
216 };
217 let Ok(Some(user_info)) = UserPublicInfo::get_by_name(&username) else {
218 error!(
219 "post_registration: failed to get user info for '{}'",
220 &username
221 );
222 return recoverable_error(
223 &state,
224 req,
225 format!("Failed to get user info for '{username}'"),
226 );
227 };
228 if user_info.name() != username {
229 error!(
230 "post_registration: user info name mismatch: '{}' != '{}'",
231 user_info.name(),
232 &username
233 );
234 return recoverable_error(
235 &state,
236 req,
237 format!(
238 "User info name mismatch: '{}' != '{}'",
239 user_info.name(),
240 &username
241 ),
242 );
243 }
244 info!("post_registration: created user '{}'", &username);
245
246 post_login_password(
247 axum::extract::State(state),
248 req,
249 jar,
250 FlexibleForm(LoginPasswordForm {
254 username: username.clone(),
255 password: password.as_string_not_zeroizing(),
256 }),
257 )
258 .await
259 .into_response()
260}
261
262fn local_account_exists(username: &str) -> bool {
265 if let Ok(Some(_)) = UserPublicInfo::get_by_name(username) {
266 return true;
267 }
268 false
269}
270
271fn remote_account_password_matches(
272 _username: &String,
273 _password: &String,
274) -> bool {
275 true
276}
277
278fn remote_account_exists(_username: &String) -> bool {
279 false
280}
281
282#[cfg(test)]
283#[allow(clippy::unwrap_in_result, clippy::panic_in_result_fn)]
284mod auth_controller_tests {
285 use super::*;
286 use crate::io::webui::test_helpers::{
287 assert_eq_or_print_body, assert_or_print_body,
288 assert_successful_and_return_resp, test_app, test_get_no_login,
289 test_post_no_login, test_request, test_request_get_response,
290 };
291 use crate::storage::user::{get_test_user, lock_by_name};
292 use axum::http::StatusCode;
293 use ctb_test_macro::ctb_test;
294 use http::Method;
295
296 const THIS_TEST_USER_PASS: &str = "test_password_auth_controller";
301
302 #[ctb_test(tokio::test)]
303 async fn test_get_login_route() {
304 let (status, body) = test_get_no_login("/login").await;
305 assert_eq!(status, StatusCode::OK);
306 assert!(body.contains("login"));
307 }
308
309 #[ctb_test(tokio::test)]
310 async fn test_registration_and_login_flow() -> Result<()> {
311 let name = function_name!();
312 let _ = lock_by_name(name)?;
313 User::delete_by_name(name).ok();
314 let (_state, app) = test_app();
315 #[derive(serde::Serialize)]
316 struct RegistrationForm<'a> {
317 username: &'a str,
318 password: &'a str,
319 password_confirm: &'a str,
320 }
321 let reg_form = RegistrationForm {
322 username: name,
323 password: THIS_TEST_USER_PASS,
324 password_confirm: THIS_TEST_USER_PASS,
325 };
326 let resp = test_request_get_response(
327 &app,
328 axum::http::Method::POST,
329 "/registration",
330 None,
331 None,
332 None,
333 None,
334 Some(®_form),
335 )
336 .await;
337 let resp = assert_successful_and_return_resp(resp, true).await;
338 let cookie = resp.headers().get("Set-Cookie");
339 assert!(cookie.is_some(), "No Set-Cookie header found");
340 let user_info = UserPublicInfo::get_by_name(name)?
341 .expect("Failed to get user info");
342 assert!(user_info.name() == name);
343
344 #[derive(serde::Serialize)]
345 struct LoginForm<'a> {
346 username: &'a str,
347 }
348 let login_form = LoginForm { username: name };
349 let (status, body) =
350 test_post_no_login("/login", None, None, Some(&login_form)).await;
351 assert_eq!(status, StatusCode::OK);
352 assert!(body.contains("login-password"));
353
354 #[derive(serde::Serialize)]
355 struct LoginPasswordForm<'a> {
356 username: &'a str,
357 password: &'a str,
358 }
359 let login_pw_form = LoginPasswordForm {
360 username: name,
361 password: THIS_TEST_USER_PASS,
362 };
363 let resp = test_request_get_response(
364 &app,
365 axum::http::Method::POST,
366 "/login-password",
367 None,
368 None,
369 None,
370 None,
371 Some(&login_pw_form),
372 )
373 .await;
374 let resp = assert_successful_and_return_resp(resp, true).await;
375 let cookie = resp.headers().get("Set-Cookie");
376 assert!(cookie.is_some(), "No Set-Cookie header found");
377 let cookie = cookie
378 .and_then(|v| v.to_str().ok())
379 .ok_or_else(|| anyhow::anyhow!("Could not get cookie"));
380 assert!(cookie.is_ok(), "Could not get cookie");
381 let cookie = cookie.expect("checked").to_string();
382
383 let (status, body) = test_request::<()>(
384 &app,
385 Method::GET,
386 "/search",
387 None,
388 Some(&cookie),
389 None,
390 None,
391 None,
392 )
393 .await;
394 assert_eq_or_print_body(status, 200, &body);
395 assert_or_print_body(body.contains("name=\"search-text\""), &body);
396 Ok(())
397 }
398
399 #[ctb_test(tokio::test)]
400 async fn test_post_login_password_route_wrong() -> Result<()> {
401 let name = function_name!();
402 let _ = lock_by_name(name)?;
403 let (_state, app) = test_app();
404 #[derive(serde::Serialize)]
405 struct LoginPasswordForm<'a> {
406 username: &'a str,
407 password: &'a str,
408 }
409 get_test_user(name);
411 let login_pw_form = LoginPasswordForm {
412 username: name,
413 password: "WRONG_test_password",
414 };
415 let resp = test_request_get_response(
416 &app,
417 axum::http::Method::POST,
418 "/login-password",
419 None,
420 None,
421 None,
422 None,
423 Some(&login_pw_form),
424 )
425 .await;
426 let resp = assert_successful_and_return_resp(resp, false).await;
427 let cookie = resp.headers().get("Set-Cookie");
428 assert!(cookie.is_none(), "No Set-Cookie header found");
429 Ok(())
430 }
431}