From 8570b8660f7d7fed0921571d133beba87dddd342 Mon Sep 17 00:00:00 2001 From: Lephe Date: Wed, 5 Jun 2019 19:59:49 -0400 Subject: [PATCH] account: introduce normalized names Adds a normalized name field to the user record. Also uses normalized names conflicts to deny new user names. --- app/forms/account.py | 2 +- app/models/users.py | 52 +++---------------- app/templates/account.html | 2 +- app/templates/register.html | 1 + app/utils/valid_name.py | 42 +++++++++++++++ app/utils/validators.py | 37 +++++++++++-- config.py | 6 +-- .../a6e89f3510d9_add_normalized_user_names.py | 34 ++++++++++++ 8 files changed, 121 insertions(+), 55 deletions(-) create mode 100644 app/utils/valid_name.py create mode 100644 migrations/versions/a6e89f3510d9_add_normalized_user_names.py diff --git a/app/forms/account.py b/app/forms/account.py index 103b669..fcfb0df 100644 --- a/app/forms/account.py +++ b/app/forms/account.py @@ -7,7 +7,7 @@ import app.utils.validators as vd class RegistrationForm(FlaskForm): - username = StringField('Pseudonyme', validators=[DataRequired(), vd.name_valid, vd.name_available]) + username = StringField('Pseudonyme', description='Ce nom est définitif !', validators=[DataRequired(), vd.name_valid, vd.name_available]) email = StringField('Adresse Email', validators=[DataRequired(), Email(), vd.email]) password = PasswordField('Mot de passe', validators=[DataRequired(), vd.password]) password2 = PasswordField('Répéter le mot de passe', validators=[DataRequired(), EqualTo('password')]) diff --git a/app/models/users.py b/app/models/users.py index d975e3a..b1096fa 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -4,6 +4,7 @@ from flask_login import UserMixin from app.models.contents import Content from app.models.privs import SpecialPrivilege, Group, GroupMember, \ GroupPrivilege +import app.utils.unicode_names as unicode_names from config import V5Config import werkzeug.security @@ -32,42 +33,6 @@ class User(UserMixin, db.Model): def __repr__(self): return f'' - @staticmethod - def valid_name(name): - """ - Checks whether a string is a valid user name. The criteria are: - 1. At least 3 characters and no longer than 32 characters - 2. No whitespace-class character - 3. No special chars - 4. At least one letter - 5. Not in forbidden usernames - - Possibily other intresting criteria: - 6. Unicode restriction - """ - - # Rule 1 - if type(name) != str or len(name) < 3 or len(name) > 32: - return False - # Rule 2 - # Reject all Unicode whitespaces. This is important to avoid the most - # common Unicode tricks! - if re.search(r'\s', name) is not None: - return False - # Rule 3 - if re.search(V5Config.FORBIDDEN_CHARS_USERNAMES, name) is not None: - return False - # Rule 4 - # There must be at least one letter (avoid complete garbage) - if re.search(r'\w', name) is None: - return False - # Rule 5 - if name in V5Config.FORBIDDEN_USERNAMES: - return False - - return True - - # Guest: Unregistered user with minimal privileges class Guest(User, db.Model): __tablename__ = 'guest' @@ -94,7 +59,9 @@ class Member(User, db.Model): id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) # Primary attributes (needed for the system to work) - name = db.Column(db.Unicode(32), index=True, unique=True) + name = db.Column(db.Unicode(V5Config.USER_NAME_MAXLEN), index=True) + norm = db.Column(db.Unicode(V5Config.USER_NAME_MAXLEN), index=True, + unique=True) email = db.Column(db.Unicode(120), index=True, unique=True) password_hash = db.Column(db.String(255)) xp = db.Column(db.Integer) @@ -128,10 +95,8 @@ class Member(User, db.Model): def __init__(self, name, email, password): """Register a new user.""" - if not User.valid_name(name): - raise Exception(f'{name} is not a valid user name') - self.name = name + self.norm = unicode_names.normalize(name) self.email = email self.set_password(password) self.xp = 0 @@ -171,7 +136,6 @@ class Member(User, db.Model): """ Update all or part of the user's metadata. The [data] dictionary accepts the following keys: - "name" str User name "email" str User mail ddress "password" str Raw password "bio" str Biograpy @@ -188,11 +152,6 @@ class Member(User, db.Model): data = {key: data[key] for key in data if data[key] is not None} - if "name" in data: - if not User.valid_name(data["name"]): - raise Exception(f'{data["name"]} is not a valid user name') - self.name = data["name"] - # TODO: verify good type of those args, think about the password mgt if "email" in data: self.email = data["email"] @@ -206,6 +165,7 @@ class Member(User, db.Model): self.birthday = data["birthday"] if "newsletter" in data: self.newsletter = data["newsletter"] + # For admins only if "xp" in data: self.xp = data["xp"] diff --git a/app/templates/account.html b/app/templates/account.html index 603ca48..7c51129 100644 --- a/app/templates/account.html +++ b/app/templates/account.html @@ -73,7 +73,7 @@
{{ form.newsletter.label }} {{ form.newsletter(checked=current_user.newsletter) }} -
{{ form.newsletter.description }}
+
{{ form.newsletter.description }}
{% for error in form.newsletter.errors %} {{ error }} {% endfor %} diff --git a/app/templates/register.html b/app/templates/register.html index f4689d1..556ef4d 100644 --- a/app/templates/register.html +++ b/app/templates/register.html @@ -8,6 +8,7 @@ {{ form.hidden_tag() }}
{{ form.username.label }} +
{{ form.username.description }}
{{ form.username() }} {% for error in form.username.errors %} {{ error }} diff --git a/app/utils/valid_name.py b/app/utils/valid_name.py new file mode 100644 index 0000000..96124c9 --- /dev/null +++ b/app/utils/valid_name.py @@ -0,0 +1,42 @@ +from config import V5Config +from app.utils.unicode_names import normalize +import re + +def valid_name(name, msg=False): + """ + Checks whether a string is a valid user name. The criteria are: + 1. At least 3 characters and no longer than 32 characters + 2. Only characters allowed in the [unicode_names] utility + 3. At least one letter character (avoid complete garbage) + 4. Not in forbidden user names + + Returns True if the name is valid, otherwise a list of error messages + that can contain these errors: + "too-short", "too-long", "cant-normalize", "no-letter", "forbidden" + Otherwise, returns a bool. + """ + + errors = [] + + # Rule 1 + if len(name) < V5Config.USER_NAME_MINLEN: + errors.append("too-short") + + if len(name) > V5Config.USER_NAME_MAXLEN: + errors.append("too-long") + + # Rule 2 + try: + normalize(name) + except ValueError: + errors.append("cant-normalize") + + # Rule 3 + if re.search(r'\w', name) is None: + errors.append("no-letter") + + # Rule 4 + if name in V5Config.FORBIDDEN_USERNAMES: + errors.append("forbidden") + + return True if errors == [] else errors diff --git a/app/utils/validators.py b/app/utils/validators.py index ba5e9d1..a922f38 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -1,17 +1,46 @@ from flask_login import current_user from wtforms.validators import ValidationError from app.models.users import User, Member +from app.utils.valid_name import valid_name +from app.utils.unicode_names import normalize +from config import V5Config def name_valid(form, name): - if not User.valid_name(name.data): - raise ValidationError("Nom d'utilisateur invalide.") + valid = valid_name(name.data) + default = "Nom d'utilisateur invalide (erreur interne)" + msg = { + "too-short": + "Le nom d'utilisateur doit faire au moins " + f"{V5Config.USER_NAME_MINLEN} caractères.", + "too-long": + "Le nom d'utilisateur doit faire au plus " + f"{V5Config.USER_NAME_MAXLEN} caractères.", + "cant-normalize": + "Ce nom d'utilisateur contient des caractères interdits. Les " + "caractères autorisés sont les lettres, lettres accentuées, " + 'chiffres ainsi que "-" (tiret), "." (point), "~" (tilde) et ' + '"_" (underscore).', + "no-letter": + "Le nom d'utilisateur doit contenir au moins une lettre.", + "forbidden": + "Ce nom d'utilisateur est interdit." + } + if valid is not True: + err = ' '.join(msg.get(code, default) for code in valid) + raise ValidationError(err) def name_available(form, name): - member = Member.query.filter_by(name=name.data).first() + # If the name is invalid, name_valid() will return a meaningful message + try: + norm = normalize(name.data) + except ValueError: + return + + member = Member.query.filter_by(norm=norm).first() if member is not None: - raise ValidationError('Pseudo indisponible.') + raise ValidationError("Ce nom d'utilisateur est indisponible.") def email(form, email): diff --git a/config.py b/config.py index 8e366d7..bf71675 100644 --- a/config.py +++ b/config.py @@ -7,7 +7,6 @@ class Config(object): 'postgresql+psycopg2://' + os.environ.get('USER') + ':@/pcv5' SQLALCHEMY_TRACK_MODIFICATIONS = False UPLOAD_FOLDER = './app/static/avatars' - LOGIN_DISABLED = False class V5Config(object): @@ -15,7 +14,8 @@ class V5Config(object): PRIVS_MAXLEN = 64 # Forbidden user names FORBIDDEN_USERNAMES = ["admin", "root", "webmaster", "contact"] - # Forbidden chars in user names (regex) - FORBIDDEN_CHARS_USERNAMES = r"[/]" # Unauthorized message (@priv_required) UNAUTHORIZED_MSG = "Vous n'avez pas l'autorisation d'effectuer cette action !" + # Minimum and maximum user name length + USER_NAME_MINLEN = 3 + USER_NAME_MAXLEN = 32 diff --git a/migrations/versions/a6e89f3510d9_add_normalized_user_names.py b/migrations/versions/a6e89f3510d9_add_normalized_user_names.py new file mode 100644 index 0000000..018acf4 --- /dev/null +++ b/migrations/versions/a6e89f3510d9_add_normalized_user_names.py @@ -0,0 +1,34 @@ +"""add normalized user names + +Revision ID: a6e89f3510d9 +Revises: 0fffe230b8ba +Create Date: 2019-06-05 19:50:08.493893 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a6e89f3510d9' +down_revision = '0fffe230b8ba' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('member', sa.Column('norm', sa.Unicode(length=32), nullable=True)) + op.create_index(op.f('ix_member_norm'), 'member', ['norm'], unique=True) + op.drop_index('ix_member_name', table_name='member') + op.create_index(op.f('ix_member_name'), 'member', ['name'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_member_name'), table_name='member') + op.create_index('ix_member_name', 'member', ['name'], unique=True) + op.drop_index(op.f('ix_member_norm'), table_name='member') + op.drop_column('member', 'norm') + # ### end Alembic commands ###