Skip to main content

max / makenotwork

10.3 KB · 293 lines History Blame Raw
1 //! Email verification and one-time login link handlers.
2
3 use crate::extractors::ValidatedQuery;
4 use axum::{
5 extract::State,
6 response::{IntoResponse, Redirect, Response},
7 };
8 use serde::Deserialize;
9 use sqlx::PgPool;
10 use tower_sessions::Session;
11
12 use crate::{
13 auth::{SessionUser, login_user, track_session},
14 background::BackgroundTx,
15 config::Config,
16 constants,
17 db::{self, UserId},
18 email::{self, EmailClient},
19 error::{Result, ResultExt},
20 templates::EmailResultTemplate,
21 };
22
23 /// Query parameters for the email verification link.
24 #[derive(Debug, Deserialize)]
25 pub(super) struct VerifyEmailQuery {
26 pub user: Option<String>,
27 pub expires: Option<i64>,
28 pub sig: Option<String>,
29 }
30
31 /// Verify a user's email address via a signed link.
32 #[tracing::instrument(skip_all, name = "email_actions::verify_email_handler")]
33 pub(super) async fn verify_email_handler(
34 State(db): State<PgPool>,
35 State(config): State<Config>,
36 _session: Session,
37 ValidatedQuery(query): ValidatedQuery<VerifyEmailQuery>,
38 ) -> Result<Response> {
39 let error_page = |title: &str, msg: &str, link_url: &str, link_text: &str| -> Response {
40 EmailResultTemplate {
41 csrf_token: None,
42 title: title.to_string(),
43 message: msg.to_string(),
44 link_url: link_url.to_string(),
45 link_text: link_text.to_string(),
46 }
47 .into_response()
48 };
49
50 // Validate all required parameters are present
51 let (user_id_str, expires, sig) = match (&query.user, query.expires, &query.sig) {
52 (Some(u), Some(e), Some(s)) => (u.clone(), e, s.clone()),
53 _ => {
54 return Ok(error_page(
55 "Email Verification Failed",
56 "Invalid verification link",
57 "/dashboard",
58 "Go to dashboard",
59 ));
60 }
61 };
62
63 let user_id: UserId = match user_id_str.parse() {
64 Ok(id) => id,
65 Err(_) => {
66 return Ok(error_page(
67 "Email Verification Failed",
68 "Invalid verification link",
69 "/dashboard",
70 "Go to dashboard",
71 ));
72 }
73 };
74
75 // Generic failure response reused for every pre-signature outcome so this
76 // endpoint is not an account-enumeration oracle: "user not found", "already
77 // verified", and "bad signature" must be indistinguishable to a caller
78 // without a valid HMAC. UserIds are visible in `/feed/{id}` URLs, so any
79 // observable difference would leak account existence + verified-state.
80 let invalid_link = || {
81 error_page(
82 "Email Verification Failed",
83 "Verification link has expired or is invalid. Please request a new one.",
84 "/dashboard",
85 "Go to dashboard",
86 )
87 };
88
89 // Get user. A missing user yields the same generic response as a bad
90 // signature (the signature is keyed on the user's email, so a genuine link
91 // can only exist for a real user anyway).
92 let Ok(Some(user)) = db::users::get_user_by_id(&db, user_id).await else {
93 return Ok(invalid_link());
94 };
95
96 // Verify the HMAC signature (and embedded expiry) BEFORE revealing any
97 // account state such as the already-verified redirect below.
98 if !email::verify_email_signature(user_id, expires, &user.email, &sig, &config.signing_secret) {
99 return Ok(invalid_link());
100 }
101
102 // Signature is valid, only now is it safe to reveal verified state.
103 if user.email_verified {
104 return Ok(Redirect::to("/dashboard").into_response());
105 }
106
107 // Mark email as verified
108 db::users::verify_user_email(&db, user_id).await?;
109
110 // Auto-attach any guest purchases made with this email before signup
111 match db::transactions::attach_guest_purchases_by_email(&db, &user.email, user_id).await {
112 Ok(0) => {}
113 Ok(n) => {
114 tracing::info!(user_id = %user_id, count = n, "auto-attached guest purchases on email verification");
115 }
116 Err(e) => {
117 tracing::warn!(user_id = %user_id, error = ?e, "failed to attach guest purchases");
118 }
119 }
120
121 tracing::info!(user_id = %user_id, event = "email_verified", "Email verified");
122
123 // Return success page
124 Ok(EmailResultTemplate {
125 csrf_token: None,
126 title: "Email Verified".to_string(),
127 message: "Your email has been verified successfully.".to_string(),
128 link_url: "/dashboard".to_string(),
129 link_text: "Go to dashboard".to_string(),
130 }
131 .into_response())
132 }
133
134 /// Query parameters for one-time login links.
135 #[derive(Debug, Deserialize)]
136 pub(super) struct LoginLinkQuery {
137 pub token: Option<String>,
138 }
139
140 /// Authenticate a user via a one-time login link token.
141 #[tracing::instrument(skip_all, name = "email_actions::login_link_handler")]
142 pub(super) async fn login_link_handler(
143 State(db): State<PgPool>,
144 State(config): State<Config>,
145 State(email): State<EmailClient>,
146 State(bg): State<BackgroundTx>,
147 headers: axum::http::header::HeaderMap,
148 session: Session,
149 ValidatedQuery(query): ValidatedQuery<LoginLinkQuery>,
150 ) -> Result<Response> {
151 let error_page = |msg: &str| -> Response {
152 EmailResultTemplate {
153 csrf_token: None,
154 title: "Login Link Invalid".to_string(),
155 message: msg.to_string(),
156 link_url: "/login".to_string(),
157 link_text: "Go to login".to_string(),
158 }
159 .into_response()
160 };
161
162 // Get token from query
163 let token = match &query.token {
164 Some(t) => t.clone(),
165 None => return Ok(error_page("Invalid login link")),
166 };
167
168 // Hash the provided token to look it up
169 let token_hash = {
170 use sha2::{Digest, Sha256};
171 let mut hasher = Sha256::new();
172 hasher.update(token.as_bytes());
173 hex::encode(hasher.finalize())
174 };
175
176 // Atomically consume the token (marks it used and returns it in one query)
177 let Some(login_token) = db::auth::consume_login_token(&db, &token_hash).await? else {
178 return Ok(error_page(
179 "This login link has expired or has already been used.",
180 ));
181 };
182
183 let Some(user) = db::users::get_user_by_id(&db, login_token.user_id).await? else {
184 return Ok(error_page("User not found"));
185 };
186
187 // Reset failed login attempts and unlock account
188 db::auth::reset_failed_login(&db, user.id).await?;
189
190 // If user has TOTP 2FA enabled, redirect to 2FA verification instead of creating session
191 if user.totp_enabled {
192 session.cycle_id().await.context("session cycle")?;
193 session
194 .insert("pending_2fa_user_id", user.id)
195 .await
196 .context("session insert")?;
197 session
198 .insert("pending_2fa_started_at", chrono::Utc::now().timestamp())
199 .await
200 .context("session insert")?;
201 session
202 .insert("pending_2fa_notify_email", &user.email)
203 .await
204 .context("session insert")?;
205 session
206 .insert("pending_2fa_notify_name", &user.display_name)
207 .await
208 .context("session insert")?;
209
210 // Track the pending_2fa session so "log out everywhere" can sweep it.
211 let ua = headers
212 .get("user-agent")
213 .and_then(|v| v.to_str().ok())
214 .map(|s| {
215 s.chars()
216 .take(crate::constants::USER_AGENT_MAX_LENGTH)
217 .collect::<String>()
218 });
219 let ip = crate::helpers::extract_client_ip(&headers);
220 let tracking_id =
221 db::sessions::create_pending_2fa_session(&db, user.id, ua.as_deref(), ip.as_deref())
222 .await?;
223 session
224 .insert("pending_2fa_tracking_id", tracking_id)
225 .await
226 .context("session insert")?;
227
228 tracing::info!(user_id = %user.id, event = "login_link_2fa_pending", "Login link used, 2FA verification required");
229 return Ok(Redirect::to("/auth/2fa").into_response());
230 }
231
232 // Capture notification fields before moving user into session
233 let user_id = user.id;
234 let notify_email = user.email.clone();
235 let notify_name = user.display_name.clone();
236
237 // Create session
238 let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await;
239
240 login_user(&session, session_user).await?;
241 track_session(&session, &db, user_id, &headers).await?;
242 tracing::info!(user_id = %user_id, event = "login_link_used", "One-time login link used");
243
244 // Send new-device login notification (fire-and-forget). The Login
245 // preference is checked by the send path.
246 {
247 let session_count = db::sessions::count_user_sessions(&db, user_id)
248 .await
249 .unwrap_or(0);
250 if session_count > 1 {
251 let user_agent = headers
252 .get("user-agent")
253 .and_then(|v| v.to_str().ok())
254 .map(|s| {
255 s.chars()
256 .take(constants::USER_AGENT_MAX_LENGTH)
257 .collect::<String>()
258 });
259 // Use the trusted client-IP extractor (cf-connecting-ip only), same
260 // as the 2FA-tracking path above. Reading raw X-Forwarded-For here
261 // let an attacker triggering a login spoof the IP shown in the
262 // victim's "new device" security email, the one IP most directly
263 // presented to a human as a security signal.
264 let ip = crate::helpers::extract_client_ip(&headers);
265 let unsub_url = email::generate_unsubscribe_url(
266 &config.host_url,
267 user_id,
268 email::UnsubscribeAction::Login,
269 &user_id.to_string(),
270 &config.signing_secret,
271 );
272 let email = email.clone();
273 bg.spawn("login notification", async move {
274 if let Err(e) = email
275 .send_new_login_notification(
276 user_id,
277 &notify_email,
278 notify_name.as_deref(),
279 user_agent.as_deref(),
280 ip.as_deref(),
281 Some(&unsub_url),
282 )
283 .await
284 {
285 tracing::error!(error = ?e, "failed to send login notification");
286 }
287 });
288 }
289 }
290
291 Ok(Redirect::to("/dashboard").into_response())
292 }
293