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 // 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: public_url(state),
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(Some(id)) = body.default_root_id
134 && !roots.iter().any(|r| r.id == id)
135 {
136 return Err(ApiError::localized(
137 StatusCode::BAD_REQUEST,
138 "not one of your folders",
139 "err_invalid_default_root",
140 ));
141 }
142 if let Some(v) = body.single_click_open {
143 user.single_click = v;
144 }
145 if let Some(v) = body.thumbnails {
146 user.thumbnails = v;
147 }
148 if let Some(lang) = body.language {
149 user.language = lang;
150 }
151 if let Some(root_id) = body.default_root_id {
152 user.default_root_id = root_id;
153 }
154 state.db.set_user_profile(&user).await?;
155 Ok(Json(me_for(&state, &user, roots).await?))
156}
157
158fn already_set_up() -> ApiError {
159 ApiError::localized(
160 StatusCode::CONFLICT,
161 "server is already set up",
162 "err_already_set_up",
163 )
164}
165
166/// POST /api/auth/setup — create the first admin account.
167/// Only available while no users exist.
168pub async fn setup(
169 State(state): State<Arc<AppState>>,
170 Json(body): Json<Credentials>,
171) -> Result<Response, ApiError> {
172 let name = body.name.trim();
173 validate_account_name(name)?;
174 validate_password(&body.password)?;
175 // A cheap pre-check: it keeps a POST to an already-configured server from
176 // paying for an Argon2 hash. `create_admin` re-checks atomically.
177 if state.db.user_count().await? > 0 {
178 return Err(already_set_up());
179 }
180
181 let pass_hash = hash_password(&body.password).await?;
182 // `None` = another setup request won the race between the check above and
183 // this insert.
184 let Some(user) = state.db.create_admin(name, &pass_hash).await? else {
185 return Err(already_set_up());
186 };
187
188 let token = auth::random_token();
189 state.db.create_session(user.id, &token).await?;
190
191 Ok((
192 [(header::SET_COOKIE, session_cookie(&token, state.https()))],
193 Json(OkResp {}),
194 )
195 .into_response())
196}
197
198/// POST /api/auth/login — the password leg of signing in.
199///
200/// A correct password signs in, unless the account also requires a passkey:
201/// then a challenge comes back instead of a session. A wrong password gets
202/// the same error either way.
203///
204/// `state_id` is the other order. A passkey sign-in that landed on an account
205/// requiring both legs parks the identified user under that handle, so this
206/// route already knows who is asking and only needs the password.
207pub async fn login(
208 State(state): State<Arc<AppState>>,
209 uri: Uri,
210 headers: HeaderMap,
211 Json(body): Json<LoginReq>,
212) -> Result<Response, ApiError> {
213 let name = match &body.state_id {
214 Some(state_id) => {
215 let Some(crate::webauthn::Pending::NeedsPassword { user_id }) =
216 crate::webauthn::take(state_id)
217 else {
218 return Err(crate::api::passkeys::challenge_expired());
219 };
220 match state.db.find_user_by_id(user_id).await? {
221 Some(u) => u.name,
222 None => return Err(invalid_credentials()),
223 }
224 }
225 None => match body.name.as_deref().map(str::trim) {
226 Some(n) if !n.is_empty() => n.to_string(),
227 _ => return Err(invalid_credentials()),
228 },
229 };
230
231 // Online guessing gets slower per failed attempt on this name.
232 auth::throttle(&name).await;
233 let verified = state.db.verify_password(&name, &body.password).await?;
234 auth::record_login(&name, verified.is_some());
235 let Some(user) = verified else {
236 return Err(invalid_credentials());
237 };
238
239 // A passkey is also required, and this request did not come from one.
240 if user.auth_mode == AuthMode::Both && body.state_id.is_none() {
241 return second_factor(&state, &user, &uri, &headers).await;
242 }
243 crate::api::passkeys::sign_in(&state, user.id).await
244}
245
246fn invalid_credentials() -> ApiError {
247 ApiError::localized(
248 StatusCode::UNAUTHORIZED,
249 "invalid name or password",
250 "err_invalid_credentials",
251 )
252}
253
254/// The password passed; ask for the passkey that must follow it.
255///
256/// The relying party is built here rather than taken as an extractor. It needs
257/// a domain name, and most sign-ins do not need it at all — an extractor on
258/// `login` would fail every sign-in on a server reached by bare IP.
259async fn second_factor(
260 state: &AppState,
261 user: &crate::db::User,
262 uri: &Uri,
263 headers: &HeaderMap,
264) -> Result<Response, ApiError> {
265 let rp = crate::webauthn::relying_party(state, uri, headers)?;
266 let keys: Vec<webauthn_rs::prelude::Passkey> =
267 crate::api::passkeys::load_passkeys(state, user.id)
268 .await?
269 .into_iter()
270 .map(|(_, k)| k)
271 .collect();
272 // Only reachable if every stored passkey became unreadable: the mode
273 // cannot be set without one, and the last one cannot be deleted under it.
274 if keys.is_empty() {
275 return Err(ApiError::new(
276 StatusCode::INTERNAL_SERVER_ERROR,
277 "this account requires a passkey but has none",
278 ));
279 }
280 let (options, auth) = rp.start_passkey_authentication(&keys).map_err(|e| {
281 tracing::warn!(error = ?e, "cannot start the second factor");
282 ApiError::localized(
283 StatusCode::INTERNAL_SERVER_ERROR,
284 "that passkey could not be used",
285 "err_passkey_failed",
286 )
287 })?;
288 let challenge = crate::api::passkeys::challenge(
289 crate::webauthn::Pending::Authenticate {
290 user_id: user.id,
291 state: Box::new(auth),
292 second_factor: true,
293 },
294 &options,
295 )?;
296 Ok(Json(LoginResp {
297 ok: false,
298 passkey_challenge: Some(challenge),
299 ..Default::default()
300 })
301 .into_response())
302}
303
304/// POST /api/auth/logout
305pub async fn logout(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
306 if let Some(token) = parse_session_cookie(&headers) {
307 let _ = state.db.delete_session(&token).await;
308 }
309 (
310 [(header::SET_COOKIE, clear_session_cookie(state.https()))],
311 Json(OkResp {}),
312 )
313 .into_response()
314}
315