1
0
mirror of https://github.com/Mailu/Mailu.git synced 2025-08-10 22:31:47 +02:00

Allow multiple IP addresses/networks to be set for tokens

This commit is contained in:
Florent Daigniere
2023-06-08 13:26:41 +02:00
parent 9299b68c62
commit 29cd857c5f
8 changed files with 40 additions and 10 deletions

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@
</td>
<td>{{ token.id }}</td>
<td>{{ token.comment }}</td>
<td>{{ token.ip or "any" }}</td>
<td>{{ token.ip | join(', ') or "any" }}</td>
<td data-sort="{{ token.created_at or '0000-00-00' }}">{{ token.created_at | format_date }}</td>
<td data-sort="{{ token.updated_at or '0000-00-00' }}">{{ token.updated_at | format_date }}</td>
</tr>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
Allow a list of subnets rather than just ip addresses for tokens