repos
/ analytics-rust master

analytics-rust

mirror archived upstream

Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.

analyticsaxumdockerrustself-hostedsqliteviteweb-analytics

3.7 KB · 117 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            error => error,
 61            next => "/properties",
 62        },
 63    )
 64}
 65
 66pub async fn login_form(State(state): State<AppState>, cookies: Cookies) -> Response {
 67    if is_authenticated(&cookies, &state) {
 68        return Redirect::to("/properties").into_response();
 69    }
 70    render_login(&state, None)
 71}
 72
 73/// Compare fixed-length digests so the comparison cost does not depend on
 74/// where the supplied password diverges (or on its length).
 75fn password_matches(supplied: &str, actual: &str) -> bool {
 76    use sha2::{Digest, Sha512};
 77    let a = Sha512::digest(supplied.as_bytes());
 78    let b = Sha512::digest(actual.as_bytes());
 79    a.iter().zip(b.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
 80}
 81
 82pub async fn login_submit(
 83    State(state): State<AppState>,
 84    cookies: Cookies,
 85    Form(form): Form<LoginForm>,
 86) -> Response {
 87    if !password_matches(&form.password, &state.config.password) {
 88        // Flat delay on every failure keeps online brute force impractically
 89        // slow for a single-password app without tracking per-IP state.
 90        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
 91        let html = render_login(&state, Some("Invalid password."));
 92        return (StatusCode::UNAUTHORIZED, html).into_response();
 93    }
 94    let exp = Utc::now().timestamp() + SESSION_TTL_SECS;
 95    let value = format!("1:{exp}");
 96    let cookie = Cookie::build((COOKIE_NAME, value))
 97        .path("/")
 98        .http_only(true)
 99        .same_site(SameSite::Strict)
100        .max_age(Duration::seconds(SESSION_TTL_SECS))
101        .build();
102    cookies.signed(&state.cookie_key).add(cookie);
103    let next = form
104        .next
105        .filter(|n| n.starts_with('/') && !n.starts_with("//"))
106        .unwrap_or_else(|| "/properties".to_string());
107    Redirect::to(&next).into_response()
108}
109
110pub async fn logout(State(state): State<AppState>, cookies: Cookies) -> Redirect {
111    let signed = cookies.signed(&state.cookie_key);
112    if let Some(c) = signed.get(COOKIE_NAME) {
113        cookies.remove(c);
114    }
115    Redirect::to("/")
116}