Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework how email verification works #3784

Merged
merged 28 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f84ccda
Fix the service worker not loading in dev mode
sandhose Jan 9, 2025
5d69b34
frontend: simplify email list
sandhose Jan 9, 2025
8db3642
Add more stories for the account index page
sandhose Jan 9, 2025
ee33e9c
Remove the primary email address concept
sandhose Jan 9, 2025
75526ff
storage: new email authentication codes
sandhose Jan 9, 2025
1f83b39
Remove the dedicated page to add an email address
sandhose Jan 9, 2025
0513f19
Rip out the email verification codes
sandhose Jan 9, 2025
5f5fc44
Job to send the new email authentication codes
sandhose Jan 10, 2025
23b019c
GraphQL API to use the new email authentication codes
sandhose Jan 10, 2025
e0f4882
Use the new GraphQL APIs in the frontend to add emails
sandhose Jan 10, 2025
dbb5316
Data model and storage layer for storing user registrations
sandhose Jan 13, 2025
3da27af
Move the registration-related views into a sub-module
sandhose Jan 14, 2025
a294b37
Fix the post auth action being lost during the registration flow
sandhose Jan 14, 2025
0bedaf3
Make the password registration create a user_registration
sandhose Jan 14, 2025
f8517a5
Implement email verification in the registration flow
sandhose Jan 14, 2025
621b648
Check that the email isn't used during the registration process
sandhose Jan 14, 2025
36aa1a0
Move the finishing of registration to a dedicated view
sandhose Jan 15, 2025
f50a386
Registration step to set a display name
sandhose Jan 15, 2025
5851584
Link the registration to the browser through a signed cookie
sandhose Jan 15, 2025
02db622
Expire registration sessions after an hour
sandhose Jan 15, 2025
1ec6192
Check with the homeserver the username is still available before regi…
sandhose Jan 15, 2025
ef74d47
Cleanup the unverified emails from the database
sandhose Jan 15, 2025
6092efe
Merge branch 'main' into quenting/optional-email
sandhose Jan 20, 2025
ef077d0
Rate-limit email authentications
sandhose Jan 23, 2025
7e6ab8f
Disclose that email is already in use after verification
sandhose Jan 23, 2025
244ec18
Merge remote-tracking branch 'origin/main' into quenting/optional-email
sandhose Jan 23, 2025
a83cdfb
Clarify that VerifyEmailJob is kept for flushing old jobs
sandhose Jan 23, 2025
8d50088
Apply code style suggestion
sandhose Jan 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 25 additions & 10 deletions crates/handlers/src/graphql/mutations/user_email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ enum StartEmailAuthenticationStatus {
RateLimited,
/// The email address isn't allowed by the policy
Denied,
/// The email address is already in use
/// The email address is already in use on this account
InUse,
}

Expand Down Expand Up @@ -308,6 +308,7 @@ enum CompleteEmailAuthenticationPayload {
Completed,
InvalidCode,
CodeExpired,
InUse,
RateLimited,
}

Expand All @@ -322,6 +323,8 @@ enum CompleteEmailAuthenticationStatus {
CodeExpired,
/// Too many attempts to complete an email authentication
RateLimited,
/// The email address is already in use
InUse,
}

