Skip to content

Commit 7e6ab8f

Browse files
committed
Disclose that email is already in use after verification
1 parent ef077d0 commit 7e6ab8f

File tree

13 files changed

+244
-37
lines changed

13 files changed

+244
-37
lines changed

crates/handlers/src/graphql/mutations/user_email.rs

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ enum StartEmailAuthenticationStatus {
242242
RateLimited,
243243
/// The email address isn't allowed by the policy
244244
Denied,
245-
/// The email address is already in use
245+
/// The email address is already in use on this account
246246
InUse,
247247
}
248248

@@ -308,6 +308,7 @@ enum CompleteEmailAuthenticationPayload {
308308
Completed,
309309
InvalidCode,
310310
CodeExpired,
311+
InUse,
311312
RateLimited,
312313
}
313314

@@ -322,6 +323,8 @@ enum CompleteEmailAuthenticationStatus {
322323
CodeExpired,
323324
/// Too many attempts to complete an email authentication
324325
RateLimited,
326+
/// The email address is already in use
327+
InUse,
325328
}
326329

327330
#[Object(use_type_description)]
@@ -332,6 +335,7 @@ impl CompleteEmailAuthenticationPayload {
332335
Self::Completed => CompleteEmailAuthenticationStatus::Completed,
333336
Self::InvalidCode => CompleteEmailAuthenticationStatus::InvalidCode,
334337
Self::CodeExpired => CompleteEmailAuthenticationStatus::CodeExpired,
338+
Self::InUse => CompleteEmailAuthenticationStatus::InUse,
335339
Self::RateLimited => CompleteEmailAuthenticationStatus::RateLimited,
336340
}
337341
}
@@ -588,10 +592,17 @@ impl UserEmailMutations {
588592

589593
let mut repo = state.repository().await?;
590594

591-
// Check if the email address is already in use
595+
// Check if the email address is already in use by the same user
596+
// We don't report here if the email address is already in use by another user,
597+
// because we don't want to leak information about other users. We will do that
598+
// only when the user enters the right verification code
592599
let count = repo
593600
.user_email()
594-
.count(UserEmailFilter::new().for_email(&input.email))
601+
.count(
602+
UserEmailFilter::new()
603+
.for_email(&input.email)
604+
.for_user(&browser_session.user),
605+
)
595606
.await?;
596607
if count > 0 {
597608
return Ok(StartEmailAuthenticationPayload::InUse);
@@ -742,19 +753,23 @@ impl UserEmailMutations {
742753
return Ok(CompleteEmailAuthenticationPayload::CodeExpired);
743754
}
744755

745-
// Check that we can add the email address to the user
756+
let authentication = repo
757+
.user_email()
758+
.complete_authentication(&clock, authentication, &code)
759+
.await?;
760+
761+
// Check the email is not already in use by anyone, including the current user
746762
let count = repo
747763
.user_email()
748764
.count(UserEmailFilter::new().for_email(&authentication.email))
749765
.await?;
766+
750767
if count > 0 {
751-
return Ok(CompleteEmailAuthenticationPayload::CodeExpired);
752-
}
768+
// We still want to consume the code so that it can't be reused
769+
repo.save().await?;
753770

754-
let authentication = repo
755-
.user_email()
756-
.complete_authentication(&clock, authentication, &code)
757-
.await?;
771+
return Ok(CompleteEmailAuthenticationPayload::InUse);
772+
}
758773

759774
repo.user_email()
760775
.add(

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

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use mas_policy::Policy;
2525
use mas_router::UrlBuilder;
2626
use mas_storage::{
2727
queue::{QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob},
28-
user::{UserEmailFilter, UserEmailRepository, UserRepository},
28+
user::{UserEmailRepository, UserRepository},
2929
BoxClock, BoxRepository, BoxRng, RepositoryAccess,
3030
};
3131
use mas_templates::{
@@ -191,17 +191,13 @@ pub(crate) async fn post(
191191
homeserver_denied_username = true;
192192
}
193193

194+
// Note that we don't check here if the email is already taken here, as
195+
// we don't want to leak the information about other users. Instead, we will
196+
// show an error message once the user confirmed their email address.
194197
if form.email.is_empty() {
195198
state.add_error_on_field(RegisterFormField::Email, FieldError::Required);
196199
} else if Address::from_str(&form.email).is_err() {
197200
state.add_error_on_field(RegisterFormField::Email, FieldError::Invalid);
198-
} else if repo
199-
.user_email()
200-
.count(UserEmailFilter::new().for_email(&form.email))
201-
.await?
202-
> 0
203-
{
204-
state.add_error_on_field(RegisterFormField::Email, FieldError::Exists);
205201
}
206202

207203
if form.password.is_empty() {

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

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
use anyhow::Context as _;
77
use axum::{
88
extract::{Path, State},
9-
response::IntoResponse,
9+
response::{Html, IntoResponse, Response},
1010
};
1111
use axum_extra::TypedHeader;
1212
use chrono::Duration;
@@ -19,10 +19,11 @@ use mas_storage::{
1919
user::UserEmailFilter,
2020
BoxClock, BoxRepository, BoxRng,
2121
};
22+
use mas_templates::{RegisterStepsEmailInUseContext, TemplateContext as _, Templates};
2223
use ulid::Ulid;
2324

2425
use super::super::cookie::UserRegistrationSessions;
25-
use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker};
26+
use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker, PreferredLanguage};
2627

2728
#[tracing::instrument(
2829
name = "handlers.views.register.steps.finish.get",
@@ -38,9 +39,11 @@ pub(crate) async fn get(
3839
user_agent: Option<TypedHeader<headers::UserAgent>>,
3940
State(url_builder): State<UrlBuilder>,
4041
State(homeserver): State<BoxHomeserverConnection>,
42+
State(templates): State<Templates>,
43+
PreferredLanguage(lang): PreferredLanguage,
4144
cookie_jar: CookieJar,
4245
Path(id): Path<Ulid>,
43-
) -> Result<impl IntoResponse, FancyError> {
46+
) -> Result<Response, FancyError> {
4447
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
4548
let registration = repo
4649
.user_registration()
@@ -60,7 +63,8 @@ pub(crate) async fn get(
6063
return Ok((
6164
cookie_jar,
6265
OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder),
63-
));
66+
)
67+
.into_response());
6468
}
6569

