Skip to main content

codoseo_web/auth/
magic.rs

1//! Magic links: a 32-byte random token, stored hashed, valid for 15 minutes, usable once.
2//!
3//! The emailed link opens a small confirm page that POSTs the token back. Mail scanners that
4//! prefetch every link in an email only issue a GET, so they can't burn the single-use token
5//! before the person clicks it.
6
7use askama::Template;
8use axum::Form;
9use axum::extract::{Path, Query, State};
10use axum::http::{HeaderMap, header};
11use axum::response::{AppendHeaders, IntoResponse, Redirect, Response};
12use codoseo_store::accounts::{SignIn, SignInOutcome};
13use codoseo_store::auth::TokenPurpose;
14use codoseo_store::quick::{self, UnlockCaps, UnlockSlot};
15use serde::Deserialize;
16use uuid::Uuid;
17
18use super::{CurrentUser, email, safe_next, session, signup_policy};
19use crate::assets;
20use crate::auth::mailer::Email;
21use crate::error::AppError;
22use crate::render::{Hx, html};
23use crate::state::AppState;
24
25pub const MAGIC_TTL: time::Duration = time::Duration::minutes(15);
26
27/// What the login card shows.
28pub struct Card {
29    pub email: String,
30    pub next: String,
31    pub error: Option<String>,
32    /// The link was sent: show "check your email" instead of the form.
33    pub sent: bool,
34    pub github: bool,
35    pub dev_hint: bool,
36}
37
38#[derive(Template)]
39#[template(path = "auth/login.html")]
40pub struct LoginPage {
41    pub card: Card,
42}
43
44#[derive(Template)]
45#[template(path = "auth/card_partial.html")]
46pub struct CardPartial {
47    pub card: Card,
48}
49
50#[derive(Deserialize)]
51pub struct LoginQuery {
52    next: Option<String>,
53}
54
55pub async fn login_page(
56    State(state): State<AppState>,
57    user: Option<CurrentUser>,
58    Query(q): Query<LoginQuery>,
59) -> Result<Response, AppError> {
60    let next = safe_next(q.next.as_deref()).to_owned();
61    if user.is_some() {
62        return Ok(Redirect::to(&next).into_response());
63    }
64    Ok(html(&LoginPage {
65        card: card(&state, String::new(), next, None, false),
66    })?
67    .into_response())
68}
69
70fn card(state: &AppState, email: String, next: String, error: Option<String>, sent: bool) -> Card {
71    Card {
72        email,
73        next,
74        error,
75        sent,
76        github: state.config.github.is_some(),
77        dev_hint: state.config.mode == crate::config::Mode::SelfHost,
78    }
79}
80
81#[derive(Deserialize)]
82pub struct LoginForm {
83    email: String,
84    next: Option<String>,
85}
86
87pub async fn request_link(
88    State(state): State<AppState>,
89    hx: Hx,
90    Form(form): Form<LoginForm>,
91) -> Result<Response, AppError> {
92    let next = safe_next(form.next.as_deref()).to_owned();
93    let respond = |c: Card| -> Result<Response, AppError> {
94        Ok(if hx.request {
95            html(&CardPartial { card: c })?.into_response()
96        } else {
97            html(&LoginPage { card: c })?.into_response()
98        })
99    };
100
101    let Some(address) = email::parse(&form.email) else {
102        let err = Some("That doesn't look like an email address.".to_owned());
103        return respond(card(&state, form.email.trim().to_owned(), next, err, false));
104    };
105
106    let _ = issue_link(&state, address, &next, None).await?;
107
108    respond(card(&state, address.to_owned(), next, None, true))
109}
110
111/// A no-signup audit a sign-in link should attach to the new account when it is used.
112pub struct AuditLink<'a> {
113    pub crawl_id: Uuid,
114    pub domain: &'a str,
115}
116
117/// What [`issue_link`] did.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum LinkOutcome {
120    Sent,
121    /// An audit's unlock form already sent its share of emails this hour (nothing was sent).
122    Throttled(UnlockSlot),
123}
124
125/// Stores a magic-link token for `address` and emails the link. With an `audit`, the link also
126/// carries the audit's crawl id, and using it attaches the audited site to the account. Those
127/// links are capped per audit and per recipient (see [`quick::create_unlock_token`]).
128pub async fn issue_link(
129    state: &AppState,
130    address: &str,
131    next: &str,
132    audit: Option<AuditLink<'_>>,
133) -> Result<LinkOutcome, AppError> {
134    let token = session::random_token();
135    let token_hash = session::hash(&token);
136    let mut payload = serde_json::json!({ "email": address, "next": next });
137    match &audit {
138        None => {
139            codoseo_store::auth::create_token(
140                &state.pool,
141                TokenPurpose::MagicLink,
142                &token_hash,
143                None,
144                Some(payload),
145                MAGIC_TTL,
146            )
147            .await?;
148        }
149        Some(a) => {
150            let canonical = email::canonical(address);
151            payload["audit"] = serde_json::json!(a.crawl_id);
152            payload["canonical"] = serde_json::json!(canonical);
153            let slot = quick::create_unlock_token(
154                &state.pool,
155                a.crawl_id,
156                &canonical,
157                &token_hash,
158                payload,
159                MAGIC_TTL,
160                UnlockCaps::DEFAULT,
161            )
162            .await?;
163            if slot != UnlockSlot::Created {
164                return Ok(LinkOutcome::Throttled(slot));
165            }
166        }
167    }
168
169    let mut link = state.config.base_url.clone();
170    link.set_path(&format!("/auth/magic/{token}"));
171    let (subject, text) = match audit {
172        None => (
173            "Your CodoSEO sign-in link".to_owned(),
174            format!(
175                "Sign in to CodoSEO:\n\n{link}\n\nThe link works once and expires in 15 minutes. \
176                 If you didn't ask for it, you can ignore this email."
177            ),
178        ),
179        Some(a) => (
180            format!("Your CodoSEO report for {}", a.domain),
181            format!(
182                "Open the full CodoSEO report for {}:\n\n{link}\n\nThe link works once and \
183                 expires in 15 minutes. It also starts weekly monitoring of the site, with an \
184                 email when something important breaks. If you didn't ask for it, you can \
185                 ignore this email.",
186                a.domain
187            ),
188        ),
189    };
190    if let Err(e) = state
191        .mailer
192        .send(Email {
193            to: address.to_owned(),
194            subject,
195            text,
196            html: None,
197        })
198        .await
199    {
200        // The page says "if the address is registered we sent a link" either way; a broken mail
201        // server is for the operator to see in the logs, not for the visitor to probe.
202        tracing::error!(error = %e, "could not send the sign-in link");
203    }
204    Ok(LinkOutcome::Sent)
205}
206
207#[derive(Template)]
208#[template(path = "auth/confirm.html")]
209pub struct ConfirmPage {
210    pub token: String,
211}
212
213pub async fn confirm_page(Path(token): Path<String>) -> Result<Response, AppError> {
214    Ok(html(&ConfirmPage { token })?.into_response())
215}
216
217pub async fn consume(
218    State(state): State<AppState>,
219    Path(token): Path<String>,
220    headers: HeaderMap,
221) -> Result<Response, AppError> {
222    let used = codoseo_store::auth::consume_token(
223        &state.pool,
224        TokenPurpose::MagicLink,
225        &session::hash(&token),
226    )
227    .await?
228    .ok_or_else(|| {
229        AppError::BadRequest(
230            "This sign-in link has expired or was already used. Ask for a new one.".to_owned(),
231        )
232    })?;
233
234    let payload = used.payload.unwrap_or_default();
235    let address = payload["email"]
236        .as_str()
237        .ok_or_else(|| AppError::internal("magic link without an email"))?;
238    let next = safe_next(payload["next"].as_str()).to_owned();
239    let canonical = email::canonical(address);
240    let outcome = codoseo_store::accounts::sign_in(
241        &state.pool,
242        &SignIn {
243            email: address,
244            canonical: &canonical,
245            github_id: None,
246        },
247        signup_policy(&state),
248    )
249    .await?;
250    let account = match outcome {
251        SignInOutcome::Existing(a) | SignInOutcome::Created(a) => a,
252        SignInOutcome::SignupsClosed => return Err(signups_closed()),
253    };
254    let cookie = session::start(&state, account.id).await?;
255    // Opening a link from one of our emails counts as activity for the inactivity check.
256    codoseo_store::accounts::record_email_click(&state.pool, account.id).await?;
257
258    // A link from the no-signup audit also attaches the audited site to the account.
259    let audit = payload["audit"]
260        .as_str()
261        .and_then(|a| Uuid::parse_str(a).ok());
262    let Some(audit) = audit else {
263        return Ok(([(header::SET_COOKIE, cookie)], Redirect::to(&next)).into_response());
264    };
265    let to = crate::routes::quick::attach_after_login(&state, &account, audit, &headers).await?;
266    let spent = crate::routes::quick::clear_claim_cookie(&state);
267    Ok((
268        AppendHeaders([(header::SET_COOKIE, cookie), (header::SET_COOKIE, spent)]),
269        Redirect::to(&to),
270    )
271        .into_response())
272}
273
274pub fn signups_closed() -> AppError {
275    AppError::Forbidden(
276        "Signups are closed on this CodoSEO instance. Ask its owner to let you in.".to_owned(),
277    )
278}
279
280/// Template helper so auth pages can reference assets without the app shell.
281pub fn asset(name: &str) -> &'static str {
282    assets::url(name)
283}