Merge pull request 'papertrail, admin mifarehashid, protected groups' (#7) from papertrail into master
Reviewed-on: #7
This commit is contained in:
commit
e84974ac74
8 changed files with 271 additions and 17 deletions
27
README.md
27
README.md
|
@ -1,6 +1,23 @@
|
|||
# ldap-web aka profile.hackerspace.pl
|
||||
# LDAPWeb aka profile.hackerspace.pl
|
||||
|
||||
## quick start locally
|
||||
## Configuration
|
||||
|
||||
No configuration is required to see and change your own profile and password.
|
||||
|
||||
To test admin pages, notifications, and avatars, you need to configure these environment variables:
|
||||
|
||||
- `LDAPWEB_ADMIN_DN` - LDAP user DN, e.g. `uid=radex,ou=people,dc=hackerspace,dc=pl`
|
||||
- `LDAPWEB_ADMIN_PASSWORD` - password to the above LDAP user
|
||||
- `LDAPWEB_SMTP_USER` - username with a `@hackerspace.pl` mailbox, e.g. `radex`
|
||||
- `LDAPWEB_SMTP_PASSWORD` - password to the above mailbox
|
||||
- `LDAPWEB_ADMIN_GROUPS` - comma-separated LDAP groups allowed to see admin pages (note that every user has a group with the same name), e.g. `radex,zarzad`
|
||||
- `LDAPWEB_PAPERTRAIL_RECIPIENTS` - comma-separated email addresses that will get email alerts for changes done via admin UI
|
||||
|
||||
See `webapp/config.py` for more details.
|
||||
|
||||
(Note that you don't need to be an LDAP admin to be able to hack on this - you just won't see all attributes, and won't be able to change other people's profiles)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```
|
||||
poetry install
|
||||
|
@ -11,7 +28,7 @@ Open app at localhost:5001
|
|||
|
||||
To test changing password locally, you must add HACKERSPACE.PL realm to your /etc/krb5.conf. See krb5.conf for an example
|
||||
|
||||
## quick start (dockerized)
|
||||
## Quick Start (Dockerized)
|
||||
|
||||
```
|
||||
docker build -t ldapweb .
|
||||
|
@ -20,6 +37,6 @@ docker run -p 8000:8000 ldapweb
|
|||
|
||||
Open app at localhost:8000
|
||||
|
||||
## deployment
|
||||
## Deployment
|
||||
|
||||
Look for `ldapweb.libsonnet` in hscloud repo
|
||||
Build Docker imagine manually and bump https://code.hackerspace.pl/hswaw/hscloud/src/branch/master/hswaw/ldapweb/prod.jsonnet
|
||||
|
|
102
webapp/admin.py
102
webapp/admin.py
|
@ -2,9 +2,11 @@ import functools
|
|||
import ldap
|
||||
import re
|
||||
import flask
|
||||
import flask_wtf
|
||||
import wtforms
|
||||
|
||||
import webapp
|
||||
from webapp import app, context, config, ldaputils
|
||||
from webapp import app, context, config, ldaputils, email
|
||||
|
||||
bp = flask.Blueprint('admin', __name__)
|
||||
|
||||
|
@ -92,6 +94,9 @@ def _get_groups_of(conn, uid):
|
|||
|
||||
return groups
|
||||
|
||||
def _is_user_protected(conn, uid, groups):
|
||||
return any(group in config.ldap_protected_groups for group in groups)
|
||||
|
||||
@bp.route('/admin/users/<uid>')
|
||||
@admin_required
|
||||
def admin_user_view(uid):
|
||||
|
@ -100,8 +105,97 @@ def admin_user_view(uid):
|
|||
|
||||
profile = _get_profile(conn, uid)
|
||||
groups = _get_groups_of(conn, uid)
|
||||
is_protected = _is_user_protected(conn, uid, groups)
|
||||
|
||||
return flask.render_template('admin/user.html', uid=uid, profile=_format_profile(profile), groups=groups)
|
||||
return flask.render_template('admin/user.html', uid=uid, profile=_format_profile(profile), groups=groups, is_protected=is_protected)
|
||||
|
||||
# TODO: Deduplicate this modification logic with webapp/vcard.py
|
||||
|
||||
class AddMifareIDHash(flask_wtf.FlaskForm):
|
||||
value = wtforms.fields.StringField(label=config.readable_names.get('mifareidhash'))
|
||||
|
||||
class DelForm(flask_wtf.FlaskForm):
|
||||
pass
|
||||
|
||||
@bp.route('/admin/users/<uid>/add_mifareidhash')
|
||||
@admin_required
|
||||
def admin_user_view_add_mifareidhash(uid):
|
||||
form = AddMifareIDHash()
|
||||
return flask.render_template('admin/ops/add_mifareidhash.html', uid=uid, form=form)
|
||||
|
||||
@bp.route('/admin/users/<uid>/del_mifareidhash')
|
||||
@admin_required
|
||||
def admin_user_view_del_mifareidhash(uid):
|
||||
form = DelForm()
|
||||
value = flask.request.args.get('value')
|
||||
return flask.render_template('admin/ops/del_mifareidhash.html', uid=uid, form=form, value=value)
|
||||
|
||||
def _modify_mifareidhash(uid, form, modify_func):
|
||||
ldaputils.validate_name(uid)
|
||||
conn = context.get_connection()
|
||||
|
||||
groups = _get_groups_of(conn, uid)
|
||||
is_protected = _is_user_protected(conn, uid, groups)
|
||||
|
||||
redirect_url = flask.url_for('admin.admin_user_view', uid=uid)
|
||||
if is_protected:
|
||||
flask.flash('Cannot modify protected user', 'danger')
|
||||
return flask.redirect(redirect_url)
|
||||
|
||||
try:
|
||||
if form.validate_on_submit():
|
||||
dn = ldaputils.user_dn(uid)
|
||||
modify_func(conn, dn)
|
||||
|
||||
context.refresh_profile(dn)
|
||||
flask.flash('Added mifareidhash', category='info')
|
||||
return flask.redirect(redirect_url)
|
||||
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
flask.flash("Error in the {} field - {}".format(
|
||||
getattr(form, field).label.text,
|
||||
error
|
||||
), 'danger')
|
||||
|
||||
return flask.redirect(redirect_url)
|
||||
except ldap.LDAPError as e:
|
||||
print('LDAP error:', e)
|
||||
flask.flash(f'Could not modify profile due to LDAP error: {e}', 'danger')
|
||||
return flask.redirect(redirect_url)
|
||||
|
||||
|
||||
@bp.route('/admin/users/<uid>/add_mifareidhash', methods=['POST'])
|
||||
@admin_required
|
||||
def admin_user_add_mifareidhash(uid):
|
||||
form = AddMifareIDHash()
|
||||
def modify_func(conn, dn):
|
||||
new_value = form.value.data
|
||||
|
||||
email.send_papertrail(
|
||||
f'Adding mifareIDHash for user {uid}',
|
||||
f'New mifareIDHash: {new_value}'
|
||||
)
|
||||
|
||||
conn.modify_s(dn, [(ldap.MOD_ADD, 'mifareidhash', new_value.encode('utf-8'))])
|
||||
|
||||
return _modify_mifareidhash(uid, form, modify_func)
|
||||
|
||||
@bp.route('/admin/users/<uid>/del_mifareidhash', methods=['POST'])
|
||||
@admin_required
|
||||
def admin_user_del_mifareidhash(uid):
|
||||
form = DelForm()
|
||||
def modify_func(conn, dn):
|
||||
old_value = flask.request.args.get('value')
|
||||
|
||||
email.send_papertrail(
|
||||
f'Deleting mifareIDHash for user {uid}',
|
||||
f'Deleted mifareIDHash: {old_value}'
|
||||
)
|
||||
|
||||
conn.modify_s(dn, [(ldap.MOD_DELETE, 'mifareidhash', old_value.encode('utf-8'))])
|
||||
|
||||
return _modify_mifareidhash(uid, form, modify_func)
|
||||
|
||||
@bp.route('/admin/groups/')
|
||||
@admin_required
|
||||
|
@ -137,4 +231,6 @@ def admin_group_view(name):
|
|||
group_attrs = _get_group(conn, name)
|
||||
members = _get_user_list(conn, f'memberOf={ldaputils.group_dn(name)}')
|
||||
|
||||
return flask.render_template('admin/group.html', name=name, attributes=_format_profile(group_attrs), members=members)
|
||||
is_protected = name in config.ldap_protected_groups
|
||||
|
||||
return flask.render_template('admin/group.html', name=name, attributes=_format_profile(group_attrs), members=members, is_protected=is_protected)
|
||||
|
|
|
@ -7,28 +7,41 @@ hackerspace_name = 'Warsaw Hackerspace'
|
|||
secret_key = secrets.token_hex(32)
|
||||
|
||||
# Kerberos configuration
|
||||
|
||||
kadmin_principal_map = "{}@HACKERSPACE.PL"
|
||||
|
||||
# LDAP configuration
|
||||
|
||||
ldap_url = 'ldap://ldap.hackerspace.pl'
|
||||
ldap_base = 'dc=hackerspace,dc=pl'
|
||||
ldap_people = 'ou=people,dc=hackerspace,dc=pl'
|
||||
ldap_user_dn_format = 'uid={},ou=people,dc=hackerspace,dc=pl'
|
||||
ldap_group_dn_format = 'cn={},ou=group,dc=hackerspace,dc=pl'
|
||||
|
||||
# user groups allowed to see /admin
|
||||
# LDAP user groups allowed to see /admin
|
||||
ldap_admin_groups = os.getenv('LDAPWEB_ADMIN_GROUPS', 'ldap-admin,staff,zarzad').split(',')
|
||||
|
||||
# user groups indicating that a user is active
|
||||
# LDAP user groups indicating that a user is active
|
||||
ldap_active_groups = os.getenv('LDAPWEB_ACTIVE_GROUPS', 'fatty,starving,potato').split(',')
|
||||
|
||||
# service user with admin privileges (for admin listings, creating new users)
|
||||
# LDAP service user with admin privileges (for admin listings, creating new users)
|
||||
ldap_admin_dn = os.getenv('LDAPWEB_ADMIN_DN', 'cn=ldapweb,ou=services,dc=hackerspace,dc=pl')
|
||||
ldap_admin_password = os.getenv('LDAPWEB_ADMIN_PASSWORD', 'unused')
|
||||
|
||||
# avatar server
|
||||
# Protected LDAP user groups
|
||||
# These groups (and their members) cannot be modified by admin UI
|
||||
ldap_protected_groups = (
|
||||
'staff,zarzad,ldap-admin'.split(',') +
|
||||
os.getenv('LDAPWEB_PROTECTED_GROUPS', '').split(',')
|
||||
)
|
||||
|
||||
# Email notification (paper trail) configuration
|
||||
smtp_server = 'mail.hackerspace.pl'
|
||||
smtp_format = '{}@hackerspace.pl'
|
||||
smtp_user = os.getenv('LDAPWEB_SMTP_USER', 'ldapweb')
|
||||
smtp_password = os.getenv('LDAPWEB_SMTP_PASSWORD', 'unused')
|
||||
|
||||
papertrail_recipients = os.getenv('LDAPWEB_PAPERTRAIL_RECIPIENTS', 'zarzad@hackerspace.pl')
|
||||
|
||||
# Avatar server
|
||||
avatar_cache_timeout = int(os.getenv('LDAPWEB_AVATAR_CACHE_TIMEOUT', '1800'))
|
||||
|
||||
# LDAP attribute configuration
|
||||
|
|
50
webapp/email.py
Normal file
50
webapp/email.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
import flask
|
||||
import datetime
|
||||
|
||||
from webapp import config, context
|
||||
|
||||
cached_connection = None
|
||||
|
||||
def test_connection_open(conn):
|
||||
try:
|
||||
status = conn.noop()[0]
|
||||
except:
|
||||
status = -1
|
||||
return True if status == 250 else False
|
||||
|
||||
def create_connection():
|
||||
conn = smtplib.SMTP_SSL(config.smtp_server)
|
||||
conn.login(config.smtp_user, config.smtp_password)
|
||||
return conn
|
||||
|
||||
def get_connection():
|
||||
global cached_connection
|
||||
if test_connection_open(cached_connection):
|
||||
return cached_connection
|
||||
|
||||
cached_connection = create_connection()
|
||||
return cached_connection
|
||||
|
||||
def send_email(conn, subject, body, recipient_emails):
|
||||
msg = EmailMessage()
|
||||
msg.set_content(body)
|
||||
msg['Subject'] = subject
|
||||
|
||||
sender_email = config.smtp_format.format(config.smtp_user)
|
||||
msg['From'] = f'LDAPWeb <{sender_email}>'
|
||||
msg['To'] = recipient_emails
|
||||
|
||||
conn.send_message(msg)
|
||||
|
||||
def send_papertrail(title, description):
|
||||
username = flask.session.get('username')
|
||||
current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
subject = f'[LDAPWeb] {title}'
|
||||
body = f"Changed by {username} at {current_time}:\n\n{description or title}"
|
||||
recipients = config.papertrail_recipients
|
||||
|
||||
conn = get_connection()
|
||||
send_email(conn, subject, body, recipients)
|
|
@ -13,7 +13,12 @@
|
|||
{% endfor %}
|
||||
</p>
|
||||
|
||||
<p>Full LDAP record:</p>
|
||||
<p>
|
||||
{% if is_protected %}
|
||||
<span class="label label-danger" title="You can’t modify this group because it is protected">Protected group</span>
|
||||
{% endif %}
|
||||
Full LDAP record:
|
||||
</p>
|
||||
|
||||
<table class="table profile-table">
|
||||
<tr>
|
||||
|
|
22
webapp/templates/admin/ops/add_mifareidhash.html
Normal file
22
webapp/templates/admin/ops/add_mifareidhash.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
<div class="modal fade" tabindex="-1" role="dialog">
|
||||
<form action="/admin/users/{{ uid }}/add_mifareidhash" method="POST" class="form-signin">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title">Adding {{ form.value.label.text }}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ form.csrf_token }}
|
||||
{{ form.value(class_="form-control", placeholder=form.value.label.text) }}
|
||||
{{ form.old_value }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
20
webapp/templates/admin/ops/del_mifareidhash.html
Normal file
20
webapp/templates/admin/ops/del_mifareidhash.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
<div class="modal fade" tabindex="-1" role="dialog">
|
||||
<form action="/admin/users/{{ uid }}/del_mifareidhash?value={{ value | urlencode }}" method="POST" class="form-signin">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title">Deleting mifareIDHash</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
You are about to delete mifareIDHash: {{ value }}.
|
||||
{{ form.csrf_token }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
|
@ -6,8 +6,10 @@
|
|||
|
||||
<div style="margin-bottom: 10px">
|
||||
<a class="btn btn-default" href="/admin/users" role="button">Back</a>
|
||||
|
||||
<a class="btn btn-default" href="https://kasownik.hackerspace.pl/admin/member/{{ uid }}" role="button" target="_blank">View user in Kasownik</a>
|
||||
{% if not is_protected %}
|
||||
<a class="btn btn-default modalLink" href="/admin/users/{{ uid }}/add_mifareidhash" role="button">Add mifareIDHash</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p>
|
||||
|
@ -17,19 +19,30 @@
|
|||
{% endfor %}
|
||||
</p>
|
||||
|
||||
<p>Full LDAP record:</p>
|
||||
<p>
|
||||
{% if is_protected %}
|
||||
<span class="label label-danger" title="You can’t modify this user because it belongs to a protected group">Protected user</span>
|
||||
{% endif %}
|
||||
Full LDAP record:
|
||||
</p>
|
||||
|
||||
<table class="table profile-table">
|
||||
<tr>
|
||||
<th scope="col">Attribute</th>
|
||||
<th scope="col">Attribute</th>
|
||||
<th scope="col" class="profile-table-value">Value</th>
|
||||
<th scope="col" class="profile-table-options">Options</th>
|
||||
</tr>
|
||||
{% for attr, attr_readable, value in profile %}
|
||||
<tr>
|
||||
<td>{{ attr }}</td>
|
||||
<td>{{ attr_readable if attr_readable else '' }}</td>
|
||||
<td class="profile-table-value">{{ '(...omitted...)' if attr == 'jpegPhoto' else value }}</td>
|
||||
<td class="profile-table-options">
|
||||
{% if not is_protected and attr == 'mifareIDHash' %}
|
||||
<a class="modalLink" href="/admin/users/{{ uid }}/del_mifareidhash?value={{ value | urlencode }}"><span class="glyphicon glyphicon-minus-sign" aria-hidden="true"></span> Remove</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
@ -47,6 +60,10 @@
|
|||
.profile-table th.profile-table-value {
|
||||
width: max-content;
|
||||
}
|
||||
.profile-table td.profile-table-options,
|
||||
.profile-table th.profile-table-options {
|
||||
width: 100px;
|
||||
}
|
||||
.profile-avatar-lmao {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
|
@ -61,3 +78,17 @@
|
|||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$(".modalLink").click(function(e) {
|
||||
e.preventDefault();
|
||||
var url = $(this).attr('href');
|
||||
$.get(url, function(data) {
|
||||
$(data).modal('show');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
Loading…
Reference in a new issue