diff --git a/uffd/__init__.py b/uffd/__init__.py index 521aba6287d26343744b0d4b0d8d7e97b2b3d470..54e0873b300d25a1986f4968004c4b6d09e2368d 100644 --- a/uffd/__init__.py +++ b/uffd/__init__.py @@ -39,10 +39,10 @@ def create_app(test_config=None): db.init_app(app) # pylint: disable=C0415 - from uffd import user, selfservice, role, session, csrf, ldap + from uffd import user, selfservice, role, mail, session, csrf, ldap # pylint: enable=C0415 - for i in user.bp + selfservice.bp + role.bp + session.bp + csrf.bp + ldap.bp: + for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + ldap.bp: app.register_blueprint(i) @app.route("/") diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg index d13385e43324a3260918b2ddb3ed8000dcaea7c3..1f93e78ea5c888fc1a8e97a7bc0477630185f9d4 100644 --- a/uffd/default_config.cfg +++ b/uffd/default_config.cfg @@ -1,5 +1,6 @@ LDAP_BASE_USER="ou=users,dc=example,dc=com" LDAP_BASE_GROUPS="ou=groups,dc=example,dc=com" +LDAP_BASE_MAIL="ou=postfix,dc=example,dc=com" LDAP_SERVICE_BIND_DN="" LDAP_SERVICE_BIND_PASSWORD="" LDAP_SERVICE_URL="ldapi:///" diff --git a/uffd/mail/__init__.py b/uffd/mail/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..671578662f91c82cb987ffe679c1f102dc493d1f --- /dev/null +++ b/uffd/mail/__init__.py @@ -0,0 +1,3 @@ +from .views import bp as bp_ui + +bp = [bp_ui] diff --git a/uffd/mail/models.py b/uffd/mail/models.py new file mode 100644 index 0000000000000000000000000000000000000000..1e404b1b79b505671cea8b8aaab5b1f252292db1 --- /dev/null +++ b/uffd/mail/models.py @@ -0,0 +1,73 @@ +import secrets + +from ldap3 import MODIFY_REPLACE, MODIFY_DELETE, MODIFY_ADD, HASHED_SALTED_SHA512 +from flask import current_app + +from uffd import ldap + +class Mail(): + def __init__(self, uid=None, destinations=[], receivers=[], dn=None): + self.uid = uid + self.receivers = receivers + self.destinations = destinations + self.dn = dn + + @classmethod + def from_ldap(cls, ldapobject): + return Mail( + uid=ldapobject['uid'].value, + receivers=ldap.get_ldap_array_attribute_safe(ldapobject, 'mailacceptinggeneralid'), + destinations=ldap.get_ldap_array_attribute_safe(ldapobject, 'maildrop'), + dn=ldapobject.entry_dn, + ) + + @classmethod + def from_ldap_dn(cls, dn): + conn = ldap.get_conn() + conn.search(dn, '(objectClass=postfixVirtual)') + if not len(conn.entries) == 1: + return None + return Mail.from_ldap(conn.entries[0]) + + def to_ldap(self, new=False): + conn = ldap.get_conn() + if new: + self.uid = ldap.get_next_uid() + attributes = { + 'uidNumber': self.uid, + 'gidNumber': current_app.config['LDAP_USER_GID'], + 'homeDirectory': '/home/'+self.loginname, + 'sn': ' ', + 'userPassword': hashed(HASHED_SALTED_SHA512, secrets.token_hex(128)), + # same as for update + 'givenName': self.displayname, + 'displayName': self.displayname, + 'cn': self.displayname, + 'mail': self.mail, + } + dn = ldap.loginname_to_dn(self.loginname) + result = conn.add(dn, current_app.config['LDAP_USER_OBJECTCLASSES'], attributes) + else: + attributes = { + 'givenName': [(MODIFY_REPLACE, [self.displayname])], + 'displayName': [(MODIFY_REPLACE, [self.displayname])], + 'cn': [(MODIFY_REPLACE, [self.displayname])], + 'mail': [(MODIFY_REPLACE, [self.mail])], + } + if self.newpassword: + attributes['userPassword'] = [(MODIFY_REPLACE, [hashed(HASHED_SALTED_SHA512, self.newpassword)])] + dn = ldap.uid_to_dn(self.uid) + result = conn.modify(dn, attributes) + self.dn = dn + + group_conn = ldap.get_conn() + for group in self.initial_groups_ldap: + if not group in self.groups_ldap: + group_conn.modify(group, {'uniqueMember': [(MODIFY_DELETE, [self.dn])]}) + for group in self.groups_ldap: + if not group in self.initial_groups_ldap: + group_conn.modify(group, {'uniqueMember': [(MODIFY_ADD, [self.dn])]}) + self.groups_changed = False + + return result + diff --git a/uffd/mail/templates/mail_list.html b/uffd/mail/templates/mail_list.html new file mode 100644 index 0000000000000000000000000000000000000000..32fa3b6448a407221ae4c9fa245a3ecf959e795c --- /dev/null +++ b/uffd/mail/templates/mail_list.html @@ -0,0 +1,56 @@ +{% extends 'base.html' %} + +{% block body %} +<div class="row"> + <div class="col"> + <table class="table table-striped table-sm"> + <thead> + <tr> + <th scope="col">name</th> + <th scope="col">receiving address</th> + <th scope="col">destinations</th> + <th scope="col"> + <p class="text-right"> + <a type="button" class="btn btn-primary" href="{{ url_for("mail.show") }}"> + <i class="fa fa-plus" aria-hidden="true"></i> New + </a> + </p> + </th> + </tr> + </thead> + <tbody> + {% for mail in mails|sort(attribute="uid") %} + <tr id="mail-{{ mail.uid }}"> + <th scope="row"> + <a href="{{ url_for("mail.show", uid=mail.uid) }}"> + {{ mail.uid }} + </a> + </th> + <td> + <ul> + {% for i in mail.receivers %} + <li>{{ i }}</li> + {% endfor %} + </ul> + </td> + <td> + <ul> + {% for i in mail.destinations %} + <li>{{ i }}</li> + {% endfor %} + </ul> + </td> + <td> + <p class="text-right"> + <a href="{{ url_for("mail.show", uid=mail.uid) }}" class="btn btn-primary"> + <i class="fa fa-edit" aria-hidden="true"></i> Edit + </a> + </p> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> +</dev> +{% endblock %} diff --git a/uffd/mail/views.py b/uffd/mail/views.py new file mode 100644 index 0000000000000000000000000000000000000000..460ca53bdd123f5fda0659e4a31f357e17aa035e --- /dev/null +++ b/uffd/mail/views.py @@ -0,0 +1,123 @@ +from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app + +from uffd.navbar import register_navbar +from uffd.csrf import csrf_protect +from uffd.ldap import get_conn, escape_filter_chars +from uffd.session import login_required, is_valid_session, get_current_user + +from uffd.mail.models import Mail + +bp = Blueprint("mail", __name__, template_folder='templates', url_prefix='/mail/') +@bp.before_request +@login_required() +def mail_acl(): #pylint: disable=inconsistent-return-statements + if not mail_acl_check(): + flash('Access denied') + return redirect(url_for('index')) + +def mail_acl_check(): + return is_valid_session() and get_current_user().is_in_group(current_app.config['ACL_ADMIN_GROUP']) + +@bp.route("/") +@register_navbar('Mail', icon='envelope', blueprint=bp, visible=mail_acl_check) +def index(): + conn = get_conn() + conn.search(current_app.config["LDAP_BASE_MAIL"], '(objectclass=postfixVirtual)') + mails = [] + for i in conn.entries: + mails.append(Mail.from_ldap(i)) + print(mails) + return render_template('mail_list.html', mails=mails) + +@bp.route("/<uid>") +@bp.route("/new") +def show(uid=None): + return None + if not uid: + user = User() + ldif = '<none yet>' + else: + conn = get_conn() + conn.search(current_app.config["LDAP_BASE_USER"], '(&(objectclass=person)(uidNumber={}))'.format((escape_filter_chars(uid)))) + assert len(conn.entries) == 1 + user = User.from_ldap(conn.entries[0]) + ldif = conn.entries[0].entry_to_ldif() + return render_template('user.html', user=user, user_ldif=ldif, roles=Role.query.all()) + +@bp.route("/<uid>/update", methods=['POST']) +@bp.route("/new", methods=['POST']) +@csrf_protect(blueprint=bp) +def update(uid=False): + return None + conn = get_conn() + is_newuser = bool(not uid) + if is_newuser: + user = User() + if not user.set_loginname(request.form['loginname']): + flash('Login name does not meet requirements') + return redirect(url_for('user.show')) + else: + conn.search(current_app.config["LDAP_BASE_USER"], '(&(objectclass=person)(uidNumber={}))'.format((escape_filter_chars(uid)))) + assert len(conn.entries) == 1 + user = User.from_ldap(conn.entries[0]) + if not user.set_mail(request.form['mail']): + flash('Mail is invalide.') + return redirect(url_for('user.show', uid=uid)) + new_displayname = request.form['displayname'] if request.form['displayname'] else request.form['loginname'] + if not user.set_displayname(new_displayname): + flash('Display name does not meet requirements') + return redirect(url_for('user.show', uid=uid)) + new_password = request.form.get('password') + if new_password and not is_newuser: + user.set_password(new_password) + + session = db.session + roles = Role.query.all() + for role in roles: + role_member_dns = role.member_dns() + if request.values.get('role-{}'.format(role.id), False) or role.name in current_app.config["ROLES_BASEROLES"]: + if user.dn in role_member_dns: + continue + role.add_member(user) + elif user.dn in role_member_dns: + role.del_member(user) + usergroups = set() + for role in Role.get_for_user(user).all(): + usergroups.update(role.group_dns()) + user.replace_group_dns(usergroups) + + if user.to_ldap(new=is_newuser): + if is_newuser: + send_passwordreset(user.loginname) + flash('User created. We sent the user a password reset link by mail') + session.commit() + else: + flash('User updated') + session.commit() + else: + flash('Error updating user: {}'.format(conn.result['message'])) + session.rollback() + return redirect(url_for('user.show', uid=user.uid)) + +@bp.route("/<uid>/del") +@csrf_protect(blueprint=bp) +def delete(uid): + return None + conn = get_conn() + conn.search(current_app.config["LDAP_BASE_USER"], '(&(objectclass=person)(uidNumber={}))'.format((escape_filter_chars(uid)))) + assert len(conn.entries) == 1 + user = User.from_ldap(conn.entries[0]) + + session = db.session + roles = Role.query.all() + for role in roles: + if user.dn in role.member_dns(): + role.del_member(user) + + if conn.delete(conn.entries[0].entry_dn): + flash('Deleted user') + session.commit() + else: + flash('Could not delete user: {}'.format(conn.result['message'])) + session.rollback() + return redirect(url_for('user.index'))