Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
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}