1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-09 04:22:45 +03:00

Make the user agree to T&C during registration

This commit is contained in:
Quentin Gliech
2024-02-07 15:16:36 +01:00
parent 90c386847a
commit 0beb842195
12 changed files with 133 additions and 22 deletions

View File

@@ -160,6 +160,7 @@ impl Options {
); );
let site_config = SiteConfig { let site_config = SiteConfig {
tos_uri: config.branding.tos_uri.clone(),
access_token_ttl: config.experimental.access_token_ttl, access_token_ttl: config.experimental.access_token_ttl,
compat_token_ttl: config.experimental.compat_token_ttl, compat_token_ttl: config.experimental.compat_token_ttl,
}; };

View File

@@ -319,6 +319,7 @@ where
HttpClientFactory: FromRef<S>, HttpClientFactory: FromRef<S>,
PasswordManager: FromRef<S>, PasswordManager: FromRef<S>,
MetadataCache: FromRef<S>, MetadataCache: FromRef<S>,
SiteConfig: FromRef<S>,
BoxClock: FromRequestParts<S>, BoxClock: FromRequestParts<S>,
BoxRng: FromRequestParts<S>, BoxRng: FromRequestParts<S>,
Policy: FromRequestParts<S>, Policy: FromRequestParts<S>,

View File

@@ -13,12 +13,14 @@
// limitations under the License. // limitations under the License.
use chrono::Duration; use chrono::Duration;
use url::Url;
/// Random site configuration we don't now where to put yet. /// Random site configuration we don't now where to put yet.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SiteConfig { pub struct SiteConfig {
pub access_token_ttl: Duration, pub access_token_ttl: Duration,
pub compat_token_ttl: Duration, pub compat_token_ttl: Duration,
pub tos_uri: Option<Url>,
} }
impl Default for SiteConfig { impl Default for SiteConfig {
@@ -26,6 +28,7 @@ impl Default for SiteConfig {
Self { Self {
access_token_ttl: Duration::minutes(5), access_token_ttl: Duration::minutes(5),
compat_token_ttl: Duration::minutes(5), compat_token_ttl: Duration::minutes(5),
tos_uri: None,
} }
} }
} }

View File