#[Object(use_type_description)]
Expand All @@ -332,6 +335,7 @@ impl CompleteEmailAuthenticationPayload {
Self::Completed => CompleteEmailAuthenticationStatus::Completed,
Self::InvalidCode => CompleteEmailAuthenticationStatus::InvalidCode,
Self::CodeExpired => CompleteEmailAuthenticationStatus::CodeExpired,
Self::InUse => CompleteEmailAuthenticationStatus::InUse,
Self::RateLimited => CompleteEmailAuthenticationStatus::RateLimited,
}
}
Expand Down Expand Up @@ -588,10 +592,17 @@ impl UserEmailMutations {

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

// Check if the email address is already in use
// Check if the email address is already in use by the same user
// We don't report here if the email address is already in use by another user,
// because we don't want to leak information about other users. We will do that
// only when the user enters the right verification code
let count = repo
.user_email()
.count(UserEmailFilter::new().for_email(&input.email))
.count(
UserEmailFilter::new()
.for_email(&input.email)
.for_user(&browser_session.user),
)
.await?;
if count > 0 {
return Ok(StartEmailAuthenticationPayload::InUse);
Expand Down Expand Up @@ -742,19 +753,23 @@ impl UserEmailMutations {
return Ok(CompleteEmailAuthenticationPayload::CodeExpired);
}

// Check that we can add the email address to the user
let authentication = repo
.user_email()
.complete_authentication(&clock, authentication, &code)
.await?;

// Check the email is not already in use by anyone, including the current user
let count = repo
.user_email()
.count(UserEmailFilter::new().for_email(&authentication.email))
.await?;

if count > 0 {
return Ok(CompleteEmailAuthenticationPayload::CodeExpired);
}
// We still want to consume the code so that it can't be reused
repo.save().await?;

let authentication = repo
.user_email()
.complete_authentication(&clock, authentication, &code)
.await?;
return Ok(CompleteEmailAuthenticationPayload::InUse);
}

repo.user_email()
.add(
Expand Down
12 changes: 4 additions & 8 deletions crates/handlers/src/views/register/password.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use mas_policy::Policy;
use mas_router::UrlBuilder;
use mas_storage::{
queue::{QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob},
user::{UserEmailFilter, UserEmailRepository, UserRepository},
user::{UserEmailRepository, UserRepository},
BoxClock, BoxRepository, BoxRng, RepositoryAccess,
};
use mas_templates::{
Expand Down Expand Up @@ -191,17 +191,13 @@ pub(crate) async fn post(
homeserver_denied_username = true;
}

// Note that we don't check here if the email is already taken here, as
// we don't want to leak the information about other users. Instead, we will
// show an error message once the user confirmed their email address.
if form.email.is_empty() {
state.add_error_on_field(RegisterFormField::Email, FieldError::Required);
} else if Address::from_str(&form.email).is_err() {
state.add_error_on_field(RegisterFormField::Email, FieldError::Invalid);
} else if repo
.user_email()
.count(UserEmailFilter::new().for_email(&form.email))
.await?
> 0
{
state.add_error_on_field(RegisterFormField::Email, FieldError::Exists);
}

if form.password.is_empty() {
Expand Down
42 changes: 30 additions & 12 deletions crates/handlers/src/views/register/steps/finish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use anyhow::Context as _;
use axum::{
extract::{Path, State},
response::IntoResponse,
response::{Html, IntoResponse, Response},
};
use axum_extra::TypedHeader;
use chrono::Duration;
Expand All @@ -19,10 +19,11 @@ use mas_storage::{
user::UserEmailFilter,
BoxClock, BoxRepository, BoxRng,
};
use mas_templates::{RegisterStepsEmailInUseContext, TemplateContext as _, Templates};
use ulid::Ulid;

use super::super::cookie::UserRegistrationSessions;
use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker};
use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker, PreferredLanguage};

#[tracing::instrument(
name = "handlers.views.register.steps.finish.get",
Expand All @@ -38,9 +39,11 @@ pub(crate) async fn get(
user_agent: Option<TypedHeader<headers::UserAgent>>,
State(url_builder): State<UrlBuilder>,
State(homeserver): State<BoxHomeserverConnection>,
State(templates): State<Templates>,
PreferredLanguage(lang): PreferredLanguage,
cookie_jar: CookieJar,
Path(id): Path<Ulid>,
) -> Result<impl IntoResponse, FancyError> {
) -> Result<Response, FancyError> {
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
let registration = repo
.user_registration()
Expand All @@ -60,7 +63,8 @@ pub(crate) async fn get(
return Ok((
cookie_jar,
OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder),
));
)
.into_response());
}

// Make sure the registration session hasn't expired
Expand Down Expand Up @@ -117,29 +121,42 @@ pub(crate) async fn get(
return Ok((
cookie_jar,
url_builder.redirect(&mas_router::RegisterVerifyEmail::new(id)),
));
)
.into_response());
}

// Check that the email address isn't already used
// It is important to do that here, as we we're not checking during the
// registration, because we don't want to disclose whether an email is
// already being used or not before we verified it
if repo
.user_email()
.count(UserEmailFilter::new().for_email(&email_authentication.email))
.await?
> 0
{
// XXX: this could have a better error message, but as this is unlikely to
// happen, we're fine with a vague message for now
return Err(FancyError::from(anyhow::anyhow!(
"Email address is already used"
)));
let action = registration
.post_auth_action
.map(serde_json::from_value)
.transpose()?;

let ctx = RegisterStepsEmailInUseContext::new(email_authentication.email, action)
.with_language(lang);

return Ok((
cookie_jar,
Html(templates.render_register_steps_email_in_use(&ctx)?),
)
.into_response());
}

// Check that the display name is set
if registration.display_name.is_none() {
return Ok((
cookie_jar,
url_builder.redirect(&mas_router::RegisterDisplayName::new(registration.id)),
));
)
.into_response());
}

