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 %}
+
+
+
{% 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 %}
+
+
+
+ {% trans %}Actions{% endtrans %} |
+ {% trans %}Comment{% endtrans %} |
+ {% trans %}Authorized IP{% endtrans %} |
+ {% trans %}Created{% endtrans %} |
+
+ {% for token in user.tokens %}
+
+
+
+ |
+ {{ token.comment }} |
+ {{ token.ip or "any" }} |
+ {{ token.created_at }} |
+
+ {% endfor %}
+
+
+{% 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')