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