Accessibility (95 -> 100). Light `--muted` #7a7568 was 4.14:1 on `--paper` and 3.72:1 on `--paper-2`, under the 4.5:1 AA floor for normal text — and muted is the ears line, the nav, bylines, the footer, table headers and placeholders, so it was most of the page's small type. An audit of every text token against both surfaces turned up one more: light `--warn` #8a6d1f at 4.42 / 3.97. Both are darkened just enough to clear 4.5 on the darker of the two surfaces, at constant hue and saturation, so the warm grey and the ochre read the same: --muted #7a7568 -> #6b665a 4.14/3.72 -> 5.16/4.63 --warn #8a6d1f -> #7d631c 4.42/3.97 -> 5.16/4.63 Everything else already passed on both surfaces (light ink 15.54/13.96, ink-2 7.83/7.03, accent 6.73/6.04, loved 5.44/4.89, good 5.29/4.76, down 5.96/5.36), as did the whole dark set against #151513 / #1e1d1a (muted 5.31/4.90 is its floor), so the dark blocks are untouched and stay identical to each other. `.badge` tints sit on near-paper, so the new token values carry it. SEO (91 -> 100). The layout had no `<meta name="description">`. `Page` now carries one — `DEFAULT_DESCRIPTION` for the site, so all ~26 `Page::new` callers keep their signature — with `with_description` for the pages worth writing one for: the issue page (issue number, date and the counts the masthead already prints), the archive, the empty-latest landing page and sign-in. Dashboard pages keep the default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
545 lines
17 KiB
Rust
545 lines
17 KiB
Rust
use std::collections::HashSet;
|
|
use std::str::FromStr;
|
|
|
|
use askama::Template;
|
|
use async_trait::async_trait;
|
|
use axum::Form;
|
|
use axum::extract::{Query, Request, State};
|
|
use axum::http::{StatusCode, header};
|
|
use axum::middleware::Next;
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum_login::tower_sessions::session::{Id, Record};
|
|
use axum_login::tower_sessions::{SessionStore, session_store};
|
|
use axum_login::{AuthUser, AuthnBackend, AuthzBackend};
|
|
use serde::Deserialize;
|
|
use sqlx::{Row, SqlitePool};
|
|
use time::OffsetDateTime;
|
|
|
|
use crate::db::{Db, fmt_ts};
|
|
use crate::server::AppState;
|
|
use crate::web::users::{self, Role, User};
|
|
use crate::web::{Html, Page, WebError};
|
|
|
|
const DUMMY_HASH: &str = "$argon2i$v=19$m=65536,t=1,p=1$c29tZXNhbHQAAAAAAAAAAA$+r0d29hqEB0yasKr55ZgICsQGSkl0v0kgwhd+U3wyRo";
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct SqliteSessionStore {
|
|
pool: SqlitePool,
|
|
}
|
|
|
|
impl SqliteSessionStore {
|
|
pub fn new(pool: SqlitePool) -> Self {
|
|
Self { pool }
|
|
}
|
|
|
|
pub async fn delete_expired(&self) -> Result<u64, sqlx::Error> {
|
|
let result = sqlx::query("DELETE FROM sessions WHERE expiry <= unixepoch()")
|
|
.execute(&self.pool)
|
|
.await?;
|
|
Ok(result.rows_affected())
|
|
}
|
|
|
|
pub async fn delete_for_user(&self, user_id: i64) -> Result<u64, sqlx::Error> {
|
|
let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?")
|
|
.bind(user_id)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
Ok(result.rows_affected())
|
|
}
|
|
}
|
|
|
|
fn store_error(error: impl std::fmt::Display) -> session_store::Error {
|
|
session_store::Error::Backend(error.to_string())
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SessionStore for SqliteSessionStore {
|
|
/// Insert a brand-new record, drawing a fresh id on the (astronomically
|
|
/// unlikely) collision with an existing row instead of overwriting it.
|
|
async fn create(&self, record: &mut Record) -> session_store::Result<()> {
|
|
loop {
|
|
let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE id = ?")
|
|
.bind(record.id.to_string())
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.map_err(store_error)?;
|
|
if exists == 0 {
|
|
return self.save(record).await;
|
|
}
|
|
record.id = Id::default();
|
|
}
|
|
}
|
|
|
|
async fn save(&self, record: &Record) -> session_store::Result<()> {
|
|
let data = serde_json::to_string(&record.data).map_err(store_error)?;
|
|
let user_id = record
|
|
.data
|
|
.get("axum-login.data")
|
|
.and_then(|value| value.get("user_id"))
|
|
.and_then(serde_json::Value::as_i64);
|
|
let now = fmt_ts(jiff::Timestamp::now());
|
|
sqlx::query(
|
|
"INSERT INTO sessions (id, data, expiry, user_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET data = excluded.data, expiry = excluded.expiry,
|
|
user_id = excluded.user_id, updated_at = excluded.updated_at",
|
|
)
|
|
.bind(record.id.to_string())
|
|
.bind(data)
|
|
.bind(record.expiry_date.unix_timestamp())
|
|
.bind(user_id)
|
|
.bind(&now)
|
|
.bind(now)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(store_error)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn load(&self, id: &Id) -> session_store::Result<Option<Record>> {
|
|
let row =
|
|
sqlx::query("SELECT data, expiry FROM sessions WHERE id = ? AND expiry > unixepoch()")
|
|
.bind(id.to_string())
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(store_error)?;
|
|
row.map(|row| {
|
|
let data = serde_json::from_str(&row.get::<String, _>("data")).map_err(store_error)?;
|
|
let expiry_date =
|
|
OffsetDateTime::from_unix_timestamp(row.get("expiry")).map_err(store_error)?;
|
|
Ok(Record {
|
|
id: *id,
|
|
data,
|
|
expiry_date,
|
|
})
|
|
})
|
|
.transpose()
|
|
}
|
|
|
|
async fn delete(&self, id: &Id) -> session_store::Result<()> {
|
|
sqlx::query("DELETE FROM sessions WHERE id = ?")
|
|
.bind(id.to_string())
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(store_error)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Credentials {
|
|
pub username: String,
|
|
pub password: String,
|
|
#[serde(default)]
|
|
pub next: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum BackendError {
|
|
#[error(transparent)]
|
|
Db(#[from] crate::db::DbError),
|
|
#[error("password verification task failed: {0}")]
|
|
Join(#[from] tokio::task::JoinError),
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct Backend {
|
|
db: Db,
|
|
}
|
|
|
|
impl Backend {
|
|
pub fn new(db: Db) -> Self {
|
|
Self { db }
|
|
}
|
|
}
|
|
|
|
impl AuthUser for User {
|
|
type Id = i64;
|
|
|
|
fn id(&self) -> Self::Id {
|
|
self.id
|
|
}
|
|
|
|
fn session_auth_hash(&self) -> &[u8] {
|
|
self.password_hash.as_bytes()
|
|
}
|
|
}
|
|
|
|
impl AuthnBackend for Backend {
|
|
type User = User;
|
|
type Credentials = Credentials;
|
|
type Error = BackendError;
|
|
|
|
async fn authenticate(&self, creds: Credentials) -> Result<Option<User>, BackendError> {
|
|
let user = users::find_by_username(&self.db, &creds.username).await?;
|
|
let hash = user
|
|
.as_ref()
|
|
.map(|user| user.password_hash.clone())
|
|
.unwrap_or_else(|| DUMMY_HASH.to_string());
|
|
let password = creds.password;
|
|
let valid =
|
|
tokio::task::spawn_blocking(move || users::verify_password(&hash, &password)).await?;
|
|
Ok(user.filter(|user| valid && !user.disabled))
|
|
}
|
|
|
|
async fn get_user(&self, id: &i64) -> Result<Option<User>, BackendError> {
|
|
Ok(users::find_by_id(&self.db, *id)
|
|
.await?
|
|
.filter(|user| !user.disabled))
|
|
}
|
|
}
|
|
|
|
impl AuthzBackend for Backend {
|
|
type Permission = Role;
|
|
|
|
async fn get_user_permissions(&self, user: &User) -> Result<HashSet<Role>, BackendError> {
|
|
let permissions = match user.role {
|
|
Role::Admin => [Role::User, Role::Admin].into_iter().collect(),
|
|
Role::User => [Role::User].into_iter().collect(),
|
|
};
|
|
Ok(permissions)
|
|
}
|
|
}
|
|
|
|
pub type AuthSession = axum_login::AuthSession<Backend>;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Viewer {
|
|
pub id: i64,
|
|
pub username: String,
|
|
pub role: Role,
|
|
}
|
|
|
|
impl From<User> for Viewer {
|
|
fn from(user: User) -> Self {
|
|
Self {
|
|
id: user.id,
|
|
username: user.username,
|
|
role: user.role,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn valid_next(next: Option<&str>) -> &str {
|
|
next.filter(|next| next.starts_with('/') && !next.starts_with("//"))
|
|
.unwrap_or("/")
|
|
}
|
|
|
|
pub async fn require_same_origin(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
if request.method() != axum::http::Method::POST {
|
|
return next.run(request).await;
|
|
}
|
|
let headers = request.headers();
|
|
if let Some(site) = headers
|
|
.get("sec-fetch-site")
|
|
.and_then(|value| value.to_str().ok())
|
|
{
|
|
if !matches!(site, "same-origin" | "none") {
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
} else if let Some(origin) = request_origin(headers) {
|
|
let config = state.config();
|
|
let public_origin = url::Url::parse(&config.server.public_url)
|
|
.ok()
|
|
.map(|url| url.origin().ascii_serialization());
|
|
let host_origin = headers
|
|
.get(header::HOST)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(|host| format!("{}://{host}", forwarded_scheme(headers)));
|
|
if public_origin.as_deref() != Some(origin.as_str())
|
|
&& host_origin.as_deref() != Some(origin.as_str())
|
|
{
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
}
|
|
next.run(request).await
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
pub struct LoginQuery {
|
|
#[serde(default)]
|
|
next: Option<String>,
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "login.html")]
|
|
struct LoginTemplate {
|
|
page: Page,
|
|
next: String,
|
|
error: String,
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "account.html")]
|
|
struct AccountTemplate {
|
|
page: Page,
|
|
error: String,
|
|
}
|
|
|
|
/// The `<meta name="description">` for both renders of the sign-in page.
|
|
const LOGIN_DESCRIPTION: &str = concat!(
|
|
"Sign in to The Daily EPUB to read the full issue, ",
|
|
"rate what you read, and download the morning's editions."
|
|
);
|
|
|
|
pub async fn login_page(auth: AuthSession, Query(query): Query<LoginQuery>) -> Response {
|
|
let viewer = auth.user().await.map(Viewer::from);
|
|
Html(LoginTemplate {
|
|
page: Page::new("Sign in", viewer, "login").with_description(LOGIN_DESCRIPTION),
|
|
next: valid_next(query.next.as_deref()).to_string(),
|
|
error: String::new(),
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
pub async fn login(
|
|
State(state): State<AppState>,
|
|
auth: AuthSession,
|
|
Form(credentials): Form<Credentials>,
|
|
) -> Result<Response, WebError> {
|
|
let destination = valid_next(credentials.next.as_deref()).to_string();
|
|
match auth
|
|
.authenticate(credentials)
|
|
.await
|
|
.map_err(|error| WebError::Internal(error.into()))?
|
|
{
|
|
Some(user) => {
|
|
auth.login(&user)
|
|
.await
|
|
.map_err(|error| WebError::Internal(error.into()))?;
|
|
sqlx::query("UPDATE users SET last_login_at = ? WHERE id = ?")
|
|
.bind(fmt_ts(jiff::Timestamp::now()))
|
|
.bind(user.id)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.map_err(crate::db::DbError::from)?;
|
|
Ok(axum::response::Redirect::to(&destination).into_response())
|
|
}
|
|
None => Ok((
|
|
StatusCode::UNAUTHORIZED,
|
|
Html(LoginTemplate {
|
|
page: Page::new("Sign in", None, "login").with_description(LOGIN_DESCRIPTION),
|
|
next: destination,
|
|
error: "invalid username or password".into(),
|
|
}),
|
|
)
|
|
.into_response()),
|
|
}
|
|
}
|
|
|
|
pub async fn logout(auth: AuthSession) -> Result<Response, WebError> {
|
|
auth.logout()
|
|
.await
|
|
.map_err(|error| WebError::Internal(error.into()))?;
|
|
Ok(axum::response::Redirect::to("/").into_response())
|
|
}
|
|
|
|
pub async fn account(auth: AuthSession) -> Result<Response, WebError> {
|
|
let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
|
|
next: "/account".into(),
|
|
})?;
|
|
Ok(Html(AccountTemplate {
|
|
page: Page::new("Account", Some(user.into()), "account"),
|
|
error: String::new(),
|
|
})
|
|
.into_response())
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PasswordForm {
|
|
current_password: String,
|
|
new_password: String,
|
|
confirm_password: String,
|
|
}
|
|
|
|
pub async fn change_password(
|
|
State(state): State<AppState>,
|
|
auth: AuthSession,
|
|
Form(form): Form<PasswordForm>,
|
|
) -> Result<Response, WebError> {
|
|
let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
|
|
next: "/account".into(),
|
|
})?;
|
|
let hash = user.password_hash.clone();
|
|
let current = form.current_password;
|
|
let valid = tokio::task::spawn_blocking(move || users::verify_password(&hash, ¤t))
|
|
.await
|
|
.map_err(|error| WebError::Internal(error.into()))?;
|
|
let problem = if !valid {
|
|
Some("current password is incorrect".to_string())
|
|
} else if form.new_password != form.confirm_password {
|
|
Some("the new passwords do not match".to_string())
|
|
} else {
|
|
users::validate_password(&form.new_password)
|
|
.err()
|
|
.map(|error| error.to_string())
|
|
};
|
|
if let Some(error) = problem {
|
|
return Ok((
|
|
StatusCode::BAD_REQUEST,
|
|
Html(AccountTemplate {
|
|
page: Page::new("Account", Some(user.into()), "account"),
|
|
error,
|
|
}),
|
|
)
|
|
.into_response());
|
|
}
|
|
let password = form.new_password;
|
|
let password_hash = tokio::task::spawn_blocking(move || users::hash_password(&password))
|
|
.await
|
|
.map_err(|error| WebError::Internal(error.into()))?;
|
|
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
|
.bind(&password_hash)
|
|
.bind(user.id)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.map_err(crate::db::DbError::from)?;
|
|
SqliteSessionStore::new(state.db.pool().clone())
|
|
.delete_for_user(user.id)
|
|
.await
|
|
.map_err(crate::db::DbError::from)?;
|
|
let mut updated = user;
|
|
updated.password_hash = password_hash;
|
|
auth.login(&updated)
|
|
.await
|
|
.map_err(|error| WebError::Internal(error.into()))?;
|
|
Ok(axum::response::Redirect::to("/account").into_response())
|
|
}
|
|
|
|
pub async fn logout_everywhere(
|
|
State(state): State<AppState>,
|
|
auth: AuthSession,
|
|
) -> Result<Response, WebError> {
|
|
let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
|
|
next: "/account".into(),
|
|
})?;
|
|
SqliteSessionStore::new(state.db.pool().clone())
|
|
.delete_for_user(user.id)
|
|
.await
|
|
.map_err(crate::db::DbError::from)?;
|
|
auth.logout()
|
|
.await
|
|
.map_err(|error| WebError::Internal(error.into()))?;
|
|
Ok(axum::response::Redirect::to("/").into_response())
|
|
}
|
|
|
|
fn forwarded_scheme(headers: &axum::http::HeaderMap) -> &str {
|
|
headers
|
|
.get("x-forwarded-proto")
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or("http")
|
|
}
|
|
|
|
fn request_origin(headers: &axum::http::HeaderMap) -> Option<String> {
|
|
if let Some(origin) = headers
|
|
.get(header::ORIGIN)
|
|
.and_then(|value| value.to_str().ok())
|
|
{
|
|
return Some(origin.trim_end_matches('/').to_string());
|
|
}
|
|
let referer = headers
|
|
.get(header::REFERER)
|
|
.and_then(|value| value.to_str().ok())?;
|
|
let url = url::Url::from_str(referer).ok()?;
|
|
Some(url.origin().ascii_serialization())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::HashMap;
|
|
|
|
use axum_login::tower_sessions::SessionStore;
|
|
use serde_json::json;
|
|
use time::Duration;
|
|
|
|
use super::*;
|
|
|
|
async fn store() -> (tempfile::TempDir, Db, SqliteSessionStore) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
|
.await
|
|
.unwrap();
|
|
let store = SqliteSessionStore::new(db.pool().clone());
|
|
(dir, db, store)
|
|
}
|
|
|
|
fn record(user_id: Option<i64>, expiry: OffsetDateTime) -> Record {
|
|
let mut data = HashMap::new();
|
|
if let Some(user_id) = user_id {
|
|
data.insert(
|
|
"axum-login.data".into(),
|
|
json!({"user_id": user_id, "auth_hash": [1, 2, 3]}),
|
|
);
|
|
}
|
|
Record {
|
|
id: Id::default(),
|
|
data,
|
|
expiry_date: expiry,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_store_round_trips_denormalizes_and_deletes() {
|
|
let (_dir, db, store) = store().await;
|
|
let user = users::add(&db, "reader", "correct horse battery", false)
|
|
.await
|
|
.unwrap();
|
|
let active = record(
|
|
Some(user.id),
|
|
OffsetDateTime::now_utc() + Duration::hours(1),
|
|
);
|
|
store.save(&active).await.unwrap();
|
|
let loaded = store.load(&active.id).await.unwrap().unwrap();
|
|
assert_eq!(loaded.id, active.id);
|
|
assert_eq!(loaded.data, active.data);
|
|
assert_eq!(
|
|
loaded.expiry_date.unix_timestamp(),
|
|
active.expiry_date.unix_timestamp()
|
|
);
|
|
let denormalized: Option<i64> =
|
|
sqlx::query_scalar("SELECT user_id FROM sessions WHERE id = ?")
|
|
.bind(active.id.to_string())
|
|
.fetch_one(db.pool())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(denormalized, Some(user.id));
|
|
assert_eq!(store.delete_for_user(user.id).await.unwrap(), 1);
|
|
assert!(store.load(&active.id).await.unwrap().is_none());
|
|
|
|
let anonymous = record(None, OffsetDateTime::now_utc() + Duration::hours(1));
|
|
store.save(&anonymous).await.unwrap();
|
|
let denormalized: Option<i64> =
|
|
sqlx::query_scalar("SELECT user_id FROM sessions WHERE id = ?")
|
|
.bind(anonymous.id.to_string())
|
|
.fetch_one(db.pool())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(denormalized, None);
|
|
store.delete(&anonymous.id).await.unwrap();
|
|
assert!(store.load(&anonymous.id).await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn expired_sessions_are_hidden_and_pruned_and_errors_surface() {
|
|
let (_dir, db, store) = store().await;
|
|
let expired = record(None, OffsetDateTime::now_utc() - Duration::seconds(1));
|
|
store.save(&expired).await.unwrap();
|
|
assert!(store.load(&expired.id).await.unwrap().is_none());
|
|
assert_eq!(store.delete_expired().await.unwrap(), 1);
|
|
db.close().await;
|
|
let error = store.save(&record(None, OffsetDateTime::now_utc())).await;
|
|
assert!(matches!(error, Err(session_store::Error::Backend(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn next_targets_must_be_same_site_paths() {
|
|
assert_eq!(valid_next(Some("/dashboard")), "/dashboard");
|
|
assert_eq!(valid_next(Some("//evil.example/")), "/");
|
|
assert_eq!(valid_next(Some("https://evil.example/")), "/");
|
|
}
|
|
}
|