6670
// Make sure the registration session hasn't expired
@@ -117,29 +121,42 @@ pub(crate) async fn get(
117121
return Ok((
118122
cookie_jar,
119123
url_builder.redirect(&mas_router::RegisterVerifyEmail::new(id)),
120-
));
124+
)
125+
.into_response());
121126
}
122127

123128
// Check that the email address isn't already used
129+
// It is important to do that here, as we we're not checking during the
130+
// registration, because we don't want to disclose whether an email is
131+
// already being used or not before we verified it
124132
if repo
125133
.user_email()
126134
.count(UserEmailFilter::new().for_email(&email_authentication.email))
127135
.await?
128136
> 0
129137
{
130-
// XXX: this could have a better error message, but as this is unlikely to
131-
// happen, we're fine with a vague message for now
132-
return Err(FancyError::from(anyhow::anyhow!(
133-
"Email address is already used"
134-
)));
138+
let action = registration
139+
.post_auth_action
140+
.map(serde_json::from_value)
141+
.transpose()?;
142+
143+
let ctx = RegisterStepsEmailInUseContext::new(email_authentication.email, action)
144+
.with_language(lang);
145+
146+
return Ok((
147+
cookie_jar,
148+
Html(templates.render_register_steps_email_in_use(&ctx)?),
149+
)
150+
.into_response());
135151
}
136152

137153
// Check that the display name is set
138154
if registration.display_name.is_none() {
139155
return Ok((
140156
cookie_jar,
141157
url_builder.redirect(&mas_router::RegisterDisplayName::new(registration.id)),
142-
));
158+
)
159+
.into_response());
143160
}
144161

145162
// Everuthing is good, let's complete the registration
@@ -215,5 +232,6 @@ pub(crate) async fn get(
215232
return Ok((
216233
cookie_jar,
217234
OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder),
218-
));
235+
)
236+
.into_response());
219237
}

crates/templates/src/context.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,6 +1000,32 @@ impl TemplateContext for RegisterStepsVerifyEmailContext {
10001000
}
10011001
}
10021002