@@ -119,7 +119,9 @@ impl TestState {
let url_builder = UrlBuilder::new("https://example.com/".parse()?, None, None); let url_builder = UrlBuilder::new("https://example.com/".parse()?, None, None);
let site_branding = SiteBranding::new("example.com").with_service_name("Example"); let site_branding = SiteBranding::new("example.com")
.with_service_name("Example")
.with_tos_uri("https://example.com/tos");
let templates = Templates::load( let templates = Templates::load(
workspace_root.join("templates"), workspace_root.join("templates"),
@@ -154,7 +156,10 @@ impl TestState {
let http_client_factory = HttpClientFactory::new().await?; let http_client_factory = HttpClientFactory::new().await?;
let site_config = SiteConfig::default(); let site_config = SiteConfig {
tos_uri: Some("https://example.com/tos".parse().unwrap()),
..SiteConfig::default()
};
let clock = Arc::new(MockClock::default()); let clock = Arc::new(MockClock::default());
let rng = Arc::new(Mutex::new(ChaChaRng::seed_from_u64(42))); let rng = Arc::new(Mutex::new(ChaChaRng::seed_from_u64(42)));

View File

@@ -45,7 +45,9 @@ use tracing::warn;
use ulid::Ulid; use ulid::Ulid;
use super::{template::environment, UpstreamSessionsCookie}; use super::{template::environment, UpstreamSessionsCookie};
use crate::{impl_from_error_for_route, views::shared::OptionalPostAuthAction, PreferredLanguage}; use crate::{
impl_from_error_for_route, views::shared::OptionalPostAuthAction, PreferredLanguage, SiteConfig,
};
const DEFAULT_LOCALPART_TEMPLATE: &str = "{{ user.preferred_username }}"; const DEFAULT_LOCALPART_TEMPLATE: &str = "{{ user.preferred_username }}";
const DEFAULT_DISPLAYNAME_TEMPLATE: &str = "{{ user.name }}"; const DEFAULT_DISPLAYNAME_TEMPLATE: &str = "{{ user.name }}";
@@ -170,6 +172,8 @@ pub(crate) enum FormData {
import_email: Option<String>, import_email: Option<String>,
#[serde(default)] #[serde(default)]
import_display_name: Option<String>, import_display_name: Option<String>,
#[serde(default)]
accept_terms: Option<String>,
}, },
Link, Link,
} }
@@ -473,6 +477,7 @@ pub(crate) async fn post(
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>, State(url_builder): State<UrlBuilder>,
State(site_config): State<SiteConfig>,
Path(link_id): Path<Ulid>, Path(link_id): Path<Ulid>,
Form(form): Form<ProtectedForm<FormData>>, Form(form): Form<ProtectedForm<FormData>>,
) -> Result<Response, RouteError> { ) -> Result<Response, RouteError> {
@@ -533,6 +538,7 @@ pub(crate) async fn post(
username, username,
import_email, import_email,
import_display_name, import_display_name,
accept_terms,
}, },
) => { ) => {
// The user got the form to register a new account, and is not logged in. // The user got the form to register a new account, and is not logged in.
@@ -543,6 +549,7 @@ pub(crate) async fn post(
// Those fields are Some("on") if the checkbox is checked // Those fields are Some("on") if the checkbox is checked
let import_email = import_email.is_some(); let import_email = import_email.is_some();
let import_display_name = import_display_name.is_some(); let import_display_name = import_display_name.is_some();
let accept_terms = accept_terms.is_some();
let id_token = upstream_session let id_token = upstream_session
.id_token() .id_token()
@@ -695,6 +702,24 @@ pub(crate) async fn post(
.into_response()); .into_response());
} }
// If we need have a TOS in the config, make sure the user has accepted it
if site_config.tos_uri.is_some() && !accept_terms {
let form_state = form_state.with_error_on_field(
mas_templates::UpstreamRegisterFormField::AcceptTerms,
FieldError::Required,
);
let ctx = ctx
.with_form_state(form_state)
.with_csrf(csrf_token.form_value())
.with_language(locale);
return Ok((
cookie_jar,
Html(templates.render_upstream_oauth2_do_register(&ctx)?),
)
.into_response());
}
// Policy check // Policy check
let res = policy let res = policy
.evaluate_upstream_oauth_register(&username, email.as_deref()) .evaluate_upstream_oauth_register(&username, email.as_deref())
@@ -731,6 +756,12 @@ pub(crate) async fn post(
// Now we can create the user // Now we can create the user
let user = repo.user().add(&mut rng, &clock, username).await?; let user = repo.user().add(&mut rng, &clock, username).await?;
if let Some(terms_url) = &site_config.tos_uri {
repo.user_terms()
.accept_terms(&mut rng, &clock, &user, terms_url.clone())
.await?;
}
// And schedule the job to provision it // And schedule the job to provision it
let mut job = ProvisionUserJob::new(&user); let mut job = ProvisionUserJob::new(&user);
@@ -936,6 +967,7 @@ mod tests {
"csrf": csrf_token, "csrf": csrf_token,
"action": "register", "action": "register",
"import_email": "on", "import_email": "on",
"accept_terms": "on",
}), }),
); );
let request = cookies.with_cookies(request); let request = cookies.with_cookies(request);

View File

