diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index b5fda17d..4cb503ac 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -39,10 +39,13 @@ def check_credentials(user, password, ip, protocol=None, auth_port=None, source_ return True if utils.is_app_token(password): for token in user.tokens: - if (token.check_password(password) and - (not token.ip or token.ip == ip)): + if token.check_password(password): + if not token.ip or utils.is_ip_in_subnet(ip, token.ip): app.logger.info(f'Login attempt for: {user}/{protocol}/{auth_port} from: {ip}/{source_port}: success: token-{token.id}: {token.comment or ""!r}') return True + else: + app.logger.info(f'Login attempt for: {user}/{protocol}/{auth_port} from: {ip}/{source_port}: failed: badip: token-{token.id}: {token.comment or ""!r}') + return False # we can return directly here since the token is valid if user.check_password(password): app.logger.info(f'Login attempt for: {user}/{protocol}/{auth_port} from: {ip}/{source_port}: success: password') return True diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py index 8022709b..75417260 100644 --- a/core/admin/mailu/models.py +++ b/core/admin/mailu/models.py @@ -75,7 +75,7 @@ class CommaSeparatedList(db.TypeDecorator): """ Stores a list as a comma-separated string, compatible with Postfix. """ - impl = db.String(255) + impl = db.String(4096) cache_ok = True python_type = list @@ -732,7 +732,7 @@ class Token(Base): user = db.relationship(User, backref=db.backref('tokens', cascade='all, delete-orphan')) password = db.Column(db.String(255), nullable=False) - ip = db.Column(db.String(255)) + ip = db.Column(CommaSeparatedList, nullable=True, default=list) def check_password(self, password): """ verifies password against stored hash diff --git a/core/admin/mailu/ui/forms.py b/core/admin/mailu/ui/forms.py index c9bbb251..65f0b556 100644 --- a/core/admin/mailu/ui/forms.py +++ b/core/admin/mailu/ui/forms.py @@ -5,6 +5,7 @@ from flask_babel import lazy_gettext as _ import flask_login import flask_wtf import re +import ipaddress LOCALPART_REGEX = "^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*$" @@ -152,10 +153,18 @@ class TokenForm(flask_wtf.FlaskForm): raw_password = fields.HiddenField([validators.DataRequired()]) comment = fields.StringField(_('Comment')) ip = fields.StringField( - _('Authorized IP'), [validators.Optional(), validators.IPAddress(ipv6=True)] + _('Authorized IP'), [validators.Optional()] ) submit = fields.SubmitField(_('Save')) + def validate_ip(form, field): + if not field.data: + return True + try: + for candidate in field.data.replace(' ','').split(','): + ipaddress.ip_network(candidate, False) + except: + raise validators.ValidationError('Not a valid list of CIDRs') class AliasForm(flask_wtf.FlaskForm): localpart = fields.StringField(_('Alias'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)]) diff --git a/core/admin/mailu/ui/templates/token/list.html b/core/admin/mailu/ui/templates/token/list.html index 37b4e515..c59c8c90 100644 --- a/core/admin/mailu/ui/templates/token/list.html +++ b/core/admin/mailu/ui/templates/token/list.html @@ -32,7 +32,7 @@ {{ token.id }} {{ token.comment }} - {{ token.ip or "any" }} + {{ token.ip | join(', ') or "any" }} {{ token.created_at | format_date }} {{ token.updated_at | format_date }} diff --git a/core/admin/mailu/ui/views/tokens.py b/core/admin/mailu/ui/views/tokens.py index 820dd405..3fc7d5bd 100644 --- a/core/admin/mailu/ui/views/tokens.py +++ b/core/admin/mailu/ui/views/tokens.py @@ -1,4 +1,4 @@ -from mailu import models +from mailu import models, utils from mailu.ui import ui, forms, access from passlib import pwd @@ -27,11 +27,16 @@ def token_create(user_email): wtforms_components.read_only(form.displayed_password) if not form.raw_password.data: form.raw_password.data = pwd.genword(entropy=128, length=32, charset="hex") - form.displayed_password.data = form.raw_password.data + form.displayed_password.data = form.raw_password.data + utils.formatCSVField(form.ip) if form.validate_on_submit(): token = models.Token(user=user) token.set_password(form.raw_password.data) form.populate_obj(token) + if form.ip.data: + token.ip = form.ip.data.replace(' ','').split(',') + else: + del token.ip models.db.session.add(token) models.db.session.commit() flask.flash('Authentication token created') diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py index c0070de6..541ffb4d 100644 --- a/core/admin/mailu/utils.py +++ b/core/admin/mailu/utils.py @@ -87,6 +87,16 @@ def is_exempt_from_ratelimits(ip): ip = ipaddress.ip_address(ip) return any(ip in cidr for cidr in app.config['AUTH_RATELIMIT_EXEMPTION']) +def is_ip_in_subnet(ip, subnets=[]): + if isinstance(subnets, str): + subnets = [subnets] + ip = ipaddress.ip_address(ip) + try: + return any(ip in cidr for cidr in [ipaddress.ip_network(subnet, strict=False) for subnet in subnets]) + except: + app.logger.debug(f'Unable to parse {subnets!r}, assuming {ip!r} is not in the set') + return False + # Application translation babel = flask_babel.Babel() @@ -521,6 +531,8 @@ def isBadOrPwned(form): return None def formatCSVField(field): + if not field.data: + return if isinstance(field.data,str): data = field.data.replace(" ","").split(",") else: diff --git a/docs/webadministration.rst b/docs/webadministration.rst index 8b712be8..ed75f095 100644 --- a/docs/webadministration.rst +++ b/docs/webadministration.rst @@ -178,8 +178,8 @@ After saving the application token it is not possible anymore to view the unique The comment field can be used to enter a description for the authentication token. For example the name of the application the application token is created for. -In the Authorized IP field a white listed IP address can be entered. When an IP address is entered, then the application token can only be used when the IP address of the client matches with this IP address. -When no IP address is entered, there is no restriction on IP address. It is not possible to enter multiple IP addresses. +In the Authorized IP field a comma separated list of white listed IP addresses or networks can be entered. When the field is set, the application token can only be used when the IP address of the client matches what is in the field. +When no IP address is entered, there is no restriction on IP address. Announcement diff --git a/towncrier/newsfragments/2852.misc b/towncrier/newsfragments/2852.misc new file mode 100644 index 00000000..454b1a3f --- /dev/null +++ b/towncrier/newsfragments/2852.misc @@ -0,0 +1 @@ +Allow a list of subnets rather than just ip addresses for tokens