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:
@@ -63,6 +63,26 @@ pub enum RegisterResult {
|
||||
EmailTaken,
|
||||
}
|
||||
|
||||
/// Outcome of a `redeem_magic_link` call (PR 22).
|
||||
///
|
||||
/// - `Allowed(redemption)` — the token is valid and the browser
|
||||
/// binding either matched or was overridden via the user's
|
||||
/// explicit cross-browser confirmation. The token has been
|
||||
/// atomically marked used.
|
||||
/// - `NeedsCrossBrowserConfirm` — the token carries a
|
||||
/// `request_challenge` but the incoming cookie didn't match.
|
||||
/// The handler should render a confirmation page; the user
|
||||
/// clicks Continue and we re-redeem with `cross_browser_confirmed = true`.
|
||||
/// The token is NOT marked used yet — it stays redeemable.
|
||||
#[derive(Debug)]
|
||||
pub enum MagicLinkRedeemResult {
|
||||
/// Boxed to keep the enum's stack size small — `MagicLinkRedemption`
|
||||
/// is ~350 bytes while `NeedsCrossBrowserConfirm` is zero-sized.
|
||||
/// One redemption per request; the heap indirection is negligible.
|
||||
Allowed(Box<MagicLinkRedemption>),
|
||||
NeedsCrossBrowserConfirm,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MagicLinkRedemption {
|
||||
pub auth: AuthResponseDto,
|
||||
@@ -385,7 +405,9 @@ impl AuthApplicationService {
|
||||
is_external = false,
|
||||
"🛂 user registered",
|
||||
);
|
||||
Ok(RegisterResult::Created(Box::new(UserDto::from(created_user))))
|
||||
Ok(RegisterResult::Created(Box::new(UserDto::from(
|
||||
created_user,
|
||||
))))
|
||||
}
|
||||
|
||||
/// Create the first admin user during initial system setup.
|
||||
@@ -627,7 +649,17 @@ impl AuthApplicationService {
|
||||
///
|
||||
/// Returns `ServiceUnavailable` (mapped from `NotImplemented`) when
|
||||
/// the magic-link repo isn't wired — the handler maps that to HTTP 503.
|
||||
pub async fn redeem_magic_link(&self, token: &str) -> Result<MagicLinkRedemption, DomainError> {
|
||||
///
|
||||
/// `incoming_challenge` is the value the handler read from the
|
||||
/// browser's `oxicloud_magic_request` cookie (or `None` if absent).
|
||||
/// `cross_browser_confirmed` is `true` when the user has clicked
|
||||
/// through the cross-browser confirmation page (PR 22).
|
||||
pub async fn redeem_magic_link(
|
||||
&self,
|
||||
token: &str,
|
||||
incoming_challenge: Option<&str>,
|
||||
cross_browser_confirmed: bool,
|
||||
) -> Result<MagicLinkRedeemResult, DomainError> {
|
||||
let repo = self.magic_link_repo.as_ref().ok_or_else(|| {
|
||||
DomainError::new(
|
||||
ErrorKind::NotImplemented,
|
||||
@@ -692,6 +724,31 @@ impl AuthApplicationService {
|
||||
));
|
||||
}
|
||||
|
||||
// PR 22 — browser binding for login-via-email tokens. When the
|
||||
// token carries a `request_challenge`, compare it against the
|
||||
// cookie the handler extracted. Mismatch surfaces as a
|
||||
// cross-browser confirmation page (the handler renders the
|
||||
// HTML); the user clicks Continue and we re-enter with
|
||||
// `cross_browser_confirmed = true`. Invitation tokens have no
|
||||
// challenge — they bypass this check entirely (cross-device by
|
||||
// design). The token is NOT marked used on the prompt path —
|
||||
// it stays redeemable for the confirm round-trip.
|
||||
if let Some(expected) = mlt.request_challenge()
|
||||
&& !cross_browser_confirmed
|
||||
&& incoming_challenge != Some(expected)
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.cross_browser_prompt",
|
||||
token_id = %mlt.id(),
|
||||
user_id = %mlt.user_id(),
|
||||
incoming_present = incoming_challenge.is_some(),
|
||||
"🔗 magic-link cross-browser: cookie absent or mismatched for user {}",
|
||||
mlt.user_id(),
|
||||
);
|
||||
return Ok(MagicLinkRedeemResult::NeedsCrossBrowserConfirm);
|
||||
}
|
||||
|
||||
let consumed = repo.mark_used(mlt.id()).await?;
|
||||
if !consumed {
|
||||
// Either a concurrent redemption beat us, or the row was
|
||||
@@ -759,6 +816,7 @@ impl AuthApplicationService {
|
||||
is_external = user.is_external(),
|
||||
resource_kind = ?mlt.resource_kind(),
|
||||
resource_id = ?mlt.resource_id(),
|
||||
cross_browser_confirmed = cross_browser_confirmed,
|
||||
);
|
||||
|
||||
let auth = AuthResponseDto {
|
||||
@@ -769,11 +827,11 @@ impl AuthApplicationService {
|
||||
expires_in: self.token_service.refresh_token_expiry_secs(),
|
||||
};
|
||||
|
||||
Ok(MagicLinkRedemption {
|
||||
Ok(MagicLinkRedeemResult::Allowed(Box::new(MagicLinkRedemption {
|
||||
auth,
|
||||
resource_kind: mlt.resource_kind(),
|
||||
resource_id: mlt.resource_id(),
|
||||
})
|
||||
})))
|
||||
}
|
||||
|
||||
/// Verifies username/password credentials without creating a session.
|
||||
|
||||
@@ -239,10 +239,15 @@ impl MagicLinkInviteService {
|
||||
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
|
||||
Resource::File(id) => (MagicLinkResourceKind::File, id),
|
||||
};
|
||||
// Invitation tokens are cross-device by design (recipient has
|
||||
// no prior browser context with the server) — no challenge
|
||||
// cookie. Long TTL (default 24h) because recipients may not
|
||||
// check their email for a while.
|
||||
let token = MagicLinkToken::new(
|
||||
recipient.id(),
|
||||
self.magic_link_cfg.ttl_hours,
|
||||
chrono::Duration::hours(self.magic_link_cfg.invite_ttl_hours as i64),
|
||||
Some((kind, resource_id)),
|
||||
None,
|
||||
);
|
||||
self.magic_link_repo.create(&token).await?;
|
||||
|
||||
@@ -273,7 +278,7 @@ impl MagicLinkInviteService {
|
||||
inviter = inviter_username,
|
||||
kind = kind_label,
|
||||
link = link,
|
||||
ttl = self.magic_link_cfg.ttl_hours,
|
||||
ttl = self.magic_link_cfg.invite_ttl_hours,
|
||||
now = Utc::now().to_rfc3339(),
|
||||
);
|
||||
|
||||
@@ -333,7 +338,21 @@ impl MagicLinkInviteService {
|
||||
/// `no_account`, `oidc_user`, `has_password` — so operators can see the truth
|
||||
/// while the API stays anti-enumeration-safe. A fourth outcome
|
||||
/// `send_failed` is logged at `warn` level when SMTP errors.
|
||||
pub async fn send_login_link(&self, raw_email: &str) -> Result<(), DomainError> {
|
||||
///
|
||||
/// `request_challenge` is the per-request random value the handler
|
||||
/// already set as the `oxicloud_magic_request` cookie on the
|
||||
/// originating browser. The service mirrors it into the token row;
|
||||
/// the redemption endpoint compares it against the inbound cookie
|
||||
/// to bind the magic-link to the device that requested it.
|
||||
/// Anti-enumeration: the handler passes the same challenge whether
|
||||
/// or not the user exists / is eligible — the token row is just
|
||||
/// not created in those branches, so nothing is leaked by the
|
||||
/// presence or absence of the cookie.
|
||||
pub async fn send_login_link(
|
||||
&self,
|
||||
raw_email: &str,
|
||||
request_challenge: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let normalised = match normalize_email(raw_email) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
@@ -403,9 +422,16 @@ impl MagicLinkInviteService {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Mint a NULL-resource token. The redemption handler lands
|
||||
// NULL-resource tokens on /#/sharedwithme (see PR 8).
|
||||
let token = MagicLinkToken::new(user.id(), self.magic_link_cfg.ttl_hours, None);
|
||||
// Mint a NULL-resource token bound to the requesting browser
|
||||
// via `request_challenge` (PR 22). Short TTL (default 10 min)
|
||||
// — the user just clicked the button, so a slow click is
|
||||
// almost certainly someone else with access to the inbox.
|
||||
let token = MagicLinkToken::new(
|
||||
user.id(),
|
||||
chrono::Duration::minutes(self.magic_link_cfg.login_ttl_minutes as i64),
|
||||
None,
|
||||
Some(request_challenge.to_string()),
|
||||
);
|
||||
self.magic_link_repo.create(&token).await?;
|
||||
|
||||
let link = format!(
|
||||
@@ -418,7 +444,8 @@ impl MagicLinkInviteService {
|
||||
"Hello,\n\
|
||||
\n\
|
||||
Use the link below to sign in to OxiCloud. The link works \
|
||||
once and expires in {ttl} hours.\n\
|
||||
once and expires in {ttl} minutes. Open it on the same \
|
||||
device where you requested it.\n\
|
||||
\n\
|
||||
{link}\n\
|
||||
\n\
|
||||
@@ -426,7 +453,7 @@ impl MagicLinkInviteService {
|
||||
ignore this message — no further action is needed.\n\
|
||||
\n\
|
||||
— OxiCloud, {now}\n",
|
||||
ttl = self.magic_link_cfg.ttl_hours,
|
||||
ttl = self.magic_link_cfg.login_ttl_minutes,
|
||||
link = link,
|
||||
now = Utc::now().to_rfc3339(),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user