1
0
mirror of https://github.com/Mailu/Mailu.git synced 2025-08-10 22:31:47 +02:00
2866: Improve tokens (add ipranges) r=nextgens a=nextgens

## What type of PR?

enhancement

## What does this PR do?

Allow multiple IP addresses/networks to be set for tokens.

### Related issue(s)


## Prerequisites
Before we can consider review and merge, please make sure the following list is done and checked.
If an entry in not applicable, you can check it or remove it from the list.

- [ ] In case of feature or enhancement: documentation updated accordingly
- [x] Unless it's docs or a minor change: add [changelog](https://mailu.io/master/contributors/workflow.html#changelog) entry file.


Co-authored-by: Florent Daigniere <nextgens@freenetproject.org>
This commit is contained in:
bors[bot]
2023-06-26 10:02:58 +00:00
committed by GitHub
9 changed files with 64 additions and 10 deletions

View File

@@ -40,10 +40,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

@@ -0,0 +1,24 @@
"""Enlarge token.ip
Revision ID: 6b8f5e8caaa9
Revises: 7ac252f2bbbf
Create Date: 2023-06-08 11:35:43.477708
"""
# revision identifiers, used by Alembic.
revision = '6b8f5e8caaa9'
down_revision = '7ac252f2bbbf'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.alter_column('token', 'ip',
existing_type=sa.String(),
type=sa.VARCHAR(length=4096),
existing_nullable=True)
def downgrade():
pass