account: introduce normalized names

Adds a normalized name field to the user record. Also uses
normalized names conflicts to deny new user names.
This commit is contained in:
Lephe 2019-06-05 19:59:49 -04:00
parent 55ef818b84
commit 8570b8660f
8 changed files with 121 additions and 55 deletions

View File

@ -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')])

View File

@ -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'<User #{self.id}>'
@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"]

View File

@ -73,7 +73,7 @@
<div>
{{ form.newsletter.label }}
{{ form.newsletter(checked=current_user.newsletter) }}
<div style="font-size:80%;color:rgba(0,0,0,.5)">{{ form.newsletter.description }}</div>
<div class=desc>{{ form.newsletter.description }}</div>
{% for error in form.newsletter.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}

View File

@ -8,6 +8,7 @@
{{ form.hidden_tag() }}
<div>
{{ form.username.label }}
<div class=desc>{{ form.username.description }}</div>
{{ form.username() }}
{% for error in form.username.errors %}
<span class="msgerror">{{ error }}</span>

42
app/utils/valid_name.py Normal file
View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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 ###