config and ldap handling refactor
parent
64d62692d4
commit
dc2bb81f61
|
@ -51,7 +51,7 @@ def start():
|
||||||
|
|
||||||
app.connections = pools.LDAPConnectionPool(config.ldap_url, timeout=300.0)
|
app.connections = pools.LDAPConnectionPool(config.ldap_url, timeout=300.0)
|
||||||
def drop_profile(dn):
|
def drop_profile(dn):
|
||||||
if dn != config.admin_dn:
|
if dn != config.ldap_admin_dn:
|
||||||
del app.profiles[dn]
|
del app.profiles[dn]
|
||||||
app.connections.register_callback('drop', drop_profile)
|
app.connections.register_callback('drop', drop_profile)
|
||||||
app.connections.start()
|
app.connections.start()
|
||||||
|
|
117
webapp/admin.py
117
webapp/admin.py
|
@ -1,20 +1,31 @@
|
||||||
|
import functools
|
||||||
import ldap
|
import ldap
|
||||||
import re
|
import re
|
||||||
import flask
|
import flask
|
||||||
|
|
||||||
from webapp import app, context, config
|
import webapp
|
||||||
from webapp.auth import login_required
|
from webapp import app, context, config, ldaputils
|
||||||
|
|
||||||
bp = flask.Blueprint('admin', __name__)
|
bp = flask.Blueprint('admin', __name__)
|
||||||
|
|
||||||
def _ldap_not_in(patterns):
|
def admin_required_impl(f):
|
||||||
joined_patterns = ''.join(f'({p})' for p in patterns)
|
@functools.wraps(f)
|
||||||
one_of_pattern = f'(|{joined_patterns})'
|
def func(*a, **kw):
|
||||||
return f'!{one_of_pattern}'
|
# TODO: Actually check for admin perms
|
||||||
|
if not flask.session['is_admin']:
|
||||||
|
flask.abort(403)
|
||||||
|
|
||||||
def _ldap_get_users_list(conn, query='&'):
|
return f(*a, **kw)
|
||||||
|
return func
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
return webapp.auth.login_required(admin_required_impl(f))
|
||||||
|
|
||||||
|
def _get_user_list(conn, query='&'):
|
||||||
|
"""Returns List[Tuple[username, full name]] for query"""
|
||||||
all_users = []
|
all_users = []
|
||||||
results = conn.search_s(config.ldap_people, ldap.SCOPE_SUBTREE, f'(&(uid=*)(cn=*)({query}))', attrlist=['uid', 'cn'])
|
|
||||||
|
results = conn.search_s(config.ldap_people, ldap.SCOPE_SUBTREE, f'(&(uid=*)(cn=*){ldaputils.wrap(query)})', attrlist=['uid', 'cn'])
|
||||||
for user, attrs in results:
|
for user, attrs in results:
|
||||||
user_uid = attrs['uid'][0].decode()
|
user_uid = attrs['uid'][0].decode()
|
||||||
user_cn = attrs['cn'][0].decode()
|
user_cn = attrs['cn'][0].decode()
|
||||||
|
@ -23,81 +34,71 @@ def _ldap_get_users_list(conn, query='&'):
|
||||||
all_users.sort(key=lambda user: user[0].lower())
|
all_users.sort(key=lambda user: user[0].lower())
|
||||||
return all_users
|
return all_users
|
||||||
|
|
||||||
def _ldap_get_all_users_groupped(conn):
|
def _get_groupped_user_list(conn):
|
||||||
group_queries = [
|
"""Returns all users (uid, full name), groupped by active groups"""
|
||||||
(group_name, f'memberOf={pattern}')
|
|
||||||
for group_name, pattern in config.admin_groups.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
groupped_users = [
|
groupped_users = [
|
||||||
(group_name, _ldap_get_users_list(conn, query))
|
(group.capitalize(), _get_user_list(conn, f'memberOf={ldaputils.group_dn(group)}'))
|
||||||
for group_name, query in group_queries
|
for group in config.ldap_active_groups
|
||||||
]
|
]
|
||||||
|
|
||||||
other_users_query = _ldap_not_in(query for _, query in group_queries)
|
inactive_filter = ldaputils._not(
|
||||||
|
ldaputils.member_of_any(config.ldap_active_groups)
|
||||||
|
)
|
||||||
|
|
||||||
groupped_users.append(
|
groupped_users.append(
|
||||||
('Other', _ldap_get_users_list(conn, other_users_query))
|
('Inactive users', _get_user_list(conn, inactive_filter))
|
||||||
)
|
)
|
||||||
|
|
||||||
return groupped_users
|
return groupped_users
|
||||||
|
|
||||||
@bp.route('/admin/')
|
@bp.route('/admin/')
|
||||||
@login_required
|
@admin_required
|
||||||
def admin_list():
|
def admin_view():
|
||||||
if not flask.session['is_admin']:
|
return flask.redirect('/admin/users/')
|
||||||
flask.abort(403)
|
|
||||||
|
|
||||||
|
@bp.route('/admin/users/')
|
||||||
|
@admin_required
|
||||||
|
def admin_users_view():
|
||||||
conn = context.get_connection()
|
conn = context.get_connection()
|
||||||
user_groups = _ldap_get_all_users_groupped(conn)
|
groups = _get_groupped_user_list(conn)
|
||||||
|
|
||||||
return flask.render_template('admin/list.html', user_groups=user_groups)
|
return flask.render_template('admin/list.html', groups=groups)
|
||||||
|
|
||||||
def _ldap_get_user(conn, uid):
|
def _get_profile(conn, uid):
|
||||||
profile = []
|
results = conn.search_s(ldaputils.user_dn(uid), ldap.SCOPE_SUBTREE)
|
||||||
|
return ldaputils.normalized_entries(results)[0]
|
||||||
|
|
||||||
for user, attrs in conn.search_s(config.dn_format % uid, ldap.SCOPE_SUBTREE):
|
def _format_profile(profile):
|
||||||
for attr, values in attrs.items():
|
rendered = []
|
||||||
for value in values:
|
dn, attrs = profile
|
||||||
profile.append((attr, value.decode()))
|
for attr, value in attrs:
|
||||||
|
|
||||||
return profile
|
|
||||||
|
|
||||||
def _rendered_ldap_profile(profile):
|
|
||||||
rendered_profile = []
|
|
||||||
for attr, value in profile:
|
|
||||||
attr_sanitized = attr.lower()
|
attr_sanitized = attr.lower()
|
||||||
attr_full_name = config.full_name.get(attr_sanitized, attr_sanitized)
|
attr_full_name = config.full_name.get(attr_sanitized, attr_sanitized)
|
||||||
attr_readable_name = config.readable_names.get(attr_full_name)
|
attr_readable_name = config.readable_names.get(attr_full_name)
|
||||||
rendered_profile.append((attr, attr_readable_name, value))
|
rendered.append((attr, attr_readable_name, value))
|
||||||
|
|
||||||
# Attributes with readable names first, then by name
|
# Attributes with readable names first, then by name
|
||||||
rendered_profile.sort(key=lambda profile: profile[0])
|
rendered.sort(key=lambda profile: profile[0])
|
||||||
rendered_profile.sort(key=lambda profile: profile[1] is None)
|
rendered.sort(key=lambda profile: profile[1] is None)
|
||||||
return rendered_profile
|
return rendered
|
||||||
|
|
||||||
def _ldap_get_user_groups(conn, uid):
|
def _get_groups_of(conn, uid):
|
||||||
groups = []
|
filter = ldaputils.groups_of_user(uid)
|
||||||
user_dn = config.dn_format % uid
|
groups = [
|
||||||
filter = f'(&(objectClass=groupOfUniqueNames)(uniqueMember={user_dn}))'
|
attrs['cn'][0].decode()
|
||||||
for group_dn, attrs in conn.search_s(config.ldap_base, ldap.SCOPE_SUBTREE, filter):
|
for group_dn, attrs in
|
||||||
groups.append(attrs['cn'][0].decode())
|
conn.search_s(config.ldap_base, ldap.SCOPE_SUBTREE, filter)
|
||||||
|
]
|
||||||
|
|
||||||
return groups
|
return groups
|
||||||
|
|
||||||
def _ldap_validate_uid(uid):
|
|
||||||
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9-_]*\Z', uid):
|
|
||||||
raise RuntimeError('Invalid uid')
|
|
||||||
|
|
||||||
@bp.route('/admin/users/<uid>')
|
@bp.route('/admin/users/<uid>')
|
||||||
@login_required
|
@admin_required
|
||||||
def admin_user_view(uid):
|
def admin_user_view(uid):
|
||||||
if not flask.session['is_admin']:
|
ldaputils.validate_name(uid)
|
||||||
flask.abort(403)
|
|
||||||
|
|
||||||
conn = context.get_connection()
|
conn = context.get_connection()
|
||||||
_ldap_validate_uid(uid)
|
|
||||||
|
|
||||||
profile = _ldap_get_user(conn, uid)
|
profile = _get_profile(conn, uid)
|
||||||
groups = _ldap_get_user_groups(conn, uid)
|
groups = _get_groups_of(conn, uid)
|
||||||
|
|
||||||
return flask.render_template('admin/user.html', uid=uid, profile=_rendered_ldap_profile(profile), groups=groups)
|
return flask.render_template('admin/user.html', uid=uid, profile=_format_profile(profile), groups=groups)
|
||||||
|
|
|
@ -3,7 +3,7 @@ import ldap
|
||||||
import flask
|
import flask
|
||||||
import urllib
|
import urllib
|
||||||
|
|
||||||
from webapp import app, context, config
|
from webapp import app, context, config, ldaputils
|
||||||
|
|
||||||
bp = flask.Blueprint('auth', __name__)
|
bp = flask.Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
@ -39,7 +39,7 @@ def login_action():
|
||||||
username = flask.request.form.get("username", "").lower()
|
username = flask.request.form.get("username", "").lower()
|
||||||
password = flask.request.form.get("password", "")
|
password = flask.request.form.get("password", "")
|
||||||
goto = flask.request.values.get("goto", "/")
|
goto = flask.request.values.get("goto", "/")
|
||||||
dn = config.dn_format % username
|
dn = ldaputils.user_dn(username)
|
||||||
|
|
||||||
conn = _connect_to_ldap(dn, password)
|
conn = _connect_to_ldap(dn, password)
|
||||||
if conn:
|
if conn:
|
||||||
|
@ -51,7 +51,7 @@ def login_action():
|
||||||
username = vs[0].decode()
|
username = vs[0].decode()
|
||||||
|
|
||||||
# Check if user belongs to admin group
|
# Check if user belongs to admin group
|
||||||
is_admin = bool(conn.search_s(dn, ldap.SCOPE_SUBTREE, f'memberOf={config.ldapweb_admin_group}'))
|
is_admin = bool(conn.search_s(dn, ldap.SCOPE_SUBTREE, ldaputils.member_of_any(config.ldap_admin_groups)))
|
||||||
|
|
||||||
flask.session["username"] = username
|
flask.session["username"] = username
|
||||||
flask.session['dn'] = dn
|
flask.session['dn'] = dn
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import flask_wtf
|
import flask_wtf
|
||||||
import wtforms
|
import wtforms
|
||||||
import secrets
|
import secrets
|
||||||
|
import os
|
||||||
|
|
||||||
hackerspace_name = 'Warsaw Hackerspace'
|
hackerspace_name = 'Warsaw Hackerspace'
|
||||||
secret_key = secrets.token_hex(32)
|
secret_key = secrets.token_hex(32)
|
||||||
|
@ -12,20 +13,20 @@ kadmin_principal_map = "{}@HACKERSPACE.PL"
|
||||||
# LDAP configuration
|
# LDAP configuration
|
||||||
|
|
||||||
ldap_url = 'ldap://ldap.hackerspace.pl'
|
ldap_url = 'ldap://ldap.hackerspace.pl'
|
||||||
dn_format = "uid=%s,ou=people,dc=hackerspace,dc=pl"
|
|
||||||
|
|
||||||
ldapweb_admin_group = 'cn=ldap-admin,ou=Group,dc=hackerspace,dc=pl'
|
|
||||||
|
|
||||||
ldap_base = 'dc=hackerspace,dc=pl'
|
ldap_base = 'dc=hackerspace,dc=pl'
|
||||||
ldap_people = 'ou=People,dc=hackerspace,dc=pl'
|
ldap_people = 'ou=people,dc=hackerspace,dc=pl'
|
||||||
admin_groups = {
|
ldap_user_dn_format = 'uid={},ou=people,dc=hackerspace,dc=pl'
|
||||||
'Fatty': 'cn=fatty,ou=Group,dc=hackerspace,dc=pl',
|
ldap_group_dn_format = 'cn={},ou=group,dc=hackerspace,dc=pl'
|
||||||
'Starving': 'cn=starving,ou=Group,dc=hackerspace,dc=pl',
|
|
||||||
'Potato': 'cn=potato,ou=Group,dc=hackerspace,dc=pl',
|
|
||||||
}
|
|
||||||
|
|
||||||
admin_dn = 'cn=ldapweb,ou=Services,dc=hackerspace,dc=pl'
|
# user groups allowed to see /admin
|
||||||
admin_pw = 'changeme'
|
ldap_admin_groups = os.getenv('LDAPWEB_ADMIN_GROUPS', 'ldap-admin,staff,zarzad').split(',')
|
||||||
|
|
||||||
|
# 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_admin_dn = os.getenv('LDAPWEB_ADMIN_DN', 'cn=ldapweb,ou=services,dc=hackerspace,dc=pl')
|
||||||
|
ldap_admin_password = os.getenv('LDAPWEB_ADMIN_PASSWORD', 'unused')
|
||||||
|
|
||||||
# LDAP attribute configuration
|
# LDAP attribute configuration
|
||||||
|
|
||||||
|
|
|
@ -25,9 +25,9 @@ def get_connection(dn = None):
|
||||||
return app.connections[dn]
|
return app.connections[dn]
|
||||||
|
|
||||||
def get_admin_connection():
|
def get_admin_connection():
|
||||||
conn = app.connections[config.admin_dn]
|
conn = app.connections[config.ldap_admin_dn]
|
||||||
if not conn:
|
if not conn:
|
||||||
conn = app.connections.bind(config.admin_dn, config.admin_pw)
|
conn = app.connections.bind(config.ldap_admin_dn, config.ldap_admin_password)
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
def get_profile():
|
def get_profile():
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
import re
|
||||||
|
import ldap
|
||||||
|
from webapp import config
|
||||||
|
|
||||||
|
def is_valid_name(name):
|
||||||
|
"""`true` if `name` is a safe ldap uid/cn"""
|
||||||
|
return re.match(r'^[a-zA-Z_][a-zA-Z0-9-_]*\Z', name) is not None
|
||||||
|
|
||||||
|
def validate_name(name):
|
||||||
|
"""Raises `RuntimeError` if `name` is not a safe ldap uid/cn"""
|
||||||
|
if not is_valid_name(name):
|
||||||
|
raise RuntimeError('Invalid name')
|
||||||
|
|
||||||
|
def user_dn(uid):
|
||||||
|
validate_name(uid)
|
||||||
|
return config.ldap_user_dn_format.format(uid)
|
||||||
|
|
||||||
|
def group_dn(cn):
|
||||||
|
validate_name(cn)
|
||||||
|
return config.ldap_group_dn_format.format(cn)
|
||||||
|
|
||||||
|
def wrap(filter):
|
||||||
|
if len(filter) and filter[0] == '(' and filter[-1] == ')':
|
||||||
|
return filter
|
||||||
|
else:
|
||||||
|
return f'({filter})'
|
||||||
|
|
||||||
|
def _or(*filters):
|
||||||
|
wrapped = ''.join(wrap(f) for f in filters)
|
||||||
|
return f'(|{wrapped})'
|
||||||
|
|
||||||
|
def _and(*filters):
|
||||||
|
wrapped = ''.join(wrap(f) for f in filters)
|
||||||
|
return f'(&{wrapped})'
|
||||||
|
|
||||||
|
def _not(filter):
|
||||||
|
wrapped = wrap(filter)
|
||||||
|
return f'(!{wrapped})'
|
||||||
|
|
||||||
|
def member_of_any(groups):
|
||||||
|
"""Returns a filter that matches users that are a member of any of the given group names"""
|
||||||
|
return _or(*(f'memberOf={group_dn(group)}' for group in groups))
|
||||||
|
|
||||||
|
def groups_of_user(uid):
|
||||||
|
"""Returns a filter that matches groups that have the given user as a member"""
|
||||||
|
return f'(&(objectClass=groupOfUniqueNames)(uniqueMember={user_dn(uid)}))'
|
||||||
|
|
||||||
|
def normalized_entries(entries):
|
||||||
|
"""
|
||||||
|
Converts ldap entries from python-ldap format into a more convenient
|
||||||
|
List[Tuple[
|
||||||
|
dn,
|
||||||
|
List[tuple[attr_name, attr_value]]
|
||||||
|
]]
|
||||||
|
"""
|
||||||
|
normalized = []
|
||||||
|
for dn, attrs in entries:
|
||||||
|
normalized_attrs = []
|
||||||
|
for attr, values in attrs.items():
|
||||||
|
for value in values:
|
||||||
|
normalized_attrs.append((attr, value.decode()))
|
||||||
|
normalized.append((dn, normalized_attrs))
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,7 @@ class LDAPConnectionPool(lru.LRUPool):
|
||||||
lru.LRUPool.__init__(self, **kw)
|
lru.LRUPool.__init__(self, **kw)
|
||||||
self.use_tls = use_tls
|
self.use_tls = use_tls
|
||||||
self.url = url
|
self.url = url
|
||||||
self.admin_dn = config.admin_dn
|
|
||||||
self.admin_pw = config.admin_pw
|
|
||||||
@lru.locked
|
@lru.locked
|
||||||
def bind(self, dn, password):
|
def bind(self, dn, password):
|
||||||
conn = ldap.initialize(self.url)
|
conn = ldap.initialize(self.url)
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p>Good evening, professor {{ session['username'] }}. All LDAP accounts:</p>
|
<p>Good evening, professor {{ session['username'] }}. All LDAP accounts:</p>
|
||||||
|
|
||||||
{% for group_name, users in user_groups %}
|
{% for group_name, users in groups %}
|
||||||
<h2>{{ group_name }}</h2>
|
<h2>{{ group_name }}</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{% for uid, name in users %}
|
{% for uid, name in users %}
|
||||||
|
|
Loading…
Reference in New Issue