From 582e1fa077bdbca2deac54e3a50649e0ff89ed9c Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 20 Dec 2024 10:24:44 +0100 Subject: [PATCH 1/4] Tweak the login page to match the design --- .../src/components/Layout/Layout.module.css | 6 +- templates/base.html | 2 +- templates/pages/consent.html | 2 + templates/pages/device_consent.html | 2 + templates/pages/login.html | 110 +++++++++--------- 5 files changed, 68 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/Layout/Layout.module.css b/frontend/src/components/Layout/Layout.module.css index 7c589b52f..79347e4db 100644 --- a/frontend/src/components/Layout/Layout.module.css +++ b/frontend/src/components/Layout/Layout.module.css @@ -10,7 +10,7 @@ display: flex; flex-direction: column; - max-width: calc(420px + var(--cpd-space-5x) * 2); + max-width: calc(378px + var(--cpd-space-5x) * 2); /* Fallback for browsers that do not support 100svh */ min-height: 100vh; @@ -21,6 +21,10 @@ padding-block: var(--cpd-space-12x); gap: var(--cpd-space-8x); + &.consent { + max-width: calc(460px + var(--cpd-space-5x) * 2); + } + &.wide { max-width: calc(520px + var(--cpd-space-5x) * 2); } diff --git a/templates/base.html b/templates/base.html index 116e7e272..51cb70dad 100644 --- a/templates/base.html +++ b/templates/base.html @@ -28,7 +28,7 @@ {{ captcha.head() }} -
+ diff --git a/templates/pages/consent.html b/templates/pages/consent.html index 3a5593ac8..7b02f76da 100644 --- a/templates/pages/consent.html +++ b/templates/pages/consent.html @@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details. -#} +{% set consent_page = true %} + {% extends "base.html" %} {% block content %} diff --git a/templates/pages/device_consent.html b/templates/pages/device_consent.html index fd6563914..e8abbdb15 100644 --- a/templates/pages/device_consent.html +++ b/templates/pages/device_consent.html @@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details. -#} +{% set consent_page = true %} + {% extends "base.html" %} {% block content %} diff --git a/templates/pages/login.html b/templates/pages/login.html index e94a88d78..cf123ae80 100644 --- a/templates/pages/login.html +++ b/templates/pages/login.html @@ -12,27 +12,27 @@ {% block content %}
- {% if features.password_login %} -
-
- {{ icon.user_profile_solid() }} -
+
+
+ {{ icon.user_profile_solid() }} +
- {% if next and next.kind == "link_upstream" %} -
-

{{ _("mas.login.link.headline") }}

- {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} -

{{ _("mas.login.link.description", provider=name) }}

-
- {% else %} -
-

{{ _("mas.login.headline") }}

-

{{ _("mas.login.description") }}

-
- {% endif %} -
+ {% if next and next.kind == "link_upstream" %} +
+

{{ _("mas.login.link.headline") }}

+ {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} +

{{ _("mas.login.link.description", provider=name) }}

+
+ {% else %} +
+

{{ _("mas.login.headline") }}

+

{{ _("mas.login.description") }}

+
+ {% endif %} +
-
+ +
{% if form.errors is not empty %} {% for error in form.errors %}
@@ -47,47 +47,47 @@

{{ _("mas.login.headline") }}

{% endcall %} - {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} - - {% endcall %} + {% if features.password_login %} + {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} + + {% endcall %} - {% if features.account_recovery %} - {{ button.link_text(text=_("mas.login.forgot_password"), href="/recover", class="self-center") }} + {% if features.account_recovery %} + {{ button.link_text(text=_("mas.login.forgot_password"), href="/recover", class="self-center") }} + {% endif %} {% endif %} +
- {{ button.button(text=_("action.continue")) }} - +
+ {% if features.password_login %} + {{ button.button(text=_("action.continue")) }} + {% endif %} - {% if (not next or next.kind != "link_upstream") and features.password_registration %} -
-

- {{ _("mas.login.call_to_register") }} -

+ {% if features.password_login and providers %} + {{ field.separator() }} + {% endif %} + {% if providers %} {% set params = next["params"] | default({}) | to_params(prefix="?") %} - {{ button.link_text(text=_("action.create_account"), href="/register" ~ params) }} -
- {% endif %} - {% endif %} - - {% if providers %} - {% if features.password_login %} - {{ field.separator() }} - {% endif %} + {% for provider in providers %} + {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} + + {{ logo(provider.brand_name) }} + {{ _("mas.login.continue_with_provider", provider=name) }} + + {% endfor %} + {% endif %} +
+ - {% set params = next["params"] | default({}) | to_params(prefix="?") %} - {% for provider in providers %} - {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} - - {{ logo(provider.brand_name) }} - {{ _("mas.login.continue_with_provider", provider=name) }} - - {% endfor %} - {% endif %} + {% if (not next or next.kind != "link_upstream") and features.password_registration %} +
+

+ {{ _("mas.login.call_to_register") }} +

- {% if not providers and not features.password_login %} -
- {{ _("mas.login.no_login_methods") }} + {% set params = next["params"] | default({}) | to_params(prefix="?") %} + {{ button.link_text(text=_("action.create_account"), href="/register" ~ params) }}
{% endif %} @@ -101,5 +101,11 @@

{{ _("mas.login.headline") }}

