//! Authentication and authorization (SPEC §8.2, §8.4, §14.3.1). //! //! * [`require_protocol`] is the middleware on every authenticated route: `X-Kvitt-Protocol` missing or not `1` is //! 426 `protocol_unsupported`. It runs before the token check, so an outdated app always learns it must update. //! * [`AuthDevice`] is the extractor for authenticated handlers: `Authorization: Bearer `, SHA-256 hex lookup //! of a device; missing, malformed or unknown is 401 `unauthorized`, a revoked device 401 `device_revoked`. //! `last_seen_at`, `app_version_code` and `app_version_name` are refreshed at most once per hour per device. //! * [`AdminDevice`] additionally requires the `admin` role (403 `forbidden`, SPEC §8.4). use std::fmt::Write as _; use axum::extract::{FromRequestParts, Request}; use axum::http::HeaderMap; use axum::http::request::Parts; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use chrono::Duration; use sha2::{Digest, Sha256}; use sqlx::Row; use uuid::Uuid; use crate::AppState; use crate::error::AppError; use crate::time::{format_ts, parse_ts}; use crate::wire::Role; /// Header carrying the sync protocol version (SPEC §14.3.1). pub const PROTOCOL_HEADER: &str = "x-kvitt-protocol"; /// The only supported protocol version. pub const PROTOCOL_VERSION: &str = "1"; /// Header carrying the app's `versionCode`. pub const APP_VERSION_HEADER: &str = "x-kvitt-app-version"; /// Header carrying the app's `versionName` (schema 3, shown on S-20). pub const APP_VERSION_NAME_HEADER: &str = "x-kvitt-app-version-name"; /// Longest `versionName` stored (the `devices.app_version_name` CHECK of migration 0003). pub const APP_VERSION_NAME_MAX: usize = 32; /// Prefix of every device token (SPEC §8.2). pub const TOKEN_PREFIX: &str = "kvt_"; /// Length of a token: `kvt_` plus 43 base64url characters (32 bytes, no padding). pub const TOKEN_LEN: usize = 47; /// How often `last_seen_at` / `app_version_code` / `app_version_name` are written at most (SPEC §8.2). const TOUCH_INTERVAL: Duration = Duration::hours(1); /// Lowercase hex SHA-256 of `text` (token and invite code hashes, SPEC §8.2, §8.3). pub fn sha256_hex(text: &str) -> String { let digest = Sha256::digest(text.as_bytes()); let mut hex = String::with_capacity(64); for byte in &digest { let _ = write!(hex, "{byte:02x}"); } hex } /// Whether `token` has the shape of a device token (prefix, length, base64url alphabet). pub fn is_token_shaped(token: &str) -> bool { token.len() == TOKEN_LEN && token.starts_with(TOKEN_PREFIX) && token[TOKEN_PREFIX.len()..] .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } /// Middleware: 426 `protocol_unsupported` unless `X-Kvitt-Protocol: 1`. pub async fn require_protocol(request: Request, next: Next) -> Response { let ok = request .headers() .get(PROTOCOL_HEADER) .and_then(|v| v.to_str().ok()) .is_some_and(|v| v.trim() == PROTOCOL_VERSION); if ok { next.run(request).await } else { AppError::protocol_unsupported().into_response() } } /// The calling device, authenticated. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthDevice { pub device_id: Uuid, pub user_id: Uuid, pub role: Role, } /// The calling device of the admin user. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AdminDevice(pub AuthDevice); /// The token of `Authorization: Bearer ` (scheme case-insensitive, token trimmed), shape unchecked. pub fn bearer_token(headers: &HeaderMap) -> Option<&str> { let value = headers .get(axum::http::header::AUTHORIZATION)? .to_str() .ok()?; let (scheme, token) = value.split_once(' ')?; scheme.eq_ignore_ascii_case("bearer").then(|| token.trim()) } /// The `X-Kvitt-App-Version` value: a non-negative integer; anything else is ignored (the stored value is kept). pub fn app_version_code(headers: &HeaderMap) -> Option { headers .get(APP_VERSION_HEADER)? .to_str() .ok()? .trim() .parse::() .ok() .filter(|v| *v >= 0) } /// The `X-Kvitt-App-Version-Name` value: trimmed, 1 to [`APP_VERSION_NAME_MAX`] characters; anything else is /// ignored (the stored value is kept), as an unparsable `X-Kvitt-App-Version` is. pub fn app_version_name(headers: &HeaderMap) -> Option { let value = headers.get(APP_VERSION_NAME_HEADER)?.to_str().ok()?.trim(); (1..=APP_VERSION_NAME_MAX) .contains(&value.chars().count()) .then(|| value.to_owned()) } impl AuthDevice { /// Authenticates the request headers against the database. pub async fn authenticate(headers: &HeaderMap, state: &AppState) -> Result { let token = bearer_token(headers).ok_or_else(AppError::unauthorized)?; if !is_token_shaped(token) { return Err(AppError::unauthorized()); } let row = sqlx::query( "SELECT d.id, d.user_id, d.revoked_at, d.last_seen_at, u.role \ FROM devices d JOIN users u ON u.id = d.user_id WHERE d.token_hash = ?", ) .bind(sha256_hex(token)) .fetch_optional(&state.db) .await? .ok_or_else(AppError::unauthorized)?; let revoked_at: Option = row.try_get("revoked_at")?; if revoked_at.is_some() { return Err(AppError::device_revoked()); } let parse_id = |column: &str| -> Result { let text: String = row.try_get(column)?; Uuid::parse_str(&text).map_err(|e| { AppError::internal(format!("devices/users.{column} is not a uuid: {e}")) }) }; let device_id = parse_id("id")?; let user_id = parse_id("user_id")?; let role_text: String = row.try_get("role")?; let role = Role::from_db(&role_text) .ok_or_else(|| AppError::internal(format!("unknown role {role_text:?}")))?; let last_seen: String = row.try_get("last_seen_at")?; let now = state.clock.now(); let due = parse_ts(&last_seen).is_none_or(|seen| now - seen >= TOUCH_INTERVAL); if due { let mut tx = crate::db::begin_immediate(&state.db).await?; sqlx::query( "UPDATE devices SET last_seen_at = ?, app_version_code = COALESCE(?, app_version_code), \ app_version_name = COALESCE(?, app_version_name) WHERE id = ?", ) .bind(format_ts(now)) .bind(app_version_code(headers)) .bind(app_version_name(headers)) .bind(device_id.to_string()) .execute(&mut *tx) .await?; tx.commit().await?; } tracing::Span::current().record("device_id", tracing::field::display(device_id)); Ok(Self { device_id, user_id, role, }) } } impl FromRequestParts for AuthDevice { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { if let Some(device) = parts.extensions.get::() { return Ok(device.clone()); } let device = Self::authenticate(&parts.headers, state).await?; parts.extensions.insert(device.clone()); Ok(device) } } impl FromRequestParts for AdminDevice { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let device = AuthDevice::from_request_parts(parts, state).await?; if device.role == Role::Admin { Ok(Self(device)) } else { Err(AppError::forbidden()) } } } #[cfg(test)] mod tests { use super::*; #[test] fn sha256_hex_known_value() { assert_eq!( sha256_hex("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" ); } #[test] fn token_shape() { let good = format!("kvt_{}", "A".repeat(43)); assert!(is_token_shaped(&good)); assert!(!is_token_shaped(&format!("kvt_{}", "A".repeat(42)))); assert!(!is_token_shaped(&format!("xyz_{}", "A".repeat(43)))); assert!(!is_token_shaped(&format!("kvt_{}=", "A".repeat(42)))); } #[test] fn bearer_parsing() { let mut headers = HeaderMap::new(); assert_eq!(bearer_token(&headers), None); headers.insert("authorization", "Bearer kvt_x".parse().unwrap()); assert_eq!(bearer_token(&headers), Some("kvt_x")); headers.insert("authorization", "Basic abc".parse().unwrap()); assert_eq!(bearer_token(&headers), None); } }