feat: Implement magic link authentication
This commit introduces a new magic link authentication flow. The desktop client can now request a temporary, one-time-use token from the server. This token is then used to open a URL in the system browser, which redirects to the server. The server consumes the token, installs a regular web session, and redirects the user
This commit is contained in:
@@ -5,18 +5,15 @@ use askama::Template;
|
||||
use axum::Form;
|
||||
use axum::extract::State;
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use rand::Rng;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
use crate::web_session::{SessionStore, WebSession};
|
||||
|
||||
const COOKIE_NAME: &str = "session";
|
||||
const TOKEN_LEN: usize = 43;
|
||||
use crate::web_session::{
|
||||
SESSION_COOKIE, SessionStore, WebSession, build_session_cookie, generate_token,
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html")]
|
||||
@@ -59,11 +56,7 @@ pub async fn handle_login_submit(
|
||||
}
|
||||
|
||||
let user = user.unwrap();
|
||||
let token: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(TOKEN_LEN)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
let token = generate_token();
|
||||
|
||||
let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600);
|
||||
{
|
||||
@@ -80,7 +73,7 @@ pub async fn handle_login_submit(
|
||||
|
||||
info!(slug = %user.slug, "login ok");
|
||||
|
||||
let cookie = build_cookie(token, ttl, config.cookie_secure);
|
||||
let cookie = build_session_cookie(token, ttl, config.cookie_secure);
|
||||
let jar = jar.add(cookie);
|
||||
Ok((jar, Redirect::to("/web/cases")).into_response())
|
||||
}
|
||||
@@ -90,7 +83,7 @@ pub async fn handle_logout(
|
||||
State(store): State<SessionStore>,
|
||||
jar: CookieJar,
|
||||
) -> Result<Response, AppError> {
|
||||
if let Some(c) = jar.get(COOKIE_NAME) {
|
||||
if let Some(c) = jar.get(SESSION_COOKIE) {
|
||||
let token = c.value().to_owned();
|
||||
let mut w = store.write().await;
|
||||
let removed = w.remove(&token);
|
||||
@@ -100,7 +93,8 @@ pub async fn handle_logout(
|
||||
}
|
||||
|
||||
// Expire the cookie by setting an empty value with max_age=0.
|
||||
let mut cookie = build_cookie(String::new(), Duration::from_secs(0), config.cookie_secure);
|
||||
let mut cookie =
|
||||
build_session_cookie(String::new(), Duration::from_secs(0), config.cookie_secure);
|
||||
cookie.make_removal();
|
||||
let jar = jar.add(cookie);
|
||||
Ok((jar, Redirect::to("/web/login")).into_response())
|
||||
@@ -112,13 +106,3 @@ fn render_login(error: Option<&'static str>) -> Result<Html<String>, AppError> {
|
||||
.map(Html)
|
||||
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
|
||||
}
|
||||
|
||||
fn build_cookie(value: String, max_age: Duration, secure: bool) -> Cookie<'static> {
|
||||
Cookie::build((COOKIE_NAME, value))
|
||||
.http_only(true)
|
||||
.secure(secure)
|
||||
.same_site(SameSite::Strict)
|
||||
.path("/")
|
||||
.max_age(time::Duration::seconds(max_age.as_secs() as i64))
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Magic-link routes — turn an API-key authenticated desktop call into a
|
||||
//! browser session without an interactive password login.
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. Desktop client → `POST /api/auth/magic-link` (X-API-Key) with the
|
||||
//! desired `return_to` path. Server stores a one-time token (TTL 60s)
|
||||
//! and returns it.
|
||||
//! 2. Desktop client opens the system browser at
|
||||
//! `{server_url}/web/magic?token=…`.
|
||||
//! 3. The browser hits `GET /web/magic`. The server consumes the token
|
||||
//! (one-time-use), installs a regular `WebSession`, sets the session
|
||||
//! cookie, and 303-redirects to `return_to`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::header;
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::{Json, http::StatusCode};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::AuthenticatedUser;
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
use crate::magic_link::{MagicLinkStore, PendingMagicLink};
|
||||
use crate::web_session::{
|
||||
SessionStore, WebSession, build_session_cookie, generate_token,
|
||||
};
|
||||
|
||||
/// Magic-link tokens only need to survive click → browser-launch → first
|
||||
/// request. 60s gives slow systems headroom without lengthening the attack
|
||||
/// window unnecessarily.
|
||||
const MAGIC_LINK_TTL_SECS: u64 = 60;
|
||||
|
||||
/// Default landing page when the desktop client does not specify one.
|
||||
const DEFAULT_RETURN_TO: &str = "/web/cases";
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct MagicLinkRequest {
|
||||
#[serde(default)]
|
||||
pub return_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MagicLinkResponse {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Issue a one-time magic-link token. Authenticated by `X-API-Key` —
|
||||
/// re-uses the existing `AuthenticatedUser` extractor so policy stays
|
||||
/// in one place.
|
||||
pub async fn handle_create(
|
||||
user: AuthenticatedUser,
|
||||
State(store): State<MagicLinkStore>,
|
||||
body: Option<Json<MagicLinkRequest>>,
|
||||
) -> Result<Json<MagicLinkResponse>, AppError> {
|
||||
let req = body.map(|Json(r)| r).unwrap_or_default();
|
||||
let return_to = req.return_to.unwrap_or_else(|| DEFAULT_RETURN_TO.to_owned());
|
||||
|
||||
// Open-redirect guard: only accept paths under our own /web/ tree.
|
||||
// Reject schemes, hosts, protocol-relative URLs, and traversal.
|
||||
if !is_safe_return_to(&return_to) {
|
||||
warn!(slug = %user.slug, return_to = %return_to, "magic-link rejected: unsafe return_to");
|
||||
return Err(AppError::BadRequest(
|
||||
"return_to must be a path under /web/".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let token = generate_token();
|
||||
let pending = PendingMagicLink {
|
||||
slug: user.slug.clone(),
|
||||
role: user.role.clone(),
|
||||
return_to,
|
||||
expires_at: Instant::now() + Duration::from_secs(MAGIC_LINK_TTL_SECS),
|
||||
};
|
||||
|
||||
{
|
||||
let mut w = store.write().await;
|
||||
// Best-effort eviction so the map cannot grow unbounded under load.
|
||||
w.retain(|_, v| v.expires_at > Instant::now());
|
||||
w.insert(token.clone(), pending);
|
||||
}
|
||||
|
||||
info!(slug = %user.slug, "magic-link issued");
|
||||
Ok(Json(MagicLinkResponse { token }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConsumeQuery {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Consume a magic-link token: install a session cookie and redirect to
|
||||
/// the previously-stored `return_to`. One-time use — the token is removed
|
||||
/// from the store on first lookup, success or expiry alike.
|
||||
pub async fn handle_consume(
|
||||
State(config): State<Arc<Config>>,
|
||||
State(magic_store): State<MagicLinkStore>,
|
||||
State(session_store): State<SessionStore>,
|
||||
jar: CookieJar,
|
||||
Query(q): Query<ConsumeQuery>,
|
||||
) -> Response {
|
||||
let pending = {
|
||||
let mut w = magic_store.write().await;
|
||||
w.remove(&q.token)
|
||||
};
|
||||
|
||||
let Some(pending) = pending else {
|
||||
warn!("magic-link consume failed: unknown or already-used token");
|
||||
return redirect_to_login();
|
||||
};
|
||||
|
||||
if pending.expires_at <= Instant::now() {
|
||||
warn!(slug = %pending.slug, "magic-link consume failed: expired");
|
||||
return redirect_to_login();
|
||||
}
|
||||
|
||||
let session_token = generate_token();
|
||||
let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600);
|
||||
{
|
||||
let mut w = session_store.write().await;
|
||||
w.insert(
|
||||
session_token.clone(),
|
||||
WebSession {
|
||||
slug: pending.slug.clone(),
|
||||
role: pending.role.clone(),
|
||||
expires_at: Instant::now() + ttl,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
info!(slug = %pending.slug, "magic-link consumed → session installed");
|
||||
|
||||
let cookie = build_session_cookie(session_token, ttl, config.cookie_secure);
|
||||
let jar = jar.add(cookie);
|
||||
|
||||
// 303 See Other forces the browser to follow with GET. Combined with
|
||||
// `Referrer-Policy: no-referrer`, the token does not leak to any
|
||||
// resource the redirect target may load.
|
||||
let mut response = (jar, Redirect::to(&pending.return_to)).into_response();
|
||||
response.headers_mut().insert(
|
||||
header::REFERRER_POLICY,
|
||||
"no-referrer".parse().expect("static header value"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn redirect_to_login() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.header(header::LOCATION, "/web/login")
|
||||
.header(header::REFERRER_POLICY, "no-referrer")
|
||||
.body(axum::body::Body::empty())
|
||||
.expect("static redirect response")
|
||||
}
|
||||
|
||||
/// Allow only same-origin paths under `/web/`. Rejects schemes, host
|
||||
/// authorities, protocol-relative URLs (`//evil.com/...`), and the
|
||||
/// trivially malformed empty string.
|
||||
fn is_safe_return_to(path: &str) -> bool {
|
||||
if path.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if path.starts_with("//") {
|
||||
return false; // protocol-relative — would jump host
|
||||
}
|
||||
if !path.starts_with("/web/") {
|
||||
return false;
|
||||
}
|
||||
// Defensive: a `\` anywhere can confuse some legacy parsers into
|
||||
// treating the rest as an authority. Reject outright.
|
||||
if path.contains('\\') {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn safe_return_to_accepts_web_paths() {
|
||||
assert!(is_safe_return_to("/web/cases"));
|
||||
assert!(is_safe_return_to(
|
||||
"/web/cases/123e4567-e89b-12d3-a456-426614174000"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_return_to_rejects_open_redirects() {
|
||||
assert!(!is_safe_return_to(""));
|
||||
assert!(!is_safe_return_to("/"));
|
||||
assert!(!is_safe_return_to("//evil.com/path"));
|
||||
assert!(!is_safe_return_to("https://evil.com/"));
|
||||
assert!(!is_safe_return_to("/api/upload"));
|
||||
assert!(!is_safe_return_to("/web\\evil"));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod case_actions;
|
||||
mod debug;
|
||||
mod health;
|
||||
mod login;
|
||||
mod magic;
|
||||
mod oneliners;
|
||||
mod upload;
|
||||
pub(crate) mod user_web;
|
||||
@@ -19,6 +20,8 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/api/debug/whoami", get(debug::handle_whoami))
|
||||
.route("/api/upload", post(upload::handle_upload))
|
||||
.route("/api/oneliners", get(oneliners::handle_oneliners))
|
||||
.route("/api/auth/magic-link", post(magic::handle_create))
|
||||
.route("/web/magic", get(magic::handle_consume))
|
||||
.route(
|
||||
"/web/login",
|
||||
get(login::handle_login_page).post(login::handle_login_submit),
|
||||
|
||||
Reference in New Issue
Block a user