Skip to content

Commit 5851584

Browse files
committed
Link the registration to the browser through a signed cookie
1 parent f50a386 commit 5851584

File tree

5 files changed

+134
-2
lines changed

5 files changed

+134
-2
lines changed

crates/axum-utils/src/cookies.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ impl CookieJar {
138138
self
139139
}
140140

141+
/// Remove a cookie from the jar
142+
#[must_use]
143+
pub fn remove(mut self, key: &str) -> Self {
144+
self.inner = self.inner.remove(key.to_owned());
145+
self
146+
}
147+
141148
/// Load and deserialize a cookie from the jar
142149
///
143150
/// Returns `None` if the cookie is not present
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
// Please see LICENSE in the repository root for full details.
5+
6+
// TODO: move that to a standalone cookie manager
7+
8+
use std::collections::BTreeSet;
9+
10+
use chrono::{DateTime, Duration, Utc};
11+
use mas_axum_utils::cookies::CookieJar;
12+
use mas_data_model::UserRegistration;
13+
use mas_storage::Clock;
14+
use serde::{Deserialize, Serialize};
15+
use thiserror::Error;
16+
use ulid::Ulid;
17+
18+
/// Name of the cookie
19+
static COOKIE_NAME: &str = "user-registration-sessions";
20+
21+
/// Sessions expire after an hour
22+
static SESSION_MAX_TIME: Duration = Duration::hours(1);
23+
24+
/// The content of the cookie, which stores a list of user registration IDs
25+
#[derive(Serialize, Deserialize, Default, Debug)]
26+
pub struct UserRegistrationSessions(BTreeSet<Ulid>);
27+
28+
#[derive(Debug, Error, PartialEq, Eq)]
29+
#[error("user registration session not found")]
30+
pub struct UserRegistrationSessionNotFound;
31+
32+
impl UserRegistrationSessions {
33+
/// Load the user registration sessions cookie
34+
pub fn load(cookie_jar: &CookieJar) -> Self {
35+
match cookie_jar.load(COOKIE_NAME) {
36+
Ok(Some(sessions)) => sessions,
37+
Ok(None) => Self::default(),
38+
Err(e) => {
39+
tracing::warn!(
40+
error = &e as &dyn std::error::Error,
41+
"Invalid upstream sessions cookie"
42+
);
43+
Self::default()
44+
}
45+
}
46+
}
47+
48+
/// Returns true if the cookie is empty
49+
pub fn is_empty(&self) -> bool {
50+
self.0.is_empty()
51+
}
52+
53+
/// Save the user registration sessions to the cookie jar
54+
pub fn save<C>(self, cookie_jar: CookieJar, clock: &C) -> CookieJar
55+
where
56+
C: Clock,
57+
{
58+
let this = self.expire(clock.now());
59+
60+
if this.is_empty() {
61+
cookie_jar.remove(COOKIE_NAME)
62+
} else {
63+
cookie_jar.save(COOKIE_NAME, &this, false)
64+
}
65+
}
66+
67+
fn expire(mut self, now: DateTime<Utc>) -> Self {
68+
self.0.retain(|id| {
69+
let Ok(ts) = id.timestamp_ms().try_into() else {
70+
return false;
71+
};
72+
let Some(when) = DateTime::from_timestamp_millis(ts) else {
73+
return false;
74+
};
75+
now - when < SESSION_MAX_TIME
76+
});
77+
78+
self
79+
}
80+
81+
/// Add a new session, for a provider and a random state
82+
pub fn add(mut self, user_registration: &UserRegistration) -> Self {
83+
self.0.insert(user_registration.id);
84+
self
85+
}
86+
87+
/// Check if the session is in the list
88+
pub fn contains(&self, user_registration: &UserRegistration) -> bool {
89+
self.0.contains(&user_registration.id)
90+
}
91+
92+
/// Mark a link as consumed to avoid replay
93+
pub fn consume_session(
94+
mut self,
95+
user_registration: &UserRegistration,
96+
) -> Result<Self, UserRegistrationSessionNotFound> {
97+
if !self.0.remove(&user_registration.id) {
98+
return Err(UserRegistrationSessionNotFound);
99+
}
100+
101+
Ok(self)
102+
}
103+
}

crates/handlers/src/views/register/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use mas_templates::{RegisterContext, TemplateContext, Templates};
1717
use super::shared::OptionalPostAuthAction;
1818
use crate::{BoundActivityTracker, PreferredLanguage};
1919

20+
mod cookie;
2021
pub(crate) mod password;
2122
pub(crate) mod steps;
2223

crates/handlers/src/views/register/password.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ use mas_templates::{
3535
use serde::{Deserialize, Serialize};
3636
use zeroize::Zeroizing;
3737

38+
use super::cookie::UserRegistrationSessions;
3839
use crate::{
3940
captcha::Form as CaptchaForm, passwords::PasswordManager,
4041
views::shared::OptionalPostAuthAction, BoundActivityTracker, Limiter, PreferredLanguage,
@@ -361,8 +362,14 @@ pub(crate) async fn post(
361362

362363
repo.save().await?;
363364

364-
Ok(url_builder
365-
.redirect(&mas_router::RegisterFinish::new(registration.id))
365+
let cookie_jar = UserRegistrationSessions::load(&cookie_jar)
366+
.add(&registration)
367+
.save(cookie_jar, &clock);
368+
369+
Ok((
370+
cookie_jar,
371+
url_builder.redirect(&mas_router::RegisterFinish::new(registration.id)),
372+
)
366373
.into_response())
367374
}
368375

crates/handlers/src/views/register/steps/finish.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use mas_storage::{
1919
};
2020
use ulid::Ulid;
2121

22+
use super::super::cookie::UserRegistrationSessions;
2223
use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker};
2324

2425
#[tracing::instrument(
@@ -59,6 +60,14 @@ pub(crate) async fn get(
5960
));
6061
}
6162

63+
// Check that this registration belongs to this browser
64+
let registrations = UserRegistrationSessions::load(&cookie_jar);
65+
if !registrations.contains(&registration) {
66+
return Err(FancyError::from(anyhow::anyhow!(
67+
"Could not find the registration in the browser cookies"
68+
)));
69+
}
70+
6271
// Let's perform last minute checks on the registration, especially to avoid
6372
// race conditions where multiple users register with the same username or email
6473
// address
@@ -116,6 +125,11 @@ pub(crate) async fn get(
116125
.complete(&clock, registration)
117126
.await?;
118127

128+
// Consume the registration session
129+
let cookie_jar = registrations
130+
.consume_session(&registration)?
131+
.save(cookie_jar, &clock);
132+
119133
// Now we can start the user creation
120134
let user = repo
121135
.user()

0 commit comments

Comments
 (0)