c942d73c66
Taken from a -W clippy::pedantic scan, no allow added: map_or/map_or_else, method references instead of closures, clone_from, write! instead of push_str(format!), borrowed Option<&str>, push_of without a needless Option, the envelope's entity moved instead of cloned, the scheduler's plan() no longer a needless async fn (its doc now says what it does on failure), sort_unstable, digit separators and inline format args in tests. Not taken: missing # Errors/# Panics docs, must_use, doc backticks, assert!(x.is_empty()) in tests, too_many_lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
243 lines
8.7 KiB
Rust
243 lines
8.7 KiB
Rust
//! 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 <token>`, 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 <token>` (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<i64> {
|
|
headers
|
|
.get(APP_VERSION_HEADER)?
|
|
.to_str()
|
|
.ok()?
|
|
.trim()
|
|
.parse::<i64>()
|
|
.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<String> {
|
|
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<Self, AppError> {
|
|
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<String> = row.try_get("revoked_at")?;
|
|
if revoked_at.is_some() {
|
|
return Err(AppError::device_revoked());
|
|
}
|
|
let parse_id = |column: &str| -> Result<Uuid, AppError> {
|
|
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<AppState> for AuthDevice {
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
if let Some(device) = parts.extensions.get::<AuthDevice>() {
|
|
return Ok(device.clone());
|
|
}
|
|
let device = Self::authenticate(&parts.headers, state).await?;
|
|
parts.extensions.insert(device.clone());
|
|
Ok(device)
|
|
}
|
|
}
|
|
|
|
impl FromRequestParts<AppState> for AdminDevice {
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
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);
|
|
}
|
|
}
|