add Kerberos delegation (#5)
This PR adds unconstrained Kerberos delegation to the API. The client obtains a forwarded TGT and sends it, base64-encoded, in an HTTP header named 'X-KRB5-CRED'. The server reads this credential, creates a new credentials cache for the user, and stores the credential into the new cache. The server can now authenticate to other services (e.g. LDAP) over GSSAPI using the forwarded client's credentials. Reviewed-on: #5 Co-authored-by: Max Erenberg <merenber@localhost> Co-committed-by: Max Erenberg <merenber@localhost>
This commit is contained in:
parent
d82b5a763b
commit
d78d31eec0
|
@ -1,3 +1,7 @@
|
|||
__pycache__/
|
||||
/venv/
|
||||
.vscode/
|
||||
/cred
|
||||
*.o
|
||||
*.so
|
||||
/ceo_common/krb5/_krb5.c
|
||||
|
|
34
README.md
34
README.md
|
@ -9,13 +9,25 @@ this repo in one of the dev environment containers.
|
|||
|
||||
Next, install and activate a virtualenv:
|
||||
```sh
|
||||
sudo apt install libkrb5-dev libsasl2-dev python3-dev
|
||||
python3 -m venv venv
|
||||
. venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
pip install -r dev-requirements.txt
|
||||
```
|
||||
|
||||
### Running the application
|
||||
## C bindings
|
||||
Due to the lack of a decent Python library for Kerberos we ended up
|
||||
writing our own C bindings using [cffi](https://cffi.readthedocs.io).
|
||||
Make sure you compile the bindings:
|
||||
```sh
|
||||
cd ceo_common/krb5
|
||||
python krb5_build.py
|
||||
```
|
||||
This should create a file named '_krb5.cpython-37m-x86_64-linux-gnu.so'.
|
||||
This will be imported by other modules in ceo.
|
||||
|
||||
## Running the application
|
||||
ceod is essentially a distributed application, with instances on different
|
||||
hosts offering different services. For example, the ceod instance on mail
|
||||
offers a service to subscribe people to mailing lists, and
|
||||
|
@ -35,13 +47,14 @@ Sometimes changes you make in the source code don't show up while Flask
|
|||
is running. Stop the flask app (Ctrl-C), run `clear_cache.sh`, then
|
||||
restart the app.
|
||||
|
||||
### Interacting with the application
|
||||
## Interacting with the application
|
||||
The client part of ceo hasn't been written yet, so we'll use curl to
|
||||
interact with ceod for now.
|
||||
|
||||
ceod uses [SPNEGO](https://en.wikipedia.org/wiki/SPNEGO) for authentication,
|
||||
and TLS for confidentiality and integrity. In development mode, TLS can be
|
||||
disabled.
|
||||
|
||||
First, make sure that your version of curl has been compiled with SPNEGO
|
||||
support:
|
||||
```sh
|
||||
|
@ -49,9 +62,22 @@ curl -V
|
|||
```
|
||||
Your should see 'SPNEGO' in the 'Features' section.
|
||||
|
||||
Here's an example of using curl with SPNEGO:
|
||||
The API also uses unconstrained Kerberos delegation when interacting with
|
||||
the LDAP database. This means that the client obtains a forwarded TGT, then
|
||||
sends that to ceod, which then uses it to interact with LDAP on the client's
|
||||
behalf. There is a script called `gen_cred.py` which can generate this
|
||||
ticket for you.
|
||||
|
||||
|
||||
Here's an example of making a request to an endpoint which writes to LDAP:
|
||||
```sh
|
||||
# Get a Kerberos TGT first
|
||||
kinit
|
||||
curl --negotiate -u : --service-name ceod -X POST http://mail:9987/api/mailman/csc-general/ctdalek
|
||||
# Obtain a forwarded TGT
|
||||
./gen_cred.py phosphoric-acid
|
||||
# Make the request
|
||||
curl --negotiate -u : --service-name ceod \
|
||||
-H "X-KRB5-CRED: $(cat cred)" \
|
||||
-d '{"uid":"test_1","cn":"Test One","program":"Math","terms":["s2021"]}' \
|
||||
-X POST http://phosphoric-acid:9987/api/members
|
||||
```
|
||||
|
|
|
@ -12,6 +12,10 @@ class BadRequest(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class KerberosError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UserAlreadyExistsError(Exception):
|
||||
def __init__(self):
|
||||
super().__init__('user already exists')
|
||||
|
|
|
@ -4,9 +4,6 @@ from zope.interface import Interface
|
|||
class IKerberosService(Interface):
|
||||
"""A utility wrapper around kinit/kadmin."""
|
||||
|
||||
def kinit():
|
||||
"""Acquire and cache a new TGT."""
|
||||
|
||||
def addprinc(principal: str, password: str):
|
||||
"""Add a new principal with the specified password."""
|
||||
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
from cffi import FFI
|
||||
|
||||
ffibuilder = FFI()
|
||||
|
||||
# Definitions selectively copied from <krb5/krb5.h>.
|
||||
# Add more if necessary.
|
||||
ffibuilder.cdef(r"""
|
||||
# define KV5M_DATA ...
|
||||
typedef int32_t krb5_int32;
|
||||
typedef krb5_int32 krb5_error_code;
|
||||
typedef krb5_error_code krb5_magic;
|
||||
struct _krb5_context;
|
||||
typedef struct _krb5_context* krb5_context;
|
||||
struct _krb5_auth_context;
|
||||
typedef struct _krb5_auth_context * krb5_auth_context;
|
||||
struct _krb5_ccache;
|
||||
typedef struct _krb5_ccache *krb5_ccache;
|
||||
typedef struct krb5_principal_data {
|
||||
...;
|
||||
} krb5_principal_data;
|
||||
typedef krb5_principal_data * krb5_principal;
|
||||
typedef const krb5_principal_data *krb5_const_principal;
|
||||
typedef struct _krb5_creds {
|
||||
krb5_principal client;
|
||||
krb5_principal server;
|
||||
...;
|
||||
} krb5_creds;
|
||||
typedef struct _krb5_data {
|
||||
krb5_magic magic;
|
||||
unsigned int length;
|
||||
char *data;
|
||||
} krb5_data;
|
||||
typedef struct krb5_replay_data {
|
||||
...;
|
||||
} krb5_replay_data;
|
||||
|
||||
krb5_error_code krb5_init_context(krb5_context * context);
|
||||
void krb5_free_context(krb5_context context);
|
||||
krb5_error_code krb5_auth_con_init(
|
||||
krb5_context context, krb5_auth_context *auth_context);
|
||||
krb5_error_code krb5_auth_con_setflags(
|
||||
krb5_context context, krb5_auth_context auth_context, krb5_int32 flags);
|
||||
krb5_error_code krb5_auth_con_free(
|
||||
krb5_context context, krb5_auth_context auth_context);
|
||||
krb5_error_code
|
||||
krb5_cc_new_unique(krb5_context context, const char *type, const char *hint,
|
||||
krb5_ccache *id);
|
||||
krb5_error_code krb5_cc_default(krb5_context context, krb5_ccache *ccache);
|
||||
krb5_error_code krb5_cc_resolve(
|
||||
krb5_context context, const char * name, krb5_ccache * cache);
|
||||
krb5_error_code
|
||||
krb5_cc_initialize(krb5_context context, krb5_ccache cache,
|
||||
krb5_principal principal);
|
||||
krb5_error_code
|
||||
krb5_cc_get_principal(krb5_context context, krb5_ccache cache,
|
||||
krb5_principal *principal);
|
||||
krb5_error_code krb5_cc_store_cred(
|
||||
krb5_context context, krb5_ccache cache, krb5_creds *creds);
|
||||
krb5_error_code krb5_cc_close(krb5_context context, krb5_ccache cache);
|
||||
krb5_error_code krb5_cc_destroy(krb5_context context, krb5_ccache cache);
|
||||
krb5_error_code
|
||||
krb5_build_principal(krb5_context context,
|
||||
krb5_principal * princ,
|
||||
unsigned int rlen,
|
||||
const char * realm, ...);
|
||||
krb5_error_code
|
||||
krb5_parse_name(krb5_context context, const char *name,
|
||||
krb5_principal *principal_out);
|
||||
void krb5_free_principal(krb5_context context, krb5_principal val);
|
||||
krb5_error_code
|
||||
krb5_rd_cred(krb5_context context, krb5_auth_context auth_context,
|
||||
krb5_data *pcreddata, krb5_creds ***pppcreds,
|
||||
krb5_replay_data *outdata);
|
||||
krb5_error_code krb5_fwd_tgt_creds(
|
||||
krb5_context context, krb5_auth_context auth_context,
|
||||
const char * rhost, krb5_principal client, krb5_principal server,
|
||||
krb5_ccache cc, int forwardable, krb5_data * outbuf);
|
||||
void krb5_free_tgt_creds(krb5_context context, krb5_creds ** tgts);
|
||||
void krb5_free_data_contents(krb5_context context, krb5_data * val);
|
||||
krb5_error_code krb5_unparse_name(
|
||||
krb5_context context, krb5_const_principal principal, char **name);
|
||||
void krb5_free_unparsed_name(krb5_context context, char *val);
|
||||
const char * krb5_get_error_message(krb5_context ctx, krb5_error_code code);
|
||||
void krb5_free_error_message(krb5_context ctx, const char * msg);
|
||||
""")
|
||||
|
||||
ffibuilder.set_source(
|
||||
"_krb5",
|
||||
"""
|
||||
#include <krb5/krb5.h>
|
||||
""",
|
||||
libraries=['krb5']
|
||||
)
|
||||
|
||||
if __name__ == '__main__':
|
||||
ffibuilder.compile(verbose=True)
|
|
@ -0,0 +1,201 @@
|
|||
import contextlib
|
||||
import functools
|
||||
from typing import Union
|
||||
|
||||
from ._krb5 import ffi, lib
|
||||
from ceo_common.errors import KerberosError
|
||||
|
||||
|
||||
def check_rc(k_ctx, rc: int):
|
||||
"""
|
||||
Check the return code of a krb5 function.
|
||||
An exception is raised if rc != 0.
|
||||
"""
|
||||
if rc == 0:
|
||||
return
|
||||
c_msg = lib.krb5_get_error_message(k_ctx, rc)
|
||||
msg = ffi.string(c_msg).decode()
|
||||
lib.krb5_free_error_message(k_ctx, c_msg)
|
||||
raise KerberosError(msg)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def get_krb5_context():
|
||||
"""Yields a krb5_context."""
|
||||
k_ctx = None
|
||||
try:
|
||||
p_k_ctx = ffi.new('krb5_context *')
|
||||
rc = lib.krb5_init_context(p_k_ctx)
|
||||
k_ctx = p_k_ctx[0] # dereference the pointer
|
||||
check_rc(k_ctx, rc)
|
||||
yield k_ctx
|
||||
finally:
|
||||
if k_ctx is not None:
|
||||
lib.krb5_free_context(k_ctx)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def get_krb5_auth_context(k_ctx):
|
||||
"""Yields a krb5_auth_context."""
|
||||
a_ctx = None
|
||||
try:
|
||||
p_a_ctx = ffi.new('krb5_auth_context *')
|
||||
rc = lib.krb5_auth_con_init(k_ctx, p_a_ctx)
|
||||
a_ctx = p_a_ctx[0]
|
||||
check_rc(k_ctx, rc)
|
||||
# clear the flags which enable the replay cache
|
||||
rc = lib.krb5_auth_con_setflags(k_ctx, a_ctx, 0)
|
||||
check_rc(k_ctx, rc)
|
||||
yield a_ctx
|
||||
finally:
|
||||
if a_ctx is not None:
|
||||
lib.krb5_auth_con_free(k_ctx, a_ctx)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def get_krb5_cc_default(k_ctx):
|
||||
"""Yields the default krb5_ccache."""
|
||||
cache = None
|
||||
try:
|
||||
p_cache = ffi.new('krb5_ccache *')
|
||||
rc = lib.krb5_cc_default(k_ctx, p_cache)
|
||||
check_rc(k_ctx, rc)
|
||||
cache = p_cache[0]
|
||||
yield cache
|
||||
finally:
|
||||
if cache is not None:
|
||||
lib.krb5_cc_close(k_ctx, cache)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def get_krb5_cc_resolve(k_ctx, name: str):
|
||||
"""
|
||||
Resolve a credential cache name.
|
||||
`name` should have the format 'type:residual'.
|
||||
"""
|
||||
cache = None
|
||||
try:
|
||||
c_name = ffi.new('char[]', name.encode())
|
||||
p_cache = ffi.new('krb5_ccache *')
|
||||
rc = lib.krb5_cc_resolve(k_ctx, c_name, p_cache)
|
||||
check_rc(k_ctx, rc)
|
||||
cache = p_cache[0]
|
||||
yield cache
|
||||
finally:
|
||||
if cache is not None:
|
||||
lib.krb5_cc_close(k_ctx, cache)
|
||||
|
||||
|
||||
def get_fwd_tgt(server: str, cache_name: Union[str, None] = None) -> bytes:
|
||||
"""
|
||||
Get a forwarded TGT formatted as a KRB-CRED message.
|
||||
`server` should have the format 'service/host',
|
||||
e.g. 'ceod/phosphoric-acid.csclub.uwaterloo.ca'.
|
||||
|
||||
If `cache_name` is None, the default cache will be used; otherwise,
|
||||
the cache with that name will be used.
|
||||
"""
|
||||
if cache_name is None:
|
||||
cache_function = get_krb5_cc_default
|
||||
else:
|
||||
cache_function = functools.partial(get_krb5_cc_resolve, name=cache_name)
|
||||
|
||||
with get_krb5_context() as k_ctx, \
|
||||
get_krb5_auth_context(k_ctx) as a_ctx, \
|
||||
cache_function(k_ctx) as cache:
|
||||
server_princ = None
|
||||
client_princ = None
|
||||
outbuf = None
|
||||
try:
|
||||
# create a server principal from the server string
|
||||
c_server = ffi.new('char[]', server.encode())
|
||||
p_server_princ = ffi.new('krb5_principal *')
|
||||
rc = lib.krb5_parse_name(k_ctx, c_server, p_server_princ)
|
||||
check_rc(k_ctx, rc)
|
||||
server_princ = p_server_princ[0]
|
||||
# get a client principal from the default cache
|
||||
p_client_princ = ffi.new('krb5_principal *')
|
||||
rc = lib.krb5_cc_get_principal(k_ctx, cache, p_client_princ)
|
||||
check_rc(k_ctx, rc)
|
||||
client_princ = p_client_princ[0]
|
||||
# get the forwarded TGT
|
||||
p_outbuf = ffi.new('krb5_data *')
|
||||
rc = lib.krb5_fwd_tgt_creds(
|
||||
k_ctx, a_ctx, ffi.NULL, client_princ, server_princ,
|
||||
cache, 0, p_outbuf)
|
||||
check_rc(k_ctx, rc)
|
||||
outbuf = p_outbuf[0]
|
||||
return ffi.unpack(outbuf.data, outbuf.length)
|
||||
finally:
|
||||
if outbuf is not None:
|
||||
lib.krb5_free_data_contents(k_ctx, p_outbuf)
|
||||
if client_princ is not None:
|
||||
lib.krb5_free_principal(k_ctx, client_princ)
|
||||
if server_princ is not None:
|
||||
lib.krb5_free_principal(k_ctx, server_princ)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def store_fwd_tgt_creds(cred_data_bytes: bytes):
|
||||
"""
|
||||
Stores the credentials found in cred_data_bytes
|
||||
in a new sub-cache in the default cache (which should be of type DIR),
|
||||
yields the name of the principal in the credential, then finally removes
|
||||
the sub-cache from the collection.
|
||||
|
||||
The principal name will have the form 'username@REALM', e.g.
|
||||
'ctdalek@CSCLUB.UWATERLOO.CA'.
|
||||
"""
|
||||
with get_krb5_context() as k_ctx, get_krb5_auth_context(k_ctx) as a_ctx:
|
||||
creds_out = None
|
||||
cache = None
|
||||
c_name = None
|
||||
|
||||
try:
|
||||
# fill a krb5_data struct
|
||||
cred_data = ffi.new('krb5_data *')
|
||||
cred_data_buf = ffi.new('char[]', cred_data_bytes)
|
||||
cred_data.magic = lib.KV5M_DATA
|
||||
cred_data.length = len(cred_data_bytes)
|
||||
cred_data.data = cred_data_buf
|
||||
|
||||
# read the KRB5-CRED message into a krb5_creds array
|
||||
p_creds_out = ffi.new('krb5_creds ***')
|
||||
rc = lib.krb5_rd_cred(k_ctx, a_ctx, cred_data, p_creds_out, ffi.NULL)
|
||||
check_rc(k_ctx, rc)
|
||||
creds_out = p_creds_out[0]
|
||||
# there should only be one cred in the array
|
||||
cred = creds_out[0]
|
||||
|
||||
# read the name of the client principal
|
||||
client_princ = cred.client
|
||||
p_name = ffi.new('char **')
|
||||
rc = lib.krb5_unparse_name(k_ctx, client_princ, p_name)
|
||||
check_rc(k_ctx, rc)
|
||||
c_name = p_name[0]
|
||||
name = ffi.string(c_name).decode()
|
||||
|
||||
# create a new cache inside the collection (default cache)
|
||||
p_cache = ffi.new('krb5_ccache *')
|
||||
cctype = ffi.new('char[]', b'DIR')
|
||||
rc = lib.krb5_cc_new_unique(k_ctx, cctype, ffi.NULL, p_cache)
|
||||
check_rc(k_ctx, rc)
|
||||
cache = p_cache[0]
|
||||
# initialize the new cache
|
||||
rc = lib.krb5_cc_initialize(k_ctx, cache, client_princ)
|
||||
check_rc(k_ctx, rc)
|
||||
|
||||
# store the cred into the cache
|
||||
rc = lib.krb5_cc_store_cred(k_ctx, cache, cred)
|
||||
check_rc(k_ctx, rc)
|
||||
|
||||
yield name
|
||||
finally:
|
||||
if cache is not None:
|
||||
# We destroy the cache (instead of closing it) since we want
|
||||
# to remove it from disk.
|
||||
lib.krb5_cc_destroy(k_ctx, cache)
|
||||
if c_name is not None:
|
||||
lib.krb5_free_unparsed_name(k_ctx, c_name)
|
||||
if creds_out is not None:
|
||||
lib.krb5_free_tgt_creds(k_ctx, creds_out)
|
|
@ -27,4 +27,6 @@ class Config:
|
|||
return True
|
||||
if val.lower() in ['false', 'no']:
|
||||
return False
|
||||
if section.startswith('auxiliary '):
|
||||
return val.split(',')
|
||||
return val
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import socket
|
||||
|
||||
from flask import g
|
||||
import gssapi
|
||||
from gssapi.raw.exceptions import ExpiredCredentialsError
|
||||
import requests
|
||||
|
@ -22,35 +23,18 @@ class HTTPClient:
|
|||
self.ceod_port = cfg.get('ceod_port')
|
||||
self.base_domain = cfg.get('base_domain')
|
||||
|
||||
# Determine which principal to use for SPNEGO
|
||||
# TODO: this code is duplicated in app_factory.py. Figure out
|
||||
# how to write it only once.
|
||||
if socket.gethostname() == cfg.get('ceod_admin_host'):
|
||||
spnego_principal = cfg.get('ldap_admin_principal')
|
||||
else:
|
||||
spnego_principal = f'ceod/{socket.getfqdn()}'
|
||||
|
||||
# Initialize variables to get Kerberos cache tickets
|
||||
krb_realm = cfg.get('ldap_sasl_realm')
|
||||
self.gssapi_name = gssapi.Name(f'{spnego_principal}@{krb_realm}')
|
||||
self.krb_srv = component.getUtility(IKerberosService)
|
||||
|
||||
def get_creds(self):
|
||||
"""Get GSSAPI credentials to use for SPNEGO."""
|
||||
for _ in range(2):
|
||||
try:
|
||||
creds = gssapi.Credentials(name=self.gssapi_name, usage='initiate')
|
||||
creds.inquire()
|
||||
return creds
|
||||
except ExpiredCredentialsError:
|
||||
self.krb_srv.kinit()
|
||||
raise Exception('could not acquire GSSAPI credentials')
|
||||
self.krb_realm = cfg.get('ldap_sasl_realm')
|
||||
|
||||
def request(self, host: str, api_path: str, method='GET', **kwargs):
|
||||
principal = g.sasl_user
|
||||
if '@' not in principal:
|
||||
principal = principal + '@' + self.krb_realm
|
||||
gssapi_name = gssapi.Name(principal)
|
||||
creds = gssapi.Credentials(name=gssapi_name, usage='initiate')
|
||||
auth = HTTPSPNEGOAuth(
|
||||
opportunistic_auth=True,
|
||||
target_name='ceod',
|
||||
creds=self.get_creds(),
|
||||
creds=creds,
|
||||
)
|
||||
# always use the FQDN, for HTTPS purposes
|
||||
if '.' not in host:
|
||||
|
|
|
@ -7,6 +7,7 @@ from flask_kerberos import init_kerberos
|
|||
from zope import component
|
||||
|
||||
from .error_handlers import register_error_handlers
|
||||
from .krb5_cred_handlers import before_request, teardown_request
|
||||
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \
|
||||
IMailmanService, IMailService, IUWLDAPService, IHTTPClient
|
||||
from ceo_common.model import Config, HTTPClient, RemoteMailmanService
|
||||
|
@ -42,6 +43,8 @@ def create_app(flask_config={}):
|
|||
app.register_blueprint(uwldap.bp, url_prefix='/api/uwldap')
|
||||
|
||||
register_error_handlers(app)
|
||||
app.before_request(before_request)
|
||||
app.teardown_request(teardown_request)
|
||||
|
||||
@app.route('/ping')
|
||||
def ping():
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
from base64 import b64decode
|
||||
import traceback
|
||||
|
||||
from flask import g, request
|
||||
|
||||
from ceo_common.logger_factory import logger_factory
|
||||
from ceo_common.krb5.utils import store_fwd_tgt_creds
|
||||
|
||||
logger = logger_factory(__name__)
|
||||
|
||||
|
||||
def before_request():
|
||||
if 'x-krb5-cred' not in request.headers:
|
||||
return
|
||||
cred = b64decode(request.headers['x-krb5-cred'])
|
||||
ctx = store_fwd_tgt_creds(cred)
|
||||
name = ctx.__enter__()
|
||||
g.stored_creds_ctx = ctx
|
||||
g.sasl_user = name
|
||||
|
||||
|
||||
def teardown_request(err):
|
||||
if 'stored_creds_ctx' not in g:
|
||||
return
|
||||
try:
|
||||
ctx = g.stored_creds_ctx
|
||||
ctx.__exit__(None, None, None)
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
|
@ -3,15 +3,16 @@ from zope import component
|
|||
|
||||
from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \
|
||||
user_is_in_group, requires_authentication_no_realm, \
|
||||
create_streaming_response, create_sync_response
|
||||
create_streaming_response, create_sync_response, development_only
|
||||
from ceo_common.errors import UserNotFoundError
|
||||
from ceo_common.interfaces import ILDAPService
|
||||
from ceod.transactions.members import (
|
||||
AddMemberTransaction,
|
||||
ModifyMemberTransaction,
|
||||
RenewMemberTransaction,
|
||||
ResetPasswordTransaction,
|
||||
DeleteMemberTransaction,
|
||||
)
|
||||
import ceod.utils as utils
|
||||
|
||||
bp = Blueprint('members', __name__)
|
||||
|
||||
|
@ -78,5 +79,16 @@ def renew_user(username: str):
|
|||
@bp.route('/<username>/pwreset', methods=['POST'])
|
||||
@authz_restrict_to_syscom
|
||||
def reset_user_password(username: str):
|
||||
txn = ResetPasswordTransaction(username)
|
||||
return create_sync_response(txn)
|
||||
user = component.getUtility(ILDAPService).get_user(username)
|
||||
password = utils.gen_password()
|
||||
user.change_password(password)
|
||||
|
||||
return {'password': password}
|
||||
|
||||
|
||||
@bp.route('/<username>', methods=['DELETE'])
|
||||
@authz_restrict_to_syscom
|
||||
@development_only
|
||||
def delete_user(username: str):
|
||||
txn = DeleteMemberTransaction(username)
|
||||
return create_streaming_response(txn)
|
||||
|
|
|
@ -6,7 +6,7 @@ import pwd
|
|||
import traceback
|
||||
from typing import Callable, List
|
||||
|
||||
from flask import current_app
|
||||
from flask import current_app, stream_with_context
|
||||
from flask.json import jsonify
|
||||
from flask_kerberos import requires_authentication
|
||||
|
||||
|
@ -103,7 +103,8 @@ def create_streaming_response(txn: AbstractTransaction):
|
|||
'error': str(err),
|
||||
}) + '\n'
|
||||
|
||||
return current_app.response_class(generate(), mimetype='text/plain')
|
||||
return current_app.response_class(
|
||||
stream_with_context(generate()), mimetype='text/plain')
|
||||
|
||||
|
||||
def create_sync_response(txn: AbstractTransaction):
|
||||
|
@ -123,3 +124,14 @@ def create_sync_response(txn: AbstractTransaction):
|
|||
return {
|
||||
'error': str(err),
|
||||
}, 500
|
||||
|
||||
|
||||
def development_only(f: Callable) -> Callable:
|
||||
@functools.wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
return f(*args, **kwargs)
|
||||
return {
|
||||
'error': 'This endpoint may only be called in development'
|
||||
}, 403
|
||||
return wrapper
|
||||
|
|
|
@ -2,9 +2,8 @@ from flask import Blueprint, request
|
|||
from flask.json import jsonify
|
||||
from zope import component
|
||||
|
||||
from .utils import create_sync_response, authz_restrict_to_syscom
|
||||
from .utils import authz_restrict_to_syscom
|
||||
from ceo_common.interfaces import IUWLDAPService, ILDAPService
|
||||
from ceod.transactions.uwldap import UpdateProgramsTransaction
|
||||
|
||||
bp = Blueprint('uwldap', __name__)
|
||||
|
||||
|
@ -26,9 +25,9 @@ def update_programs():
|
|||
ldap_srv = component.getUtility(ILDAPService)
|
||||
body = request.get_json(force=True)
|
||||
members = body.get('members')
|
||||
kwargs = {'members': members}
|
||||
if body.get('dry_run'):
|
||||
members['dry_run'] = True
|
||||
return jsonify(
|
||||
ldap_srv.update_programs(dry_run=True, members=members)
|
||||
ldap_srv.update_programs(**kwargs)
|
||||
)
|
||||
txn = UpdateProgramsTransaction(members=members)
|
||||
return create_sync_response(txn)
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from zope import component
|
||||
from zope.interface import implementer
|
||||
|
||||
from ceo_common.interfaces import IKerberosService
|
||||
from ceo_common.interfaces import IKerberosService, IConfig
|
||||
from ceo_common.krb5._krb5 import ffi, lib
|
||||
from ceo_common.krb5.utils import check_rc, get_krb5_context, \
|
||||
get_krb5_cc_default
|
||||
|
||||
|
||||
@implementer(IKerberosService)
|
||||
|
@ -11,15 +16,39 @@ class KerberosService:
|
|||
def __init__(
|
||||
self,
|
||||
admin_principal: str,
|
||||
cache_dir: str = '/run/ceod/krb5_cache',
|
||||
):
|
||||
self.admin_principal = admin_principal
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
os.environ['KRB5CCNAME'] = 'DIR:' + cache_dir
|
||||
self.kinit()
|
||||
cfg = component.getUtility(IConfig)
|
||||
|
||||
def kinit(self):
|
||||
subprocess.run(['kinit', '-k', self.admin_principal], check=True)
|
||||
self.admin_principal = admin_principal
|
||||
self.cache_dir = cfg.get('ceod_krb5_cache_dir')
|
||||
self.realm = cfg.get('ldap_sasl_realm')
|
||||
self._initialize_cache()
|
||||
|
||||
def _initialize_cache(self, **kwargs):
|
||||
if os.path.isdir(self.cache_dir):
|
||||
shutil.rmtree(self.cache_dir)
|
||||
os.makedirs(self.cache_dir)
|
||||
os.environ['KRB5CCNAME'] = 'DIR:' + self.cache_dir
|
||||
|
||||
with get_krb5_context() as k_ctx, get_krb5_cc_default(k_ctx) as cache:
|
||||
princ = None
|
||||
try:
|
||||
# build a principal for 'nobody'
|
||||
realm = self.realm.encode()
|
||||
c_realm = ffi.new('char[]', realm)
|
||||
component = ffi.new('char[]', b'nobody')
|
||||
p_princ = ffi.new('krb5_principal *')
|
||||
rc = lib.krb5_build_principal(
|
||||
k_ctx, p_princ, len(realm), c_realm, component, ffi.NULL)
|
||||
check_rc(k_ctx, rc)
|
||||
princ = p_princ[0]
|
||||
|
||||
# initialize the default cache with 'nobody' as the default principal
|
||||
rc = lib.krb5_cc_initialize(k_ctx, cache, princ)
|
||||
check_rc(k_ctx, rc)
|
||||
finally:
|
||||
if princ is not None:
|
||||
lib.krb5_free_principal(k_ctx, princ)
|
||||
|
||||
def addprinc(self, principal: str, password: str):
|
||||
subprocess.run([
|
||||
|
|
|
@ -3,6 +3,7 @@ import grp
|
|||
import pwd
|
||||
from typing import Union, List
|
||||
|
||||
from flask import g
|
||||
import ldap3
|
||||
from zope import component
|
||||
from zope.interface import implementer
|
||||
|
@ -28,18 +29,13 @@ class LDAPService:
|
|||
self.member_max_id = cfg.get('members_max_id')
|
||||
self.club_min_id = cfg.get('clubs_min_id')
|
||||
self.club_max_id = cfg.get('clubs_max_id')
|
||||
self.sasl_user = None
|
||||
|
||||
def set_sasl_user(self, sasl_user: Union[str, None]):
|
||||
# TODO: store SASL user in flask.g instead
|
||||
self.sasl_user = sasl_user
|
||||
|
||||
def _get_ldap_conn(self, gssapi_bind: bool = True) -> ldap3.Connection:
|
||||
def _get_ldap_conn(self) -> ldap3.Connection:
|
||||
kwargs = {'auto_bind': True, 'raise_exceptions': True}
|
||||
if gssapi_bind:
|
||||
if 'sasl_user' in g:
|
||||
kwargs['authentication'] = ldap3.SASL
|
||||
kwargs['sasl_mechanism'] = ldap3.KERBEROS
|
||||
kwargs['user'] = self.sasl_user
|
||||
kwargs['user'] = g.sasl_user
|
||||
# TODO: cache the connection for a single request
|
||||
conn = ldap3.Connection(self.ldap_server_url, **kwargs)
|
||||
return conn
|
||||
|
@ -77,12 +73,12 @@ class LDAPService:
|
|||
return group.ldap3_entry.entry_writable()
|
||||
|
||||
def get_user(self, username: str) -> IUser:
|
||||
conn = self._get_ldap_conn(False)
|
||||
conn = self._get_ldap_conn()
|
||||
entry = self._get_readable_entry_for_user(conn, username)
|
||||
return User.deserialize_from_ldap(entry)
|
||||
|
||||
def get_group(self, cn: str) -> IGroup:
|
||||
conn = self._get_ldap_conn(False)
|
||||
conn = self._get_ldap_conn()
|
||||
entry = self._get_readable_entry_for_group(conn, cn)
|
||||
return Group.deserialize_from_ldap(entry)
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ class MailmanService:
|
|||
def __init__(self):
|
||||
cfg = component.getUtility(IConfig)
|
||||
self.base_domain = cfg.get('base_domain')
|
||||
self.api_base_url = cfg.get('mailman3_api_base_url')
|
||||
self.api_base_url = cfg.get('mailman3_api_base_url').rstrip('/')
|
||||
api_username = cfg.get('mailman3_api_username')
|
||||
api_password = cfg.get('mailman3_api_password')
|
||||
self.basic_auth = HTTPBasicAuth(api_username, api_password)
|
||||
|
|
|
@ -4,10 +4,10 @@ from typing import Union, List
|
|||
from zope import component
|
||||
|
||||
from ..AbstractTransaction import AbstractTransaction
|
||||
from .utils import gen_password
|
||||
from ceo_common.interfaces import IConfig, IMailService
|
||||
from ceo_common.logger_factory import logger_factory
|
||||
from ceod.model import User, Group
|
||||
import ceod.utils as utils
|
||||
|
||||
logger = logger_factory(__name__)
|
||||
|
||||
|
@ -67,7 +67,7 @@ class AddMemberTransaction(AbstractTransaction):
|
|||
group.add_to_ldap()
|
||||
yield 'add_group_to_ldap'
|
||||
|
||||
password = gen_password()
|
||||
password = utils.gen_password()
|
||||
member.add_to_kerberos(password)
|
||||
yield 'add_user_to_kerberos'
|
||||
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
from zope import component
|
||||
|
||||
from ..AbstractTransaction import AbstractTransaction
|
||||
from ceo_common.errors import UserNotSubscribedError
|
||||
from ceo_common.interfaces import ILDAPService, IConfig
|
||||
|
||||
|
||||
class DeleteMemberTransaction(AbstractTransaction):
|
||||
"""
|
||||
Transaction to permanently delete a member and their resources.
|
||||
This should only be used during testing.
|
||||
"""
|
||||
|
||||
operations = [
|
||||
'remove_user_from_ldap',
|
||||
'remove_group_from_ldap',
|
||||
'remove_user_from_kerberos',
|
||||
'delete_home_dir',
|
||||
'unsubscribe_from_mailing_list',
|
||||
]
|
||||
|
||||
def __init__(self, uid: str):
|
||||
super().__init__()
|
||||
self.uid = uid
|
||||
|
||||
def child_execute_iter(self):
|
||||
ldap_srv = component.getUtility(ILDAPService)
|
||||
cfg = component.getUtility(IConfig)
|
||||
user = ldap_srv.get_user(self.uid)
|
||||
group = ldap_srv.get_group(self.uid)
|
||||
new_member_list = cfg.get('mailman3_new_member_list')
|
||||
|
||||
user.remove_from_ldap()
|
||||
yield 'remove_user_from_ldap'
|
||||
group.remove_from_ldap()
|
||||
yield 'remove_group_from_ldap'
|
||||
user.remove_from_kerberos()
|
||||
yield 'remove_user_from_kerberos'
|
||||
user.delete_home_dir()
|
||||
yield 'delete_home_dir'
|
||||
try:
|
||||
user.unsubscribe_from_mailing_list(new_member_list)
|
||||
yield 'unsubscribe_from_mailing_list'
|
||||
except UserNotSubscribedError:
|
||||
pass
|
||||
|
||||
self.finish('OK')
|
|
@ -38,12 +38,12 @@ class ModifyMemberTransaction(AbstractTransaction):
|
|||
user = self.ldap_srv.get_user(self.username)
|
||||
self.user = user
|
||||
|
||||
if self.login_shell:
|
||||
if self.login_shell is not None:
|
||||
self.old_login_shell = user.login_shell
|
||||
user.replace_login_shell(self.login_shell)
|
||||
yield 'replace_login_shell'
|
||||
|
||||
if self.forwarding_addresses:
|
||||
if self.forwarding_addresses is not None:
|
||||
self.old_forwarding_addresses = user.get_forwarding_addresses()
|
||||
user.set_forwarding_addresses(self.forwarding_addresses)
|
||||
yield 'replace_forwarding_addresses'
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
from zope import component
|
||||
|
||||
from ..AbstractTransaction import AbstractTransaction
|
||||
from .utils import gen_password
|
||||
from ceo_common.interfaces import ILDAPService
|
||||
|
||||
|
||||
class ResetPasswordTransaction(AbstractTransaction):
|
||||
"""Transaction to reset a user's password."""
|
||||
|
||||
operations = [
|
||||
'change_password',
|
||||
]
|
||||
|
||||
def __init__(self, username: str):
|
||||
super().__init__()
|
||||
self.username = username
|
||||
self.ldap_srv = component.getUtility(ILDAPService)
|
||||
|
||||
def child_execute_iter(self):
|
||||
user = self.ldap_srv.get_user(self.username)
|
||||
|
||||
password = gen_password()
|
||||
user.change_password(password)
|
||||
yield 'change_password'
|
||||
|
||||
self.finish({'password': password})
|
|
@ -1,4 +1,4 @@
|
|||
from .AddMemberTransaction import AddMemberTransaction
|
||||
from .ModifyMemberTransaction import ModifyMemberTransaction
|
||||
from .RenewMemberTransaction import RenewMemberTransaction
|
||||
from .ResetPasswordTransaction import ResetPasswordTransaction
|
||||
from .DeleteMemberTransaction import DeleteMemberTransaction
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
from typing import Union, List
|
||||
|
||||
from zope import component
|
||||
|
||||
from ..AbstractTransaction import AbstractTransaction
|
||||
from ceo_common.interfaces import ILDAPService
|
||||
|
||||
|
||||
class UpdateProgramsTransaction(AbstractTransaction):
|
||||
"""Transaction to sync the 'program' attribute in CSC LDAP with UW LDAP."""
|
||||
|
||||
def __init__(self, members: Union[List[str], None]):
|
||||
super().__init__()
|
||||
self.members = members
|
||||
self.ldap_srv = component.getUtility(ILDAPService)
|
||||
|
||||
def child_execute_iter(self):
|
||||
users_updated = self.ldap_srv.update_programs(members=self.members)
|
||||
yield 'update_programs'
|
||||
self.finish(users_updated)
|
|
@ -1 +0,0 @@
|
|||
from .UpdateProgramsTransaction import UpdateProgramsTransaction
|
|
@ -0,0 +1,14 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from base64 import b64encode
|
||||
import sys
|
||||
|
||||
from ceo_common.krb5.utils import get_fwd_tgt
|
||||
|
||||
if len(sys.argv) != 2:
|
||||
print(f'Usage: {sys.argv[0]} <ceod hostname>', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
b = get_fwd_tgt('ceod/' + sys.argv[1])
|
||||
with open('cred', 'wb') as f:
|
||||
f.write(b64encode(b))
|
|
@ -13,7 +13,13 @@ class MockMailmanServer:
|
|||
self.runner = web.AppRunner(self.app)
|
||||
self.loop = asyncio.new_event_loop()
|
||||
|
||||
self.subscriptions = []
|
||||
# add more as necessary
|
||||
self.subscriptions = {
|
||||
'csc-general': [],
|
||||
'exec': [],
|
||||
'syscom': [],
|
||||
'syscom-alerts': [],
|
||||
}
|
||||
|
||||
def _start_loop(self):
|
||||
asyncio.set_event_loop(self.loop)
|
||||
|
@ -32,18 +38,28 @@ class MockMailmanServer:
|
|||
async def subscribe(self, request):
|
||||
body = await request.post()
|
||||
subscriber = body['subscriber']
|
||||
if subscriber in self.subscriptions:
|
||||
list_id = body['list_id']
|
||||
list_id = list_id[:list_id.index('.')]
|
||||
if list_id not in self.subscriptions:
|
||||
return web.json_response({
|
||||
'description': 'No such list',
|
||||
}, status=400)
|
||||
subscribers = self.subscriptions[list_id]
|
||||
if subscriber in subscribers:
|
||||
return web.json_response({
|
||||
'description': 'user is already subscribed',
|
||||
}, status=409)
|
||||
self.subscriptions.append(subscriber)
|
||||
subscribers.append(subscriber)
|
||||
return web.json_response({'status': 'OK'})
|
||||
|
||||
async def unsubscribe(self, request):
|
||||
subscriber = request.match_info['address']
|
||||
if subscriber not in self.subscriptions:
|
||||
list_id = request.match_info['mailing_list']
|
||||
list_id = list_id[:list_id.index('@')]
|
||||
subscribers = self.subscriptions.get(list_id, [])
|
||||
if subscriber not in subscribers:
|
||||
return web.json_response({
|
||||
'description': 'user is not subscribed',
|
||||
}, status=404)
|
||||
self.subscriptions.remove(subscriber)
|
||||
subscribers.remove(subscriber)
|
||||
return web.json_response({'status': 'OK'})
|
||||
|
|
|
@ -1,23 +1,205 @@
|
|||
import pwd
|
||||
import grp
|
||||
from unittest.mock import patch
|
||||
|
||||
import ldap3
|
||||
import pytest
|
||||
|
||||
import ceod.utils as utils
|
||||
|
||||
def test_members_get_user(client):
|
||||
|
||||
def test_api_user_not_found(client):
|
||||
status, data = client.get('/api/members/no_such_user')
|
||||
assert status == 404
|
||||
assert data['error'] == 'user not found'
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def create_user_resp(client):
|
||||
return client.post('/api/members', json={
|
||||
'uid': 'test_jdoe',
|
||||
'cn': 'John Doe',
|
||||
def mocks_for_create_user():
|
||||
with patch.object(utils, 'gen_password') as gen_password_mock, \
|
||||
patch.object(pwd, 'getpwuid') as getpwuid_mock, \
|
||||
patch.object(grp, 'getgrgid') as getgrgid_mock:
|
||||
gen_password_mock.return_value = 'krb5'
|
||||
# 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()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def create_user_resp(client, mocks_for_create_user):
|
||||
status, data = client.post('/api/members', json={
|
||||
'uid': 'test_1',
|
||||
'cn': 'Test One',
|
||||
'program': 'Math',
|
||||
'terms': ['s2021'],
|
||||
})
|
||||
assert status == 200
|
||||
assert data[-1]['status'] == 'completed'
|
||||
yield status, data
|
||||
status, data = client.delete('/api/members/test_1')
|
||||
assert status == 200
|
||||
assert data[-1]['status'] == 'completed'
|
||||
|
||||
|
||||
# def test_create_user(create_user_resp):
|
||||
# status, data = create_user_resp
|
||||
# assert status == 200
|
||||
# # TODO: check response contents
|
||||
@pytest.fixture(scope='session')
|
||||
def create_user_result(create_user_resp):
|
||||
# convenience method
|
||||
_, data = create_user_resp
|
||||
return data[-1]['result']
|
||||
|
||||
|
||||
def test_api_create_user(cfg, create_user_resp):
|
||||
_, data = create_user_resp
|
||||
min_uid = cfg.get('members_min_id')
|
||||
expected = [
|
||||
{"status": "in progress", "operation": "add_user_to_ldap"},
|
||||
{"status": "in progress", "operation": "add_group_to_ldap"},
|
||||
{"status": "in progress", "operation": "add_user_to_kerberos"},
|
||||
{"status": "in progress", "operation": "create_home_dir"},
|
||||
{"status": "in progress", "operation": "set_forwarding_addresses"},
|
||||
{"status": "in progress", "operation": "send_welcome_message"},
|
||||
{"status": "in progress", "operation": "subscribe_to_mailing_list"},
|
||||
{"status": "completed", "result": {
|
||||
"cn": "Test One",
|
||||
"uid": "test_1",
|
||||
"uid_number": min_uid,
|
||||
"gid_number": min_uid,
|
||||
"login_shell": "/bin/bash",
|
||||
"home_directory": "/tmp/test_users/test_1",
|
||||
"is_club": False,
|
||||
"program": "Math",
|
||||
"terms": ["s2021"],
|
||||
"forwarding_addresses": [],
|
||||
"password": "krb5"
|
||||
}},
|
||||
]
|
||||
assert data == expected
|
||||
|
||||
|
||||
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',
|
||||
'cn': 'Test Two',
|
||||
'program': 'Math',
|
||||
'terms': ['s2021'],
|
||||
})
|
||||
assert data[-1]['status'] == 'completed'
|
||||
result = data[-1]['result']
|
||||
try:
|
||||
assert result['uid_number'] == min_uid + 1
|
||||
assert result['gid_number'] == min_uid + 1
|
||||
finally:
|
||||
client.delete('/api/members/test_2')
|
||||
|
||||
|
||||
def test_api_get_user(cfg, client, create_user_result):
|
||||
old_data = create_user_result
|
||||
uid = old_data['uid']
|
||||
del old_data['password']
|
||||
|
||||
status, data = client.get(f'/api/members/{uid}')
|
||||
assert status == 200
|
||||
assert data == old_data
|
||||
|
||||
|
||||
def test_api_patch_user(client, create_user_result):
|
||||
data = create_user_result
|
||||
uid = data['uid']
|
||||
prev_login_shell = data['login_shell']
|
||||
prev_forwarding_addresses = data['forwarding_addresses']
|
||||
|
||||
# replace login shell
|
||||
new_shell = '/bin/sh'
|
||||
status, data = client.patch(
|
||||
f'/api/members/{uid}', json={'login_shell': new_shell})
|
||||
assert status == 200
|
||||
expected = [
|
||||
{"status": "in progress", "operation": "replace_login_shell"},
|
||||
{"status": "completed", "result": "OK"},
|
||||
]
|
||||
assert data == expected
|
||||
|
||||
# replace forwarding addresses
|
||||
new_forwarding_addresses = [
|
||||
'test@example1.internal', 'test@example2.internal',
|
||||
]
|
||||
status, data = client.patch(
|
||||
f'/api/members/{uid}', json={
|
||||
'forwarding_addresses': new_forwarding_addresses
|
||||
}
|
||||
)
|
||||
assert status == 200
|
||||
expected = [
|
||||
{"status": "in progress", "operation": "replace_forwarding_addresses"},
|
||||
{"status": "completed", "result": "OK"},
|
||||
]
|
||||
assert data == expected
|
||||
|
||||
# retrieve the user and make sure that both fields were changed
|
||||
status, data = client.get(f'/api/members/{uid}')
|
||||
assert status == 200
|
||||
assert data['login_shell'] == new_shell
|
||||
assert data['forwarding_addresses'] == new_forwarding_addresses
|
||||
|
||||
# replace (restore) both
|
||||
status, data = client.patch(
|
||||
f'/api/members/{uid}', json={
|
||||
'login_shell': prev_login_shell,
|
||||
'forwarding_addresses': prev_forwarding_addresses
|
||||
}
|
||||
)
|
||||
assert status == 200
|
||||
expected = [
|
||||
{"status": "in progress", "operation": "replace_login_shell"},
|
||||
{"status": "in progress", "operation": "replace_forwarding_addresses"},
|
||||
{"status": "completed", "result": "OK"},
|
||||
]
|
||||
assert data == expected
|
||||
|
||||
|
||||
def test_api_renew_user(cfg, client, create_user_result, ldap_conn):
|
||||
data = create_user_result
|
||||
uid = data['uid']
|
||||
old_terms = data['terms']
|
||||
old_non_member_terms = data.get('non_member_terms', [])
|
||||
|
||||
new_terms = ['f2021']
|
||||
status, data = client.post(
|
||||
f'/api/members/{uid}/renew', json={'terms': new_terms})
|
||||
assert status == 200
|
||||
|
||||
new_non_member_terms = ['w2022', 's2022']
|
||||
status, data = client.post(
|
||||
f'/api/members/{uid}/renew', json={'non_member_terms': new_non_member_terms})
|
||||
assert status == 200
|
||||
|
||||
# check that the changes were applied
|
||||
_, data = client.get(f'/api/members/{uid}')
|
||||
assert data['terms'] == old_terms + new_terms
|
||||
assert data['non_member_terms'] == old_non_member_terms + new_non_member_terms
|
||||
|
||||
# cleanup
|
||||
base_dn = cfg.get('ldap_users_base')
|
||||
dn = f'uid={uid},{base_dn}'
|
||||
changes = {
|
||||
'term': [(ldap3.MODIFY_REPLACE, old_terms)],
|
||||
'nonMemberTerm': [(ldap3.MODIFY_REPLACE, old_non_member_terms)],
|
||||
}
|
||||
ldap_conn.modify(dn, changes)
|
||||
|
||||
|
||||
def test_api_reset_password(client, create_user_result):
|
||||
uid = create_user_result['uid']
|
||||
with patch.object(utils, 'gen_password') as gen_password_mock:
|
||||
gen_password_mock.return_value = 'new_password'
|
||||
status, data = client.post(f'/api/members/{uid}/pwreset')
|
||||
assert status == 200
|
||||
assert data['password'] == 'new_password'
|
||||
# cleanup
|
||||
status, data = client.post(f'/api/members/{uid}/pwreset')
|
||||
assert status == 200
|
||||
assert data['password'] == 'krb5'
|
||||
|
|
|
@ -3,7 +3,7 @@ import pytest
|
|||
from ceo_common.errors import GroupNotFoundError
|
||||
|
||||
|
||||
def test_group_add_to_ldap(simple_group, ldap_srv):
|
||||
def test_group_add_to_ldap(simple_group, ldap_srv, g_admin):
|
||||
group = simple_group
|
||||
|
||||
group.add_to_ldap()
|
||||
|
@ -15,7 +15,7 @@ def test_group_add_to_ldap(simple_group, ldap_srv):
|
|||
ldap_srv.get_group(group.cn)
|
||||
|
||||
|
||||
def test_group_members(ldap_group, ldap_srv):
|
||||
def test_group_members(ldap_group, ldap_srv, g_admin):
|
||||
group = ldap_group
|
||||
|
||||
group.add_member('member1')
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
def test_welcome_message(cfg, mock_mail_server, mail_srv, simple_user):
|
||||
base_domain = cfg.get('base_domain')
|
||||
mock_mail_server.messages.clear()
|
||||
mail_srv.send_welcome_message_to(simple_user)
|
||||
msg = mock_mail_server.messages[0]
|
||||
assert msg['from'] == f'exec@{base_domain}'
|
||||
|
|
|
@ -7,7 +7,7 @@ from ceo_common.errors import UserNotFoundError, UserAlreadyExistsError
|
|||
from ceod.model import User
|
||||
|
||||
|
||||
def test_user_add_to_ldap(cfg, ldap_srv, simple_user):
|
||||
def test_user_add_to_ldap(cfg, ldap_srv, simple_user, g_admin):
|
||||
user = simple_user
|
||||
min_id = cfg.get('members_min_id')
|
||||
user.add_to_ldap()
|
||||
|
@ -23,7 +23,7 @@ def test_user_add_to_ldap(cfg, ldap_srv, simple_user):
|
|||
ldap_srv.get_user(user.uid)
|
||||
|
||||
|
||||
def test_club_add_to_ldap(cfg, ldap_srv, simple_club):
|
||||
def test_club_add_to_ldap(cfg, ldap_srv, simple_club, g_admin):
|
||||
club = simple_club
|
||||
min_id = cfg.get('clubs_min_id')
|
||||
club.add_to_ldap()
|
||||
|
@ -74,7 +74,7 @@ def test_user_forwarding_addresses(cfg, ldap_user):
|
|||
assert not os.path.isdir(user.home_directory)
|
||||
|
||||
|
||||
def test_user_terms(ldap_user, ldap_srv):
|
||||
def test_user_terms(ldap_user, ldap_srv, g_admin):
|
||||
user = ldap_user
|
||||
|
||||
user.add_terms(['f2021'])
|
||||
|
@ -86,7 +86,7 @@ def test_user_terms(ldap_user, ldap_srv):
|
|||
assert ldap_srv.get_user(user.uid).non_member_terms == user.non_member_terms
|
||||
|
||||
|
||||
def test_user_positions(ldap_user, ldap_srv):
|
||||
def test_user_positions(ldap_user, ldap_srv, g_admin):
|
||||
user = ldap_user
|
||||
|
||||
user.add_position('treasurer')
|
||||
|
@ -108,7 +108,7 @@ def test_user_change_password(krb_user):
|
|||
user.change_password('new_password')
|
||||
|
||||
|
||||
def test_login_shell(ldap_user, ldap_srv):
|
||||
def test_login_shell(ldap_user, ldap_srv, g_admin):
|
||||
user = ldap_user
|
||||
|
||||
user.replace_login_shell('/bin/sh')
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import ldap3
|
||||
|
||||
from tests.conftest import get_ldap_conn
|
||||
|
||||
|
||||
def test_uwldap_get(uwldap_srv, uwldap_user):
|
||||
retrieved_user = uwldap_srv.get_user(uwldap_user.uid)
|
||||
|
@ -11,11 +9,12 @@ def test_uwldap_get(uwldap_srv, uwldap_user):
|
|||
assert uwldap_srv.get_user('no_such_user') is None
|
||||
|
||||
|
||||
def test_ldap_updateprograms(cfg, ldap_srv, uwldap_srv, ldap_user, uwldap_user):
|
||||
def test_ldap_updateprograms(
|
||||
cfg, ldap_conn, ldap_srv, uwldap_srv, ldap_user, uwldap_user, g_admin):
|
||||
# sanity check
|
||||
assert ldap_user.uid == uwldap_user.uid
|
||||
# modify the user's program in UWLDAP
|
||||
conn = get_ldap_conn(cfg.get('uwldap_server_url'))
|
||||
conn = ldap_conn
|
||||
base_dn = cfg.get('uwldap_base')
|
||||
dn = f'uid={uwldap_user.uid},{base_dn}'
|
||||
changes = {'ou': [(ldap3.MODIFY_REPLACE, ['New Program'])]}
|
||||
|
|
|
@ -7,6 +7,7 @@ admin_host = phosphoric-acid
|
|||
# this is the host with NFS no_root_squash
|
||||
fs_root_host = phosphoric-acid
|
||||
mailman_host = mail
|
||||
krb5_cache_dir = /run/ceod/krb5_cache
|
||||
use_https = false
|
||||
port = 9987
|
||||
|
||||
|
@ -43,3 +44,11 @@ api_base_url = http://localhost:8001/3.1
|
|||
api_username = restadmin
|
||||
api_password = mailman3
|
||||
new_member_list = csc-general
|
||||
|
||||
[auxiliary groups]
|
||||
syscom = office,staff,adm,src
|
||||
office = cdrom,audio,video,www
|
||||
|
||||
[auxiliary mailing lists]
|
||||
syscom = syscom,syscom-alerts,syscom-moderators,mirror
|
||||
exec = exec,exec-moderators
|
||||
|
|
|
@ -5,6 +5,7 @@ base_domain = csclub.internal
|
|||
admin_host = phosphoric-acid
|
||||
fs_root_host = phosphoric-acid
|
||||
mailman_host = mail
|
||||
krb5_cache_dir = /tmp/ceod_test_krb5_cache
|
||||
use_https = false
|
||||
port = 9987
|
||||
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import importlib.resources
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import flask
|
||||
import ldap3
|
||||
import pytest
|
||||
import socket
|
||||
|
@ -30,16 +33,16 @@ def cfg():
|
|||
|
||||
@pytest.fixture(scope='session')
|
||||
def krb_srv(cfg):
|
||||
# we need to be root to read the keytab
|
||||
# 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()
|
||||
cache_dir = '/tmp/ceod_test/krb5_cache'
|
||||
shutil.rmtree(cache_dir, ignore_errors=True)
|
||||
krb = KerberosService(principal, cache_dir)
|
||||
cache_dir = cfg.get('ceod_krb5_cache_dir')
|
||||
krb = KerberosService(principal)
|
||||
component.provideUtility(krb, IKerberosService)
|
||||
yield krb
|
||||
shutil.rmtree(cache_dir)
|
||||
|
@ -55,15 +58,35 @@ def delete_subtree(conn: ldap3.Connection, base_dn: str):
|
|||
pass
|
||||
|
||||
|
||||
def get_ldap_conn(server_url: str):
|
||||
return ldap3.Connection(
|
||||
server_url, auto_bind=True, raise_exceptions=True,
|
||||
authentication=ldap3.SASL, sasl_mechanism=ldap3.KERBEROS)
|
||||
@pytest.fixture(scope='session')
|
||||
def ceod_admin_creds(cfg, krb_srv):
|
||||
"""
|
||||
Acquire credentials for ceod/admin and store them
|
||||
in the default ccache.
|
||||
"""
|
||||
subprocess.run(
|
||||
['kinit', '-k', cfg.get('ldap_admin_principal')],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def ldap_srv(cfg, krb_srv):
|
||||
conn = get_ldap_conn(cfg.get('ldap_server_url'))
|
||||
def ldap_conn(cfg, ceod_admin_creds) -> ldap3.Connection:
|
||||
# Assume that the same server URL is being used for the CSC
|
||||
# and UWLDAP during the tests.
|
||||
cfg = component.getUtility(IConfig)
|
||||
server_url = cfg.get('ldap_server_url')
|
||||
# sanity check
|
||||
assert server_url == cfg.get('uwldap_server_url')
|
||||
return ldap3.Connection(
|
||||
server_url, auto_bind=True, raise_exceptions=True,
|
||||
authentication=ldap3.SASL, sasl_mechanism=ldap3.KERBEROS,
|
||||
user=cfg.get('ldap_admin_principal'))
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def ldap_srv(cfg, krb_srv, ldap_conn):
|
||||
conn = ldap_conn
|
||||
users_base = cfg.get('ldap_users_base')
|
||||
groups_base = cfg.get('ldap_groups_base')
|
||||
|
||||
|
@ -119,8 +142,8 @@ def mailman_srv(mock_mailman_server, cfg, http_client):
|
|||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def uwldap_srv(cfg, ldap_srv):
|
||||
conn = get_ldap_conn(cfg.get('uwldap_server_url'))
|
||||
def uwldap_srv(cfg, ldap_conn):
|
||||
conn = ldap_conn
|
||||
base_dn = cfg.get('uwldap_base')
|
||||
ou = base_dn.split(',', 1)[0].split('=')[1]
|
||||
|
||||
|
@ -167,6 +190,21 @@ def app(
|
|||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def g_admin(cfg, ceod_admin_creds, app):
|
||||
"""
|
||||
Store the creds for ceod/admin in flask.g.
|
||||
This fixture should be used any time LDAP is modified via the LDAPService.
|
||||
"""
|
||||
admin_principal = cfg.get('ldap_admin_principal')
|
||||
with app.app_context():
|
||||
try:
|
||||
flask.g.sasl_user = admin_principal
|
||||
yield
|
||||
finally:
|
||||
flask.g.pop('sasl_user')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_user():
|
||||
return User(
|
||||
|
@ -187,7 +225,7 @@ def simple_club():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def ldap_user(simple_user):
|
||||
def ldap_user(simple_user, g_admin):
|
||||
simple_user.add_to_ldap()
|
||||
yield simple_user
|
||||
simple_user.remove_from_ldap()
|
||||
|
@ -209,15 +247,15 @@ def simple_group():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def ldap_group(simple_group):
|
||||
def ldap_group(simple_group, g_admin):
|
||||
simple_group.add_to_ldap()
|
||||
yield simple_group
|
||||
simple_group.remove_from_ldap()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uwldap_user(cfg, uwldap_srv):
|
||||
conn = get_ldap_conn(cfg.get('uwldap_server_url'))
|
||||
def uwldap_user(cfg, uwldap_srv, ldap_conn):
|
||||
conn = ldap_conn
|
||||
base_dn = cfg.get('uwldap_base')
|
||||
user = UWLDAPRecord(
|
||||
uid='test_jdoe',
|
||||
|
|
|
@ -1,59 +1,82 @@
|
|||
from base64 import b64encode
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
import gssapi
|
||||
import pytest
|
||||
from requests import Request
|
||||
from requests_gssapi import HTTPSPNEGOAuth
|
||||
from zope import component
|
||||
|
||||
from ceo_common.interfaces import IConfig
|
||||
from ceo_common.krb5.utils import get_fwd_tgt
|
||||
|
||||
__all__ = ['client']
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def client(app):
|
||||
app_client = app.test_client()
|
||||
return CeodTestClient(app_client)
|
||||
with tempfile.TemporaryDirectory() as cache_dir:
|
||||
yield CeodTestClient(app_client, cache_dir)
|
||||
|
||||
|
||||
class CeodTestClient:
|
||||
def __init__(self, app_client: FlaskClient):
|
||||
cfg = component.getUtility(IConfig)
|
||||
def __init__(self, app_client: FlaskClient, cache_dir: str):
|
||||
self.client = app_client
|
||||
self.admin_principal = cfg.get('ldap_admin_principal')
|
||||
self.syscom_principal = 'ctdalek'
|
||||
# this is only used for the HTTPSNEGOAuth
|
||||
self.base_url = f'http://{socket.getfqdn()}'
|
||||
self.cached_auth = {}
|
||||
# keep a list of all of the principals for which we acquired a TGT
|
||||
self.principals = []
|
||||
# this is where we'll store the credentials for each principal
|
||||
self.ccache = 'DIR:' + cache_dir
|
||||
|
||||
def get_auth(self, principal):
|
||||
if principal in self.cached_auth:
|
||||
return self.cached_auth[principal]
|
||||
name = gssapi.Name(principal)
|
||||
creds = gssapi.Credentials(name=name, usage='initiate')
|
||||
creds = gssapi.Credentials(
|
||||
name=name,
|
||||
usage='initiate',
|
||||
store={'ccache': self.ccache},
|
||||
)
|
||||
auth = HTTPSPNEGOAuth(
|
||||
opportunistic_auth=True,
|
||||
target_name='ceod',
|
||||
creds=creds,
|
||||
)
|
||||
self.cached_auth[principal] = auth
|
||||
return auth
|
||||
|
||||
def get_headers(self, principal):
|
||||
# method doesn't matter here because we just need the headers
|
||||
if principal not in self.principals:
|
||||
# Acquire the initial TGT
|
||||
subprocess.run(
|
||||
['kinit', '-c', self.ccache, principal],
|
||||
text=True, input='krb5',
|
||||
check=True, stdout=subprocess.DEVNULL)
|
||||
self.principals.append(principal)
|
||||
# Get the Authorization header (SPNEGO).
|
||||
# The method doesn't matter here because we just need to extract
|
||||
# the header using req.prepare().
|
||||
req = Request('GET', self.base_url, auth=self.get_auth(principal))
|
||||
return req.prepare().headers.items()
|
||||
headers = list(req.prepare().headers.items())
|
||||
# Get the X-KRB5-CRED header (forwarded TGT).
|
||||
cred = b64encode(
|
||||
get_fwd_tgt('ceod/' + socket.getfqdn(), self.ccache)
|
||||
).decode()
|
||||
headers.append(('X-KRB5-CRED', cred))
|
||||
return headers
|
||||
|
||||
def request(self, method, path, principal, **kwargs):
|
||||
if principal is None:
|
||||
principal = self.admin_principal
|
||||
principal = self.syscom_principal
|
||||
resp = self.client.open(
|
||||
path, method=method, headers=self.get_headers(principal), **kwargs)
|
||||
status = int(resp.status.split(' ', 1)[0])
|
||||
try:
|
||||
if resp.headers['content-type'] == 'application/json':
|
||||
data = json.loads(resp.data)
|
||||
except json.JSONDecodeError:
|
||||
data = resp.data.decode()
|
||||
else:
|
||||
data = [json.loads(line) for line in resp.data.splitlines()]
|
||||
return status, data
|
||||
|
||||
def get(self, path, principal=None, **kwargs):
|
||||
|
@ -62,5 +85,8 @@ class CeodTestClient:
|
|||
def post(self, path, principal=None, **kwargs):
|
||||
return self.request('POST', path, principal, **kwargs)
|
||||
|
||||
def patch(self, path, principal=None, **kwargs):
|
||||
return self.request('PATCH', path, principal, **kwargs)
|
||||
|
||||
def delete(self, path, principal=None, **kwargs):
|
||||
return self.request('DELETE', path, principal, **kwargs)
|
||||
|
|
Loading…
Reference in New Issue