Warn when registering with email containing uppercase characters

Closes #884 and #803

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2021-11-16 11:38:17 +01:00
parent be1664ec85
commit d291a83cc9
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
6 changed files with 125 additions and 29 deletions

View file

@ -1251,5 +1251,6 @@
"Approve member": "Approve member", "Approve member": "Approve member",
"Reject member": "Reject member", "Reject member": "Reject member",
"The membership request from {profile} was rejected": "The membership request from {profile} was rejected", "The membership request from {profile} was rejected": "The membership request from {profile} was rejected",
"The member was approved": "The member was approved" "The member was approved": "The member was approved",
"Emails usually don't contain capitals, make sure you haven't made a typo.": "Emails usually don't contain capitals, make sure you haven't made a typo."
} }

View file

@ -1355,5 +1355,6 @@
"Approve member": "Approuver le ou la membre", "Approve member": "Approuver le ou la membre",
"Reject member": "Rejeter le ou la membre", "Reject member": "Rejeter le ou la membre",
"The membership request from {profile} was rejected": "La demande d'adhésion de {profile} a été rejetée", "The membership request from {profile} was rejected": "La demande d'adhésion de {profile} a été rejetée",
"The member was approved": "Le ou la membre a été approuvée" "The member was approved": "Le ou la membre a été approuvée",
"Emails usually don't contain capitals, make sure you haven't made a typo.": "Les emails ne contiennent d'ordinaire pas de capitales, assurez-vous de n'avoir pas fait de faute de frappe."
} }

5
js/src/types/apollo.ts Normal file
View file

@ -0,0 +1,5 @@
import { GraphQLError } from "graphql/error/GraphQLError";
export class AbsintheGraphQLError extends GraphQLError {
readonly field: string | undefined;
}

View file

@ -83,8 +83,8 @@
<form v-on:submit.prevent="submit()"> <form v-on:submit.prevent="submit()">
<b-field <b-field
:label="$t('Email')" :label="$t('Email')"
:type="errors.email ? 'is-danger' : null" :type="errorEmailType"
:message="errors.email" :message="errorEmailMessages"
label-for="email" label-for="email"
> >
<b-input <b-input
@ -100,8 +100,8 @@
<b-field <b-field
:label="$t('Password')" :label="$t('Password')"
:type="errors.password ? 'is-danger' : null" :type="errorPasswordType"
:message="errors.password" :message="errorPasswordMessages"
label-for="password" label-for="password"
> >
<b-input <b-input
@ -178,12 +178,6 @@
<auth-providers :oauthProviders="config.auth.oauthProviders" /> <auth-providers :oauthProviders="config.auth.oauthProviders" />
</div> </div>
</form> </form>
<div v-if="errors.length > 0">
<b-message type="is-danger" v-for="error in errors" :key="error">{{
error
}}</b-message>
</div>
</div> </div>
</div> </div>
</section> </section>
@ -191,13 +185,18 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { CREATE_USER } from "../../graphql/user"; import { CREATE_USER } from "../../graphql/user";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { IConfig } from "../../types/config.model"; import { IConfig } from "../../types/config.model";
import { CONFIG } from "../../graphql/config"; import { CONFIG } from "../../graphql/config";
import Subtitle from "../../components/Utils/Subtitle.vue"; import Subtitle from "../../components/Utils/Subtitle.vue";
import AuthProviders from "../../components/User/AuthProviders.vue"; import AuthProviders from "../../components/User/AuthProviders.vue";
import { AbsintheGraphQLError } from "../../types/apollo";
type errorType = "is-danger" | "is-warning";
type errorMessage = { type: errorType; message: string };
type credentials = { email: string; password: string; locale: string };
@Component({ @Component({
components: { Subtitle, AuthProviders }, components: { Subtitle, AuthProviders },
@ -218,13 +217,14 @@ export default class Register extends Vue {
@Prop({ type: String, required: false, default: "" }) password!: string; @Prop({ type: String, required: false, default: "" }) password!: string;
credentials = { credentials: credentials = {
email: this.email, email: this.email,
password: this.password, password: this.password,
locale: "en", locale: "en",
}; };
errors: string[] = []; emailErrors: errorMessage[] = [];
passwordErrors: errorMessage[] = [];
sendingForm = false; sendingForm = false;
@ -245,7 +245,8 @@ export default class Register extends Vue {
this.sendingForm = true; this.sendingForm = true;
this.credentials.locale = this.$i18n.locale; this.credentials.locale = this.$i18n.locale;
try { try {
this.errors = []; this.emailErrors = [];
this.passwordErrors = [];
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: CREATE_USER, mutation: CREATE_USER,
@ -257,17 +258,67 @@ export default class Register extends Vue {
params: { email: this.credentials.email }, params: { email: this.credentials.email },
}); });
} catch (error: any) { } catch (error: any) {
console.error(error); error.graphQLErrors.forEach(
this.errors = error.graphQLErrors.reduce( ({ field, message }: AbsintheGraphQLError) => {
(acc: string[], localError: any) => { switch (field) {
acc.push(localError.message); case "email":
return acc; this.emailErrors.push({
}, type: "is-danger" as errorType,
[] message: message[0] as string,
});
break;
case "password":
this.passwordErrors.push({
type: "is-danger" as errorType,
message: message[0] as string,
});
break;
default:
}
}
); );
this.sendingForm = false; this.sendingForm = false;
} }
} }
@Watch("credentials", { deep: true })
watchCredentials(credentials: credentials): void {
if (credentials.email !== credentials.email.toLowerCase()) {
const error = {
type: "is-warning" as errorType,
message: this.$t(
"Emails usually don't contain capitals, make sure you haven't made a typo."
) as string,
};
this.emailErrors = [error];
this.$forceUpdate();
}
}
maxErrorType(errors: errorMessage[]): errorType | undefined {
if (!errors || errors.length === 0) return undefined;
return errors.reduce<errorType>((acc, error) => {
if (error.type === "is-danger" || acc === "is-danger") return "is-danger";
return "is-warning";
}, "is-warning");
}
get errorEmailType(): errorType | undefined {
return this.maxErrorType(this.emailErrors);
}
get errorPasswordType(): errorType | undefined {
return this.maxErrorType(this.passwordErrors);
}
get errorEmailMessages(): string[] {
return this.emailErrors.map(({ message }) => message);
}
get errorPasswordMessages(): string[] {
return this.passwordErrors?.map(({ message }) => message);
}
} }
</script> </script>
@ -302,4 +353,7 @@ p.create-account {
margin: 1rem auto 2rem; margin: 1rem auto 2rem;
} }
} }
::v-deep .help.is-warning {
color: #755033;
}
</style> </style>

