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