params=dict(error="access_denied", state=next.grant.state) ) }} {% endif %} + + {% if not providers and not features.password_login %} +
+ {{ _("mas.login.no_login_methods") }} +
+ {% endif %}
{% endblock content %} From f26897b393df6a266cd0d0678aff94108ce89e65 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 20 Dec 2024 16:28:59 +0100 Subject: [PATCH 2/4] Update the IDP brand icons from Figma --- templates/components/idp_brand.html | 36 +++++++++++++++++------------ 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/templates/components/idp_brand.html b/templates/components/idp_brand.html index 14a8fa386..9781f09da 100644 --- a/templates/components/idp_brand.html +++ b/templates/components/idp_brand.html @@ -9,20 +9,20 @@ {% macro logo(brand, class="") -%} {% if brand == "google" -%} - - - - + + + + {% elif brand == "gitlab" %} - - - - - - - + + + + + + + {% elif brand == "twitter" %} @@ -34,12 +34,18 @@ {% elif brand == "facebook" %} - - + + + + + + + + {% elif brand == "apple" %} - - + + {% endif %} {% endmacro %} From 18dd3c1574ca74d360486f923f22316855fb50fa Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 20 Dec 2024 16:40:37 +0100 Subject: [PATCH 3/4] Split the base registration page with local password registration --- crates/handlers/src/lib.rs | 6 +- crates/handlers/src/views/mod.rs | 1 + .../handlers/src/views/password_register.rs | 657 ++++++++++++++++++ crates/handlers/src/views/register.rs | 633 ++--------------- crates/handlers/src/views/shared.rs | 6 + crates/router/src/endpoints.rs | 71 +- crates/templates/src/context.rs | 45 +- crates/templates/src/lib.rs | 18 +- templates/pages/login.html | 93 ++- templates/pages/password_register.html | 79 +++ templates/pages/register.html | 108 +-- translations/en.json | 78 +-- 12 files changed, 1023 insertions(+), 772 deletions(-) create mode 100644 crates/handlers/src/views/password_register.rs create mode 100644 templates/pages/password_register.html diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 10f96ca9b..e2daee59c 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -370,7 +370,11 @@ where ) .route( mas_router::Register::route(), - get(self::views::register::get).post(self::views::register::post), + get(self::views::register::get), + ) + .route( + mas_router::PasswordRegister::route(), + get(self::views::password_register::get).post(self::views::password_register::post), ) .route( mas_router::AccountVerifyEmail::route(), diff --git a/crates/handlers/src/views/mod.rs b/crates/handlers/src/views/mod.rs index 1ce31f3cf..5a3928444 100644 --- a/crates/handlers/src/views/mod.rs +++ b/crates/handlers/src/views/mod.rs @@ -9,6 +9,7 @@ pub mod app; pub mod index; pub mod login; pub mod logout; +pub mod password_register; pub mod reauth; pub mod recovery; pub mod register; diff --git a/crates/handlers/src/views/password_register.rs b/crates/handlers/src/views/password_register.rs new file mode 100644 index 000000000..a8cdc4f51 --- /dev/null +++ b/crates/handlers/src/views/password_register.rs @@ -0,0 +1,657 @@ +// Copyright 2024 New Vector Ltd. +// Copyright 2021-2024 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::str::FromStr; + +use axum::{ + extract::{Form, Query, State}, + response::{Html, IntoResponse, Response}, +}; +use axum_extra::typed_header::TypedHeader; +use hyper::StatusCode; +use lettre::Address; +use mas_axum_utils::{ + cookies::CookieJar, + csrf::{CsrfExt, CsrfToken, ProtectedForm}, + FancyError, SessionInfoExt, +}; +use mas_data_model::{CaptchaConfig, UserAgent}; +use mas_i18n::DataLocale; +use mas_matrix::BoxHomeserverConnection; +use mas_policy::Policy; +use mas_router::UrlBuilder; +use mas_storage::{ + queue::{ProvisionUserJob, QueueJobRepositoryExt as _, VerifyEmailJob}, + user::{BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository}, + BoxClock, BoxRepository, BoxRng, RepositoryAccess, +}; +use mas_templates::{ + FieldError, FormError, FormState, PasswordRegisterContext, RegisterFormField, TemplateContext, + Templates, ToFormState, +}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use super::shared::OptionalPostAuthAction; +use crate::{ + captcha::Form as CaptchaForm, passwords::PasswordManager, BoundActivityTracker, Limiter, + PreferredLanguage, RequesterFingerprint, SiteConfig, +}; + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct RegisterForm { + username: String, + email: String, + password: String, + password_confirm: String, + #[serde(default)] + accept_terms: String, + + #[serde(flatten, skip_serializing)] + captcha: CaptchaForm, +} + +impl ToFormState for RegisterForm { + type Field = RegisterFormField; +} + +#[derive(Deserialize)] +pub struct QueryParams { + username: Option, + #[serde(flatten)] + action: OptionalPostAuthAction, +} + +#[tracing::instrument(name = "handlers.views.password_register.get", skip_all, err)] +pub(crate) async fn get( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(url_builder): State, + State(site_config): State, + mut repo: BoxRepository, + Query(query): Query, + cookie_jar: CookieJar, +) -> Result { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + let (session_info, cookie_jar) = cookie_jar.session_info(); + + let maybe_session = session_info.load_session(&mut repo).await?; + + if maybe_session.is_some() { + let reply = query.action.go_next(&url_builder); + return Ok((cookie_jar, reply).into_response()); + } + + if !site_config.password_registration_enabled { + // If password-based registration is disabled, redirect to the login page here + return Ok(url_builder + .redirect(&mas_router::Login::from(query.action.post_auth_action)) + .into_response()); + } + + let mut ctx = PasswordRegisterContext::default(); + + // If we got a username from the query string, use it to prefill the form + if let Some(username) = query.username { + let mut form_state = FormState::default(); + form_state.set_value(RegisterFormField::Username, Some(username)); + ctx = ctx.with_form_state(form_state); + } + + let content = render( + locale, + ctx, + query.action, + csrf_token, + &mut repo, + &templates, + site_config.captcha.clone(), + ) + .await?; + + Ok((cookie_jar, Html(content)).into_response()) +} + +#[tracing::instrument(name = "handlers.views.password_register.post", skip_all, err)] +#[allow(clippy::too_many_lines, clippy::too_many_arguments)] +pub(crate) async fn post( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(password_manager): State, + State(templates): State, + State(url_builder): State, + State(site_config): State, + State(homeserver): State, + State(http_client): State, + (State(limiter), requester): (State, RequesterFingerprint), + mut policy: Policy, + mut repo: BoxRepository, + (user_agent, activity_tracker): ( + Option>, + BoundActivityTracker, + ), + Query(query): Query, + cookie_jar: CookieJar, + Form(form): Form>, +) -> Result { + let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); + if !site_config.password_registration_enabled { + return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()); + } + + let form = cookie_jar.verify_form(&clock, form)?; + + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + // Validate the captcha + // TODO: display a nice error message to the user + let passed_captcha = form + .captcha + .verify( + &activity_tracker, + &http_client, + url_builder.public_hostname(), + site_config.captcha.as_ref(), + ) + .await + .is_ok(); + + // Validate the form + let state = { + let mut state = form.to_form_state(); + + if !passed_captcha { + state.add_error_on_form(FormError::Captcha); + } + + let mut homeserver_denied_username = false; + if form.username.is_empty() { + state.add_error_on_field(RegisterFormField::Username, FieldError::Required); + } else if repo.user().exists(&form.username).await? { + // The user already exists in the database + state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); + } else if !homeserver.is_localpart_available(&form.username).await? { + // The user already exists on the homeserver + tracing::warn!( + username = &form.username, + "Homeserver denied username provided by user" + ); + + // We defer adding the error on the field, until we know whether we had another + // error from the policy, to avoid showing both + homeserver_denied_username = true; + } + + 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); + } + + if form.password.is_empty() { + state.add_error_on_field(RegisterFormField::Password, FieldError::Required); + } + + if form.password_confirm.is_empty() { + state.add_error_on_field(RegisterFormField::PasswordConfirm, FieldError::Required); + } + + if form.password != form.password_confirm { + state.add_error_on_field(RegisterFormField::Password, FieldError::Unspecified); + state.add_error_on_field( + RegisterFormField::PasswordConfirm, + FieldError::PasswordMismatch, + ); + } + + if !password_manager.is_password_complex_enough(&form.password)? { + // TODO localise this error + state.add_error_on_field( + RegisterFormField::Password, + FieldError::Policy { + code: None, + message: "Password is too weak".to_owned(), + }, + ); + } + + // If the site has terms of service, the user must accept them + if site_config.tos_uri.is_some() && form.accept_terms != "on" { + state.add_error_on_field(RegisterFormField::AcceptTerms, FieldError::Required); + } + + let res = policy + .evaluate_register(&form.username, &form.email) + .await?; + + for violation in res.violations { + match violation.field.as_deref() { + Some("email") => state.add_error_on_field( + RegisterFormField::Email, + FieldError::Policy { + code: violation.code.map(|c| c.as_str()), + message: violation.msg, + }, + ), + Some("username") => { + // If the homeserver denied the username, but we also had an error on the policy + // side, we don't want to show both, so we reset the state here + homeserver_denied_username = false; + state.add_error_on_field( + RegisterFormField::Username, + FieldError::Policy { + code: violation.code.map(|c| c.as_str()), + message: violation.msg, + }, + ); + } + Some("password") => state.add_error_on_field( + RegisterFormField::Password, + FieldError::Policy { + code: violation.code.map(|c| c.as_str()), + message: violation.msg, + }, + ), + _ => state.add_error_on_form(FormError::Policy { + code: violation.code.map(|c| c.as_str()), + message: violation.msg, + }), + } + } + + if homeserver_denied_username { + // XXX: we may want to return different errors like "this username is reserved" + state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); + } + + if state.is_valid() { + // Check the rate limit if we are about to process the form + if let Err(e) = limiter.check_registration(requester) { + tracing::warn!(error = &e as &dyn std::error::Error); + state.add_error_on_form(FormError::RateLimitExceeded); + } + } + + state + }; + + if !state.is_valid() { + let content = render( + locale, + PasswordRegisterContext::default().with_form_state(state), + query, + csrf_token, + &mut repo, + &templates, + site_config.captcha.clone(), + ) + .await?; + + return Ok((cookie_jar, Html(content)).into_response()); + } + + let user = repo.user().add(&mut rng, &clock, form.username).await?; + + if let Some(tos_uri) = &site_config.tos_uri { + repo.user_terms() + .accept_terms(&mut rng, &clock, &user, tos_uri.clone()) + .await?; + } + + let password = Zeroizing::new(form.password.into_bytes()); + let (version, hashed_password) = password_manager.hash(&mut rng, password).await?; + let user_password = repo + .user_password() + .add(&mut rng, &clock, &user, version, hashed_password, None) + .await?; + + let user_email = repo + .user_email() + .add(&mut rng, &clock, &user, form.email) + .await?; + + let next = mas_router::AccountVerifyEmail::new(user_email.id).and_maybe(query.post_auth_action); + + let session = repo + .browser_session() + .add(&mut rng, &clock, &user, user_agent) + .await?; + + repo.browser_session() + .authenticate_with_password(&mut rng, &clock, &session, &user_password) + .await?; + + repo.queue_job() + .schedule_job( + &mut rng, + &clock, + VerifyEmailJob::new(&user_email).with_language(locale.to_string()), + ) + .await?; + + repo.queue_job() + .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + .await?; + + repo.save().await?; + + activity_tracker + .record_browser_session(&clock, &session) + .await; + + let cookie_jar = cookie_jar.set_session(&session); + Ok((cookie_jar, url_builder.redirect(&next)).into_response()) +} + +async fn render( + locale: DataLocale, + ctx: PasswordRegisterContext, + action: OptionalPostAuthAction, + csrf_token: CsrfToken, + repo: &mut impl RepositoryAccess, + templates: &Templates, + captcha_config: Option, +) -> Result { + let next = action.load_context(repo).await?; + let ctx = if let Some(next) = next { + ctx.with_post_action(next) + } else { + ctx + }; + let ctx = ctx + .with_captcha(captcha_config) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_password_register(&ctx)?; + Ok(content) +} + +#[cfg(test)] +mod tests { + use hyper::{ + header::{CONTENT_TYPE, LOCATION}, + Request, StatusCode, + }; + use mas_router::Route; + use sqlx::PgPool; + + use crate::{ + test_utils::{ + setup, test_site_config, CookieHelper, RequestBuilderExt, ResponseExt, TestState, + }, + SiteConfig, + }; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_password_disabled(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_login_enabled: false, + password_registration_enabled: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value(LOCATION, "/login"); + + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": "abc", + "username": "john", + "email": "john@example.com", + "password": "hunter2", + "password_confirm": "hunter2", + })); + let response = state.request(request).await; + response.assert_status(StatusCode::METHOD_NOT_ALLOWED); + } + + /// Test the registration happy path + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "john", + "email": "john@example.com", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::SEE_OTHER); + + // Now if we get to the home page, we should see the user's username + let request = Request::get("/").empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + assert!(response.body().contains("john")); + } + + /// When the two password fields mismatch, it should give an error + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_password_mismatch(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "john", + "email": "john@example.com", + "password": "hunter2", + "password_confirm": "mismatch", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + assert!(response.body().contains("Password fields don't match")); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_username_too_long(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "a".repeat(256), + "email": "john@example.com", + "password": "hunter2", + "password_confirm": "hunter2", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + assert!( + response.body().contains("Username is too long"), + "response body: {}", + response.body() + ); + } + + /// When the user already exists in the database, it should give an error + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_user_exists(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let mut rng = state.rng(); + let cookies = CookieHelper::new(); + + // Insert a user in the database first + let mut repo = state.repository().await.unwrap(); + repo.user() + .add(&mut rng, &state.clock, "john".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "john", + "email": "john@example.com", + "password": "hunter2", + "password_confirm": "hunter2", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + assert!(response.body().contains("This username is already taken")); + } + + /// When the username is already reserved on the homeserver, it should give + /// an error + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_user_reserved(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Reserve "john" on the homeserver + state.homeserver_connection.reserve_localpart("john").await; + + // Submit the registration form + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "john", + "email": "john@example.com", + "password": "hunter2", + "password_confirm": "hunter2", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + assert!(response.body().contains("This username is already taken")); + } +} diff --git a/crates/handlers/src/views/register.rs b/crates/handlers/src/views/register.rs index 5c81eb164..63d6ef1b6 100644 --- a/crates/handlers/src/views/register.rs +++ b/crates/handlers/src/views/register.rs @@ -1,62 +1,21 @@ // Copyright 2024 New Vector Ltd. -// Copyright 2021-2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -use std::str::FromStr; - use axum::{ - extract::{Form, Query, State}, - response::{Html, IntoResponse, Response}, -}; -use axum_extra::typed_header::TypedHeader; -use hyper::StatusCode; -use lettre::Address; -use mas_axum_utils::{ - cookies::CookieJar, - csrf::{CsrfExt, CsrfToken, ProtectedForm}, - FancyError, SessionInfoExt, -}; -use mas_data_model::{CaptchaConfig, UserAgent}; -use mas_i18n::DataLocale; -use mas_matrix::BoxHomeserverConnection; -use mas_policy::Policy; -use mas_router::UrlBuilder; -use mas_storage::{ - queue::{ProvisionUserJob, QueueJobRepositoryExt as _, VerifyEmailJob}, - user::{BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository}, - BoxClock, BoxRepository, BoxRng, RepositoryAccess, -}; -use mas_templates::{ - FieldError, FormError, RegisterContext, RegisterFormField, TemplateContext, Templates, - ToFormState, + extract::{Query, State}, + response::{IntoResponse, Response}, }; -use serde::{Deserialize, Serialize}; -use zeroize::Zeroizing; +use axum_extra::response::Html; +use mas_axum_utils::{cookies::CookieJar, csrf::CsrfExt as _, FancyError, SessionInfoExt}; +use mas_data_model::SiteConfig; +use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder}; +use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_templates::{RegisterContext, TemplateContext, Templates}; use super::shared::OptionalPostAuthAction; -use crate::{ - captcha::Form as CaptchaForm, passwords::PasswordManager, BoundActivityTracker, Limiter, - PreferredLanguage, RequesterFingerprint, SiteConfig, -}; - -#[derive(Debug, Deserialize, Serialize)] -pub(crate) struct RegisterForm { - username: String, - email: String, - password: String, - password_confirm: String, - #[serde(default)] - accept_terms: String, - - #[serde(flatten, skip_serializing)] - captcha: CaptchaForm, -} - -impl ToFormState for RegisterForm { - type Field = RegisterFormField; -} +use crate::{BoundActivityTracker, PreferredLanguage}; #[tracing::instrument(name = "handlers.views.register.get", skip_all, err)] pub(crate) async fn get( @@ -67,6 +26,7 @@ pub(crate) async fn get( State(url_builder): State, State(site_config): State, mut repo: BoxRepository, + activity_tracker: BoundActivityTracker, Query(query): Query, cookie_jar: CookieJar, ) -> Result { @@ -75,567 +35,52 @@ pub(crate) async fn get( let maybe_session = session_info.load_session(&mut repo).await?; - if maybe_session.is_some() { + if let Some(session) = maybe_session { + activity_tracker + .record_browser_session(&clock, &session) + .await; + let reply = query.go_next(&url_builder); return Ok((cookie_jar, reply).into_response()); - } - - if !site_config.password_registration_enabled { - // If password-based registration is disabled, redirect to the login page here - return Ok(url_builder - .redirect(&mas_router::Login::from(query.post_auth_action)) - .into_response()); - } - - let content = render( - locale, - RegisterContext::default(), - query, - csrf_token, - &mut repo, - &templates, - site_config.captcha.clone(), - ) - .await?; - - Ok((cookie_jar, Html(content)).into_response()) -} - -#[tracing::instrument(name = "handlers.views.register.post", skip_all, err)] -#[allow(clippy::too_many_lines, clippy::too_many_arguments)] -pub(crate) async fn post( - mut rng: BoxRng, - clock: BoxClock, - PreferredLanguage(locale): PreferredLanguage, - State(password_manager): State, - State(templates): State, - State(url_builder): State, - State(site_config): State, - State(homeserver): State, - State(http_client): State, - (State(limiter), requester): (State, RequesterFingerprint), - mut policy: Policy, - mut repo: BoxRepository, - (user_agent, activity_tracker): ( - Option>, - BoundActivityTracker, - ), - Query(query): Query, - cookie_jar: CookieJar, - Form(form): Form>, -) -> Result { - let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); - if !site_config.password_registration_enabled { - return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()); - } - - let form = cookie_jar.verify_form(&clock, form)?; - - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - - // Validate the captcha - // TODO: display a nice error message to the user - let passed_captcha = form - .captcha - .verify( - &activity_tracker, - &http_client, - url_builder.public_hostname(), - site_config.captcha.as_ref(), - ) - .await - .is_ok(); - - // Validate the form - let state = { - let mut state = form.to_form_state(); - - if !passed_captcha { - state.add_error_on_form(FormError::Captcha); - } - - let mut homeserver_denied_username = false; - if form.username.is_empty() { - state.add_error_on_field(RegisterFormField::Username, FieldError::Required); - } else if repo.user().exists(&form.username).await? { - // The user already exists in the database - state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); - } else if !homeserver.is_localpart_available(&form.username).await? { - // The user already exists on the homeserver - tracing::warn!( - username = &form.username, - "Homeserver denied username provided by user" - ); - - // We defer adding the error on the field, until we know whether we had another - // error from the policy, to avoid showing both - homeserver_denied_username = true; - } - - 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); - } - - if form.password.is_empty() { - state.add_error_on_field(RegisterFormField::Password, FieldError::Required); - } + }; - if form.password_confirm.is_empty() { - state.add_error_on_field(RegisterFormField::PasswordConfirm, FieldError::Required); - } + let providers = repo.upstream_oauth_provider().all_enabled().await?; - if form.password != form.password_confirm { - state.add_error_on_field(RegisterFormField::Password, FieldError::Unspecified); - state.add_error_on_field( - RegisterFormField::PasswordConfirm, - FieldError::PasswordMismatch, - ); - } + // If password-based login is disabled, and there is only one upstream provider, + // we can directly start an authorization flow + if !site_config.password_registration_enabled && providers.len() == 1 { + let provider = providers.into_iter().next().unwrap(); - if !password_manager.is_password_complex_enough(&form.password)? { - // TODO localise this error - state.add_error_on_field( - RegisterFormField::Password, - FieldError::Policy { - code: None, - message: "Password is too weak".to_owned(), - }, - ); - } + let mut destination = UpstreamOAuth2Authorize::new(provider.id); - // If the site has terms of service, the user must accept them - if site_config.tos_uri.is_some() && form.accept_terms != "on" { - state.add_error_on_field(RegisterFormField::AcceptTerms, FieldError::Required); + if let Some(action) = query.post_auth_action { + destination = destination.and_then(action); } - let res = policy - .evaluate_register(&form.username, &form.email) - .await?; - - for violation in res.violations { - match violation.field.as_deref() { - Some("email") => state.add_error_on_field( - RegisterFormField::Email, - FieldError::Policy { - code: violation.code.map(|c| c.as_str()), - message: violation.msg, - }, - ), - Some("username") => { - // If the homeserver denied the username, but we also had an error on the policy - // side, we don't want to show both, so we reset the state here - homeserver_denied_username = false; - state.add_error_on_field( - RegisterFormField::Username, - FieldError::Policy { - code: violation.code.map(|c| c.as_str()), - message: violation.msg, - }, - ); - } - Some("password") => state.add_error_on_field( - RegisterFormField::Password, - FieldError::Policy { - code: violation.code.map(|c| c.as_str()), - message: violation.msg, - }, - ), - _ => state.add_error_on_form(FormError::Policy { - code: violation.code.map(|c| c.as_str()), - message: violation.msg, - }), - } - } + return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); + } - if homeserver_denied_username { - // XXX: we may want to return different errors like "this username is reserved" - state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); - } + // If password-based registration is enabled and there are no upstream + // providers, we redirect to the password registration page + if site_config.password_registration_enabled && providers.is_empty() { + let mut destination = PasswordRegister::default(); - if state.is_valid() { - // Check the rate limit if we are about to process the form - if let Err(e) = limiter.check_registration(requester) { - tracing::warn!(error = &e as &dyn std::error::Error); - state.add_error_on_form(FormError::RateLimitExceeded); - } + if let Some(action) = query.post_auth_action { + destination = destination.and_then(action); } - state - }; - - if !state.is_valid() { - let content = render( - locale, - RegisterContext::default().with_form_state(state), - query, - csrf_token, - &mut repo, - &templates, - site_config.captcha.clone(), - ) - .await?; - - return Ok((cookie_jar, Html(content)).into_response()); + return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); } - let user = repo.user().add(&mut rng, &clock, form.username).await?; - - if let Some(tos_uri) = &site_config.tos_uri { - repo.user_terms() - .accept_terms(&mut rng, &clock, &user, tos_uri.clone()) - .await?; + let mut ctx = RegisterContext::new(providers); + let post_action = query.load_context(&mut repo).await?; + if let Some(action) = post_action { + ctx = ctx.with_post_action(action); } - let password = Zeroizing::new(form.password.into_bytes()); - let (version, hashed_password) = password_manager.hash(&mut rng, password).await?; - let user_password = repo - .user_password() - .add(&mut rng, &clock, &user, version, hashed_password, None) - .await?; - - let user_email = repo - .user_email() - .add(&mut rng, &clock, &user, form.email) - .await?; - - let next = mas_router::AccountVerifyEmail::new(user_email.id).and_maybe(query.post_auth_action); - - let session = repo - .browser_session() - .add(&mut rng, &clock, &user, user_agent) - .await?; - - repo.browser_session() - .authenticate_with_password(&mut rng, &clock, &session, &user_password) - .await?; - - repo.queue_job() - .schedule_job( - &mut rng, - &clock, - VerifyEmailJob::new(&user_email).with_language(locale.to_string()), - ) - .await?; - - repo.queue_job() - .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) - .await?; - - repo.save().await?; - - activity_tracker - .record_browser_session(&clock, &session) - .await; - - let cookie_jar = cookie_jar.set_session(&session); - Ok((cookie_jar, url_builder.redirect(&next)).into_response()) -} - -async fn render( - locale: DataLocale, - ctx: RegisterContext, - action: OptionalPostAuthAction, - csrf_token: CsrfToken, - repo: &mut impl RepositoryAccess, - templates: &Templates, - captcha_config: Option, -) -> Result { - let next = action.load_context(repo).await?; - let ctx = if let Some(next) = next { - ctx.with_post_action(next) - } else { - ctx - }; - let ctx = ctx - .with_captcha(captcha_config) - .with_csrf(csrf_token.form_value()) - .with_language(locale); + let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale); let content = templates.render_register(&ctx)?; - Ok(content) -} - -#[cfg(test)] -mod tests { - use hyper::{ - header::{CONTENT_TYPE, LOCATION}, - Request, StatusCode, - }; - use mas_router::Route; - use sqlx::PgPool; - - use crate::{ - test_utils::{ - setup, test_site_config, CookieHelper, RequestBuilderExt, ResponseExt, TestState, - }, - SiteConfig, - }; - - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_password_disabled(pool: PgPool) { - setup(); - let state = TestState::from_pool_with_site_config( - pool, - SiteConfig { - password_login_enabled: false, - password_registration_enabled: false, - ..test_site_config() - }, - ) - .await - .unwrap(); - - let request = Request::get(&*mas_router::Register::default().path_and_query()).empty(); - let response = state.request(request).await; - response.assert_status(StatusCode::SEE_OTHER); - response.assert_header_value(LOCATION, "/login"); - let request = Request::post(&*mas_router::Register::default().path_and_query()).form( - serde_json::json!({ - "csrf": "abc", - "username": "john", - "email": "john@example.com", - "password": "hunter2", - "password_confirm": "hunter2", - }), - ); - let response = state.request(request).await; - response.assert_status(StatusCode::METHOD_NOT_ALLOWED); - } - - /// Test the registration happy path - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register(pool: PgPool) { - setup(); - let state = TestState::from_pool(pool).await.unwrap(); - let cookies = CookieHelper::new(); - - // Render the registration page and get the CSRF token - let request = Request::get(&*mas_router::Register::default().path_and_query()).empty(); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); - - // Submit the registration form - let request = Request::post(&*mas_router::Register::default().path_and_query()).form( - serde_json::json!({ - "csrf": csrf_token, - "username": "john", - "email": "john@example.com", - "password": "correcthorsebatterystaple", - "password_confirm": "correcthorsebatterystaple", - "accept_terms": "on", - }), - ); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::SEE_OTHER); - - // Now if we get to the home page, we should see the user's username - let request = Request::get("/").empty(); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - assert!(response.body().contains("john")); - } - - /// When the two password fields mismatch, it should give an error - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register_password_mismatch(pool: PgPool) { - setup(); - let state = TestState::from_pool(pool).await.unwrap(); - let cookies = CookieHelper::new(); - - // Render the registration page and get the CSRF token - let request = Request::get(&*mas_router::Register::default().path_and_query()).empty(); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); - - // Submit the registration form - let request = Request::post(&*mas_router::Register::default().path_and_query()).form( - serde_json::json!({ - "csrf": csrf_token, - "username": "john", - "email": "john@example.com", - "password": "hunter2", - "password_confirm": "mismatch", - "accept_terms": "on", - }), - ); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - assert!(response.body().contains("Password fields don't match")); - } - - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register_username_too_long(pool: PgPool) { - setup(); - let state = TestState::from_pool(pool).await.unwrap(); - let cookies = CookieHelper::new(); - - // Render the registration page and get the CSRF token - let request = Request::get(&*mas_router::Register::default().path_and_query()).empty(); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); - - // Submit the registration form - let request = Request::post(&*mas_router::Register::default().path_and_query()).form( - serde_json::json!({ - "csrf": csrf_token, - "username": "a".repeat(256), - "email": "john@example.com", - "password": "hunter2", - "password_confirm": "hunter2", - "accept_terms": "on", - }), - ); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - assert!( - response.body().contains("Username is too long"), - "response body: {}", - response.body() - ); - } - - /// When the user already exists in the database, it should give an error - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register_user_exists(pool: PgPool) { - setup(); - let state = TestState::from_pool(pool).await.unwrap(); - let mut rng = state.rng(); - let cookies = CookieHelper::new(); - - // Insert a user in the database first - let mut repo = state.repository().await.unwrap(); - repo.user() - .add(&mut rng, &state.clock, "john".to_owned()) - .await - .unwrap(); - repo.save().await.unwrap(); - - // Render the registration page and get the CSRF token - let request = Request::get(&*mas_router::Register::default().path_and_query()).empty(); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); - - // Submit the registration form - let request = Request::post(&*mas_router::Register::default().path_and_query()).form( - serde_json::json!({ - "csrf": csrf_token, - "username": "john", - "email": "john@example.com", - "password": "hunter2", - "password_confirm": "hunter2", - "accept_terms": "on", - }), - ); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - assert!(response.body().contains("This username is already taken")); - } - - /// When the username is already reserved on the homeserver, it should give - /// an error - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register_user_reserved(pool: PgPool) { - setup(); - let state = TestState::from_pool(pool).await.unwrap(); - let cookies = CookieHelper::new(); - - // Render the registration page and get the CSRF token - let request = Request::get(&*mas_router::Register::default().path_and_query()).empty(); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); - - // Reserve "john" on the homeserver - state.homeserver_connection.reserve_localpart("john").await; - - // Submit the registration form - let request = Request::post(&*mas_router::Register::default().path_and_query()).form( - serde_json::json!({ - "csrf": csrf_token, - "username": "john", - "email": "john@example.com", - "password": "hunter2", - "password_confirm": "hunter2", - "accept_terms": "on", - }), - ); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - assert!(response.body().contains("This username is already taken")); - } + Ok((cookie_jar, Html(content)).into_response()) } diff --git a/crates/handlers/src/views/shared.rs b/crates/handlers/src/views/shared.rs index 6a92a950a..929efce11 100644 --- a/crates/handlers/src/views/shared.rs +++ b/crates/handlers/src/views/shared.rs @@ -21,6 +21,12 @@ pub(crate) struct OptionalPostAuthAction { pub post_auth_action: Option, } +impl From> for OptionalPostAuthAction { + fn from(post_auth_action: Option) -> Self { + Self { post_auth_action } + } +} + impl OptionalPostAuthAction { pub fn go_next_or_default( &self, diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index 131315219..32faf919e 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -315,7 +315,7 @@ impl From> for Reauth { } } -/// `GET|POST /register` +/// `POST /register` #[derive(Default, Debug, Clone)] pub struct Register { post_auth_action: Option, @@ -375,6 +375,75 @@ impl From> for Register { } } +/// `GET|POST /register/password` +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct PasswordRegister { + username: Option, + + #[serde(flatten)] + post_auth_action: Option, +} + +impl PasswordRegister { + #[must_use] + pub fn and_then(mut self, action: PostAuthAction) -> Self { + self.post_auth_action = Some(action); + self + } + + #[must_use] + pub fn and_continue_grant(mut self, data: Ulid) -> Self { + self.post_auth_action = Some(PostAuthAction::continue_grant(data)); + self + } + + #[must_use] + pub fn and_continue_compat_sso_login(mut self, data: Ulid) -> Self { + self.post_auth_action = Some(PostAuthAction::continue_compat_sso_login(data)); + self + } + + /// Get a reference to the post auth action. + #[must_use] + pub fn post_auth_action(&self) -> Option<&PostAuthAction> { + self.post_auth_action.as_ref() + } + + /// Get a reference to the username chosen by the user. + #[must_use] + pub fn username(&self) -> Option<&str> { + self.username.as_deref() + } + + pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect { + match &self.post_auth_action { + Some(action) => action.go_next(url_builder), + None => url_builder.redirect(&Index), + } + } +} + +impl Route for PasswordRegister { + type Query = Self; + + fn route() -> &'static str { + "/register/password" + } + + fn query(&self) -> Option<&Self::Query> { + Some(self) + } +} + +impl From> for PasswordRegister { + fn from(post_auth_action: Option) -> Self { + Self { + username: None, + post_auth_action, + } + } +} + /// `GET|POST /verify-email/:id` #[derive(Debug, Clone)] pub struct AccountVerifyEmail { diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index eb10e9592..8e8dd2e4e 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -539,7 +539,7 @@ impl FormField for RegisterFormField { /// Context used by the `register.html` template #[derive(Serialize, Default)] pub struct RegisterContext { - form: FormState, + providers: Vec, next: Option, } @@ -548,15 +548,54 @@ impl TemplateContext for RegisterContext { where Self: Sized, { - // TODO: samples with errors vec![RegisterContext { - form: FormState::default(), + providers: Vec::new(), next: None, }] } } impl RegisterContext { + /// Create a new context with the given upstream providers + #[must_use] + pub fn new(providers: Vec) -> Self { + Self { + providers, + next: None, + } + } + + /// Add a post authentication action to the context + #[must_use] + pub fn with_post_action(self, next: PostAuthContext) -> Self { + Self { + next: Some(next), + ..self + } + } +} + +/// Context used by the `password_register.html` template +#[derive(Serialize, Default)] +pub struct PasswordRegisterContext { + form: FormState, + next: Option, +} + +impl TemplateContext for PasswordRegisterContext { + fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + where + Self: Sized, + { + // TODO: samples with errors + vec![PasswordRegisterContext { + form: FormState::default(), + next: None, + }] + } +} + +impl PasswordRegisterContext { /// Add an error on the registration form #[must_use] pub fn with_form_state(self, form: FormState) -> Self { diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 9314d4e33..7faea6b2c 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -38,12 +38,13 @@ pub use self::{ DeviceLinkContext, DeviceLinkFormField, EmailAddContext, EmailRecoveryContext, EmailVerificationContext, EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext, - PolicyViolationContext, PostAuthContext, PostAuthContextInner, ReauthContext, - ReauthFormField, RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField, - RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, - RegisterFormField, SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, - UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField, - UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession, + PasswordRegisterContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner, + ReauthContext, ReauthFormField, RecoveryExpiredContext, RecoveryFinishContext, + RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext, + RecoveryStartFormField, RegisterContext, RegisterFormField, SiteBranding, SiteConfigExt, + SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, + UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, + WithOptionalSession, WithSession, }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; @@ -326,7 +327,10 @@ register_templates! { pub fn render_login(WithLanguage>) { "pages/login.html" } /// Render the registration page - pub fn render_register(WithLanguage>>) { "pages/register.html" } + pub fn render_register(WithLanguage>) { "pages/register.html" } + + /// Render the password registration page + pub fn render_password_register(WithLanguage>>) { "pages/password_register.html" } /// Render the client consent page pub fn render_consent(WithLanguage>>) { "pages/consent.html" } diff --git a/templates/pages/login.html b/templates/pages/login.html index cf123ae80..3341e9f5d 100644 --- a/templates/pages/login.html +++ b/templates/pages/login.html @@ -11,7 +11,7 @@ {% from "components/idp_brand.html" import logo %} {% block content %} -
+
{{ icon.user_profile_solid() }} @@ -31,54 +31,52 @@

{{ _("mas.login.headline") }}

{% endif %} -
-
- {% if form.errors is not empty %} - {% for error in form.errors %} -
- {{ errors.form_error_message(error=error) }} -
- {% endfor %} - {% endif %} +
+ {% if form.errors is not empty %} + {% for error in form.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} - + - {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} - - {% endcall %} + {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} + + {% endcall %} - {% if features.password_login %} - {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} - - {% endcall %} + {% if features.password_login %} + {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} + + {% endcall %} - {% if features.account_recovery %} - {{ button.link_text(text=_("mas.login.forgot_password"), href="/recover", class="self-center") }} - {% endif %} + {% if features.account_recovery %} + {{ button.link_text(text=_("mas.login.forgot_password"), href="/recover", class="self-center") }} {% endif %} -
+ {% endif %} +
-
- {% if features.password_login %} - {{ button.button(text=_("action.continue")) }} - {% endif %} +
+ {% if features.password_login %} + {{ button.button(text=_("action.continue")) }} + {% endif %} - {% if features.password_login and providers %} - {{ field.separator() }} - {% endif %} + {% if features.password_login and providers %} + {{ field.separator() }} + {% endif %} - {% if providers %} - {% set params = next["params"] | default({}) | to_params(prefix="?") %} - {% for provider in providers %} - {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} - - {{ logo(provider.brand_name) }} - {{ _("mas.login.continue_with_provider", provider=name) }} - - {% endfor %} - {% endif %} -
- + {% if providers %} + {% set params = next["params"] | default({}) | to_params(prefix="?") %} + {% for provider in providers %} + {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} + + {{ logo(provider.brand_name) }} + {{ _("mas.login.continue_with_provider", provider=name) }} + + {% endfor %} + {% endif %} +
{% if (not next or next.kind != "link_upstream") and features.password_registration %}
@@ -91,21 +89,10 @@

{{ _("mas.login.headline") }}

{% endif %} - {% if next and next.kind == "continue_authorization_grant" %} - {{ back_to_client.link( - text=_("action.cancel"), - kind="secondary", - destructive=True, - uri=next.grant.redirect_uri, - mode=next.grant.response_mode, - params=dict(error="access_denied", state=next.grant.state) - ) }} - {% endif %} - {% if not providers and not features.password_login %}
{{ _("mas.login.no_login_methods") }}
{% endif %} - + {% endblock content %} diff --git a/templates/pages/password_register.html b/templates/pages/password_register.html new file mode 100644 index 000000000..8769c36c4 --- /dev/null +++ b/templates/pages/password_register.html @@ -0,0 +1,79 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.user_profile_solid() }} +
+ +
+

