Skip to content
Snippets Groups Projects
Commit 25a0e566 authored by f00wl's avatar f00wl
Browse files

feat(sec): Add rules to username creation

parent 60fcf436
No related branches found
No related tags found
No related merge requests found
from hiboo.captcha import captcha
from hiboo.format import UsernameFormat
from wtforms import validators, fields
from flask_babel import lazy_gettext as _
from flask_bootstrap import SwitchField
......@@ -18,12 +19,12 @@ class TotpForm(flask_wtf.FlaskForm):
class SignupForm(flask_wtf.FlaskForm):
username = fields.StringField(_('Username'), [
validators.DataRequired(),
validators.Regexp("(^[a-z0-9-_]+$)", message=(_("Your username must be \
comprised of lowercase letters, numbers and '-' '_' only")))
], description = (_("Your username must be \
comprised of lowercase letters, numbers and '-' '_' only")))
formatter = UsernameFormat.registry["lowercase"]
username = fields.StringField(
_('Username'),
formatter.validators(),
description = (_("Your username must be comprised of ") + formatter.message)
)
password = fields.PasswordField(_('Password'), [validators.DataRequired()])
password2 = fields.PasswordField(_('Confirm password'),
[validators.DataRequired(), validators.EqualTo('password')])
......
......@@ -19,12 +19,8 @@ class BaseForm(flask_wtf.FlaskForm):
[validators.NumberRange(1, 1000)])
profile_format = fields.SelectField(_('Profile username format'),
choices=(
[("", (_("Default ({})".format(
format.ProfileFormat.registry[None].message))
))] +
[(name, format.message.capitalize())
for name, format in format.ProfileFormat.registry.items()
if name is not None
for name, format in format.UsernameFormat.registry.items()
]
)
)
......
import os
import ast
import logging
from hiboo.security import username_blocklist
DEFAULT_CONFIG = {
# Common configuration variables
......@@ -17,7 +20,8 @@ DEFAULT_CONFIG = {
'WEBSITE_NAME': 'Hiboo',
'OPEN_SIGNUP': True,
'USER_TIMEOUT': 86400,
'API_TOKEN': 'changeMe'
'API_TOKEN': 'changeMe',
'USERNAME_BLOCKLIST': username_blocklist(),
}
class ConfigManager(dict):
......@@ -32,6 +36,8 @@ class ConfigManager(dict):
return True
elif isinstance(value, str) and value.lower() in ('false', 'no'):
return False
elif isinstance(value, str) and value[0] in "[{(" and value[-1] in ")}]":
return ast.literal_eval(value)
return value
def init_app(self, app):
......
from wtforms import validators
from flask_babel import lazy_gettext as _
from hiboo.security import username_blocklist
import string
class ProfileFormat(object):
class UsernameFormat(object):
registry = {}
regex = ".*"
......@@ -25,7 +26,7 @@ class ProfileFormat(object):
return register_function
@classmethod
def validators(cls):
def validators(cls, blocklist=True):
""" Return a username validator for wtforms
"""
return [
......@@ -37,6 +38,10 @@ class ProfileFormat(object):
validators.Regexp(
"^{}$".format(cls.regex),
message=(_("must comprise only of ")) + cls.message
),
validators.NoneOf(
username_blocklist() if blocklist else (),
message="Username not available"
)
]
......@@ -57,11 +62,11 @@ class ProfileFormat(object):
index += 1
register = ProfileFormat.register
register = UsernameFormat.register
@register("lowercase", "", None)
class LowercaseAlphanumPunct(ProfileFormat):
@register("lowercase")
class LowercaseAlphanumPunct(UsernameFormat):
""" Lowercase username, including digits and very basic punctuation
"""
......@@ -72,7 +77,7 @@ class LowercaseAlphanumPunct(ProfileFormat):
@register("alnum")
class AlphanumPunct(ProfileFormat):
class AlphanumPunct(UsernameFormat):
""" Alphanum username, including some very basic punctuation
"""
......
from passlib import context, hash
from flask import current_app as app
from hiboo.security import username_blocklist
from sqlalchemy import orm, types, schema, sql
from sqlalchemy.ext import mutable
from flask_babel import lazy_gettext as _
......@@ -148,6 +149,20 @@ class User(db.Model):
db.session.delete(user)
db.session.commit()
@classmethod
def exist_username(cls, username):
return cls.query.filter(cls.username.ilike(username)).first()
def reserved_username(self, username):
return self.username == username.lower()
def available_username(self, username, service, spoof_allowed=False):
return not Profile.exist_username(username, service=service) and (
not User.exist_username(username)
or self.reserved_username(username)
or spoof_allowed
)
class Auth(db.Model):
""" An authenticator is a method to authenticate a user.
......@@ -220,9 +235,6 @@ class Service(db.Model):
single_profile = db.Column(db.Boolean(), nullable=False, default=False)
config = db.Column(mutable.MutableDict.as_mutable(JSONEncoded))
def check_username(self, username):
return Profile.query.filter_by(service_uuid=self.uuid, username=username).first()
class Profile(db.Model):
""" A profile is a per-service custom identity.
......@@ -329,6 +341,10 @@ class Profile(db.Model):
)
))
@classmethod
def exist_username(cls, username, service):
return cls.query.filter_by(service_uuid=service.uuid).filter(cls.username.ilike(username)).first()
class ClaimName(db.Model):
""" A profile might have multiple claimable names.
......
......@@ -7,6 +7,11 @@ import flask_wtf
class ProfileForm(flask_wtf.FlaskForm):
username = fields.StringField(_('Username'), [validators.DataRequired()])
comment = fields.StringField(_('Comment'))
username_spoof_protection = fields.BooleanField(
_('Username spoof protection'),
description=_("Prevent to register a profile username that case-insensitively exists in user database"),
default=True
)
submit = fields.SubmitField(_('Create profile'))
def force_username(self, username):
......
......@@ -26,7 +26,7 @@ def create(service_uuid, create_for=False, quick=False):
service = models.Service.query.get(service_uuid) or flask.abort(404)
status = models.Profile.ACTIVE
is_admin = flask_login.current_user.is_admin
formatter = format.ProfileFormat.registry[service.profile_format]
formatter = format.UsernameFormat.registry[service.profile_format]
# If the admin passed a user uuid, use that one, otherwise ignore it
user = hiboo_user.get_user(intent="profile.create_for", create_for=None) if (create_for and is_admin) else flask_login.current_user
# Check that profile creation is allowed
......@@ -46,21 +46,25 @@ def create(service_uuid, create_for=False, quick=False):
status = models.Profile.REQUEST
# Initialize and validate the form before applying overrides
form = forms.ProfileForm()
form.username.validators = formatter.validators()
form.username.validators = formatter.validators(blocklist=not is_admin)
if not is_admin:
del form.username_spoof_protection
submit = form.validate_on_submit()
# If this is a quick creation or the service prevents custom profile creation, force the username
if quick or service.single_profile:
for username in formatter.alternatives(formatter.coalesce(user.username)):
if not service.check_username(username):
if user.available_username(username, service=service):
form.force_username(username)
break
if quick:
form.hide_fields()
# Handle the creation form
if submit:
if service.check_username(form.username.data):
flask.flash(_("A profile with that username exists already"), "danger")
else:
if user.available_username(
username=form.username.data,
service=service,
spoof_allowed=not form.username_spoof_protection.data if is_admin else False,
):
profile = models.Profile(
user_uuid=user.uuid, service_uuid=service.uuid,
username=form.username.data,
......@@ -72,6 +76,8 @@ def create(service_uuid, create_for=False, quick=False):
user=user, service=service, profile=profile)
models.db.session.commit()
return flask.redirect(utils.url_or_intent("account.home"))
else:
flask.flash(_("A profile with that username exists already"), "danger")
# Display either the quick version or the full version (one can switch from one to another)
return flask.render_template("profile_quick.html" if quick else "profile_create.html",
form=form, service=service, user=user, create_for=create_for)
......
......@@ -62,3 +62,18 @@ def confirmation_required(action):
)
return wrapper
return inner
def username_blocklist():
""" Return an opinionated set of unsafe usernames
"""
return set.union(
# linux common users and groups
{"abuild", "adm", "_apt", "at", "audio", "avahi", "backup", "bin", "bluetooth", "cdrom", "cdrw", "console", "cron", "crontab", "cyrus", "daemon", "dbus", "dhcp", "dialout", "dip", "disk", "dnsmasq", "docker", "fax", "floppy", "ftp", "games", "gnats", "guest", "halt", "http", "input", "irc", "kmem", "kvm", "libvirt", "list", "locate", "lock", "log", "lp", "lpadmin", "mail", "man", "mem", "messagebus", "netdev", "network", "news", "nobody", "nofiles", "nogroup", "ntp", "nullmail", "nvpd", "openvpn", "operator", "optical", "pcap", "ping", "plugdev", "polkitd", "portage", "postfix", "postmaster", "power", "proc", "proxy", "qemu", "radvd", "readproc", "render", "rfkill", "root", "saned", "sasl", "scanner", "sgx", "shadow", "shutdown", "smmsp", "squid", "src", "ssh", "sshd", "ssl-cert", "staff", "storage", "sudo", "sync", "sys", "systemd", "systemd-coredump", "systemd-journal", "systemd-network", "systemd-resolve", "systemd-timesync", "tape", "tcpdump", "tss", "tty", "usb", "users", "utmp", "uucp", "uuidd", "video", "voice", "vpopmail", "wheel", "www", "www-data", "xfs"},
# linux reserved uids
{uid for uid in range(1000)},
# web stack commons
{"admin", "administrator", "git", "localhost", "localdomain", "mariadb", "master", "mysql", "nginx", "no-reply", "noreply", "passwd", "password", "postgres", "python", "sqlite", "user", "users", "username"},
# Hiboo specifics
{"account", "action", "api", "app", "application_id", "assign", "auth", "authentication", "authorize", "cancel", "check", "claim", "complete", "contact", "create", "create_for", "create_quick", "delete", "details", "disable", "edit", "email", "enable", "flask", "home", "invite", "jwks", "list", "login", "logout", "metadata", "oidc", "openid-configuration", "password", "pick", "profile", "profiles", "profile_uuid", "redirect", "reset", "saml", "service", "service_uuid", "setapp", "signin", "signout", "signup", "sso", "static", "status", "token", "totp", "transition", "transition_id", "unclaimedexport", "user", "userinfo", "user_uuid"}
)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment