Accounts and sign-in, and fix the read toggle
epAction's redraw closure called itself when handed a row, so Mark read recursed until the stack blew; it now swaps that row in place. Opening an item also marks it read, redrawn where it stands so nothing vanishes from under the pointer on the Unread tab. Step A of multi-user: users and sessions tables, Argon2id, a session cookie, ipx user subcommands, and a trusted proxy header for Cloudflare Zero Trust -- honoured only from a trusted_proxies address. The shared token still works and is the admin. A new database starts with admin/ipodderx. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
235
src/web.rs
235
src/web.rs
@@ -7,7 +7,7 @@ use axum::{
|
||||
http::{StatusCode, header},
|
||||
middleware::{self, Next},
|
||||
response::{
|
||||
Html, IntoResponse, Response,
|
||||
Html, IntoResponse, Redirect, Response,
|
||||
sse::{Event as SseEvent, Sse},
|
||||
},
|
||||
routing::{delete, get, patch, post},
|
||||
@@ -60,6 +60,8 @@ pub fn generate_token() -> String {
|
||||
pub fn router(state: WebState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/api/me", get(me))
|
||||
.route("/api/logout", post(logout))
|
||||
.route("/api/feeds", get(feeds).post(add_feed))
|
||||
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
||||
.route("/api/feeds/{id}/entries", get(entries))
|
||||
@@ -76,6 +78,9 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/api/events", get(events))
|
||||
.route("/media/{id}", get(media))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
||||
// Signing in cannot require being signed in, so these sit outside the auth layer.
|
||||
.route("/login", get(login_page))
|
||||
.route("/api/login", post(login))
|
||||
.layer(middleware::from_fn(access_log))
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -85,53 +90,223 @@ pub async fn serve(state: WebState, bind: &str) -> Result<()> {
|
||||
.await
|
||||
.with_context(|| format!("binding {bind}"))?;
|
||||
tracing::info!(bind, "web ui listening");
|
||||
axum::serve(listener, router(state))
|
||||
.await
|
||||
.context("serving the web ui")
|
||||
axum::serve(
|
||||
listener,
|
||||
router(state).into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.context("serving the web ui")
|
||||
}
|
||||
|
||||
/// Token in `?token=` (which then sets a cookie) or in the cookie itself.
|
||||
/// Who is asking, in order of how specific the claim is: a proxy that vouches for a name,
|
||||
/// a session cookie, then the shared token (which is the admin).
|
||||
///
|
||||
/// It has to be a cookie rather than a header: an `<audio src>` request is issued by the
|
||||
/// browser, and there is no way to attach a header to it.
|
||||
async fn auth(State(state): State<WebState>, req: Request, next: Next) -> Response {
|
||||
let expected = state.ctx.cfg().web.token.clone();
|
||||
if expected.is_empty() {
|
||||
// Refuse to serve rather than serve unauthenticated.
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "no web token configured").into_response();
|
||||
/// Any of them has to survive being put in a cookie: an `<audio src>` request is issued by
|
||||
/// the browser, and there is no way to attach a header to it.
|
||||
async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Response {
|
||||
let cfg = state.ctx.cfg();
|
||||
let token = cfg.web.token.clone();
|
||||
|
||||
let peer = req
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|c| c.0.ip().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 1. A header, but only from a hop we were told to believe. Anyone able to reach the
|
||||
// port could otherwise send it and be whoever they liked.
|
||||
let vouched = (!cfg.web.trusted_header.is_empty()
|
||||
&& cfg.web.trusted_proxies.iter().any(|p| p == &peer))
|
||||
.then(|| {
|
||||
req.headers()
|
||||
.get(&cfg.web.trusted_header)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(crate::auth::name_from_header)
|
||||
})
|
||||
.flatten();
|
||||
|
||||
let mut set_cookie: Option<String> = None;
|
||||
let mut user = None;
|
||||
|
||||
if let Some(name) = vouched {
|
||||
user = match state.ctx.db.user_by_name(&name) {
|
||||
Ok(Some(u)) => Some(u),
|
||||
Ok(None) if cfg.web.auto_create_users => {
|
||||
tracing::info!(user = %name, "creating an account for a name the proxy vouched for");
|
||||
state
|
||||
.ctx
|
||||
.db
|
||||
.create_user(&name, None, state.ctx.db.users().map(|u| u.is_empty()).unwrap_or(false))
|
||||
.ok()
|
||||
.and_then(|id| state.ctx.db.user_by_id(id).ok().flatten())
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "looking up the vouched user");
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 2. A session cookie from signing in here.
|
||||
if user.is_none() {
|
||||
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
|
||||
user = state
|
||||
.ctx
|
||||
.db
|
||||
.session_user(&sid, cfg.web.session_days.max(1) * 86_400)
|
||||
.unwrap_or(None);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. The shared token, which is the admin: the healthcheck and any scripts predate
|
||||
// accounts and must keep working.
|
||||
let from_query = req.uri().query().and_then(|q| {
|
||||
q.split('&')
|
||||
.find_map(|kv| kv.strip_prefix("token=").map(str::to_owned))
|
||||
});
|
||||
let from_cookie = req
|
||||
.headers()
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|c| {
|
||||
c.split(';')
|
||||
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(str::to_owned))
|
||||
});
|
||||
|
||||
let supplied = from_query.clone().or(from_cookie);
|
||||
if !supplied.is_some_and(|t| constant_time_eq(&t, &expected)) {
|
||||
return (StatusCode::UNAUTHORIZED, "bad or missing token").into_response();
|
||||
if user.is_none() && !token.is_empty() {
|
||||
let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
|
||||
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
|
||||
user = admin_user(&state);
|
||||
if from_query.is_some() {
|
||||
set_cookie = Some(format!(
|
||||
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(user) = user else {
|
||||
// A browser asking for a page gets the sign-in form; anything else gets a 401 it
|
||||
// can act on.
|
||||
let wants_html = req
|
||||
.headers()
|
||||
.get(header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|a| a.contains("text/html"));
|
||||
return if wants_html {
|
||||
Redirect::to("/login").into_response()
|
||||
} else {
|
||||
(StatusCode::UNAUTHORIZED, "sign in").into_response()
|
||||
};
|
||||
};
|
||||
|
||||
req.extensions_mut().insert(user);
|
||||
let mut resp = next.run(req).await;
|
||||
if from_query.is_some() {
|
||||
// Remember it so the rest of the page (and the audio element) authenticates.
|
||||
if let Ok(v) = header::HeaderValue::from_str(&format!(
|
||||
"{COOKIE}={expected}; Path=/; SameSite=Lax; Max-Age=31536000"
|
||||
)) {
|
||||
if let Some(c) = set_cookie {
|
||||
if let Ok(v) = header::HeaderValue::from_str(&c) {
|
||||
resp.headers_mut().insert(header::SET_COOKIE, v);
|
||||
}
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// Compares without leaking length or position through timing.
|
||||
const SESSION_COOKIE: &str = "ipx_session";
|
||||
|
||||
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
||||
/// reaches a handler without passing through it.
|
||||
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for crate::db::User {
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_state: &S,
|
||||
) -> std::result::Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<crate::db::User>()
|
||||
.cloned()
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "sign in"))
|
||||
}
|
||||
}
|
||||
|
||||
fn cookie(req: &Request, name: &str) -> Option<String> {
|
||||
req.headers()
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|c| {
|
||||
c.split(';')
|
||||
.find_map(|kv| kv.trim().strip_prefix(&format!("{name}=")).map(str::to_owned))
|
||||
})
|
||||
}
|
||||
|
||||
/// The account the shared token stands for: the first admin, or the first user at all.
|
||||
fn admin_user(state: &WebState) -> Option<crate::db::User> {
|
||||
let users = state.ctx.db.users().ok()?;
|
||||
users
|
||||
.iter()
|
||||
.find(|u| u.is_admin)
|
||||
.or_else(|| users.first())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Credentials {
|
||||
name: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn login(
|
||||
State(state): State<WebState>,
|
||||
Json(body): Json<Credentials>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let name = body.name.trim().to_ascii_lowercase();
|
||||
let user = state.ctx.db.user_by_name(&name)?;
|
||||
// The same answer either way: whether a name exists is not something to leak.
|
||||
let ok = user
|
||||
.as_ref()
|
||||
.and_then(|u| u.pass_hash.as_deref())
|
||||
.is_some_and(|h| crate::auth::verify_password(&body.password, h));
|
||||
if !ok {
|
||||
tracing::warn!(user = %name, "failed sign-in");
|
||||
return Ok((StatusCode::UNAUTHORIZED, "wrong name or password").into_response());
|
||||
}
|
||||
|
||||
let user = user.expect("verified above");
|
||||
let token = crate::auth::new_session_token();
|
||||
state.ctx.db.create_session(user.id, &token)?;
|
||||
tracing::info!(user = %user.name, "signed in");
|
||||
|
||||
let days = state.ctx.cfg().web.session_days.max(1);
|
||||
let mut resp = Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
|
||||
.into_response();
|
||||
if let Ok(v) = header::HeaderValue::from_str(&format!(
|
||||
"{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}",
|
||||
days * 86_400
|
||||
)) {
|
||||
resp.headers_mut().insert(header::SET_COOKIE, v);
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
async fn logout(State(state): State<WebState>, req: Request) -> Response {
|
||||
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
|
||||
let _ = state.ctx.db.delete_session(&sid);
|
||||
}
|
||||
let mut resp = StatusCode::NO_CONTENT.into_response();
|
||||
for c in [
|
||||
format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
|
||||
format!("{COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
|
||||
] {
|
||||
if let Ok(v) = header::HeaderValue::from_str(&c) {
|
||||
resp.headers_mut().append(header::SET_COOKIE, v);
|
||||
}
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
async fn me(user: crate::db::User) -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
|
||||
}
|
||||
|
||||
async fn login_page() -> Html<&'static str> {
|
||||
Html(include_str!("../web/login.html"))
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
if a.len() != b.len() {
|
||||
|
||||
Reference in New Issue
Block a user