@@ -43,7 +43,7 @@ use serde::{Deserialize, Serialize};
use zeroize::Zeroizing; use zeroize::Zeroizing;
use super::shared::OptionalPostAuthAction; use super::shared::OptionalPostAuthAction;
use crate::{passwords::PasswordManager, BoundActivityTracker, PreferredLanguage}; use crate::{passwords::PasswordManager, BoundActivityTracker, PreferredLanguage, SiteConfig};
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub(crate) struct RegisterForm { pub(crate) struct RegisterForm {
@@ -51,6 +51,8 @@ pub(crate) struct RegisterForm {
email: String, email: String,
password: String, password: String,
password_confirm: String, password_confirm: String,
#[serde(default)]
accept_terms: String,
} }
impl ToFormState for RegisterForm { impl ToFormState for RegisterForm {
@@ -108,6 +110,7 @@ pub(crate) async fn post(
State(password_manager): State<PasswordManager>, State(password_manager): State<PasswordManager>,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>, State(url_builder): State<UrlBuilder>,
State(site_config): State<SiteConfig>,
mut policy: Policy, mut policy: Policy,
mut repo: BoxRepository, mut repo: BoxRepository,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
@@ -155,6 +158,11 @@ pub(crate) async fn post(
state.add_error_on_field(RegisterFormField::PasswordConfirm, FieldError::Unspecified); state.add_error_on_field(RegisterFormField::PasswordConfirm, FieldError::Unspecified);
} }
// 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 let res = policy
.evaluate_register(&form.username, &form.password, &form.email) .evaluate_register(&form.username, &form.password, &form.email)
.await?; .await?;
@@ -203,6 +211,13 @@ pub(crate) async fn post(
} }
let user = repo.user().add(&mut rng, &clock, form.username).await?; 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 password = Zeroizing::new(form.password.into_bytes());
let (version, hashed_password) = password_manager.hash(&mut rng, password).await?; let (version, hashed_password) = password_manager.hash(&mut rng, password).await?;
let user_password = repo let user_password = repo

View File

@@ -19,7 +19,8 @@ use url::Url;
use crate::{repository_impl, Clock}; use crate::{repository_impl, Clock};
/// A [`UserTermsRepository`] helps interacting with the terms of service agreed by a [`User`] /// A [`UserTermsRepository`] helps interacting with the terms of service agreed
/// by a [`User`]
#[async_trait] #[async_trait]
pub trait UserTermsRepository: Send + Sync { pub trait UserTermsRepository: Send + Sync {
/// The error type returned by the repository /// The error type returned by the repository

View File

@@ -493,12 +493,15 @@ pub enum RegisterFormField {
/// The password confirmation field /// The password confirmation field
PasswordConfirm, PasswordConfirm,
/// The terms of service agreement field
AcceptTerms,
} }
impl FormField for RegisterFormField { impl FormField for RegisterFormField {
fn keep(&self) -> bool { fn keep(&self) -> bool {
match self { match self {
Self::Username | Self::Email => true, Self::Username | Self::Email | Self::AcceptTerms => true,
Self::Password | Self::PasswordConfirm => false, Self::Password | Self::PasswordConfirm => false,
} }
} }
@@ -974,12 +977,15 @@ impl TemplateContext for UpstreamSuggestLink {
pub enum UpstreamRegisterFormField { pub enum UpstreamRegisterFormField {
/// The username field /// The username field
Username, Username,
/// Accept the terms of service
AcceptTerms,
} }
impl FormField for UpstreamRegisterFormField { impl FormField for UpstreamRegisterFormField {
fn keep(&self) -> bool { fn keep(&self) -> bool {
match self { match self {
Self::Username => true, Self::Username | Self::AcceptTerms => true,
} }
} }
} }

View File

@@ -27,7 +27,7 @@ limitations under the License.
{%- if value %} value="{{ value }}" {% endif %} {%- if value %} value="{{ value }}" {% endif %}
{%- endmacro %} {%- endmacro %}
{% macro field(label, name, form_state=false, class="") %} {% macro field(label, name, form_state=false, class="", inline=false) %}
{% set field_id = new_id() %} {% set field_id = new_id() %}
{% if not form_state %} {% if not form_state %}
{% set form_state = {"fields": {}} %} {% set form_state = {"fields": {}} %}
@@ -41,12 +41,24 @@ limitations under the License.
"value": state.value, "value": state.value,
} %} } %}
<div class="cpd-form-field {{ class }}"> <div class="{% if inline %}cpd-form-inline-field{% else %}cpd-form-field{% endif %} {{ class }}">
<label class="cpd-form-label" for="{{ field.id }}" {% if not inline %}
{%- if field.errors is not empty %} data-invalid{% endif -%} <label class="cpd-form-label" for="{{ field.id }}"
>{{ label }}</label> {%- if field.errors is not empty %} data-invalid{% endif -%}
>{{ label }}</label>
{{ caller(field) }}
{% else %}
<div class="cpd-form-inline-field-control">
{{ caller(field) }}
</div>
<div class="cpd-form-inline-field-body">
<label class="cpd-form-label" for="{{ field.id }}"
{%- if field.errors is not empty %} data-invalid{% endif -%}
>{{ label }}</label>
{% endif %}
{{ caller(field) }}
{% if field.errors is not empty %} {% if field.errors is not empty %}
{% for error in field.errors %} {% for error in field.errors %}
@@ -65,6 +77,10 @@ limitations under the License.
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if inline %}
</div>
{% endif %}
</div> </div>
{% endmacro %} {% endmacro %}

View File

@@ -56,6 +56,19 @@ limitations under the License.
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="new-password" required /> <input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="new-password" required />
{% endcall %} {% 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") %}
<div class="cpd-form-inline-field-control">
<div class="cpd-checkbox-container">
<input {{ field.attributes(f) }} class="cpd-checkbox-input" type="checkbox" required />
<div class="cpd-checkbox-ui">
{{ icon.check() }}
</div>
</div>
</div>
{% endcall %}
{% endif %}
{{ button.button(text=_("action.continue")) }} {{ button.button(text=_("action.continue")) }}
</form> </form>

View File

@@ -140,6 +140,20 @@ limitations under the License.
</div> </div>
{% endif %} {% 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_state, inline=true, class="my-4") %}
<div class="cpd-form-inline-field-control">
<div class="cpd-checkbox-container">
<input {{ field.attributes(f) }} class="cpd-checkbox-input" type="checkbox" required />
<div class="cpd-checkbox-ui">
{{ icon.check() }}
</div>
</div>
</div>
{% endcall %}
{% endif %}
{{ button.button(text=_("action.create_account")) }} {{ button.button(text=_("action.create_account")) }}
</form> </form>

View File

@@ -2,15 +2,15 @@
"action": { "action": {
"cancel": "Cancel", "cancel": "Cancel",
"@cancel": { "@cancel": {
"context": "pages/consent.html:72:11-29, pages/device_consent.html:94:13-31, pages/login.html:100:13-31, pages/policy_violation.html:52:13-31, pages/register.html:64:13-31" "context": "pages/consent.html:72:11-29, pages/device_consent.html:94:13-31, pages/login.html:100:13-31, pages/policy_violation.html:52:13-31, pages/register.html:77:13-31"
}, },
"continue": "Continue", "continue": "Continue",
"@continue": { "@continue": {
"context": "pages/account/emails/add.html:45:26-46, pages/account/emails/verify.html:60:26-46, pages/consent.html:60:28-48, pages/device_consent.html:91:13-33, pages/device_link.html:50:26-46, pages/login.html:62:30-50, pages/reauth.html:40:28-48, pages/register.html:59:28-48, pages/sso.html:45:28-48" "context": "pages/account/emails/add.html:45:26-46, pages/account/emails/verify.html:60:26-46, pages/consent.html:60:28-48, pages/device_consent.html:91:13-33, pages/device_link.html:50:26-46, pages/login.html:62:30-50, pages/reauth.html:40:28-48, pages/register.html:72:28-48, pages/sso.html:45:28-48"
}, },
"create_account": "Create Account", "create_account": "Create Account",
"@create_account": { "@create_account": {
"context": "pages/login.html:72:35-61, pages/upstream_oauth2/do_register.html:143:26-52" "context": "pages/login.html:72:35-61, pages/upstream_oauth2/do_register.html:157:26-52"
}, },
"sign_in": "Sign in", "sign_in": "Sign in",
"@sign_in": { "@sign_in": {
@@ -167,11 +167,11 @@
"errors": { "errors": {
"denied_policy": "Denied by policy: %(policy)s", "denied_policy": "Denied by policy: %(policy)s",
"@denied_policy": { "@denied_policy": {
"context": "components/errors.html:23:7-58, components/field.html:60:17-68" "context": "components/errors.html:23:7-58, components/field.html:72:17-68"
}, },
"field_required": "This field is required", "field_required": "This field is required",
"@field_required": { "@field_required": {
"context": "components/field.html:56:17-47" "context": "components/field.html:68:17-47"
}, },
"invalid_credentials": "Invalid credentials", "invalid_credentials": "Invalid credentials",
"@invalid_credentials": { "@invalid_credentials": {
@@ -183,7 +183,7 @@
}, },
"username_taken": "This username is already taken", "username_taken": "This username is already taken",
"@username_taken": { "@username_taken": {
"context": "components/field.html:58:17-47" "context": "components/field.html:70:17-47"
} }
}, },
"login": { "login": {
@@ -251,7 +251,7 @@
}, },
"or_separator": "Or", "or_separator": "Or",
"@or_separator": { "@or_separator": {
"context": "components/field.html:75:10-31", "context": "components/field.html:91:10-31",
"description": "Separator between the login methods" "description": "Separator between the login methods"
}, },
"policy_violation": { "policy_violation": {
@@ -273,7 +273,7 @@
"register": { "register": {
"call_to_login": "Already have an account?", "call_to_login": "Already have an account?",
"@call_to_login": { "@call_to_login": {
"context": "pages/register.html:74:11-42", "context": "pages/register.html:87:11-42",
"description": "Displayed on the registration page to suggest to log in instead" "description": "Displayed on the registration page to suggest to log in instead"
}, },
"create_account": { "create_account": {
@@ -288,7 +288,11 @@
}, },
"sign_in_instead": "Sign in instead", "sign_in_instead": "Sign in instead",
"@sign_in_instead": { "@sign_in_instead": {
"context": "pages/register.html:78:31-64" "context": "pages/register.html:91:31-64"
},
"terms_of_service": "I agree to the <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Terms and Conditions</a>",
"@terms_of_service": {
"context": "pages/register.html:60:37-97, pages/upstream_oauth2/do_register.html:144:35-95"
} }
}, },
"scope": { "scope": {