Skip to main content

max / makenotwork

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