Files
narl-workspace/src/auth.rs
T
nvrl c606169b01 test: cover session tokens, and run the suite in make check
auth.rs was the largest untested surface and the one where a mistake is
worth the most: the tests pin the round trip, expiry, secret rotation,
forged expiries, flipped signature bytes, malformed input, and that
credentials have to match in full rather than by prefix.

Also documents autosave, :export! and the security headers in the README.
2026-09-05 09:48:24 +02:00

171 lines
5.6 KiB
Rust

use axum::{
extract::FromRequestParts,
http::{header, request::Parts},
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::{config::Config, error::AppError, state::AppState};
pub const COOKIE_NAME: &str = "diary_session";
type HmacSha256 = Hmac<Sha256>;
fn sign(secret: &[u8], payload: &str) -> String {
let mut mac = HmacSha256::new_from_slice(secret).expect("hmac accepts any key length");
mac.update(payload.as_bytes());
B64.encode(mac.finalize().into_bytes())
}
/// A session token is `base64(expiry_unix_seconds).base64(hmac)`. There is no
/// server-side session store: rotating `DIARY_SECRET` invalidates every session.
pub fn issue_token(config: &Config) -> String {
let expires = crate::now() + config.session_days * 86_400;
let payload = B64.encode(expires.to_string());
let signature = sign(&config.secret, &payload);
format!("{payload}.{signature}")
}
pub fn verify_token(config: &Config, token: &str) -> bool {
let Some((payload, signature)) = token.split_once('.') else {
return false;
};
let expected = sign(&config.secret, payload);
if !bool::from(expected.as_bytes().ct_eq(signature.as_bytes())) {
return false;
}
let Ok(decoded) = B64.decode(payload) else {
return false;
};
let Ok(expires) = String::from_utf8_lossy(&decoded).parse::<i64>() else {
return false;
};
expires > crate::now()
}
pub fn credentials_match(config: &Config, username: &str, password: &str) -> bool {
let user_ok = username.as_bytes().ct_eq(config.username.as_bytes());
let pass_ok = password.as_bytes().ct_eq(config.password.as_bytes());
bool::from(user_ok & pass_ok)
}
pub fn session_cookie(config: &Config, token: &str) -> String {
let max_age = config.session_days * 86_400;
let secure = if config.secure_cookie { "; Secure" } else { "" };
format!(
"{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}"
)
}
pub fn clear_cookie(config: &Config) -> String {
let secure = if config.secure_cookie { "; Secure" } else { "" };
format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}")
}
fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> {
parts
.headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|pair| pair.split_once('='))
.find(|(k, _)| k.trim() == name)
.map(|(_, v)| v.trim())
}
/// Extractor that rejects the request unless a valid session cookie is present.
pub struct Session;
impl FromRequestParts<AppState> for Session {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let token = cookie_value(parts, COOKIE_NAME).ok_or(AppError::Unauthorized)?;
if verify_token(&state.config, token) {
Ok(Session)
} else {
Err(AppError::Unauthorized)
}
}
}
#[cfg(test)]
mod tests {
use super::{credentials_match, issue_token, verify_token};
use crate::config::Config;
fn config(session_days: i64) -> Config {
Config {
username: "narl".into(),
password: "hunter2".into(),
secret: b"a-secret-of-at-least-16-bytes".to_vec(),
bind: "127.0.0.1:0".parse().unwrap(),
data_dir: ".".into(),
session_days,
max_upload_bytes: 1,
secure_cookie: false,
}
}
#[test]
fn a_fresh_token_verifies() {
let config = config(30);
assert!(verify_token(&config, &issue_token(&config)));
}
#[test]
fn an_expired_token_does_not() {
let config = config(-1);
assert!(!verify_token(&config, &issue_token(&config)));
}
#[test]
fn rotating_the_secret_invalidates_every_session() {
let token = issue_token(&config(30));
let mut rotated = config(30);
rotated.secret = b"a-different-secret-entirely".to_vec();
assert!(!verify_token(&rotated, &token));
}
#[test]
fn a_tampered_token_does_not_verify() {
let config = config(30);
let token = issue_token(&config);
let (payload, signature) = token.split_once('.').unwrap();
// A far-future expiry the holder signed themselves.
let forged = base64::Engine::encode(&super::B64, "99999999999");
assert!(!verify_token(&config, &format!("{forged}.{signature}")));
// The real expiry with the last byte of the signature flipped.
let mut bytes = signature.as_bytes().to_vec();
bytes[0] = if bytes[0] == b'A' { b'B' } else { b'A' };
let flipped = String::from_utf8(bytes).unwrap();
assert!(!verify_token(&config, &format!("{payload}.{flipped}")));
}
#[test]
fn malformed_tokens_are_rejected_rather_than_panicking() {
let config = config(30);
for token in ["", ".", "no-dot", "!!!.!!!", "a.b.c", &".".repeat(100)] {
assert!(!verify_token(&config, token), "{token:?}");
}
}
#[test]
fn credentials_must_match_exactly() {
let config = config(30);
assert!(credentials_match(&config, "narl", "hunter2"));
assert!(!credentials_match(&config, "narl", "hunter"));
assert!(!credentials_match(&config, "narl", "hunter22"));
assert!(!credentials_match(&config, "nar", "hunter2"));
assert!(!credentials_match(&config, "", ""));
}
}