Skip to main content

max / makenotwork

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