1use std::time::Duration;
5
6use argon2::password_hash::{Salt, SaltString};
7use argon2::{Argon2, PasswordHash, PasswordHasher as _, PasswordVerifier as _};
8use axum::extract::{FromRequestParts, Request, State};
9use axum::http::header;
10use axum::http::request::Parts;
11use axum::middleware::Next;
12use axum::response::{IntoResponse as _, Redirect, Response};
13use axum_extra::extract::cookie::{Cookie, Key, SameSite, SignedCookieJar};
14use base64::Engine as _;
15use chrono::{DateTime, Utc};
16use rand::Rng as _;
17use secrecy::ExposeSecret as _;
18use sha2::{Digest as _, Sha256};
19use sqlx::SqlitePool;
20use subtle::ConstantTimeEq as _;
21use uuid::Uuid;
22
23use crate::config::Config;
24use crate::error::Error;
25use crate::state::AppState;
26
27pub type SessionRow = (Vec<u8>, String, String, DateTime<Utc>);
30
31pub type SetPasswordTokenRow = (Vec<u8>, DateTime<Utc>, Option<DateTime<Utc>>);
34
35fn build_session_cookie<'a>(
39 name: String,
40 value: String,
41 cfg: &Config,
42 max_age_seconds: i64,
43) -> Cookie<'a> {
44 let mut cookie = Cookie::new(name, value);
45 cookie.set_path("/");
46 cookie.set_http_only(true);
47 cookie.set_secure(cfg.cookie_secure);
48 cookie.set_same_site(SameSite::Lax);
49 cookie.set_max_age(time::Duration::seconds(max_age_seconds));
50 cookie
51}
52
53#[must_use]
55pub fn removal_cookie<'a>(cfg: &Config) -> Cookie<'a> {
56 let mut cookie = Cookie::new(cfg.cookie_name.clone(), String::new());
57 cookie.set_path("/");
58 cookie.set_http_only(true);
59 cookie.set_secure(cfg.cookie_secure);
60 cookie.set_same_site(SameSite::Lax);
61 cookie.set_max_age(time::Duration::seconds(0));
62 cookie
63}
64
65#[derive(Debug, Clone)]
68pub struct CurrentUser {
69 pub user_id: Uuid,
71 pub legacy_name: String,
73 pub username: String,
75}
76
77impl FromRequestParts<AppState> for CurrentUser {
78 type Rejection = Error;
79
80 async fn from_request_parts(
81 parts: &mut Parts,
82 state: &AppState,
83 ) -> Result<Self, Self::Rejection> {
84 let jar = SignedCookieJar::<Key>::from_headers(&parts.headers, state.cookie_key.clone());
85 let Some(cookie) = jar.get(&state.config.cookie_name) else {
86 return Err(Error::Unauthenticated);
87 };
88 let Some(session_id) = decode_session_id(cookie.value()) else {
89 return Err(Error::Unauthenticated);
90 };
91 let session_id_hash = Sha256::digest(session_id).to_vec();
92 let now = Utc::now();
93 let row: Option<SessionRow> = sqlx::query_as(
95 "SELECT users.user_id, users.legacy_name, users.username, sessions.expires_at \
96 FROM sessions \
97 JOIN users ON users.user_id = sessions.user_id \
98 WHERE sessions.session_id_hash = ?1",
99 )
100 .bind(session_id_hash.as_slice())
101 .fetch_optional(&state.db)
102 .await
103 .map_err(|err| {
104 tracing::error!("session lookup failed: {err}");
105 Error::Database
106 })?;
107 let Some((uid_bytes, legacy_name, username, expires_at)) = row else {
108 return Err(Error::Unauthenticated);
109 };
110 if expires_at <= now {
111 return Err(Error::Unauthenticated);
112 }
113 let user_id = uuid_from_bytes(&uid_bytes).ok_or(Error::Unauthenticated)?;
114 let stale_cutoff = now
122 .checked_sub_signed(chrono::Duration::seconds(60))
123 .unwrap_or(chrono::DateTime::<Utc>::MIN_UTC);
124 if let Err(err) = sqlx::query(
125 "UPDATE sessions SET last_seen_at = ?1 \
126 WHERE session_id_hash = ?2 AND last_seen_at < ?3",
127 )
128 .bind(now)
129 .bind(session_id_hash.as_slice())
130 .bind(stale_cutoff)
131 .execute(&state.db)
132 .await
133 {
134 tracing::warn!("failed to bump sessions.last_seen_at: {err}");
135 }
136 Ok(Self {
137 user_id,
138 legacy_name,
139 username,
140 })
141 }
142}
143
144pub async fn require_session(State(state): State<AppState>, req: Request, next: Next) -> Response {
156 let (mut parts, body) = req.into_parts();
157 match CurrentUser::from_request_parts(&mut parts, &state).await {
158 Ok(user) => {
159 parts.extensions.insert(user);
160 let req = Request::from_parts(parts, body);
161 next.run(req).await
162 }
163 Err(_) => {
164 let wants_html = parts.method == axum::http::Method::GET && prefers_html(&parts);
165 if wants_html {
166 let next_url = parts
167 .uri
168 .path_and_query()
169 .map_or_else(|| "/".to_owned(), |pq| pq.as_str().to_owned());
170 let encoded = percent_encode(&next_url).unwrap_or_else(|| "/".to_owned());
171 let target = format!("/login?next={encoded}");
172 Redirect::to(&target).into_response()
173 } else {
174 Error::Unauthenticated.into_response()
175 }
176 }
177 }
178}
179
180fn prefers_html(parts: &Parts) -> bool {
184 parts
185 .headers
186 .get(header::ACCEPT)
187 .and_then(|v| v.to_str().ok())
188 .is_some_and(|s| s.contains("text/html"))
189}
190
191fn percent_encode(input: &str) -> Option<String> {
194 let mut out = String::with_capacity(input.len());
197 for byte in input.bytes() {
198 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'/') {
199 out.push(char::from(byte));
200 } else {
201 use std::fmt::Write as _;
202 write!(out, "%{byte:02X}").ok()?;
203 }
204 }
205 Some(out)
206}
207
208#[derive(Debug)]
211pub struct LslBearer;
212
213impl FromRequestParts<AppState> for LslBearer {
214 type Rejection = Error;
215
216 async fn from_request_parts(
217 parts: &mut Parts,
218 state: &AppState,
219 ) -> Result<Self, Self::Rejection> {
220 let Some(value) = parts.headers.get(header::AUTHORIZATION) else {
221 return Err(Error::Unauthenticated);
222 };
223 let bytes = value.as_bytes();
224 let prefix = b"Bearer ";
225 if bytes.len() <= prefix.len() || !bytes.starts_with(prefix) {
226 return Err(Error::Unauthenticated);
227 }
228 let presented = bytes.get(prefix.len()..).unwrap_or(&[]);
229 let expected = state
230 .config
231 .lsl_registration_bearer_token
232 .expose_secret()
233 .as_bytes();
234 let len_ok = presented.len() == expected.len();
238 let lhs = if len_ok { presented } else { expected };
239 let eq: bool = lhs.ct_eq(expected).into();
240 if len_ok && eq {
241 Ok(Self)
242 } else {
243 Err(Error::Unauthenticated)
244 }
245 }
246}
247
248pub fn hash_password(password: &str) -> Result<String, Error> {
256 let mut salt_bytes = [0_u8; Salt::RECOMMENDED_LENGTH];
257 rand::rng().fill_bytes(&mut salt_bytes);
258 let salt =
259 SaltString::encode_b64(&salt_bytes).map_err(|err| Error::PasswordHash(err.to_string()))?;
260 let argon = Argon2::default();
261 let hash = argon
262 .hash_password(password.as_bytes(), &salt)
263 .map_err(|err| Error::PasswordHash(err.to_string()))?;
264 Ok(hash.to_string())
265}
266
267pub fn verify_password(password: &str, stored_hash: &str) -> Result<bool, Error> {
274 let parsed =
275 PasswordHash::new(stored_hash).map_err(|err| Error::PasswordHash(err.to_string()))?;
276 match Argon2::default().verify_password(password.as_bytes(), &parsed) {
277 Ok(()) => Ok(true),
278 Err(argon2::password_hash::Error::Password) => Ok(false),
279 Err(err) => Err(Error::PasswordHash(err.to_string())),
280 }
281}
282
283#[must_use]
287pub fn generate_token() -> (String, Vec<u8>) {
288 let mut raw = [0_u8; 32];
289 rand::rng().fill_bytes(&mut raw);
290 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
291 let hash = Sha256::digest(raw).to_vec();
292 (encoded, hash)
293}
294
295#[must_use]
298pub fn hash_token(raw_token: &str) -> Option<Vec<u8>> {
299 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
300 .decode(raw_token)
301 .ok()?;
302 if bytes.len() != 32 {
303 return None;
304 }
305 Some(Sha256::digest(&bytes).to_vec())
306}
307
308#[must_use]
312pub fn generate_session_id() -> ([u8; 32], String) {
313 let mut raw = [0_u8; 32];
314 rand::rng().fill_bytes(&mut raw);
315 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
316 (raw, encoded)
317}
318
319fn decode_session_id(value: &str) -> Option<[u8; 32]> {
321 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
322 .decode(value)
323 .ok()?;
324 if bytes.len() != 32 {
325 return None;
326 }
327 let mut out = [0_u8; 32];
328 out.copy_from_slice(&bytes);
329 Some(out)
330}
331
332#[must_use]
335pub fn uuid_from_bytes(bytes: &[u8]) -> Option<Uuid> {
336 let arr: [u8; 16] = bytes.try_into().ok()?;
337 Some(Uuid::from_bytes(arr))
338}
339
340pub async fn create_session(
347 pool: &SqlitePool,
348 cfg: &Config,
349 user_id: Uuid,
350 client_ip: Option<String>,
351) -> Result<Cookie<'static>, Error> {
352 let (raw, encoded) = generate_session_id();
353 let now = Utc::now();
354 let expires_at = now
355 .checked_add_signed(chrono::Duration::seconds(cfg.session_ttl_seconds))
356 .unwrap_or(chrono::DateTime::<Utc>::MAX_UTC);
357 let session_id_hash = Sha256::digest(raw).to_vec();
358 sqlx::query(
359 "INSERT INTO sessions \
360 (session_id_hash, user_id, expires_at, created_at, last_seen_at, client_ip) \
361 VALUES (?1, ?2, ?3, ?4, ?4, ?5)",
362 )
363 .bind(session_id_hash)
364 .bind(user_id.as_bytes().to_vec())
365 .bind(expires_at)
366 .bind(now)
367 .bind(client_ip)
368 .execute(pool)
369 .await
370 .map_err(|err| {
371 tracing::error!("failed to insert session row: {err}");
372 Error::Database
373 })?;
374 Ok(build_session_cookie(
375 cfg.cookie_name.clone(),
376 encoded,
377 cfg,
378 cfg.session_ttl_seconds,
379 ))
380}
381
382pub async fn delete_session(pool: &SqlitePool, session_id: &[u8]) -> Result<(), Error> {
389 let session_id_hash = Sha256::digest(session_id).to_vec();
390 sqlx::query("DELETE FROM sessions WHERE session_id_hash = ?1")
391 .bind(session_id_hash)
392 .execute(pool)
393 .await
394 .map_err(|err| {
395 tracing::error!("failed to delete session row: {err}");
396 Error::Database
397 })?;
398 Ok(())
399}
400
401#[must_use]
404pub fn session_id_from_jar(jar: &SignedCookieJar, cookie_name: &str) -> Option<[u8; 32]> {
405 let cookie = jar.get(cookie_name)?;
406 decode_session_id(cookie.value())
407}
408
409pub type UserRow = (Vec<u8>, String, String, Option<String>);
412
413pub async fn lookup_user_by_identifier(
422 pool: &SqlitePool,
423 identifier: &str,
424) -> Result<Option<UserRow>, Error> {
425 if let Ok(uuid) = Uuid::parse_str(identifier) {
426 let bytes = uuid.as_bytes().to_vec();
427 let row: Option<UserRow> = sqlx::query_as(
428 "SELECT user_id, legacy_name, username, password_hash FROM users WHERE user_id = ?1",
429 )
430 .bind(bytes)
431 .fetch_optional(pool)
432 .await
433 .map_err(|err| {
434 tracing::error!("user lookup (uuid) failed: {err}");
435 Error::Database
436 })?;
437 if row.is_some() {
438 return Ok(row);
439 }
440 }
441 let by_username: Option<UserRow> = sqlx::query_as(
442 "SELECT user_id, legacy_name, username, password_hash FROM users WHERE username = ?1",
443 )
444 .bind(identifier)
445 .fetch_optional(pool)
446 .await
447 .map_err(|err| {
448 tracing::error!("user lookup (username) failed: {err}");
449 Error::Database
450 })?;
451 if by_username.is_some() {
452 return Ok(by_username);
453 }
454 let by_legacy: Option<UserRow> = sqlx::query_as(
455 "SELECT user_id, legacy_name, username, password_hash FROM users WHERE legacy_name = ?1",
456 )
457 .bind(identifier)
458 .fetch_optional(pool)
459 .await
460 .map_err(|err| {
461 tracing::error!("user lookup (legacy_name) failed: {err}");
462 Error::Database
463 })?;
464 Ok(by_legacy)
465}
466
467pub async fn run_cleanup(pool: SqlitePool) {
470 let mut interval = tokio::time::interval(Duration::from_secs(300));
471 loop {
472 interval.tick().await;
473 let now = Utc::now();
474 if let Err(err) = sqlx::query("DELETE FROM sessions WHERE expires_at <= ?1")
475 .bind(now)
476 .execute(&pool)
477 .await
478 {
479 tracing::warn!("session cleanup failed: {err}");
480 }
481 if let Err(err) = sqlx::query(
482 "DELETE FROM set_password_tokens WHERE expires_at <= ?1 OR used_at IS NOT NULL",
483 )
484 .bind(now)
485 .execute(&pool)
486 .await
487 {
488 tracing::warn!("set-password token cleanup failed: {err}");
489 }
490 }
491}