View file

@ -145,13 +145,17 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
""" """
@spec create_user(any, %{email: String.t()}, any) :: {:ok, User.t()} | {:error, String.t()} @spec create_user(any, %{email: String.t()}, any) :: {:ok, User.t()} | {:error, String.t()}
def create_user(_parent, %{email: email} = args, _resolution) do def create_user(_parent, %{email: email} = args, _resolution) do
with :registration_ok <- check_registration_config(email), with {:ok, email} <- lowercase_domain(email),
:registration_ok <- check_registration_config(email),
:not_deny_listed <- check_registration_denylist(email), :not_deny_listed <- check_registration_denylist(email),
{:ok, %User{} = user} <- Users.register(args), {:ok, %User{} = user} <- Users.register(%{args | email: email}),
%Bamboo.Email{} <- %Bamboo.Email{} <-
Email.User.send_confirmation_email(user, Map.get(args, :locale, "en")) do Email.User.send_confirmation_email(user, Map.get(args, :locale, "en")) do
{:ok, user} {:ok, user}
else else
{:error, :invalid_email} ->
{:error, dgettext("errors", "Your email seems to be using an invalid format")}
:registration_closed -> :registration_closed ->
{:error, dgettext("errors", "Registrations are not open")} {:error, dgettext("errors", "Registrations are not open")}
@ -190,24 +194,40 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
# Remove everything behind the + # Remove everything behind the +
email = String.replace(email, ~r/(\+.*)(?=\@)/, "") email = String.replace(email, ~r/(\+.*)(?=\@)/, "")
if email_in_list(email, Config.instance_registrations_denylist()), if email_in_list?(email, Config.instance_registrations_denylist()),
do: :deny_listed, do: :deny_listed,
else: :not_deny_listed else: :not_deny_listed
end end
@spec check_allow_listed_email(String.t()) :: :registration_ok | :not_allowlisted @spec check_allow_listed_email(String.t()) :: :registration_ok | :not_allowlisted
defp check_allow_listed_email(email) do defp check_allow_listed_email(email) do
if email_in_list(email, Config.instance_registrations_allowlist()), if email_in_list?(email, Config.instance_registrations_allowlist()),
do: :registration_ok, do: :registration_ok,
else: :not_allowlisted else: :not_allowlisted
end end
defp email_in_list(email, list) do @spec email_in_list?(String.t(), list(String.t())) :: boolean()
[_, domain] = String.split(email, "@", parts: 2, trim: true) defp email_in_list?(email, list) do
[_, domain] = split_email(email)
domain in list or email in list domain in list or email in list
end end
# Domains should always be lower-case, so let's force that
@spec lowercase_domain(String.t()) :: {:ok, String.t()} | {:error, :invalid_email}
defp lowercase_domain(email) do
case split_email(email) do
[user_part, domain_part] ->
{:ok, "#{user_part}@#{String.downcase(domain_part)}"}
_ ->
{:error, :invalid_email}
end
end
@spec split_email(String.t()) :: list(String.t())
defp split_email(email), do: String.split(email, "@", parts: 2, trim: true)
@doc """ @doc """
Validate an user, get its actor and a token Validate an user, get its actor and a token
""" """

View file

@ -454,6 +454,21 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
Config.put([:instance, :registration_email_denylist], []) Config.put([:instance, :registration_email_denylist], [])
end end
test "create_user/3 lowers domain part of email",
%{
conn: conn
} do
res =
conn
|> AbsintheHelpers.graphql_query(
query: @create_user_mutation,
variables: Map.put(@user_creation, :email, "test+alias@DEMO.tld")
)
assert res["errors"] == nil
assert res["data"]["createUser"]["email"] == "test+alias@demo.tld"
end
test "register_person/3 doesn't register a profile from an unknown email", %{conn: conn} do test "register_person/3 doesn't register a profile from an unknown email", %{conn: conn} do
conn conn
|> put_req_header("accept-language", "fr") |> put_req_header("accept-language", "fr")