use admin GSSAPI creds for some API endpoints #45
|
@ -72,6 +72,7 @@ addprinc -pw krb5 sysadmin/admin
|
|||
addprinc -pw krb5 ctdalek
|
||||
addprinc -pw krb5 exec1
|
||||
addprinc -pw krb5 regular1
|
||||
addprinc -pw krb5 office1
|
||||
addprinc -randkey host/auth1.csclub.internal
|
||||
addprinc -randkey ldap/auth1.csclub.internal
|
||||
ktadd host/auth1.csclub.internal
|
||||
|
|
|
@ -6,6 +6,9 @@ sed -E '/^(domain|search)[[:space:]]+csclub.uwaterloo.ca/d' /etc/resolv.conf > /
|
|||
cp /tmp/resolv.conf /etc/resolv.conf
|
||||
rm /tmp/resolv.conf
|
||||
|
||||
# normally systemd creates /run/ceod for us
|
||||
mkdir -p /run/ceod
|
||||
|
||||
# mock out systemctl
|
||||
ln -s /bin/true /usr/local/bin/systemctl
|
||||
# mock out acme.sh
|
||||
|
@ -40,8 +43,8 @@ sync_with() {
|
|||
port=$2
|
||||
fi
|
||||
synced=false
|
||||
# give it 5 minutes
|
||||
for i in {1..60}; do
|
||||
# give it 20 minutes (can be slow if you're using e.g. NFS or Ceph)
|
||||
for i in {1..240}; do
|
||||
if nc -vz $host $port ; then
|
||||
synced=true
|
||||
break
|
||||
|
|
|
@ -61,6 +61,7 @@ objectClass: posixGroup
|
|||
gidNumber: 10003
|
||||
cn: office
|
||||
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
|
||||
uniqueMember: uid=office1,ou=People,dc=csclub,dc=internal
|
||||
|
||||
dn: cn=src,ou=Group,dc=csclub,dc=internal
|
||||
objectClass: top
|
||||
|
@ -160,3 +161,28 @@ objectClass: posixGroup
|
|||
cn: exec
|
||||
gidNumber: 10013
|
||||
uniqueMember: uid=exec1,ou=People,dc=csclub,dc=internal
|
||||
|
||||
dn: uid=office1,ou=People,dc=csclub,dc=internal
|
||||
cn: Office One
|
||||
givenName: Office
|
||||
sn: One
|
||||
userPassword: {SASL}office1@CSCLUB.INTERNAL
|
||||
loginShell: /bin/bash
|
||||
homeDirectory: /users/office1
|
||||
uid: office1
|
||||
uidNumber: 20004
|
||||
gidNumber: 20004
|
||||
objectClass: top
|
||||
objectClass: account
|
||||
objectClass: posixAccount
|
||||
objectClass: shadowAccount
|
||||
objectClass: member
|
||||
program: MAT/Mathematics Computer Science
|
||||
term: f2021
|
||||
|
||||
dn: cn=office1,ou=Group,dc=csclub,dc=internal
|
||||
objectClass: top
|
||||
objectClass: group
|
||||
objectClass: posixGroup
|
||||
cn: office1
|
||||
gidNumber: 20004
|
||||
|
|
|
@ -78,15 +78,10 @@ def register_services(app):
|
|||
cfg = Config(config_file)
|
||||
component.provideUtility(cfg, IConfig)
|
||||
|
||||
# KerberosService
|
||||
hostname = socket.gethostname()
|
||||
fqdn = socket.getfqdn()
|
||||
# Only ceod_admin_host has the ceod/admin key in its keytab
|
||||
if hostname == cfg.get('ceod_admin_host'):
|
||||
principal = cfg.get('ldap_admin_principal')
|
||||
else:
|
||||
principal = f'ceod/{fqdn}'
|
||||
krb_srv = KerberosService(principal)
|
||||
|
||||
# KerberosService
|
||||
krb_srv = KerberosService()
|
||||
component.provideUtility(krb_srv, IKerberosService)
|
||||
|
||||
# LDAPService
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from flask import Blueprint, request, json
|
||||
from flask import Blueprint, g, json, request
|
||||
from zope import component
|
||||
|
||||
from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \
|
||||
|
@ -28,6 +28,10 @@ def create_user():
|
|||
if not body.get(attr):
|
||||
raise BadRequest(f"Attribute '{attr}' is missing or empty")
|
||||
|
||||
# We need to use the admin creds here because office members may not
|
||||
# directly create new LDAP records.
|
||||
g.need_admin_creds = True
|
||||
|
||||
txn = AddMemberTransaction(
|
||||
uid=body['uid'],
|
||||
cn=body['cn'],
|
||||
|
@ -82,6 +86,11 @@ def renew_user(username: str):
|
|||
if (terms and non_member_terms) or not (terms or non_member_terms):
|
||||
raise BadRequest('Must specify either terms or non-member terms')
|
||||
|
||||
# We need to use the admin creds here because office members should
|
||||
# not be able to directly modify the shadowExpire field; this could
|
||||
# prevent syscom members from logging into the machines.
|
||||
g.need_admin_creds = True
|
||||
|
||||
ldap_srv = component.getUtility(ILDAPService)
|
||||
user = ldap_srv.get_user(username)
|
||||
if body.get('terms'):
|
||||
|
|
|
@ -2,6 +2,7 @@ import os
|
|||
import subprocess
|
||||
from typing import List
|
||||
|
||||
import gssapi
|
||||
from zope import component
|
||||
from zope.interface import implementer
|
||||
|
||||
|
@ -10,20 +11,53 @@ from ceo_common.interfaces import IKerberosService, IConfig
|
|||
|
||||
@implementer(IKerberosService)
|
||||
class KerberosService:
|
||||
def __init__(
|
||||
self,
|
||||
admin_principal: str,
|
||||
):
|
||||
def __init__(self):
|
||||
cfg = component.getUtility(IConfig)
|
||||
|
||||
self.admin_principal = admin_principal
|
||||
self.realm = cfg.get('ldap_sasl_realm')
|
||||
# We don't need a credentials cache because the client forwards
|
||||
# their credentials to us
|
||||
# For creating new members and renewing memberships, we use the
|
||||
# admin credentials
|
||||
self.admin_principal = cfg.get('ldap_admin_principal')
|
||||
self.admin_principal_name = gssapi.Name(self.admin_principal)
|
||||
ccache_file = cfg.get('ldap_admin_principal_ccache')
|
||||
self.admin_principal_ccache = 'FILE:' + ccache_file
|
||||
self.admin_principal_store = {'ccache': self.admin_principal_ccache}
|
||||
# For everything else, the clients forwards (delegates) their
|
||||
# credentials to us. Set KRB5CCNAME to /dev/null to mitigate the
|
||||
# risk of the admin creds getting accidentally used instead.
|
||||
os.environ['KRB5CCNAME'] = 'FILE:/dev/null'
|
||||
|
||||
def _run(self, args: List[str]):
|
||||
subprocess.run(args, check=True)
|
||||
def _run(self, args: List[str], **kwargs):
|
||||
subprocess.run(args, check=True, **kwargs)
|
||||
|
||||
def _kinit_admin_creds(self):
|
||||
env = {'KRB5CCNAME': self.admin_principal_ccache}
|
||||
self._run([
|
||||
'kinit', '-k', '-p', self.admin_principal
|
||||
], env=env)
|
||||
|
||||
def _get_admin_creds_token_impl(self) -> bytes:
|
||||
creds = gssapi.Credentials(
|
||||
usage='initiate', name=self.admin_principal_name,
|
||||
store=self.admin_principal_store)
|
||||
# this will raise a gssapi.raw.exceptions.ExpiredCredentialsError
|
||||
# if the ticket has expired
|
||||
creds.inquire()
|
||||
return creds.export()
|
||||
|
||||
def get_admin_creds_token(self) -> bytes:
|
||||
"""
|
||||
Returns a serialized GSSAPI credential which can be used to
|
||||
authenticate to the CSC LDAP server with administrative privileges.
|
||||
"""
|
||||
try:
|
||||
return self._get_admin_creds_token_impl()
|
||||
except gssapi.raw.misc.GSSError:
|
||||
# Either the ccache file does not exist, or the ticket has
|
||||
# expired.
|
||||
# Run kinit again.
|
||||
self._kinit_admin_creds()
|
||||
# This time should work
|
||||
return self._get_admin_creds_token_impl()
|
||||
|
||||
def addprinc(self, principal: str, password: str):
|
||||
self._run([
|
||||
|
|
|
@ -10,7 +10,7 @@ from zope.interface import implementer
|
|||
from ceo_common.errors import UserNotFoundError, GroupNotFoundError, \
|
||||
UserAlreadyExistsError, GroupAlreadyExistsError
|
||||
from ceo_common.interfaces import ILDAPService, IConfig, \
|
||||
IUser, IGroup, IUWLDAPService
|
||||
IUser, IGroup, IUWLDAPService, IKerberosService
|
||||
from ceo_common.model import Term
|
||||
import ceo_common.utils as ceo_common_utils
|
||||
from .User import User
|
||||
|
@ -31,16 +31,26 @@ class LDAPService:
|
|||
self.club_min_id = cfg.get('clubs_min_id')
|
||||
self.club_max_id = cfg.get('clubs_max_id')
|
||||
|
||||
self.krb_srv = component.getUtility(IKerberosService)
|
||||
|
||||
def _get_ldap_conn(self) -> ldap3.Connection:
|
||||
if 'ldap_conn' in g:
|
||||
return g.ldap_conn
|
||||
kwargs = {'auto_bind': True, 'raise_exceptions': True}
|
||||
if 'client_token' in g:
|
||||
|
||||
# Use GSSAPI authentication if creds are available
|
||||
creds_token = None
|
||||
if g.get('need_admin_creds', False):
|
||||
creds_token = self.krb_srv.get_admin_creds_token()
|
||||
elif 'client_token' in g:
|
||||
creds_token = g.client_token
|
||||
if creds_token is not None:
|
||||
kwargs['authentication'] = ldap3.SASL
|
||||
kwargs['sasl_mechanism'] = ldap3.KERBEROS
|
||||
creds = gssapi.Credentials(token=g.client_token)
|
||||
creds = gssapi.Credentials(token=creds_token)
|
||||
# see https://github.com/cannatag/ldap3/blob/master/ldap3/protocol/sasl/kerberos.py
|
||||
kwargs['sasl_credentials'] = (None, None, creds)
|
||||
|
||||
conn = ldap3.Connection(self.ldap_server_url, **kwargs)
|
||||
# cache the connection for a single request
|
||||
g.ldap_conn = conn
|
||||
|
|
|
@ -17,6 +17,7 @@ port = 9987
|
|||
|
||||
[ldap]
|
||||
admin_principal = ceod/admin
|
||||
admin_principal_ccache = /run/ceod/admin_ccache
|
||||
server_url = ldaps://auth1.csclub.uwaterloo.ca
|
||||
sasl_realm = CSCLUB.UWATERLOO.CA
|
||||
users_base = ou=People,dc=csclub,dc=uwaterloo,dc=ca
|
||||
|
|
|
@ -22,7 +22,7 @@ def test_cloud_account_activate(cli_setup, mock_cloud_server, new_user, cfg):
|
|||
assert result.output == expected
|
||||
|
||||
|
||||
def test_cloud_accounts_purge(cli_setup, mock_cloud_server):
|
||||
def test_cloud_accounts_purge(cli_setup, mock_cloud_server, mock_harbor_server):
|
||||
mock_cloud_server.clear()
|
||||
|
||||
runner = CliRunner()
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import ldap3
|
||||
|
@ -84,7 +87,7 @@ def test_api_create_user(cfg, create_user_resp, mock_mail_server):
|
|||
def test_api_next_uid(cfg, client, create_user_result):
|
||||
min_uid = cfg.get('members_min_id')
|
||||
_, data = client.post('/api/members', json={
|
||||
'uid': 'test_2',
|
||||
'uid': 'test2',
|
||||
'cn': 'Test Two',
|
||||
'given_name': 'Test',
|
||||
'sn': 'Two',
|
||||
|
@ -97,7 +100,7 @@ def test_api_next_uid(cfg, client, create_user_result):
|
|||
assert result['uid_number'] == min_uid + 1
|
||||
assert result['gid_number'] == min_uid + 1
|
||||
finally:
|
||||
client.delete('/api/members/test_2')
|
||||
client.delete('/api/members/test2')
|
||||
|
||||
|
||||
def test_api_get_user(cfg, client, create_user_result):
|
||||
|
@ -321,3 +324,44 @@ def test_expire_syscom_member(client, new_user, syscom_group, g_admin_ctx, ldap_
|
|||
assert data == []
|
||||
else:
|
||||
assert data == [uid]
|
||||
|
||||
|
||||
def test_office_member(cfg, client):
|
||||
admin_principal = cfg.get('ldap_admin_principal')
|
||||
ccache_file = cfg.get('ldap_admin_principal_ccache')
|
||||
if os.path.isfile(ccache_file):
|
||||
os.unlink(ccache_file)
|
||||
body = {
|
||||
'uid': 'test3',
|
||||
'cn': 'Test Three',
|
||||
'given_name': 'Test',
|
||||
'sn': 'Three',
|
||||
'program': 'Math',
|
||||
'terms': ['w2022'],
|
||||
'forwarding_addresses': ['test3@uwaterloo.internal'],
|
||||
}
|
||||
status, data = client.post('/api/members', json=body, principal='office1')
|
||||
assert status == 200 and data[-1]['status'] == 'completed'
|
||||
|
||||
# Make sure new admin creds were obtained
|
||||
assert os.path.isfile(ccache_file)
|
||||
os.unlink(ccache_file)
|
||||
|
||||
# Obtain new creds which expire
|
||||
subprocess.run([
|
||||
'kinit', '-k', '-p', admin_principal, '-l', '1'
|
||||
], check=True, env={'KRB5CCNAME': 'FILE:' + ccache_file})
|
||||
old_mtime = os.stat(ccache_file).st_mtime
|
||||
# Wait for the ticket to expire
|
||||
time.sleep(1)
|
||||
|
||||
status, _ = client.post(
|
||||
'/api/members/test3/renew',
|
||||
json={'terms': ['s2022']}, principal='office1')
|
||||
assert status == 200
|
||||
|
||||
# Make sure new admin creds were obtained
|
||||
assert os.stat(ccache_file).st_mtime > old_mtime
|
||||
|
||||
status, _ = client.delete('/api/members/test3')
|
||||
assert status == 200
|
||||
|
|
|
@ -14,6 +14,7 @@ port = 9987
|
|||
|
||||
[ldap]
|
||||
admin_principal = ceod/admin
|
||||
admin_principal_ccache = /run/ceod/admin_ccache
|
||||
server_url = ldap://auth1.csclub.internal
|
||||
sasl_realm = CSCLUB.INTERNAL
|
||||
users_base = ou=People,dc=csclub,dc=internal
|
||||
|
|
|
@ -14,6 +14,7 @@ port = 9988
|
|||
|
||||
[ldap]
|
||||
admin_principal = ceod/admin
|
||||
admin_principal_ccache = /run/ceod/admin_ccache
|
||||
server_url = ldap://auth1.csclub.internal
|
||||
sasl_realm = CSCLUB.INTERNAL
|
||||
users_base = ou=TestPeople,dc=csclub,dc=internal
|
||||
|
|
|
@ -76,12 +76,11 @@ def krb_srv(cfg):
|
|||
# TODO: create temporary Kerberos database using kdb5_util.
|
||||
# We need to be root to read the keytab
|
||||
assert os.geteuid() == 0
|
||||
# this dance again... ugh
|
||||
if socket.gethostname() == cfg.get('ceod_admin_host'):
|
||||
principal = 'ceod/admin'
|
||||
else:
|
||||
principal = 'ceod/' + socket.getfqdn()
|
||||
krb = KerberosService(principal)
|
||||
# Delete the admin creds file, if it exists
|
||||
ccache_file = cfg.get('ldap_admin_principal_ccache')
|
||||
if os.path.isfile(ccache_file):
|
||||
os.unlink(ccache_file)
|
||||
krb = KerberosService()
|
||||
component.getGlobalSiteManager().registerUtility(krb, IKerberosService)
|
||||
|
||||
delete_test_princs(krb)
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import ceod.utils as ceod_utils
|
||||
import contextlib
|
||||
import os
|
||||
import grp
|
||||
import pwd
|
||||
import subprocess
|
||||
from subprocess import DEVNULL
|
||||
import tempfile
|
||||
|
@ -60,12 +58,5 @@ def gen_password_mock_ctx():
|
|||
|
||||
@contextlib.contextmanager
|
||||
def mocks_for_create_user_ctx():
|
||||
with gen_password_mock_ctx(), \
|
||||
patch.object(pwd, 'getpwuid') as getpwuid_mock, \
|
||||
patch.object(grp, 'getgrgid') as getgrgid_mock:
|
||||
# Normally, if getpwuid or getgrgid do *not* raise a KeyError,
|
||||
# then LDAPService will skip that UID. Therefore, by raising a
|
||||
# KeyError, we are making sure that the UID will *not* be skipped.
|
||||
getpwuid_mock.side_effect = KeyError()
|
||||
getgrgid_mock.side_effect = KeyError()
|
||||
with gen_password_mock_ctx():
|
||||
yield
|
||||
|
|
Loading…
Reference in New Issue