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