Skip to content

Commit

Permalink
Propagate more specific error messages from the policy on registration
Browse files Browse the repository at this point in the history
This makes some policy errors translatable
  • Loading branch information
sandhose committed Dec 19, 2024
1 parent a396f20 commit 551beb0
Show file tree
Hide file tree
Showing 11 changed files with 137 additions and 19 deletions.
2 changes: 2 additions & 0 deletions crates/handlers/src/upstream_oauth2/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -759,10 +759,12 @@ pub(crate) async fn post(
Some("username") => form_state.with_error_on_field(
mas_templates::UpstreamRegisterFormField::Username,
FieldError::Policy {
code: violation.code.map(|c| c.as_str()),
message: violation.msg,
},
),
_ => form_state.with_error_on_form(FormError::Policy {
code: violation.code.map(|c| c.as_str()),
message: violation.msg,
}),
}
Expand Down
35 changes: 26 additions & 9 deletions crates/handlers/src/views/register.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,20 +154,22 @@ pub(crate) async fn post(
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
// XXX: we may want to return different errors like "this username is reserved"
tracing::warn!(
username = &form.username,
"User tried to register with a reserved username"
"Homeserver denied username provided by user"
);

state.add_error_on_field(RegisterFormField::Username, FieldError::Exists);
// 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() {
Expand Down Expand Up @@ -197,6 +199,7 @@ pub(crate) async fn post(
state.add_error_on_field(
RegisterFormField::Password,
FieldError::Policy {
code: None,
message: "Password is too weak".to_owned(),
},
);
Expand All @@ -216,27 +219,41 @@ pub(crate) async fn post(
Some("email") => state.add_error_on_field(
RegisterFormField::Email,
FieldError::Policy {
code: violation.code.map(|c| c.as_str()),
message: violation.msg,
},
),
Some("username") => state.add_error_on_field(
RegisterFormField::Username,
FieldError::Policy {
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) {
Expand Down
2 changes: 1 addition & 1 deletion crates/policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use thiserror::Error;
use tokio::io::{AsyncRead, AsyncReadExt};

use self::model::{AuthorizationGrantInput, ClientRegistrationInput, EmailInput, RegisterInput};
pub use self::model::{EvaluationResult, Violation};
pub use self::model::{Code as ViolationCode, EvaluationResult, Violation};
use crate::model::GrantType;

#[derive(Debug, Error)]
Expand Down
40 changes: 40 additions & 0 deletions crates/policy/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,53 @@ use mas_data_model::{Client, User};
use oauth2_types::{registration::VerifiedClientMetadata, scope::Scope};
use serde::{Deserialize, Serialize};

/// A well-known policy code.
#[derive(Deserialize, Debug, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
pub enum Code {
/// The username is too short.
UsernameTooShort,

/// The username is too long.
UsernameTooLong,

/// The username contains invalid characters.
UsernameInvalidChars,

/// The username contains only numeric characters.
UsernameAllNumeric,

/// The email domain is not allowed.
EmailDomainNotAllowed,

/// The email domain is banned.
EmailDomainBanned,
}

impl Code {
/// Returns the code as a string
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::UsernameTooShort => "username-too-short",
Self::UsernameTooLong => "username-too-long",
Self::UsernameInvalidChars => "username-invalid-chars",
Self::UsernameAllNumeric => "username-all-numeric",
Self::EmailDomainNotAllowed => "email-domain-not-allowed",
Self::EmailDomainBanned => "email-domain-banned",
}
}
}

/// A single violation of a policy.
#[derive(Deserialize, Debug)]
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
pub struct Violation {
pub msg: String,
pub redirect_uri: Option<String>,
pub field: Option<String>,
pub code: Option<Code>,
}

/// The result of a policy evaluation.
Expand Down
1 change: 1 addition & 0 deletions crates/templates/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ impl TemplateContext for LoginContext {
.with_error_on_field(
LoginFormField::Password,
FieldError::Policy {
code: None,
message: "password too short".to_owned(),
},
),
Expand Down
6 changes: 6 additions & 0 deletions crates/templates/src/forms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ pub enum FieldError {

/// Denied by the policy
Policy {
/// Well-known policy code
code: Option<&'static str>,

/// Message for this policy violation
message: String,
},
Expand All @@ -59,6 +62,9 @@ pub enum FormError {

/// Denied by the policy
Policy {
/// Well-known policy code
code: Option<&'static str>,

/// Message for this policy violation
message: String,
},
Expand Down
4 changes: 2 additions & 2 deletions policies/email/email.rego
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ domain_allowed if {

# METADATA
# entrypoint: true
violation contains {"msg": "email domain is not allowed"} if {
violation contains {"code": "email-domain-not-allowed", "msg": "email domain is not allowed"} if {
not domain_allowed
}

# Deny emails with their domain in the domains banlist
violation contains {"msg": "email domain is banned"} if {
violation contains {"code": "email-domain-banned", "msg": "email domain is banned"} if {
[_, domain] := split(input.email, "@")
some banned_domain in data.banned_domains
glob.match(banned_domain, ["."], domain)
Expand Down
16 changes: 13 additions & 3 deletions policies/register/register.rego
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,26 @@ mxid(username, server_name) := sprintf("@%s:%s", [username, server_name])

# METADATA
# entrypoint: true
violation contains {"field": "username", "msg": "username too short"} if {
violation contains {"field": "username", "code": "username-too-short", "msg": "username too short"} if {
count(input.username) == 0
}

violation contains {"field": "username", "msg": "username too long"} if {
violation contains {"field": "username", "code": "username-too-long", "msg": "username too long"} if {
user_id := mxid(input.username, data.server_name)
count(user_id) > 255
}

violation contains {"field": "username", "msg": "username contains invalid characters"} if {
violation contains {
"field": "username", "code": "username-all-numeric",
"msg": "username must contain at least one non-numeric character",
} if {
regex.match(`^[0-9]+$`, input.username)
}

violation contains {
"field": "username", "code": "username-invalid-chars",
"msg": "username contains invalid characters",
} if {
not regex.match(`^[a-z0-9.=_/-]+$`, input.username)
}

Expand Down
4 changes: 4 additions & 0 deletions policies/register/register_test.rego
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,7 @@ test_long_username if {
test_invalid_username if {
not register.allow with input as {"username": "hello world", "registration_method": "upstream-oauth2"}
}

test_numeric_username if {
not register.allow with input as {"username": "1234", "registration_method": "upstream-oauth2"}
}
16 changes: 15 additions & 1 deletion templates/components/field.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,21 @@
{% elif error.kind == "exists" and field.name == "username" %}
{{ _("mas.errors.username_taken") }}
{% elif error.kind == "policy" %}
{{ _("mas.errors.denied_policy", policy=error.message) }}
{% if error.code == "username-too-short" %}
{{ _("mas.errors.username_too_short") }}
{% elif error.code == "username-too-long" %}
{{ _("mas.errors.username_too_long") }}
{% elif error.code == "username-invalid-chars" %}
{{ _("mas.errors.username_invalid_chars") }}
{% elif error.code == "username-all-numeric" %}
{{ _("mas.errors.username_all_numeric") }}
{% elif error.code == "email-domain-not-allowed" %}
{{ _("mas.errors.email_domain_not_allowed") }}
{% elif error.code == "email-domain-banned" %}
{{ _("mas.errors.email_domain_banned") }}
{% else %}
{{ _("mas.errors.denied_policy", policy=error.message) }}
{% endif %}
{% elif error.kind == "password_mismatch" %}
{{ _("mas.errors.password_mismatch") }}
{% else %}
Expand Down
30 changes: 27 additions & 3 deletions translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,15 @@
},
"denied_policy": "Denied by policy: %(policy)s",
"@denied_policy": {
"context": "components/errors.html:17:7-58, components/field.html:64:17-68"
"context": "components/errors.html:17:7-58, components/field.html:77:19-70"
},
"email_domain_banned": "Email domain is banned by the server policy",
"@email_domain_banned": {
"context": "components/field.html:75:19-54"
},
"email_domain_not_allowed": "Email domain is not allowed by the server policy",
"@email_domain_not_allowed": {
"context": "components/field.html:73:19-59"
},
"field_required": "This field is required",
"@field_required": {
Expand All @@ -296,15 +304,31 @@
},
"password_mismatch": "Password fields don't match",
"@password_mismatch": {
"context": "components/errors.html:13:7-40, components/field.html:66:17-50"
"context": "components/errors.html:13:7-40, components/field.html:80:17-50"
},
"rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again.",
"@rate_limit_exceeded": {
"context": "components/errors.html:15:7-42, pages/recovery/progress.html:26:11-46"
},
"username_all_numeric": "Username cannot consist solely of numbers",
"@username_all_numeric": {
"context": "components/field.html:71:19-55"
},
"username_invalid_chars": "Username contains invalid characters. Use lowercase letters, numbers, dashes and underscores only.",
"@username_invalid_chars": {
"context": "components/field.html:69:19-57"
},
"username_taken": "This username is already taken",
"@username_taken": {
"context": "components/field.html:62:17-47"
},
"username_too_long": "Username is too long",
"@username_too_long": {
"context": "components/field.html:67:19-52"
},
"username_too_short": "Username is too short",
"@username_too_short": {
"context": "components/field.html:65:19-53"
}
},
"login": {
Expand Down Expand Up @@ -377,7 +401,7 @@
},
"or_separator": "Or",
"@or_separator": {
"context": "components/field.html:85:10-31",
"context": "components/field.html:99:10-31",
"description": "Separator between the login methods"
},
"policy_violation": {
Expand Down

0 comments on commit 551beb0

Please sign in to comment.