diff --git a/admin/mailu/internal/nginx.py b/admin/mailu/internal/nginx.py index 4c5cc334..dc834cb9 100644 --- a/admin/mailu/internal/nginx.py +++ b/admin/mailu/internal/nginx.py @@ -35,7 +35,16 @@ def handle_authentication(headers): server, port = get_server(headers["Auth-Protocol"], True) user_email = urllib.parse.unquote(headers["Auth-User"]) password = urllib.parse.unquote(headers["Auth-Pass"]) + ip = urllib.parse.unquote(headers["Client-Ip"]) user = models.User.query.get(user_email) + for token in user.tokens: + if (token.check_password(password) and + (not token.ip or token.ip == ip)): + return { + "Auth-Status": "OK", + "Auth-Server": server, + "Auth-Port": port + } if user and user.check_password(password): return { "Auth-Status": "OK", diff --git a/admin/mailu/models.py b/admin/mailu/models.py index a0cda366..e42978cd 100644 --- a/admin/mailu/models.py +++ b/admin/mailu/models.py @@ -1,7 +1,7 @@ from mailu import app, db, dkim, login_manager from sqlalchemy.ext import declarative -from passlib import context +from passlib import context, hash from datetime import datetime import re @@ -249,6 +249,29 @@ class Alias(Base, Email): destination = db.Column(CommaSeparatedList, nullable=False, default=[]) +class Token(Base): + """ A token is an application password for a given user. + """ + __tablename__ = "token" + + id = db.Column(db.Integer(), primary_key=True) + user_email = db.Column(db.String(255), db.ForeignKey(User.email), + nullable=False) + 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)) + + def check_password(self, password): + return hash.sha256_crypt.verify(password, self.password) + + def set_password(self, password): + self.password = hash.sha256_crypt.using(rounds=1000).hash(password) + + def __str__(self): + return self.comment + + class Fetch(Base): """ A fetched account is a repote POP/IMAP account fetched into a local account. diff --git a/admin/mailu/ui/forms.py b/admin/mailu/ui/forms.py index b00f4dac..fa2ec1dc 100644 --- a/admin/mailu/ui/forms.py +++ b/admin/mailu/ui/forms.py @@ -104,6 +104,18 @@ class UserReplyForm(flask_wtf.FlaskForm): submit = fields.SubmitField(_('Update')) +class TokenForm(flask_wtf.FlaskForm): + displayed_password = fields.StringField( + _('Your token (write it down, as it will never be displayed again)') + ) + raw_password = fields.HiddenField([validators.DataRequired()]) + comment = fields.StringField(_('Comment')) + ip = fields.StringField( + _('Authorized IP'), [validators.Optional(), validators.IPAddress()] + ) + submit = fields.SubmitField(_('Create')) + + class AliasForm(flask_wtf.FlaskForm): localpart = fields.StringField(_('Alias'), [validators.DataRequired()]) wildcard = fields.BooleanField( diff --git a/admin/mailu/ui/templates/sidebar.html b/admin/mailu/ui/templates/sidebar.html index 5ef9733c..f9b94016 100644 --- a/admin/mailu/ui/templates/sidebar.html +++ b/admin/mailu/ui/templates/sidebar.html @@ -31,7 +31,12 @@ {% trans %}Fetched accounts{% endtrans %} - +
  • + + {% trans %}Authentication tokens{% endtrans %} + +
  • +
  • {% trans %}Administration{% endtrans %}
  • {% if current_user.global_admin %}
  • diff --git a/admin/mailu/ui/templates/token/create.html b/admin/mailu/ui/templates/token/create.html new file mode 100644 index 00000000..a64e662c --- /dev/null +++ b/admin/mailu/ui/templates/token/create.html @@ -0,0 +1,9 @@ +{% extends "form.html" %} + +{% block title %} +{% trans %}Create an authentication token{% endtrans %} +{% endblock %} + +{% block subtitle %} +{{ user }} +{% endblock %} diff --git a/admin/mailu/ui/templates/token/list.html b/admin/mailu/ui/templates/token/list.html new file mode 100644 index 00000000..b3996da8 --- /dev/null +++ b/admin/mailu/ui/templates/token/list.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %} +{% trans %}Authentication tokens{% endtrans %} +{% endblock %} + +{% block subtitle %} +{{ user }} +{% endblock %} + +{% block main_action %} +{% trans %}New token{% endtrans %} +{% endblock %} + +{% block box %} + + + + + + + + + {% for token in user.tokens %} + + + + + + + {% endfor %} + +
    {% trans %}Actions{% endtrans %}{% trans %}Comment{% endtrans %}{% trans %}Authorized IP{% endtrans %}{% trans %}Created{% endtrans %}
    + + {{ token.comment }}{{ token.ip or "any" }}{{ token.created_at }}
    +{% endblock %} diff --git a/admin/mailu/ui/views/__init__.py b/admin/mailu/ui/views/__init__.py index e4f5d7d2..e7ae8b35 100644 --- a/admin/mailu/ui/views/__init__.py +++ b/admin/mailu/ui/views/__init__.py @@ -1 +1,4 @@ -__all__ = ['admins', 'aliases', 'alternatives', 'base', 'domains', 'fetches', 'managers', 'users', 'relays'] +__all__ = [ + 'admins', 'aliases', 'alternatives', 'base', 'domains', 'fetches', + 'managers', 'users', 'relays', 'tokens' +] diff --git a/admin/mailu/ui/views/tokens.py b/admin/mailu/ui/views/tokens.py new file mode 100644 index 00000000..4b9881af --- /dev/null +++ b/admin/mailu/ui/views/tokens.py @@ -0,0 +1,53 @@ +from mailu import db, models +from mailu.ui import ui, forms, access + +from passlib import pwd + +import flask +import flask_login +import wtforms_components + + +@ui.route('/token/list', methods=['GET', 'POST'], defaults={'user_email': None}) +@ui.route('/token/list/', methods=['GET']) +@access.owner(models.User, 'user_email') +def token_list(user_email): + user_email = user_email or flask_login.current_user.email + user = models.User.query.get(user_email) or flask.abort(404) + return flask.render_template('token/list.html', user=user) + + +@ui.route('/token/create', methods=['GET', 'POST'], defaults={'user_email': None}) +@ui.route('/token/create/', methods=['GET', 'POST']) +@access.owner(models.User, 'user_email') +def token_create(user_email): + user_email = user_email or flask_login.current_user.email + user = models.User.query.get(user_email) or flask.abort(404) + form = forms.TokenForm() + wtforms_components.read_only(form.displayed_password) + if not form.raw_password.data: + form.raw_password.data = pwd.genword(entropy=128, charset="hex") + form.displayed_password.data = form.raw_password.data + if form.validate_on_submit(): + token = models.Token(user=user) + token.set_password(form.raw_password.data) + form.populate_obj(token) + db.session.add(token) + db.session.commit() + flask.flash('Authentication token created') + return flask.redirect( + flask.url_for('.token_list', user_email=user.email)) + return flask.render_template('token/create.html', form=form) + + +@ui.route('/token/delete/', methods=['GET', 'POST']) +@access.confirmation_required("delete an authentication token") +@access.owner(models.Token, 'token_id') +def token_delete(token_id): + token = models.Token.query.get(token_id) or flask.abort(404) + user = token.user + db.session.delete(token) + db.session.commit() + flask.flash('Authentication token deleted') + return flask.redirect( + flask.url_for('.token_list', user_email=user.email)) diff --git a/admin/migrations/versions/9400a032eb1a_.py b/admin/migrations/versions/9400a032eb1a_.py new file mode 100644 index 00000000..f629a7eb --- /dev/null +++ b/admin/migrations/versions/9400a032eb1a_.py @@ -0,0 +1,32 @@ +""" Add authentication tokens + +Revision ID: 9400a032eb1a +Revises: 9c28df23f77e +Create Date: 2017-10-29 14:31:58.032989 + +""" + +# revision identifiers, used by Alembic. +revision = '9400a032eb1a' +down_revision = '9c28df23f77e' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_table('token', + sa.Column('created_at', sa.Date(), nullable=False), + sa.Column('updated_at', sa.Date(), nullable=True), + sa.Column('comment', sa.String(length=255), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_email', sa.String(length=255), nullable=False), + sa.Column('password', sa.String(length=255), nullable=False), + sa.Column('ip', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['user_email'], ['user.email'], ), + sa.PrimaryKeyConstraint('id') + ) + + +def downgrade(): + op.drop_table('token')