Skip to content

Commit 551beb0

Browse files
committed
Propagate more specific error messages from the policy on registration
This makes some policy errors translatable
1 parent a396f20 commit 551beb0

File tree

11 files changed

+137
-19
lines changed

11 files changed

+137
-19
lines changed

crates/handlers/src/upstream_oauth2/link.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,10 +759,12 @@ pub(crate) async fn post(
759759
Some("username") => form_state.with_error_on_field(
760760
mas_templates::UpstreamRegisterFormField::Username,
761761
FieldError::Policy {
762+
code: violation.code.map(|c| c.as_str()),
762763
message: violation.msg,
763764
},
764765
),
765766
_ => form_state.with_error_on_form(FormError::Policy {
767+
code: violation.code.map(|c| c.as_str()),
766768
message: violation.msg,
767769
}),
768770
}

crates/handlers/src/views/register.rs

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -154,20 +154,22 @@ pub(crate) async fn post(
154154
state.add_error_on_form(FormError::Captcha);
155155
}
156156

157+
let mut homeserver_denied_username = false;
157158
if form.username.is_empty() {
158159
state.add_error_on_field(RegisterFormField::Username, FieldError::Required);
159160
} else if repo.user().exists(&form.username).await? {
160161
// The user already exists in the database
161162
state.add_error_on_field(RegisterFormField::Username, FieldError::Exists);
162163
} else if !homeserver.is_localpart_available(&form.username).await? {
163164
// The user already exists on the homeserver
164-
// XXX: we may want to return different errors like "this username is reserved"
165165
tracing::warn!(
166166
username = &form.username,
167-
"User tried to register with a reserved username"
167+
"Homeserver denied username provided by user"
168168
);
169169

170-
state.add_error_on_field(RegisterFormField::Username, FieldError::Exists);
170+
// We defer adding the error on the field, until we know whether we had another
171+
// error from the policy, to avoid showing both
172+
homeserver_denied_username = true;
171173
}
172174

