ctoolbox/io/webui/controllers/
auth.rs

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    // FIXME  TODO
81    /*if !local_exists && !remote_exists {
82        // Registration view with suggested username
83        return respond_page(
84            &state,
85            req,
86            "register",
87            &btreemap! { "username".to_string() => username.clone() },
88        );
89    } else if !local_exists && remote_exists {
90        // Log in with remote account only: auto-create local account and do initial sync
91        panic!("Not implemented: log in with remote account only");
92    }*/
93
94    // Either both exist, or only local exists. No problem, continue
95    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, // Hidden, just kept to maintain state
108}
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    // Log in was ok; return redirect home with session cookie set
153    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/// Handles registration form submission.
167/// Logs detailed errors if multipart parsing fails or if any step fails.
168#[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        // FIXME: Avoid cloning password string. The zeroizing can't actually
251        // clear out all the copies if it's being passed around everywhere like
252        // this, but that might just be inevitable.
253        FlexibleForm(LoginPasswordForm {
254            username: username.clone(),
255            password: password.as_string_not_zeroizing(),
256        }),
257    )
258    .await
259    .into_response()
260}
261
262// ================ Auth and user helpers (stubs) ================
263
264fn 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    // In the interest of actually testing this flow, these few tests should NOT
297    // use the default test password (which bypasses the hash check for
298    // performance)
299
300    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(&reg_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        // Make sure the user exists
410        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}