1003+
/// Context used by the `pages/register/steps/email_in_use.html` template
1004+
#[derive(Serialize)]
1005+
pub struct RegisterStepsEmailInUseContext {
1006+
email: String,
1007+
action: Option<PostAuthAction>,
1008+
}
1009+
1010+
impl RegisterStepsEmailInUseContext {
1011+
/// Constructs a context for the email in use page
1012+
#[must_use]
1013+
pub fn new(email: String, action: Option<PostAuthAction>) -> Self {
1014+
Self { email, action }
1015+
}
1016+
}
1017+
1018+
impl TemplateContext for RegisterStepsEmailInUseContext {
1019+
fn sample(_now: chrono::DateTime<Utc>, _rng: &mut impl Rng) -> Vec<Self>
1020+
where
1021+
Self: Sized,
1022+
{
1023+
let email = "[email protected]".to_owned();
1024+
let action = PostAuthAction::continue_grant(Ulid::nil());
1025+
vec![Self::new(email, Some(action))]
1026+
}
1027+
}
1028+
10031029
/// Fields for the display name form
10041030
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
10051031
#[serde(rename_all = "snake_case")]

crates/templates/src/lib.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ pub use self::{
4242
RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext,
4343
RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField,
4444
RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
45-
RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding,
46-
SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext,
47-
UpstreamRegister, UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf,
48-
WithLanguage, WithOptionalSession, WithSession,
45+
RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext,
46+
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
47+
TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
48+
UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession,
4949
},
5050
forms::{FieldError, FormError, FormField, FormState, ToFormState},
5151
};
@@ -336,6 +336,9 @@ register_templates! {
336336
/// Render the email verification page
337337
pub fn render_register_steps_verify_email(WithLanguage<WithCsrf<RegisterStepsVerifyEmailContext>>) { "pages/register/steps/verify_email.html" }
338338

339+
/// Render the email in use page
340+
pub fn render_register_steps_email_in_use(WithLanguage<RegisterStepsEmailInUseContext>) { "pages/register/steps/email_in_use.html" }
341+
339342
/// Render the display name page
340343
pub fn render_register_steps_display_name(WithLanguage<WithCsrf<RegisterStepsDisplayNameContext>>) { "pages/register/steps/display_name.html" }
341344

@@ -432,6 +435,7 @@ impl Templates {
432435
check::render_register(self, now, rng)?;
433436
check::render_password_register(self, now, rng)?;
434437
check::render_register_steps_verify_email(self, now, rng)?;
438+
check::render_register_steps_email_in_use(self, now, rng)?;
435439
check::render_register_steps_display_name(self, now, rng)?;
436440
check::render_consent(self, now, rng)?;
437441
check::render_policy_violation(self, now, rng)?;

frontend/locales/en.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@
7878
"tablet": "Tablet",
7979
"unknown": "Unknown device type"
8080
},
81+
"email_in_use": {
82+
"heading": "The email address {{email}} is already in use."
83+
},
8184
"end_session_button": {
8285
"confirmation_modal_title": "Are you sure you want to end this session?",
8386
"text": "Sign out"
@@ -266,6 +269,10 @@
266269
}
267270
},
268271
"verify_email": {
272+
"code_expired_alert": {
273+
"description": "The code has expired. Please request a new code.",
274+
"title": "Code expired"
275+
},
269276
"code_field_error": "Code not recognised",
270277
"code_field_label": "6-digit code",
271278
"code_field_wrong_shape": "Code must be 6 digits",

frontend/schema.graphql

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,10 @@ enum CompleteEmailAuthenticationStatus {
528528
Too many attempts to complete an email authentication
529529
"""
530530
RATE_LIMITED
531+
"""
532+
The email address is already in use
533+
"""
534+
IN_USE
531535
}
532536

533537
"""
@@ -1650,7 +1654,7 @@ enum StartEmailAuthenticationStatus {
16501654
"""
16511655
DENIED
16521656
"""
1653-
The email address is already in use
1657+
The email address is already in use on this account
16541658
"""
16551659
IN_USE
16561660
}

frontend/src/gql/graphql.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,8 @@ export type CompleteEmailAuthenticationStatus =
349349
| 'COMPLETED'
350350
/** The authentication code is invalid */
351351
| 'INVALID_CODE'
352+
/** The email address is already in use */
353+
| 'IN_USE'
352354
/** Too many attempts to complete an email authentication */
353355
| 'RATE_LIMITED';
354356

@@ -1207,7 +1209,7 @@ export type StartEmailAuthenticationStatus =
12071209
| 'DENIED'
12081210
/** The email address is invalid */
12091211
| 'INVALID_EMAIL_ADDRESS'
1210-
/** The email address is already in use */
1212+
/** The email address is already in use on this account */
12111213
| 'IN_USE'
12121214
/** Too many attempts to start an email authentication */
12131215
| 'RATE_LIMITED'

0 commit comments

Comments
 (0)