auth.rs
⎇
Raw
1use std::sync::Arc;
2
3use api_types::{AuthMode, Credentials, LoginReq, LoginResp, Me, OkResp, RootInfo, UserInfo};
4use axum::Json;
5use axum::extract::State;
6use axum::http::{HeaderMap, StatusCode, Uri, header};
7use axum::response::{IntoResponse, Response};
8use serde::Deserialize;
9
10use crate::api::common::{
11 SessionUser, hash_password, root_info, session_auth, validate_account_name, validate_password,
12};
13use crate::auth::{self, clear_session_cookie, parse_session_cookie, session_cookie};
14use crate::db::{RootRow, User};
15use crate::error::{ApiError, AppState};
16
17/// GET /api/auth/me
18///
19/// - No users at all → `200 {"first_boot": true}`
20/// - No/invalid session → `401`
21/// - Valid session → user info + visible roots
22pub async fn me(
23 State(state): State<Arc<AppState>>,
24 headers: HeaderMap,
25) -> Result<Json<Me>, ApiError> {
26 if state.db.user_count().await? == 0 {
27 return Ok(Json(Me {
28 first_boot: true,
29 user: None,
30 roots: Vec::new(),
31 allow_writable_shares: false,
32 thumbnails_available: state.thumbs.is_some(),
33 public_url: public_url(&state),
34 }));
35 }
36
37 let (user, roots) = session_auth(&headers, &state).await?;
38 Ok(Json(me_for(&state, &user, roots).await?))
39}
40
41/// `--public-url` without the trailing slash `Url` adds: the client appends
42/// paths to it.
43fn public_url(state: &AppState) -> Option<String> {
44 state
45 .public_url
46 .as_ref()
47 .map(|u| u.as_str().trim_end_matches('/').to_string())
48}
49
50/// Build the `/api/auth/me` payload for an authenticated user.
51async fn me_for(state: &AppState, user: &User, roots: Vec<RootRow>) -> Result<Me, ApiError> {
52 let roots: Vec<RootInfo> = roots.iter().map(|r| root_info(state, r)).collect();
53
54 Ok(Me {
55 first_boot: false,
56 user: Some(UserInfo {
57 id: user.id,
58 name: user.name.clone(),
59 is_admin: user.is_admin,
60 single_click_open: user.single_click,
61 thumbnails: user.thumbnails,
62 language: user.language.clone(),
63 week_start: user.week_start,
64 // A removed root leaves a stale id behind; the client never
65 // sees it.
66 default_root_id: user
67 .default_root_id
68 .filter(|id| roots.iter().any(|r| r.id == *id)),
69 auth_mode: user.auth_mode,
70 has_password: user.has_password,
71 }),
72 roots,
73 allow_writable_shares: state.db.allow_writable_shares().await?,
74 thumbnails_available: state.thumbs.is_some(),
75 public_url: public_url(state),
76 })
77}
78
79/// PUT /api/auth/me
80///
81/// Update the signed-in user's profile settings. Each field is optional;
82/// omitted fields are left untouched. Returns the fresh `/me` payload so
83/// clients can apply the change immediately.
84#[derive(Deserialize)]
85pub(crate) struct ProfilePatch {
86 #[serde(default)]
87 pub single_click_open: Option<bool>,
88 pub thumbnails: Option<bool>,
89 #[serde(default, deserialize_with = "patch_field")]
90 pub language: Option<Option<String>>,
91 pub week_start: Option<u8>,
92 /// `null` clears the default root (back to the root picker).
93 #[serde(default, deserialize_with = "patch_field")]
94 pub default_root_id: Option<Option<i64>>,
95}
96
97/// Deserializes a nullable patch field into the three-state value:
98/// `"de"` → `Some(Some("de"))`, `null` → `Some(None)` (a missing field
99/// never calls this and stays `None` via `#[serde(default)]`). The inner
100/// `Option<T>` already maps `null` → `None` and a value → `Some`, so only
101/// the outer wrap is custom.
102fn patch_field<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
103where
104 D: serde::Deserializer<'de>,
105 T: serde::Deserialize<'de>,
106{
107 serde::Deserialize::deserialize(deserializer).map(Some)
108}
109
110/// A language tag we are willing to store: short, ASCII letters/digits and
111/// `-`/`_` (BCP-47 style). Checked at the trust boundary so a raw API
112/// client cannot write arbitrary blobs into the DB.
113fn valid_language(tag: &str) -> bool {
114 !tag.is_empty()
115 && tag.len() <= 12
116 && tag
117 .bytes()
118 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
119}
120
121pub async fn update_profile(
122 State(state): State<Arc<AppState>>,
123 SessionUser { mut user, roots }: SessionUser,
124 Json(body): Json<ProfilePatch>,
125) -> Result<Json<Me>, ApiError> {
126 if let Some(Some(ref tag)) = body.language
127 && !valid_language(tag)
128 {
129 return Err(ApiError::localized(
130 StatusCode::BAD_REQUEST,
131 "invalid language tag",
132 "err_invalid_language",
133 ));
134 }
135 // The UI offers these three: common enough, and all a month grid needs.
136 if body.week_start.is_some_and(|d| ![0, 1, 6].contains(&d)) {
137 return Err(ApiError::localized(
138 StatusCode::BAD_REQUEST,
139 "invalid week start",
140 "err_invalid_week_start",
141 ));
142 }
143 if let Some(Some(id)) = body.default_root_id
144 && !roots.iter().any(|r| r.id == id)
145 {
146 return Err(ApiError::localized(
147 StatusCode::BAD_REQUEST,
148 "not one of your folders",
149 "err_invalid_default_root",
150 ));
151 }
152 if let Some(v) = body.single_click_open {
153 user.single_click = v;
154 }
155 if let Some(v) = body.thumbnails {
156 user.thumbnails = v;
157 }
158 if let Some(lang) = body.language {
159 user.language = lang;
160 }
161 if let Some(d) = body.week_start {
162 user.week_start = d;
163 }
164 if let Some(root_id) = body.default_root_id {
165 user.default_root_id = root_id;
166 }
167 state.db.set_user_profile(&user).await?;
168 Ok(Json(me_for(&state, &user, roots).await?))
169}
170
171fn already_set_up() -> ApiError {
172 ApiError::localized(
173 StatusCode::CONFLICT,
174 "server is already set up",
175 "err_already_set_up",
176 )
177}
178
179/// POST /api/auth/setup — create the first admin account.
180/// Only available while no users exist.
181pub async fn setup(
182 State(state): State<Arc<AppState>>,
183 Json(body): Json<Credentials>,
184) -> Result<Response, ApiError> {
185 let name = body.name.trim();
186 validate_account_name(name)?;
187 validate_password(&body.password)?;
188 // A cheap pre-check: it keeps a POST to an already-configured server from
189 // paying for an Argon2 hash. `create_admin` re-checks atomically.
190 if state.db.user_count().await? > 0 {
191 return Err(already_set_up());
192 }
193
194 let pass_hash = hash_password(&body.password).await?;
195 // `None` = another setup request won the race between the check above and
196 // this insert.
197 let Some(user) = state.db.create_admin(name, &pass_hash).await? else {
198 return Err(already_set_up());
199 };
200
201 let token = auth::random_token();
202 state.db.create_session(user.id, &token).await?;
203
204 Ok((
205 [(header::SET_COOKIE, session_cookie(&token, state.https()))],
206 Json(OkResp {}),
207 )
208 .into_response())
209}
210
211/// POST /api/auth/login — the password leg of signing in.
212///
213/// A correct password signs in, unless the account also requires a passkey:
214/// then a challenge comes back instead of a session. A wrong password gets
215/// the same error either way.
216///
217/// `state_id` is the other order. A passkey sign-in that landed on an account
218/// requiring both legs parks the identified user under that handle, so this
219/// route already knows who is asking and only needs the password.
220pub async fn login(
221 State(state): State<Arc<AppState>>,
222 uri: Uri,
223 headers: HeaderMap,
224 Json(body): Json<LoginReq>,
225) -> Result<Response, ApiError> {
226 let name = match &body.state_id {
227 Some(state_id) => {
228 let Some(crate::webauthn::Pending::NeedsPassword { user_id }) =
229 crate::webauthn::take(state_id)
230 else {
231 return Err(crate::api::passkeys::challenge_expired());
232 };
233 match state.db.find_user_by_id(user_id).await? {
234 Some(u) => u.name,
235 None => return Err(invalid_credentials()),
236 }
237 }
238 None => match body.name.as_deref().map(str::trim) {
239 Some(n) if !n.is_empty() => n.to_string(),
240 _ => return Err(invalid_credentials()),
241 },
242 };
243
244 // Online guessing gets slower per failed attempt on this name.
245 auth::throttle(&name).await;
246 let verified = state.db.verify_password(&name, &body.password).await?;
247 auth::record_login(&name, verified.is_some());
248 let Some(user) = verified else {
249 return Err(invalid_credentials());
250 };
251
252 // A passkey is also required, and this request did not come from one.
253 if user.auth_mode == AuthMode::Both && body.state_id.is_none() {
254 return second_factor(&state, &user, &uri, &headers).await;
255 }
256 crate::api::passkeys::sign_in(&state, user.id).await
257}
258
259fn invalid_credentials() -> ApiError {
260 ApiError::localized(
261 StatusCode::UNAUTHORIZED,
262 "invalid name or password",
263 "err_invalid_credentials",
264 )
265}
266
267/// The password passed; ask for the passkey that must follow it.
268///
269/// The relying party is built here rather than taken as an extractor. It needs
270/// a domain name, and most sign-ins do not need it at all — an extractor on
271/// `login` would fail every sign-in on a server reached by bare IP.
272async fn second_factor(
273 state: &AppState,
274 user: &crate::db::User,
275 uri: &Uri,
276 headers: &HeaderMap,
277) -> Result<Response, ApiError> {
278 let rp = crate::webauthn::relying_party(state, uri, headers)?;
279 let keys: Vec<webauthn_rs::prelude::Passkey> =
280 crate::api::passkeys::load_passkeys(state, user.id)
281 .await?
282 .into_iter()
283 .map(|(_, k)| k)
284 .collect();
285 // Only reachable if every stored passkey became unreadable: the mode
286 // cannot be set without one, and the last one cannot be deleted under it.
287 if keys.is_empty() {
288 return Err(ApiError::new(
289 StatusCode::INTERNAL_SERVER_ERROR,
290 "this account requires a passkey but has none",
291 ));
292 }
293 let (options, auth) = rp.start_passkey_authentication(&keys).map_err(|e| {
294 tracing::warn!(error = ?e, "cannot start the second factor");
295 ApiError::localized(
296 StatusCode::INTERNAL_SERVER_ERROR,
297 "that passkey could not be used",
298 "err_passkey_failed",
299 )
300 })?;
301 let challenge = crate::api::passkeys::challenge(
302 crate::webauthn::Pending::Authenticate {
303 user_id: user.id,
304 state: Box::new(auth),
305 second_factor: true,
306 },
307 &options,
308 )?;
309 Ok(Json(LoginResp {
310 ok: false,
311 passkey_challenge: Some(challenge),
312 ..Default::default()
313 })
314 .into_response())
315}
316
317/// POST /api/auth/logout
318pub async fn logout(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
319 if let Some(token) = parse_session_cookie(&headers) {
320 let _ = state.db.delete_session(&token).await;
321 }
322 (
323 [(header::SET_COOKIE, clear_session_cookie(state.https()))],
324 Json(OkResp {}),
325 )
326 .into_response()
327}
328