diff --git a/hiboo/account/forms.py b/hiboo/account/forms.py index edd0fbbe063ddd486e9dba352ea30c449b90bf4b..025c759c1bcce1a797f442a9afe044d9a3310ccc 100644 --- a/hiboo/account/forms.py +++ b/hiboo/account/forms.py @@ -1,4 +1,5 @@ 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')]) diff --git a/hiboo/application/base.py b/hiboo/application/base.py index 56f056714d5c0ef284191dbf87d8dd7ff09cb88b..ee9d02ff03a01e8ac694f50c9625df8067d348a2 100644 --- a/hiboo/application/base.py +++ b/hiboo/application/base.py @@ -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() ] ) ) diff --git a/hiboo/configuration.py b/hiboo/configuration.py index 5839ee8a12848b0e93145975ec84a66c3e49154e..c035cc245b6cdfd62d10ddcf367d15e827ed39fd 100644 --- a/hiboo/configuration.py +++ b/hiboo/configuration.py @@ -1,6 +1,9 @@ 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): diff --git a/hiboo/format.py b/hiboo/format.py index 6954122d3b514e5680b8e97f9f01c8dffa20f0ce..4dad141739b0899bb1a843bb3f2ee6e1fd6db6ae 100644 --- a/hiboo/format.py +++ b/hiboo/format.py @@ -1,10 +1,11 @@ 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 """ diff --git a/hiboo/models.py b/hiboo/models.py index 6c8125d7fd29f00be7195834a2847d2306096dba..fd2977aa36ff4d0738401d5dc9935063c607a846 100644 --- a/hiboo/models.py +++ b/hiboo/models.py @@ -1,5 +1,6 @@ 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. diff --git a/hiboo/profile/forms.py b/hiboo/profile/forms.py index 6775c5d400eb237537e8bb5ef07b5628e8f4565e..e5c4e1d9620277f7133438be2b84af965e74e28c 100644 --- a/hiboo/profile/forms.py +++ b/hiboo/profile/forms.py @@ -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): diff --git a/hiboo/profile/views.py b/hiboo/profile/views.py index 45df1b93477a01242a2e35212cd8140a99aacf55..1965e26cc5b457b15998dd21a0befacde81bdab1 100644 --- a/hiboo/profile/views.py +++ b/hiboo/profile/views.py @@ -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) diff --git a/hiboo/security.py b/hiboo/security.py index 6ee5ea39a4d49e7bbfc639080ddb3f320429dcd6..859b855fbeed588ebd2913f510b3cf85c3378806 100644 --- a/hiboo/security.py +++ b/hiboo/security.py @@ -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"} + )