// Everuthing is good, let's complete the registration
Expand Down Expand Up @@ -215,5 +232,6 @@ pub(crate) async fn get(
return Ok((
cookie_jar,
OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder),
));
)
.into_response());
}
26 changes: 26 additions & 0 deletions crates/templates/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,32 @@ impl TemplateContext for RegisterStepsVerifyEmailContext {
}
}

/// Context used by the `pages/register/steps/email_in_use.html` template
#[derive(Serialize)]
pub struct RegisterStepsEmailInUseContext {
email: String,
action: Option<PostAuthAction>,
}

impl RegisterStepsEmailInUseContext {
/// Constructs a context for the email in use page
#[must_use]
pub fn new(email: String, action: Option<PostAuthAction>) -> Self {
Self { email, action }
}
}

impl TemplateContext for RegisterStepsEmailInUseContext {
fn sample(_now: chrono::DateTime<Utc>, _rng: &mut impl Rng) -> Vec<Self>
where
Self: Sized,
{
let email = "[email protected]".to_owned();
let action = PostAuthAction::continue_grant(Ulid::nil());
vec![Self::new(email, Some(action))]
}
}

/// Fields for the display name form
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
Expand Down
12 changes: 8 additions & 4 deletions crates/templates/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ pub use self::{
RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext,
RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField,
RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding,
SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext,
UpstreamRegister, UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf,
WithLanguage, WithOptionalSession, WithSession,
RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext,
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession,
},
forms::{FieldError, FormError, FormField, FormState, ToFormState},
};
Expand Down Expand Up @@ -336,6 +336,9 @@ register_templates! {
/// Render the email verification page
pub fn render_register_steps_verify_email(WithLanguage<WithCsrf<RegisterStepsVerifyEmailContext>>) { "pages/register/steps/verify_email.html" }

/// Render the email in use page
pub fn render_register_steps_email_in_use(WithLanguage<RegisterStepsEmailInUseContext>) { "pages/register/steps/email_in_use.html" }

/// Render the display name page
pub fn render_register_steps_display_name(WithLanguage<WithCsrf<RegisterStepsDisplayNameContext>>) { "pages/register/steps/display_name.html" }

Expand Down Expand Up @@ -432,6 +435,7 @@ impl Templates {
check::render_register(self, now, rng)?;
check::render_password_register(self, now, rng)?;
check::render_register_steps_verify_email(self, now, rng)?;
check::render_register_steps_email_in_use(self, now, rng)?;
check::render_register_steps_display_name(self, now, rng)?;
check::render_consent(self, now, rng)?;
check::render_policy_violation(self, now, rng)?;
Expand Down
7 changes: 7 additions & 0 deletions frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@
"tablet": "Tablet",
"unknown": "Unknown device type"
},
"email_in_use": {
"heading": "The email address {{email}} is already in use."
},
"end_session_button": {
"confirmation_modal_title": "Are you sure you want to end this session?",
"text": "Sign out"
Expand Down Expand Up @@ -266,6 +269,10 @@
}
},
"verify_email": {
"code_expired_alert": {
"description": "The code has expired. Please request a new code.",
"title": "Code expired"
},
"code_field_error": "Code not recognised",
"code_field_label": "6-digit code",
"code_field_wrong_shape": "Code must be 6 digits",
Expand Down
6 changes: 5 additions & 1 deletion frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,10 @@ enum CompleteEmailAuthenticationStatus {
Too many attempts to complete an email authentication
"""
RATE_LIMITED
"""
The email address is already in use
"""
IN_USE
}

"""
Expand Down Expand Up @@ -1650,7 +1654,7 @@ enum StartEmailAuthenticationStatus {
"""
DENIED
"""
The email address is already in use
The email address is already in use on this account
"""
IN_USE
}
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/gql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ export type CompleteEmailAuthenticationStatus =
| 'COMPLETED'
/** The authentication code is invalid */
| 'INVALID_CODE'
/** The email address is already in use */
| 'IN_USE'
/** Too many attempts to complete an email authentication */
| 'RATE_LIMITED';

Expand Down Expand Up @@ -1207,7 +1209,7 @@ export type StartEmailAuthenticationStatus =
| 'DENIED'
/** The email address is invalid */
| 'INVALID_EMAIL_ADDRESS'
/** The email address is already in use */
/** The email address is already in use on this account */
| 'IN_USE'
/** Too many attempts to start an email authentication */
| 'RATE_LIMITED'
Expand Down
Loading