{{ _("mas.register.create_account.heading") }}

+
+
+ +
+ {% for error in form.errors %} + {# Special case for the captcha error, as we want to put it at the bottom #} + {% if error.kind != "captcha" %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endif %} + {% endfor %} + + + + {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} + + {% endcall %} + + {% call(f) field.field(label=_("common.email_address"), name="email", form_state=form) %} + + {% endcall %} + + {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} + + {% endcall %} + + {% call(f) field.field(label=_("common.password_confirm"), name="password_confirm", form_state=form) %} + + {% endcall %} + + {% if branding.tos_uri %} + {% call(f) field.field(label=_("mas.register.terms_of_service", tos_uri=branding.tos_uri), name="accept_terms", form_state=form, inline=true, class="my-4") %} +
+
+ +
+ {{ icon.check() }} +
+
+
+ {% endcall %} + {% endif %} + + {{ captcha.form(class="mb-4 self-center") }} + + {% for error in form.errors %} + {# Special case for the captcha error #} + {% if error.kind == "captcha" %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endif %} + {% endfor %} + + {{ button.button(text=_("action.continue")) }} + + {% set params = next["params"] | default({}) | to_params(prefix="?") %} + {{ button.link_tertiary(text=_("mas.register.call_to_login"), href="/login" ~ params) }} +
+{% endblock content %} diff --git a/templates/pages/register.html b/templates/pages/register.html index 65ba1ba0b..830517043 100644 --- a/templates/pages/register.html +++ b/templates/pages/register.html @@ -8,91 +8,51 @@ {% extends "base.html" %} +{% from "components/idp_brand.html" import logo %} + {% block content %} -
-
- {{ icon.user_profile_solid() }} -
+
+
+
+ {{ icon.user_profile_solid() }} +
-
-

{{ _("mas.register.create_account.heading") }}

-

{{ _("mas.register.create_account.description") }}

-
-
+
+

{{ _("mas.register.create_account.heading") }}

-
- - {% for error in form.errors %} - {# Special case for the captcha error, as we want to put it at the bottom #} - {% if error.kind != "captcha" %} -
- {{ errors.form_error_message(error=error) }} -
+ {% if features.password_registration %} +

{{ _("mas.register.create_account.description") }}

{% endif %} - {% endfor %} - - +
+
+ {% if features.password_registration %} {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} - - {% endcall %} - - {% call(f) field.field(label=_("common.email_address"), name="email", form_state=form) %} - - {% endcall %} - - {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} - - {% endcall %} - - {% call(f) field.field(label=_("common.password_confirm"), name="password_confirm", form_state=form) %} - + +
+ @username:{{ branding.server_name }} +
{% endcall %} + {% endif %} - {% if branding.tos_uri %} - {% call(f) field.field(label=_("mas.register.terms_of_service", tos_uri=branding.tos_uri), name="accept_terms", form_state=form, inline=true, class="my-4") %} -
-
- -
- {{ icon.check() }} -
-
-
- {% endcall %} +
+ {% if features.password_registration %} + {{ button.button(text=_("mas.register.continue_with_email")) }} {% endif %} - {{ captcha.form(class="mb-4 self-center") }} - - {% for error in form.errors %} - {# Special case for the captcha error #} - {% if error.kind == "captcha" %} -
- {{ errors.form_error_message(error=error) }} -
- {% endif %} - {% endfor %} - - {{ button.button(text=_("action.continue")) }} - - - {% if next and next.kind == "continue_authorization_grant" %} - {{ back_to_client.link( - text=_("action.cancel"), - destructive=True, - uri=next.grant.redirect_uri, - mode=next.grant.response_mode, - params=dict(error="access_denied", state=next.grant.state) - ) }} - {% endif %} - -
-

- {{ _("mas.register.call_to_login") }} -

+ {% if providers %} + {% set params = next["params"] | default({}) | to_params(prefix="?") %} + {% for provider in providers %} + {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} + + {{ logo(provider.brand_name) }} + {{ _("mas.login.continue_with_provider", provider=name) }} + + {% endfor %} + {% endif %} {% set params = next["params"] | default({}) | to_params(prefix="?") %} - {{ button.link_text(text=_("mas.register.sign_in_instead"), href="/login" ~ params) }} + {{ button.link_tertiary(text=_("mas.register.call_to_login"), href="/login" ~ params) }}
- + {% endblock content %} diff --git a/translations/en.json b/translations/en.json index e52d4f6db..a3befe599 100644 --- a/translations/en.json +++ b/translations/en.json @@ -6,15 +6,15 @@ }, "cancel": "Cancel", "@cancel": { - "context": "pages/consent.html:67:11-29, pages/device_consent.html:124:13-31, pages/login.html:96:13-31, pages/policy_violation.html:44:13-31, pages/register.html:81:13-31" + "context": "pages/consent.html:69:11-29, pages/device_consent.html:126:13-31, pages/policy_violation.html:44:13-31" }, "continue": "Continue", "@continue": { - "context": "form_post.html:25:28-48, pages/account/emails/add.html:37:26-46, pages/account/emails/verify.html:52:26-46, pages/consent.html:55:28-48, pages/device_consent.html:121:13-33, pages/device_link.html:40:26-46, pages/login.html:58:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register.html:76:28-48, pages/sso.html:37:28-48" + "context": "form_post.html:25:28-48, pages/account/emails/add.html:37:26-46, pages/account/emails/verify.html:52:26-46, pages/consent.html:57:28-48, pages/device_consent.html:123:13-33, pages/device_link.html:40:26-46, pages/login.html:62:30-50, pages/password_register.html:74:26-46, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/sso.html:37:28-48" }, "create_account": "Create Account", "@create_account": { - "context": "pages/login.html:68:35-61, pages/upstream_oauth2/do_register.html:192:26-52" + "context": "pages/login.html:88:33-59, pages/upstream_oauth2/do_register.html:192:26-52" }, "sign_in": "Sign in", "@sign_in": { @@ -22,7 +22,7 @@ }, "sign_out": "Sign out", "@sign_out": { - "context": "pages/consent.html:63:28-48, pages/device_consent.html:133:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" + "context": "pages/consent.html:65:28-48, pages/device_consent.html:135:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" }, "start_over": "Start over", "@start_over": { @@ -75,7 +75,7 @@ }, "email_address": "Email address", "@email_address": { - "context": "pages/account/emails/add.html:33:33-58, pages/recovery/start.html:34:33-58, pages/register.html:40:35-60, pages/upstream_oauth2/do_register.html:114:37-62" + "context": "pages/account/emails/add.html:33:33-58, pages/password_register.html:38:33-58, pages/recovery/start.html:34:33-58, pages/upstream_oauth2/do_register.html:114:37-62" }, "loading": "Loading…", "@loading": { @@ -87,15 +87,15 @@ }, "password": "Password", "@password": { - "context": "pages/login.html:50:37-57, pages/reauth.html:28:35-55, pages/register.html:44:35-55" + "context": "pages/login.html:50:37-57, pages/password_register.html:42:33-53, pages/reauth.html:28:35-55" }, "password_confirm": "Confirm password", "@password_confirm": { - "context": "pages/register.html:48:35-63" + "context": "pages/password_register.html:46:33-61" }, "username": "Username", "@username": { - "context": "pages/login.html:46:37-57, pages/register.html:36:35-55, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" + "context": "pages/login.html:45:35-55, pages/password_register.html:34:33-53, pages/register.html:30:35-55, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" } }, "error": { @@ -154,41 +154,41 @@ "consent": { "client_wants_access": "%(client_name)s at %(redirect_uri)s wants to acccess your account.", "@client_wants_access": { - "context": "pages/consent.html:25:11-122" + "context": "pages/consent.html:27:11-122" }, "heading": "Allow access to your account?", "@heading": { - "context": "pages/consent.html:23:27-51, pages/device_consent.html:25:29-53" + "context": "pages/consent.html:25:27-51, pages/device_consent.html:27:29-53" }, "make_sure_you_trust": "Make sure that you trust %(client_name)s.", "@make_sure_you_trust": { - "context": "pages/consent.html:36:81-142, pages/device_consent.html:101:83-144" + "context": "pages/consent.html:38:81-142, pages/device_consent.html:103:83-144" }, "this_will_allow": "This will allow %(client_name)s to:", "@this_will_allow": { - "context": "pages/consent.html:26:11-68, pages/device_consent.html:91:13-70" + "context": "pages/consent.html:28:11-68, pages/device_consent.html:93:13-70" }, "you_may_be_sharing": "You may be sharing sensitive information with this site or app.", "@you_may_be_sharing": { - "context": "pages/consent.html:37:7-42, pages/device_consent.html:102:9-44" + "context": "pages/consent.html:39:7-42, pages/device_consent.html:104:9-44" } }, "device_card": { "access_requested": "Access requested", "@access_requested": { - "context": "pages/device_consent.html:79:34-71" + "context": "pages/device_consent.html:81:34-71" }, "device_code": "Code", "@device_code": { - "context": "pages/device_consent.html:83:34-66" + "context": "pages/device_consent.html:85:34-66" }, "generic_device": "Device", "@generic_device": { - "context": "pages/device_consent.html:67:22-57" + "context": "pages/device_consent.html:69:22-57" }, "ip_address": "IP address", "@ip_address": { - "context": "pages/device_consent.html:74:36-67" + "context": "pages/device_consent.html:76:36-67" } }, "device_code_link": { @@ -204,26 +204,26 @@ "device_consent": { "another_device_access": "Another device wants to access your account.", "@another_device_access": { - "context": "pages/device_consent.html:90:13-58" + "context": "pages/device_consent.html:92:13-58" }, "denied": { "description": "You denied access to %(client_name)s. You can close this window.", "@description": { - "context": "pages/device_consent.html:144:27-94" + "context": "pages/device_consent.html:146:27-94" }, "heading": "Access denied", "@heading": { - "context": "pages/device_consent.html:143:29-67" + "context": "pages/device_consent.html:145:29-67" } }, "granted": { "description": "You granted access to %(client_name)s. You can close this window.", "@description": { - "context": "pages/device_consent.html:155:27-95" + "context": "pages/device_consent.html:157:27-95" }, "heading": "Access granted", "@heading": { - "context": "pages/device_consent.html:154:29-68" + "context": "pages/device_consent.html:156:29-68" } } }, @@ -334,16 +334,16 @@ "login": { "call_to_register": "Don't have an account yet?", "@call_to_register": { - "context": "pages/login.html:64:15-46" + "context": "pages/login.html:84:13-44" }, "continue_with_provider": "Continue with %(provider)s", "@continue_with_provider": { - "context": "pages/login.html:83:13-65", + "context": "pages/login.html:75:15-67, pages/register.html:49:15-67", "description": "Button to log in with an upstream provider" }, "description": "Please sign in to continue:", "@description": { - "context": "pages/login.html:30:31-57" + "context": "pages/login.html:29:29-55" }, "forgot_password": "Forgot password?", "@forgot_password": { @@ -352,21 +352,21 @@ }, "headline": "Sign in", "@headline": { - "context": "pages/login.html:29:33-56" + "context": "pages/login.html:28:31-54" }, "link": { "description": "Linking your %(provider)s account", "@description": { - "context": "pages/login.html:25:31-77" + "context": "pages/login.html:24:29-75" }, "headline": "Sign in to link", "@headline": { - "context": "pages/login.html:23:33-61" + "context": "pages/login.html:22:31-59" } }, "no_login_methods": "No login methods available.", "@no_login_methods": { - "context": "pages/login.html:90:11-42" + "context": "pages/login.html:94:11-42" } }, "navbar": { @@ -396,7 +396,7 @@ }, "not_you": "Not %(username)s?", "@not_you": { - "context": "pages/consent.html:60:11-67, pages/device_consent.html:130:13-69, pages/sso.html:42:11-67", + "context": "pages/consent.html:62:11-67, pages/device_consent.html:132:13-69, pages/sso.html:42:11-67", "description": "Suggestions for the user to log in as a different user" }, "or_separator": "Or", @@ -524,26 +524,26 @@ "register": { "call_to_login": "Already have an account?", "@call_to_login": { - "context": "pages/register.html:91:11-42", + "context": "pages/password_register.html:77:33-64, pages/register.html:55:35-66", "description": "Displayed on the registration page to suggest to log in instead" }, + "continue_with_email": "Continue with email address", + "@continue_with_email": { + "context": "pages/register.html:40:30-67" + }, "create_account": { - "description": "Please create an account to get started:", + "description": "Choose a username to continue.", "@description": { - "context": "pages/register.html:19:25-69" + "context": "pages/register.html:24:29-73" }, "heading": "Create an account", "@heading": { - "context": "pages/register.html:18:27-67" + "context": "pages/password_register.html:18:27-67, pages/register.html:21:29-69" } }, - "sign_in_instead": "Sign in instead", - "@sign_in_instead": { - "context": "pages/register.html:95:31-64" - }, "terms_of_service": "I agree to the Terms and Conditions", "@terms_of_service": { - "context": "pages/register.html:53:37-97, pages/upstream_oauth2/do_register.html:179:35-95" + "context": "pages/password_register.html:51:35-95, pages/upstream_oauth2/do_register.html:179:35-95" } }, "scope": { From 91d394c5ece9d9bfc72fc1b6f2a9e32de0bd8ee2 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 20 Dec 2024 16:44:35 +0100 Subject: [PATCH 4/4] Username on the first registration page is optional --- templates/pages/register.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/pages/register.html b/templates/pages/register.html index 830517043..7604f9301 100644 --- a/templates/pages/register.html +++ b/templates/pages/register.html @@ -28,7 +28,7 @@

{{ _("mas.register.create_account.heading") }}

{% if features.password_registration %} {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} - +
@username:{{ branding.server_name }}