repos
/ status-rust master

status-rust

mirror archived upstream

Single-binary self-hosted uptime monitoring and status pages on Rust axum: HTTP probes, Lighthouse audits, SEO crawler, and PDF reports.

axumdockerrustself-hostedsqlitestatus-pageuptime-monitoringvite

3.7 KB · 119 lines · Rust Raw History
  1use axum::{
  2    extract::{Form, State},
  3    http::StatusCode,
  4    response::{IntoResponse, Redirect, Response},
  5    routing::{get, post},
  6    Router,
  7};
  8use chrono::Utc;
  9use serde::Deserialize;
 10use tower_cookies::{
 11    cookie::{time::Duration, SameSite},
 12    Cookie, Cookies,
 13};
 14
 15use crate::render::render;
 16use crate::AppState;
 17
 18const COOKIE_NAME: &str = "session";
 19// 30 days. Matches the cookie max-age the browser stores.
 20const SESSION_TTL_SECS: i64 = 30 * 24 * 60 * 60;
 21
 22pub fn router() -> Router<AppState> {
 23    Router::new()
 24        .route("/login", get(login_form).post(login_submit))
 25        .route("/logout", post(logout))
 26}
 27
 28#[derive(Debug, Deserialize)]
 29pub struct LoginForm {
 30    pub password: String,
 31    #[serde(default)]
 32    pub next: Option<String>,
 33}
 34
 35/// Returns true if the request carries a valid, unexpired signed session
 36/// cookie. Used by every auth-gated route module.
 37pub fn is_authenticated(cookies: &Cookies, state: &AppState) -> bool {
 38    let signed = cookies.signed(&state.cookie_key);
 39    let Some(c) = signed.get(COOKIE_NAME) else { return false };
 40    let value = c.value();
 41    let Some((flag, exp_str)) = value.split_once(':') else { return false };
 42    if flag != "1" {
 43        return false;
 44    }
 45    let Ok(exp) = exp_str.parse::<i64>() else { return false };
 46    Utc::now().timestamp() < exp
 47}
 48
 49fn render_login(state: &AppState, error: Option<&str>) -> Response {
 50    render(
 51        state,
 52        "registration/login.html",
 53        "/login",
 54        false,
 55        minijinja::context! {
 56            page => minijinja::context! {
 57                title => "Log in",
 58                description => "Log in to your dashboard.",
 59            },
 60            title => "Log in",
 61            description => "Log in to your dashboard.",
 62            error => error,
 63            next => "/properties",
 64        },
 65    )
 66}
 67
 68pub async fn login_form(State(state): State<AppState>, cookies: Cookies) -> Response {
 69    if is_authenticated(&cookies, &state) {
 70        return Redirect::to("/properties").into_response();
 71    }
 72    render_login(&state, None)
 73}
 74
 75/// Compare fixed-length digests so the comparison cost does not depend on
 76/// where the supplied password diverges (or on its length).
 77fn password_matches(supplied: &str, actual: &str) -> bool {
 78    use sha2::{Digest, Sha512};
 79    let a = Sha512::digest(supplied.as_bytes());
 80    let b = Sha512::digest(actual.as_bytes());
 81    a.iter().zip(b.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
 82}
 83
 84pub async fn login_submit(
 85    State(state): State<AppState>,
 86    cookies: Cookies,
 87    Form(form): Form<LoginForm>,
 88) -> Response {
 89    if !password_matches(&form.password, &state.config.password) {
 90        // Flat delay on every failure keeps online brute force impractically
 91        // slow for a single-password app without tracking per-IP state.
 92        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
 93        let html = render_login(&state, Some("Invalid password."));
 94        return (StatusCode::UNAUTHORIZED, html).into_response();
 95    }
 96    let exp = Utc::now().timestamp() + SESSION_TTL_SECS;
 97    let value = format!("1:{exp}");
 98    let cookie = Cookie::build((COOKIE_NAME, value))
 99        .path("/")
100        .http_only(true)
101        .same_site(SameSite::Strict)
102        .max_age(Duration::seconds(SESSION_TTL_SECS))
103        .build();
104    cookies.signed(&state.cookie_key).add(cookie);
105    let next = form
106        .next
107        .filter(|n| n.starts_with('/') && !n.starts_with("//"))
108        .unwrap_or_else(|| "/properties".to_string());
109    Redirect::to(&next).into_response()
110}
111
112pub async fn logout(State(state): State<AppState>, cookies: Cookies) -> Redirect {
113    let signed = cookies.signed(&state.cookie_key);
114    if let Some(c) = signed.get(COOKIE_NAME) {
115        cookies.remove(c);
116    }
117    Redirect::to("/")
118}