173175
if form.email.is_empty() {
@@ -197,6 +199,7 @@ pub(crate) async fn post(
197199
state.add_error_on_field(
198200
RegisterFormField::Password,
199201
FieldError::Policy {
202+
code: None,
200203
message: "Password is too weak".to_owned(),
201204
},
202205
);
@@ -216,27 +219,41 @@ pub(crate) async fn post(
216219
Some("email") => state.add_error_on_field(
217220
RegisterFormField::Email,
218221
FieldError::Policy {
222+
code: violation.code.map(|c| c.as_str()),
219223
message: violation.msg,
220224
},
221225
),
222-
Some("username") => state.add_error_on_field(
223-
RegisterFormField::Username,
224-
FieldError::Policy {
225-
message: violation.msg,
226-
},
227-
),
226+
Some("username") => {
227+
// If the homeserver denied the username, but we also had an error on the policy
228+
// side, we don't want to show both, so we reset the state here
229+
homeserver_denied_username = false;
230+
state.add_error_on_field(
231+
RegisterFormField::Username,
232+
FieldError::Policy {
233+
code: violation.code.map(|c| c.as_str()),
234+
message: violation.msg,
235+
},
236+
);
237+
}
228238
Some("password") => state.add_error_on_field(
229239
RegisterFormField::Password,
230240
FieldError::Policy {
241+
code: violation.code.map(|c| c.as_str()),
231242
message: violation.msg,
232243
},
233244
),
234245
_ => state.add_error_on_form(FormError::Policy {
246+
code: violation.code.map(|c| c.as_str()),
235247
message: violation.msg,
236248
}),
237249
}
238250
}
239251

252+
if homeserver_denied_username {
253+
// XXX: we may want to return different errors like "this username is reserved"
254+
state.add_error_on_field(RegisterFormField::Username, FieldError::Exists);
255+
}
256+
240257
if state.is_valid() {
241258
// Check the rate limit if we are about to process the form
242259
if let Err(e) = limiter.check_registration(requester) {

crates/policy/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use thiserror::Error;
1717
use tokio::io::{AsyncRead, AsyncReadExt};
1818

1919
use self::model::{AuthorizationGrantInput, ClientRegistrationInput, EmailInput, RegisterInput};
20-
pub use self::model::{EvaluationResult, Violation};
20+
pub use self::model::{Code as ViolationCode, EvaluationResult, Violation};
2121
use crate::model::GrantType;
2222

2323
#[derive(Debug, Error)]

crates/policy/src/model.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,53 @@ use mas_data_model::{Client, User};
1313
use oauth2_types::{registration::VerifiedClientMetadata, scope::Scope};
1414
use serde::{Deserialize, Serialize};
1515

16+
/// A well-known policy code.
17+
#[derive(Deserialize, Debug, Clone, Copy)]
18+
#[serde(rename_all = "kebab-case")]
19+
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
20+
pub enum Code {
21+
/// The username is too short.
22+
UsernameTooShort,
23+
24+
/// The username is too long.
25+
UsernameTooLong,
26+
27+
/// The username contains invalid characters.
28+
UsernameInvalidChars,
29+
30+
/// The username contains only numeric characters.
31+
UsernameAllNumeric,
32+
33+
/// The email domain is not allowed.
34+
EmailDomainNotAllowed,
35+
36+
/// The email domain is banned.
37+
EmailDomainBanned,
38+
}
39+
40+
impl Code {
41+
/// Returns the code as a string
42+
#[must_use]
43+
pub fn as_str(&self) -> &'static str {
44+
match self {
45+
Self::UsernameTooShort => "username-too-short",
46+
Self::UsernameTooLong => "username-too-long",
47+
Self::UsernameInvalidChars => "username-invalid-chars",
48+
Self::UsernameAllNumeric => "username-all-numeric",
49+
Self::EmailDomainNotAllowed => "email-domain-not-allowed",
50+
Self::EmailDomainBanned => "email-domain-banned",
51+
}
52+
}
53+
}
54+
1655
/// A single violation of a policy.
1756
#[derive(Deserialize, Debug)]
1857
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1958
pub struct Violation {
2059
pub msg: String,
2160
pub redirect_uri: Option<String>,
2261
pub field: Option<String>,
62+
pub code: Option<Code>,
2363
}
2464

2565
/// The result of a policy evaluation.

crates/templates/src/context.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,7 @@ impl TemplateContext for LoginContext {
462462
.with_error_on_field(
463463
LoginFormField::Password,
464464
FieldError::Policy {
465+
code: None,
465466
message: "password too short".to_owned(),
466467
},
467468
),

crates/templates/src/forms.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ pub enum FieldError {
3636

3737
/// Denied by the policy
3838
Policy {
39+
/// Well-known policy code
40+
code: Option<&'static str>,
41+
3942
/// Message for this policy violation
4043
message: String,
4144
},
@@ -59,6 +62,9 @@ pub enum FormError {
5962

6063
/// Denied by the policy
6164
Policy {
65+
/// Well-known policy code
66+
code: Option<&'static str>,
67+
6268
/// Message for this policy violation
6369
message: String,
6470
},

policies/email/email.rego

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ domain_allowed if {
2525

2626
# METADATA
2727
# entrypoint: true
28-
violation contains {"msg": "email domain is not allowed"} if {
28+
violation contains {"code": "email-domain-not-allowed", "msg": "email domain is not allowed"} if {
2929
not domain_allowed
3030
}
3131

3232
# Deny emails with their domain in the domains banlist
33-
violation contains {"msg": "email domain is banned"} if {
33+
violation contains {"code": "email-domain-banned", "msg": "email domain is banned"} if {
3434
[_, domain] := split(input.email, "@")
3535
some banned_domain in data.banned_domains
3636
glob.match(banned_domain, ["."], domain)

policies/register/register.rego

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,26 @@ mxid(username, server_name) := sprintf("@%s:%s", [username, server_name])
1717

1818
# METADATA
1919
# entrypoint: true
20-
violation contains {"field": "username", "msg": "username too short"} if {
20+
violation contains {"field": "username", "code": "username-too-short", "msg": "username too short"} if {
2121
count(input.username) == 0
2222
}
2323

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

29-
violation contains {"field": "username", "msg": "username contains invalid characters"} if {
29+
violation contains {
30+
"field": "username", "code": "username-all-numeric",
31+
"msg": "username must contain at least one non-numeric character",
32+
} if {
33+
regex.match(`^[0-9]+$`, input.username)
34+
}
35+
36+
violation contains {
37+
"field": "username", "code": "username-invalid-chars",
38+
"msg": "username contains invalid characters",
39+
} if {
3040
not regex.match(`^[a-z0-9.=_/-]+$`, input.username)
3141
}
3242

policies/register/register_test.rego

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,7 @@ test_long_username if {
7070
test_invalid_username if {
7171
not register.allow with input as {"username": "hello world", "registration_method": "upstream-oauth2"}
7272
}
73+
74+
test_numeric_username if {
75+
not register.allow with input as {"username": "1234", "registration_method": "upstream-oauth2"}
76+
}

templates/components/field.html

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,21 @@
6161
{% elif error.kind == "exists" and field.name == "username" %}
6262
{{ _("mas.errors.username_taken") }}
6363
{% elif error.kind == "policy" %}
64-
{{ _("mas.errors.denied_policy", policy=error.message) }}
64+
{% if error.code == "username-too-short" %}
65+
{{ _("mas.errors.username_too_short") }}
66+
{% elif error.code == "username-too-long" %}
67+
{{ _("mas.errors.username_too_long") }}
68+
{% elif error.code == "username-invalid-chars" %}
69+
{{ _("mas.errors.username_invalid_chars") }}
70+
{% elif error.code == "username-all-numeric" %}
71+
{{ _("mas.errors.username_all_numeric") }}
72+
{% elif error.code == "email-domain-not-allowed" %}
73+
{{ _("mas.errors.email_domain_not_allowed") }}
74+
{% elif error.code == "email-domain-banned" %}
75+
{{ _("mas.errors.email_domain_banned") }}
76+
{% else %}
77+
{{ _("mas.errors.denied_policy", policy=error.message) }}
78+
{% endif %}
6579
{% elif error.kind == "password_mismatch" %}
6680
{{ _("mas.errors.password_mismatch") }}
6781
{% else %}

0 commit comments

Comments
 (0)