use std::sync::Arc; use api_types::{ AuthMode, Credentials, LoginReq, LoginResp, Me, OkResp, ProfilePatch, RootInfo, UserInfo, }; use axum::Json; use axum::extract::State; use axum::http::{HeaderMap, StatusCode, Uri, header}; use axum::response::{IntoResponse, Response}; use crate::api::common::{ SessionUser, hash_password, root_info, session_auth, validate_account_name, validate_password, }; use crate::auth::{self, clear_session_cookie, parse_session_cookie, session_cookie}; use crate::db::{RootRow, User}; use crate::error::{ApiError, AppState}; /// GET /api/auth/me /// /// - No users at all → `200 {"first_boot": true}` /// - No/invalid session → `401` /// - Valid session → user info + visible roots pub async fn me( State(state): State>, headers: HeaderMap, ) -> Result, ApiError> { if state.db.user_count().await? == 0 { return Ok(Json(Me { first_boot: true, user: None, roots: Vec::new(), allow_writable_shares: false, thumbnails_available: state.thumbs.is_some(), public_url: public_url(&state), })); } let (user, roots) = session_auth(&headers, &state).await?; Ok(Json(me_for(&state, &user, roots).await?)) } /// `--public-url` without the trailing slash `Url` adds: the client appends /// paths to it. fn public_url(state: &AppState) -> Option { state .public_url .as_ref() .map(|u| u.as_str().trim_end_matches('/').to_string()) } /// Build the `/api/auth/me` payload for an authenticated user. async fn me_for(state: &AppState, user: &User, roots: Vec) -> Result { let roots: Vec = roots.iter().map(|r| root_info(state, r)).collect(); Ok(Me { first_boot: false, user: Some(UserInfo { id: user.id, name: user.name.clone(), is_admin: user.is_admin, single_click_open: user.single_click, thumbnails: user.thumbnails, language: user.language.clone(), week_start: user.week_start, // A removed root leaves a stale id behind; the client never // sees it. default_root_id: user .default_root_id .filter(|id| roots.iter().any(|r| r.id == *id)), auth_mode: user.auth_mode, has_password: user.has_password, }), roots, allow_writable_shares: state.db.allow_writable_shares().await?, thumbnails_available: state.thumbs.is_some(), public_url: public_url(state), }) } /// A language tag we are willing to store: short, ASCII letters/digits and /// `-`/`_` (BCP-47 style). Checked at the trust boundary so a raw API /// client cannot write arbitrary blobs into the DB. fn valid_language(tag: &str) -> bool { !tag.is_empty() && tag.len() <= 12 && tag .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } /// PUT /api/auth/me. Answers with the fresh `/me` payload, so clients apply /// the change at once. pub async fn update_profile( State(state): State>, SessionUser { mut user, roots }: SessionUser, Json(body): Json, ) -> Result, ApiError> { if let Some(Some(ref tag)) = body.language && !valid_language(tag) { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "invalid language tag", "err_invalid_language", )); } // The UI offers these three: common enough, and all a month grid needs. if body.week_start.is_some_and(|d| ![0, 1, 6].contains(&d)) { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "invalid week start", "err_invalid_week_start", )); } if let Some(Some(id)) = body.default_root_id && !roots.iter().any(|r| r.id == id) { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "not one of your folders", "err_invalid_default_root", )); } if let Some(v) = body.single_click_open { user.single_click = v; } if let Some(v) = body.thumbnails { user.thumbnails = v; } if let Some(lang) = body.language { user.language = lang; } if let Some(d) = body.week_start { user.week_start = d; } if let Some(root_id) = body.default_root_id { user.default_root_id = root_id; } state.db.set_user_profile(&user).await?; Ok(Json(me_for(&state, &user, roots).await?)) } fn already_set_up() -> ApiError { ApiError::localized( StatusCode::CONFLICT, "server is already set up", "err_already_set_up", ) } /// POST /api/auth/setup — create the first admin account. /// Only available while no users exist. pub async fn setup( State(state): State>, Json(body): Json, ) -> Result { let name = body.name.trim(); validate_account_name(name)?; validate_password(&body.password)?; // A cheap pre-check: it keeps a POST to an already-configured server from // paying for an Argon2 hash. `create_admin` re-checks atomically. if state.db.user_count().await? > 0 { return Err(already_set_up()); } let pass_hash = hash_password(&body.password).await?; // `None` = another setup request won the race between the check above and // this insert. let Some(user) = state.db.create_admin(name, &pass_hash).await? else { return Err(already_set_up()); }; let token = auth::random_token(); state.db.create_session(user.id, &token).await?; Ok(( [(header::SET_COOKIE, session_cookie(&token, state.https()))], Json(OkResp {}), ) .into_response()) } /// POST /api/auth/login — the password leg of signing in. /// /// A correct password signs in, unless the account also requires a passkey: /// then a challenge comes back instead of a session. A wrong password gets /// the same error either way. /// /// `state_id` is the other order. A passkey sign-in that landed on an account /// requiring both legs parks the identified user under that handle, so this /// route already knows who is asking and only needs the password. pub async fn login( State(state): State>, uri: Uri, headers: HeaderMap, Json(body): Json, ) -> Result { let name = match &body.state_id { Some(state_id) => { let Some(crate::webauthn::Pending::NeedsPassword { user_id }) = crate::webauthn::take(state_id) else { return Err(crate::api::passkeys::challenge_expired()); }; match state.db.find_user_by_id(user_id).await? { Some(u) => u.name, None => return Err(invalid_credentials()), } } None => match body.name.as_deref().map(str::trim) { Some(n) if !n.is_empty() => n.to_string(), _ => return Err(invalid_credentials()), }, }; // Online guessing gets slower per failed attempt on this name. auth::throttle(&name).await; let verified = state.db.verify_password(&name, &body.password).await?; auth::record_login(&name, verified.is_some()); let Some(user) = verified else { return Err(invalid_credentials()); }; // A passkey is also required, and this request did not come from one. if user.auth_mode == AuthMode::Both && body.state_id.is_none() { return second_factor(&state, &user, &uri, &headers).await; } crate::api::passkeys::sign_in(&state, user.id).await } fn invalid_credentials() -> ApiError { ApiError::localized( StatusCode::UNAUTHORIZED, "invalid name or password", "err_invalid_credentials", ) } /// The password passed; ask for the passkey that must follow it. /// /// The relying party is built here rather than taken as an extractor. It needs /// a domain name, and most sign-ins do not need it at all — an extractor on /// `login` would fail every sign-in on a server reached by bare IP. async fn second_factor( state: &AppState, user: &crate::db::User, uri: &Uri, headers: &HeaderMap, ) -> Result { let rp = crate::webauthn::relying_party(state, uri, headers)?; let keys: Vec = crate::api::passkeys::load_passkeys(state, user.id) .await? .into_iter() .map(|(_, k)| k) .collect(); // Only reachable if every stored passkey became unreadable: the mode // cannot be set without one, and the last one cannot be deleted under it. if keys.is_empty() { return Err(ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, "this account requires a passkey but has none", )); } let (options, auth) = rp.start_passkey_authentication(&keys).map_err(|e| { tracing::warn!(error = ?e, "cannot start the second factor"); ApiError::localized( StatusCode::INTERNAL_SERVER_ERROR, "that passkey could not be used", "err_passkey_failed", ) })?; let challenge = crate::api::passkeys::challenge( crate::webauthn::Pending::Authenticate { user_id: user.id, state: Box::new(auth), second_factor: true, }, &options, )?; Ok(Json(LoginResp { ok: false, passkey_challenge: Some(challenge), ..Default::default() }) .into_response()) } /// POST /api/auth/logout pub async fn logout(State(state): State>, headers: HeaderMap) -> Response { if let Some(token) = parse_session_cookie(&headers) { let _ = state.db.delete_session(&token).await; } ( [(header::SET_COOKIE, clear_session_cookie(state.https()))], Json(OkResp {}), ) .into_response() }