Skip to main content

tuwunel_api/client/account/3pid/
email_validate.rs

1use std::net::IpAddr;
2
3use axum::{
4	extract::{Form, Request, State},
5	response::{Html, IntoResponse, Response},
6};
7use const_str::format as const_format;
8use http::{
9	StatusCode,
10	header::{CACHE_CONTROL, REFERRER_POLICY},
11};
12use serde::Deserialize;
13use tuwunel_core::utils::html::escape as html_escape;
14
15use crate::ClientIp;
16
17static VALIDATE_HEAD: &str = r#"
18	<meta charset="UTF-8">
19	<link rel="stylesheet" href="/_tuwunel/oidc/account.css">
20"#;
21
22static GENERIC_FAILURE: &str =
23	"This verification link is invalid or has expired. Request a new one from your client.";
24
25#[derive(Debug, Default, Deserialize)]
26pub(crate) struct ValidateParams {
27	sid: Option<String>,
28	client_secret: Option<String>,
29	token: Option<String>,
30}
31
32/// # `GET /_tuwunel/3pid/email/validate`
33///
34/// The magic-link target. Renders a confirmation page whose form posts the same
35/// parameters back; the token is never consumed on this request, so an email
36/// scanner that prefetches the link cannot spend it.
37pub(crate) async fn get_email_validate_route(
38	State(services): State<crate::State>,
39	ClientIp(client): ClientIp,
40	request: Request,
41) -> Response {
42	if let Some(limited) = rate_limited(services, client) {
43		return limited;
44	}
45
46	let params: ValidateParams =
47		serde_html_form::from_str(request.uri().query().unwrap_or_default()).unwrap_or_default();
48
49	validate_html(StatusCode::OK, confirm_html(&params))
50}
51
52/// # `POST /_tuwunel/3pid/email/validate`
53///
54/// Confirms the validation. A wrong or expired session renders the same failure
55/// page as any other error, so the page never reveals whether a session or
56/// token is live.
57pub(crate) async fn post_email_validate_route(
58	State(services): State<crate::State>,
59	ClientIp(client): ClientIp,
60	Form(params): Form<ValidateParams>,
61) -> Response {
62	if let Some(limited) = rate_limited(services, client) {
63		return limited;
64	}
65
66	let (Some(sid), Some(client_secret), Some(token)) =
67		(&params.sid, &params.client_secret, &params.token)
68	else {
69		return validate_html(StatusCode::OK, error_html(GENERIC_FAILURE));
70	};
71
72	match services
73		.threepid
74		.validate_pending_token(sid, client_secret, token)
75		.await
76	{
77		| Ok(()) => validate_html(
78			StatusCode::OK,
79			result_html(
80				"Email verified",
81				"Your email address has been verified. Return to your client to continue.",
82			),
83		),
84		| Err(_) => validate_html(StatusCode::OK, error_html(GENERIC_FAILURE)),
85	}
86}
87
88fn rate_limited(services: crate::State, client: IpAddr) -> Option<Response> {
89	services
90		.threepid
91		.check_ip_rate_limit(client)
92		.is_err()
93		.then(|| {
94			validate_html(
95				StatusCode::TOO_MANY_REQUESTS,
96				error_html("Too many requests. Please wait and try again."),
97			)
98		})
99}
100
101fn confirm_html(params: &ValidateParams) -> String {
102	let escape = |value: &Option<String>| html_escape(value.as_deref().unwrap_or_default());
103
104	// Token first: a later replace must not refill an injected {token}.
105	CONFIRM_HTML
106		.replace("{token}", &escape(&params.token))
107		.replace("{client_secret}", &escape(&params.client_secret))
108		.replace("{sid}", &escape(&params.sid))
109}
110
111static CONFIRM_HTML: &str = const_format!(
112	r#"
113<!DOCTYPE html>
114<html lang="en">
115	<head>
116		{VALIDATE_HEAD}
117		<title>Verify your email address</title>
118	</head>
119	<body>
120		<h1>Verify your email address</h1>
121		<p>Confirm that you want to verify this email address.</p>
122		<form method="POST" action="/_tuwunel/3pid/email/validate">
123			<input type="hidden" name="sid" value="{{sid}}">
124			<input type="hidden" name="client_secret" value="{{client_secret}}">
125			<input type="hidden" name="token" value="{{token}}">
126			<button type="submit" class="primary">Verify</button>
127		</form>
128	</body>
129</html>"#
130);
131
132fn result_html(title: &str, message: &str) -> String {
133	RESULT_HTML
134		.replace("{title}", &html_escape(title))
135		.replace("{message}", &html_escape(message))
136}
137
138static RESULT_HTML: &str = const_format!(
139	r#"
140<!DOCTYPE html>
141<html lang="en">
142	<head>
143		{VALIDATE_HEAD}
144		<title>{{title}}</title>
145	</head>
146	<body>
147		<h1>{{title}}</h1>
148		<p>{{message}}</p>
149	</body>
150</html>"#
151);
152
153fn error_html(message: &str) -> String { ERROR_HTML.replace("{msg}", &html_escape(message)) }
154
155static ERROR_HTML: &str = const_format!(
156	r#"
157<!DOCTYPE html>
158<html lang="en">
159	<head>
160		{VALIDATE_HEAD}
161		<title>Verification failed</title>
162	</head>
163	<body>
164		<h1 class="err">Verification failed</h1>
165		<p>{{msg}}</p>
166	</body>
167</html>"#
168);
169
170fn validate_html(status: StatusCode, html: String) -> Response {
171	let headers = [(CACHE_CONTROL, "no-store"), (REFERRER_POLICY, "no-referrer")];
172
173	(status, headers, Html(html)).into_response()
174}