feat(passwordless): add cookie challenge + low TTL
magic-link as now 2 modes:
- invitation: long TTL (24), no challenge
- passwordless login: short TTL (10min), cookie challenge to ensure that
user goes back to same browser (no man in the middle capturing email)
This commit is contained in:
@@ -24,6 +24,12 @@ pub const REFRESH_COOKIE: &str = "oxicloud_refresh";
|
||||
pub const CSRF_COOKIE: &str = "oxicloud_csrf";
|
||||
/// Header the frontend must send with the CSRF token value.
|
||||
pub const CSRF_HEADER: &str = "x-csrf-token";
|
||||
/// Per-request challenge cookie for browser-bound magic-link
|
||||
/// redemption (PR 22). Set by `POST /api/auth/magic-link/send` on
|
||||
/// the requesting browser; checked by `GET /magic/v1/{token}` against
|
||||
/// the token row's `request_challenge` column. Limited to `/magic`
|
||||
/// so it only travels back on the redemption endpoint.
|
||||
pub const MAGIC_REQUEST_COOKIE: &str = "oxicloud_magic_request";
|
||||
|
||||
/// Whether the `Secure` flag should be set on cookies.
|
||||
///
|
||||
@@ -167,6 +173,47 @@ pub fn append_csrf_cookie(headers: &mut HeaderMap, access_expiry_secs: i64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a per-request challenge for the magic-link browser
|
||||
/// binding (PR 22). 128-bit UUIDv4 — same shape as `generate_csrf_token`,
|
||||
/// plenty of entropy to make brute-force matching infeasible during
|
||||
/// the 10-minute login TTL. The value is set as a cookie on the
|
||||
/// originating browser AND mirrored into the token row so the
|
||||
/// redemption endpoint can compare them.
|
||||
pub fn generate_magic_request_challenge() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Append the `oxicloud_magic_request` cookie that binds a
|
||||
/// login-via-email magic-link to the originating browser (PR 22).
|
||||
/// HttpOnly + SameSite=Strict + Path=/magic — only sent back when
|
||||
/// the user clicks the redemption link, never on cross-site
|
||||
/// navigations. `value` is a random URL-safe string the handler
|
||||
/// also mirrors into `auth.magic_link_tokens.request_challenge`.
|
||||
pub fn append_magic_request_cookie(headers: &mut HeaderMap, value: &str, max_age_secs: i64) {
|
||||
if let Ok(val) = HeaderValue::from_str(&build_cookie(
|
||||
MAGIC_REQUEST_COOKIE,
|
||||
value,
|
||||
"/magic",
|
||||
max_age_secs,
|
||||
"Strict",
|
||||
)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the `oxicloud_magic_request` cookie after redemption — the
|
||||
/// challenge is single-use, so we don't want a stale cookie on the
|
||||
/// browser confusing a later flow.
|
||||
pub fn append_clear_magic_request_cookie(headers: &mut HeaderMap) {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
let val = format!(
|
||||
"{MAGIC_REQUEST_COOKIE}=; HttpOnly; SameSite=Strict; Path=/magic; Max-Age=0{secure}",
|
||||
);
|
||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||
headers.append(SET_COOKIE, hv);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the CSRF cookie (on logout).
|
||||
pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
|
||||
@@ -175,11 +175,14 @@ pub async fn register(
|
||||
|
||||
match result {
|
||||
RegisterResult::Created(user) => {
|
||||
// Email-only signup: dispatch the welcome magic-link.
|
||||
// Best-effort — SMTP failures don't roll back the user.
|
||||
// Email-only signup: dispatch the welcome magic-link with
|
||||
// a fresh browser-binding challenge (PR 22). Best-effort —
|
||||
// SMTP failures don't roll back the user.
|
||||
let challenge = cookie_auth::generate_magic_request_challenge();
|
||||
let login_ttl_secs = (state.core.config.magic_link.login_ttl_minutes * 60) as i64;
|
||||
if was_passwordless
|
||||
&& let Some(invite) = state.magic_link_invite_service.as_ref()
|
||||
&& let Err(e) = invite.send_login_link(&email).await
|
||||
&& let Err(e) = invite.send_login_link(&email, &challenge).await
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
@@ -192,8 +195,19 @@ pub async fn register(
|
||||
}
|
||||
if smtp_enabled {
|
||||
// Anti-enumeration mode: hide success-vs-collision behind
|
||||
// the uniform "check your email" cover story.
|
||||
Ok(uniform_ok())
|
||||
// the uniform "check your email" cover story. Attach the
|
||||
// browser-binding challenge cookie on every email-only
|
||||
// path — preserves the "did a mail go out" anti-enum
|
||||
// property at the cookie level too.
|
||||
let mut resp = uniform_ok();
|
||||
if was_passwordless {
|
||||
cookie_auth::append_magic_request_cookie(
|
||||
resp.headers_mut(),
|
||||
&challenge,
|
||||
login_ttl_secs,
|
||||
);
|
||||
}
|
||||
Ok(resp)
|
||||
} else {
|
||||
// Classic mode: clear 201 + UserDto so the frontend can
|
||||
// log the user in directly with the password they just
|
||||
@@ -1076,11 +1090,28 @@ pub async fn send_magic_link(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Per-request browser-binding challenge (PR 22). Generated for
|
||||
// every request and set as a cookie on every 200 response —
|
||||
// including the silent-rate-limit paths — so the cookie's
|
||||
// presence is uniform and can't be used as an enumeration oracle.
|
||||
// The corresponding token row only carries the challenge when a
|
||||
// token is actually minted; cookie-without-token simply fails to
|
||||
// match on the eventual redemption.
|
||||
let challenge = cookie_auth::generate_magic_request_challenge();
|
||||
let login_ttl_secs = (state.core.config.magic_link.login_ttl_minutes * 60) as i64;
|
||||
let challenge_for_closure = challenge.clone();
|
||||
|
||||
let uniform_ok = || {
|
||||
let payload = serde_json::json!({
|
||||
"message": "If an account exists for that email, a sign-in link will be sent.",
|
||||
});
|
||||
(StatusCode::OK, Json(payload)).into_response()
|
||||
let mut resp = (StatusCode::OK, Json(payload)).into_response();
|
||||
cookie_auth::append_magic_request_cookie(
|
||||
resp.headers_mut(),
|
||||
&challenge_for_closure,
|
||||
login_ttl_secs,
|
||||
);
|
||||
resp
|
||||
};
|
||||
|
||||
if !is_authenticated {
|
||||
@@ -1128,7 +1159,7 @@ pub async fn send_magic_link(
|
||||
// via the audit channel; we surface only an internal error (DB down,
|
||||
// etc.). Anti-enumeration means we always return the same body.
|
||||
invite_svc
|
||||
.send_login_link(&body.email)
|
||||
.send_login_link(&body.email, &challenge)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
||||
@@ -25,13 +25,16 @@ use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Path, State},
|
||||
http::{HeaderValue, StatusCode, header::CONTENT_TYPE, header::LOCATION},
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE, header::LOCATION},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::application::services::auth_application_service::MagicLinkRedemption;
|
||||
use crate::application::services::auth_application_service::{
|
||||
MagicLinkRedeemResult, MagicLinkRedemption,
|
||||
};
|
||||
use crate::common::di::AppState;
|
||||
use crate::common::errors::ErrorKind;
|
||||
use crate::domain::entities::magic_link_token::MagicLinkResourceKind;
|
||||
@@ -44,11 +47,21 @@ pub fn magic_link_routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/magic/v1/{token}", get(redeem_magic_link))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RedeemQuery {
|
||||
/// PR 22: `?confirm=1` means the user clicked the cross-browser
|
||||
/// confirmation prompt's Continue button. The service skips the
|
||||
/// challenge-cookie check on this re-entry.
|
||||
#[serde(default)]
|
||||
confirm: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/magic/v1/{token}",
|
||||
params(("token" = String, Path, description = "Opaque magic-link token")),
|
||||
responses(
|
||||
(status = 200, description = "Cross-browser confirmation prompt (HTML page)"),
|
||||
(status = 302, description = "Redemption succeeded — redirects to the resource or to /#/sharedwithme"),
|
||||
(status = 410, description = "Token is unknown, expired, or already used"),
|
||||
(status = 503, description = "Magic-link feature is not configured on this server"),
|
||||
@@ -58,6 +71,8 @@ pub fn magic_link_routes() -> Router<Arc<AppState>> {
|
||||
async fn redeem_magic_link(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
Query(query): Query<RedeemQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let Some(auth_svc) = state.auth_service.as_ref() else {
|
||||
return error_page(
|
||||
@@ -66,12 +81,35 @@ async fn redeem_magic_link(
|
||||
);
|
||||
};
|
||||
|
||||
// PR 22 browser binding: read the per-request challenge from the
|
||||
// cookie (set by `POST /api/auth/magic-link/send` on the originating
|
||||
// browser). The service compares it to the token's stored
|
||||
// challenge. `confirm=1` means the user just clicked through the
|
||||
// cross-browser prompt and is fine redeeming from a different
|
||||
// browser anyway.
|
||||
let incoming_challenge =
|
||||
cookie_auth::extract_cookie_value(&headers, cookie_auth::MAGIC_REQUEST_COOKIE);
|
||||
let cross_browser_confirmed = query
|
||||
.confirm
|
||||
.as_deref()
|
||||
.map(|v| v == "1" || v == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
match auth_svc
|
||||
.auth_application_service
|
||||
.redeem_magic_link(&token)
|
||||
.redeem_magic_link(
|
||||
&token,
|
||||
incoming_challenge.as_deref(),
|
||||
cross_browser_confirmed,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(redemption) => build_success_response(&state, redemption),
|
||||
Ok(MagicLinkRedeemResult::Allowed(redemption)) => {
|
||||
build_success_response(&state, *redemption)
|
||||
}
|
||||
Ok(MagicLinkRedeemResult::NeedsCrossBrowserConfirm) => {
|
||||
cross_browser_confirmation_page(&token)
|
||||
}
|
||||
Err(e) => {
|
||||
// Log the cause for ops; the user gets a generic page so the
|
||||
// outcome can't be used as an enumeration oracle.
|
||||
@@ -113,10 +151,53 @@ fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption
|
||||
state.core.config.auth.refresh_token_expiry_secs,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(response.headers_mut(), redemption.auth.expires_in);
|
||||
// Clear the request-challenge cookie — it's single-use and we don't
|
||||
// want a stale value on the browser confusing a later flow.
|
||||
cookie_auth::append_clear_magic_request_cookie(response.headers_mut());
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Render the cross-browser confirmation page (PR 22). Shown when the
|
||||
/// magic-link token carries a `request_challenge` (login-via-email)
|
||||
/// but the inbound cookie didn't match — typically because the user
|
||||
/// requested the link from one browser and clicked it from another
|
||||
/// (phone vs desktop, work vs personal). The Continue button submits
|
||||
/// back to the same endpoint with `?confirm=1` so the service skips
|
||||
/// the challenge check and proceeds with redemption. Audit-logged at
|
||||
/// `magic_link.redeemed reason="cross_browser_confirmed"`.
|
||||
fn cross_browser_confirmation_page(token: &str) -> Response {
|
||||
let confirm_url = format!("/magic/v1/{}?confirm=1", html_escape(token));
|
||||
let body = format!(
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\">\
|
||||
<title>Sign in — OxiCloud</title>\
|
||||
<style>body{{font-family:system-ui,sans-serif;max-width:520px;margin:6em auto;\
|
||||
padding:0 1em;color:#333;line-height:1.5}}\
|
||||
h1{{font-size:1.4em}}.btn{{display:inline-block;padding:.7em 1.4em;\
|
||||
background:#2563eb;color:#fff;border-radius:6px;text-decoration:none;\
|
||||
font-weight:600;margin-top:1em}}.btn:hover{{background:#1d4ed8}}\
|
||||
.note{{background:#fef3c7;border-left:3px solid #f59e0b;\
|
||||
padding:.75em 1em;margin:1.5em 0;border-radius:4px;font-size:.95em}}</style>\
|
||||
</head><body>\
|
||||
<h1>Continue signing in on this device?</h1>\
|
||||
<p>You opened this sign-in link in a different browser or device than \
|
||||
the one where you requested it.</p>\
|
||||
<p class=\"note\">If <strong>you</strong> requested this link, it's safe to continue. \
|
||||
If you didn't request it, close this page — clicking Continue would sign \
|
||||
someone else into your account.</p>\
|
||||
<p><a class=\"btn\" href=\"{confirm_url}\">Continue and sign in</a></p>\
|
||||
</body></html>",
|
||||
confirm_url = confirm_url,
|
||||
);
|
||||
|
||||
let mut response = (StatusCode::OK, body).into_response();
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
/// Build the SPA hash-route the redemption should land on. Mirrors the
|
||||
/// front-end's `deserializeHash()` parser at `static/js/app/main.js`.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user