use GSSAPI delegation
continuous-integration/drone/push Build was killed Details

This commit is contained in:
Max Erenberg 2021-08-26 02:19:18 +00:00
parent 95e167578f
commit e011e98026
25 changed files with 132 additions and 566 deletions

View File

@ -61,17 +61,6 @@ pip install -r requirements.txt
pip install -r dev-requirements.txt
```
#### 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 a distributed application, with instances on different hosts offering
different services.

View File

@ -40,8 +40,8 @@ not worth it if ceo is the only app which will use it.
Therefore, we will use unconstrained delegation. The client essentially
forwards their TGT to ceod, which uses it to access other services over GSSAPI
on the client's behalf. The TGT is formatted as a KRB-CRED message,
base64-encoded, and placed in an HTTP header named 'X-KRB5-CRED'.
on the client's behalf. We accomplish this using GSSAPI delegation (i.e. set
the GSS_C_DELEG_FLAG when creating a security context).
Since the client's credentials are used when interacting with LDAP, this means
that most LDAP-related endpoints can actually be accessed from any host.
@ -57,7 +57,7 @@ to protect the KRB-CRED message, which is unencrypted.)
SPNEGO is pretty awkward, to be honest, as it completely breaks the stateless
nature of HTTP. If we decide that SPNEGO is too much trouble, we should switch
to plain HTTP cookies instead, and cache them somewhere in the client's home
to plain HTTP cookies instead, and cache them somewhere in the client's home
directory.
## Web UI

View File

@ -11,14 +11,13 @@ def http_request(method: str, path: str, **kwargs) -> requests.Response:
cfg = component.getUtility(IConfig)
if path.startswith('/api/db'):
host = cfg.get('ceod_db_host')
need_cred = False
delegate = False
else:
host = cfg.get('ceod_admin_host')
# The forwarded TGT is only needed for endpoints which write to LDAP
need_cred = method != 'GET'
delegate = method != 'GET'
return client.request(
host, path, method, principal=None, need_cred=need_cred,
stream=True, **kwargs)
host, path, method, delegate=delegate, stream=True, **kwargs)
def http_get(path: str, **kwargs) -> requests.Response:

View File

@ -1,27 +1,24 @@
from typing import Union
from zope.interface import Interface
class IHTTPClient(Interface):
"""A helper class for HTTP requests to ceod."""
def request(host: str, api_path: str, method: str, principal: str,
need_cred: bool, **kwargs):
"""Make an HTTP request."""
def request(host: str, api_path: str, method: str, delegate: bool, **kwargs):
"""
Make an HTTP request.
If `delegate` is True, GSSAPI credentials will be forwarded to the
remote.
"""
def get(host: str, api_path: str, principal: Union[str, None] = None,
need_cred: bool = True, **kwargs):
def get(host: str, api_path: str, delegate: bool = True, **kwargs):
"""Make a GET request."""
def post(host: str, api_path: str, principal: Union[str, None] = None,
need_cred: bool = True, **kwargs):
def post(host: str, api_path: str, delegate: bool = True, **kwargs):
"""Make a POST request."""
def patch(host: str, api_path: str, principal: Union[str, None] = None,
need_cred: bool = True, **kwargs):
def patch(host: str, api_path: str, delegate: bool = True, **kwargs):
"""Make a PATCH request."""
def delete(host: str, api_path: str, principal: Union[str, None] = None,
need_cred: bool = True, **kwargs):
def delete(host: str, api_path: str, delegate: bool = True, **kwargs):
"""Make a DELETE request."""

View File

@ -1,97 +0,0 @@
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'],
extra_link_args=['-fsanitize=address', '-static-libasan'],
)
if __name__ == '__main__':
ffibuilder.compile(verbose=True)

View File

@ -1,202 +0,0 @@
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. Must have the format
'TYPE:residual'.
"""
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)

View File

@ -1,13 +1,10 @@
from base64 import b64encode
from typing import Union
import flask
import gssapi
import requests
from requests_gssapi import HTTPSPNEGOAuth
from zope import component
from zope.interface import implementer
from ceo_common.krb5.utils import get_fwd_tgt
from ceo_common.interfaces import IConfig, IHTTPClient
@ -23,48 +20,40 @@ class HTTPClient:
self.ceod_port = cfg.get('ceod_port')
self.base_domain = cfg.get('base_domain')
def request(self, host: str, api_path: str, method: str, principal: str,
need_cred: bool, **kwargs):
def request(self, host: str, api_path: str, method: str, delegate: bool, **kwargs):
# always use the FQDN
if '.' not in host:
host = host + '.' + self.base_domain
# SPNEGO
if principal is not None:
gssapi_name = gssapi.Name(principal)
creds = gssapi.Credentials(name=gssapi_name, usage='initiate')
else:
creds = None
auth = HTTPSPNEGOAuth(
opportunistic_auth=True,
target_name=gssapi.Name('ceod/' + host),
creds=creds,
)
# Forwarded TGT (X-KRB5-CRED)
headers = {}
if need_cred:
b = get_fwd_tgt('ceod/' + host)
headers['X-KRB5-CRED'] = b64encode(b).decode()
spnego_kwargs = {
'opportunistic_auth': True,
'target_name': gssapi.Name('ceod/' + host),
}
if flask.has_request_context() and 'client_creds' in flask.g:
# This is reached when we are the server and the client has forwarded
# their credentials to us.
spnego_kwargs['creds'] = flask.g.client_creds
if delegate:
# This is reached when we are the client and we want to forward our
# credentials to the server.
spnego_kwargs['delegate'] = True
auth = HTTPSPNEGOAuth(**spnego_kwargs)
return requests.request(
method,
f'{self.scheme}://{host}:{self.ceod_port}{api_path}',
auth=auth, headers=headers, **kwargs,
auth=auth, **kwargs,
)
def get(self, host: str, api_path: str, principal: Union[str, None] = None,
need_cred: bool = False, **kwargs):
return self.request(host, api_path, 'GET', principal, need_cred, **kwargs)
def get(self, host: str, api_path: str, delegate: bool = True, **kwargs):
return self.request(host, api_path, 'GET', delegate, **kwargs)
def post(self, host: str, api_path: str, principal: Union[str, None] = None,
need_cred: bool = False, **kwargs):
return self.request(host, api_path, 'POST', principal, need_cred, **kwargs)
def post(self, host: str, api_path: str, delegate: bool = True, **kwargs):
return self.request(host, api_path, 'POST', delegate, **kwargs)
def patch(self, host: str, api_path: str, principal: Union[str, None] = None,
need_cred: bool = False, **kwargs):
return self.request(host, api_path, 'PATCH', principal, need_cred, **kwargs)
def patch(self, host: str, api_path: str, delegate: bool = True, **kwargs):
return self.request(host, api_path, 'PATCH', delegate, **kwargs)
def delete(self, host: str, api_path: str, principal: Union[str, None] = None,
need_cred: bool = False, **kwargs):
return self.request(host, api_path, 'DELETE', principal, need_cred, **kwargs)
def delete(self, host: str, api_path: str, delegate: bool = True, **kwargs):
return self.request(host, api_path, 'DELETE', delegate, **kwargs)

View File

@ -1,4 +1,3 @@
from flask import g
from zope import component
from zope.interface import implementer
@ -17,7 +16,7 @@ class RemoteMailmanService:
def subscribe(self, address: str, mailing_list: str):
resp = self.http_client.post(
self.mailman_host, f'/api/mailman/{mailing_list}/{address}',
principal=g.sasl_user)
delegate=False)
if not resp.ok:
if resp.status_code == 409:
raise UserAlreadySubscribedError()
@ -28,7 +27,7 @@ class RemoteMailmanService:
def unsubscribe(self, address: str, mailing_list: str):
resp = self.http_client.delete(
self.mailman_host, f'/api/mailman/{mailing_list}/{address}',
principal=g.sasl_user)
delegate=False)
if not resp.ok:
if resp.status_code == 404:
raise UserNotSubscribedError()

View File

@ -6,7 +6,6 @@ from flask import Flask
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
@ -47,8 +46,6 @@ 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():

View File

@ -1,31 +0,0 @@
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)
g.pop('sasl_user')
g.pop('stored_creds_ctx')
except Exception:
logger.error(traceback.format_exc())

View File

@ -3,8 +3,9 @@ import functools
import socket
from typing import Union
from flask import request, Response, make_response
from flask import request, Response, make_response, g
import gssapi
from gssapi.raw import RequirementFlag
_server_name = None
@ -45,7 +46,16 @@ def requires_authentication(f):
# necessary?)
assert ctx.complete, 'only one round trip expected'
resp = make_response(f(str(ctx.initiator_name), *args, **kwargs))
# Store the username in flask.g
client_princ = str(ctx.initiator_name)
g.auth_user = client_princ[:client_princ.index('@')]
# Store the delegated credentials, if they were given
if ctx.actual_flags & RequirementFlag.delegate_to_peer:
g.client_creds = ctx.delegated_creds
# TODO: don't pass client_princ to f anymore since it's stored in flask.g
resp = make_response(f(client_princ, *args, **kwargs))
# RFC 2744, section 5.1:
# "If no token need be sent, gss_accept_sec_context will indicate this
# by setting the length field of the output_token argument to zero."

View File

@ -1,5 +1,4 @@
import os
import shutil
import subprocess
from typing import List
@ -7,9 +6,6 @@ from zope import component
from zope.interface import implementer
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)
@ -21,35 +17,10 @@ class KerberosService:
cfg = component.getUtility(IConfig)
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)
# We don't need a credentials cache because the client forwards
# their credentials to us
os.environ['KRB5CCNAME'] = 'FILE:/dev/null'
def _run(self, args: List[str]):
subprocess.run(args, check=True)

View File

@ -34,10 +34,11 @@ class LDAPService:
if 'ldap_conn' in g:
return g.ldap_conn
kwargs = {'auto_bind': True, 'raise_exceptions': True}
if 'sasl_user' in g:
if 'client_creds' in g:
kwargs['authentication'] = ldap3.SASL
kwargs['sasl_mechanism'] = ldap3.KERBEROS
kwargs['user'] = g.sasl_user
# see https://github.com/cannatag/ldap3/blob/master/ldap3/protocol/sasl/kerberos.py
kwargs['sasl_credentials'] = (None, None, g.client_creds)
conn = ldap3.Connection(self.ldap_server_url, **kwargs)
# cache the connection for a single request
g.ldap_conn = conn

View File

@ -65,8 +65,7 @@ class MailService:
def announce_new_user(self, user: IUser, operations: List[str]):
# The person who added the new user
# TODO: store the auth_user from SPNEGO into flask.g
auth_user = g.sasl_user
auth_user = g.auth_user
if '@' in auth_user:
auth_user = auth_user[:auth_user.index('@')]

View File

@ -1,7 +1,6 @@
flake8==3.9.2
setuptools==40.8.0
wheel==0.36.2
cffi==1.14.6
pytest==6.2.4
aiosmtpd==1.4.2
aiohttp==3.7.4.post0

View File

@ -1,45 +0,0 @@
import os
import socket
import subprocess
from subprocess import DEVNULL
import tempfile
import ldap3
from ceo_common.krb5.utils import get_fwd_tgt, store_fwd_tgt_creds
def test_fwd_tgt(cfg):
realm = cfg.get('ldap_sasl_realm')
ldap_server = cfg.get('ldap_server_url')
hostname = socket.gethostname()
old_krb5ccname = os.environ['KRB5CCNAME']
f1 = tempfile.NamedTemporaryFile()
d2 = tempfile.TemporaryDirectory()
try:
subprocess.run(
['kinit', '-c', 'FILE:' + f1.name, 'regular1'],
text=True, input='krb5', check=True, stdout=DEVNULL)
subprocess.run(
['kinit', '-c', 'DIR:' + d2.name, 'ctdalek'],
text=True, input='krb5', check=True, stdout=DEVNULL)
os.environ['KRB5CCNAME'] = 'FILE:' + f1.name
b = get_fwd_tgt(hostname)
os.environ['KRB5CCNAME'] = 'DIR:' + d2.name
# make sure that we can import the creds from regular1 into the
# cache collection
with store_fwd_tgt_creds(b) as name:
assert name == 'regular1@' + realm
kwargs = {
'server': ldap_server, 'auto_bind': True,
'authentication': ldap3.SASL, 'sasl_mechanism': ldap3.KERBEROS,
}
conn = ldap3.Connection(**kwargs, user='regular1')
assert conn.extend.standard.who_am_i().startswith('dn:uid=regular1,')
conn.unbind()
finally:
os.environ['KRB5CCNAME'] = old_krb5ccname
f1.close()
d2.cleanup()

View File

@ -210,5 +210,5 @@ def test_authz_check(client, create_user_result):
# If we're syscom but we don't pass credentials, the request should fail
_, data = client.post('/api/members', json={
'uid': 'test_1', 'cn': 'Test One', 'terms': ['s2021'],
}, principal='ctdalek', need_cred=False)
}, principal='ctdalek', delegate=False)
assert data[-1]['status'] == 'aborted'

View File

@ -7,7 +7,6 @@ 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

View File

@ -7,7 +7,6 @@ uw_domain = uwaterloo.internal
admin_host = phosphoric-acid
fs_root_host = phosphoric-acid
mailman_host = phosphoric-acid
krb5_cache_dir = /tmp/ceod_test_krb5_cache
use_https = false
port = 9987

View File

@ -17,7 +17,7 @@ import requests
import socket
from zope import component
from .utils import krb5ccname_ctx
from .utils import gssapi_creds_ctx, ccache_cleanup # noqa: F401
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, \
IFileService, IMailmanService, IHTTPClient, IUWLDAPService, IMailService
from ceo_common.model import Config, HTTPClient
@ -51,18 +51,6 @@ def cfg(_drone_hostname_mock):
return _cfg
@pytest.fixture(scope='session', autouse=True)
def _delete_ccaches():
# I've noticed when pytest finishes, the temporary files
# created by tempfile.NamedTemporaryFile() aren't destroyed.
# So, we clean them up here.
from .utils import _ccaches
yield
# forcefully decrement the reference counts, which will trigger
# the destructors
_ccaches.clear()
def delete_test_princs(krb_srv):
proc = subprocess.run([
'kadmin', '-k', '-p', krb_srv.admin_principal, 'listprincs', 'test_*',
@ -82,14 +70,12 @@ def krb_srv(cfg):
principal = 'ceod/admin'
else:
principal = 'ceod/' + socket.getfqdn()
cache_dir = cfg.get('ceod_krb5_cache_dir')
krb = KerberosService(principal)
component.provideUtility(krb, IKerberosService)
delete_test_princs(krb)
yield krb
delete_test_princs(krb)
shutil.rmtree(cache_dir)
def delete_subtree(conn: ldap3.Connection, base_dn: str):
@ -105,7 +91,7 @@ def delete_subtree(conn: ldap3.Connection, base_dn: str):
@pytest.fixture
def g_admin_ctx(app):
"""
Store the principal for ceod/admin in flask.g.
Store the credentials for ceod/admin in flask.g, and override KBR5CCNAME.
This context manager should be used any time LDAP is modified via the
LDAPService, and we are not in a request context.
This should NOT be active when CeodTestClient is making a request, since
@ -113,28 +99,31 @@ def g_admin_ctx(app):
"""
@contextlib.contextmanager
def wrapper():
with krb5ccname_ctx('ceod/admin'), app.app_context():
with gssapi_creds_ctx('ceod/admin') as creds, app.app_context():
try:
flask.g.sasl_user = 'ceod/admin'
flask.g.auth_user = 'ceod/admin'
flask.g.client_creds = creds
yield
finally:
flask.g.pop('sasl_user')
flask.g.pop('client_creds')
flask.g.pop('auth_user')
return wrapper
@pytest.fixture
def g_syscom(app):
"""
Store the principal for the syscom member in flask.g, and point
KRB5CCNAME to the file where the TGT is stored.
Store the credentials for ctdalek in flask.g and override KRB5CCNAME.
Use this fixture if you need syscom credentials for an HTTP request
to a different process.
"""
with krb5ccname_ctx('ctdalek'), app.app_context():
with gssapi_creds_ctx('ctdalek') as creds, app.app_context():
try:
flask.g.sasl_user = 'ctdalek'
flask.g.client_creds = creds
yield
finally:
flask.g.pop('client_creds')
flask.g.pop('sasl_user')
@ -146,11 +135,11 @@ def ldap_conn(cfg) -> ldap3.Connection:
server_url = cfg.get('ldap_server_url')
# sanity check
assert server_url == cfg.get('uwldap_server_url')
with krb5ccname_ctx('ceod/admin'):
with gssapi_creds_ctx('ceod/admin') as creds:
conn = ldap3.Connection(
server_url, auto_bind=True, raise_exceptions=True,
authentication=ldap3.SASL, sasl_mechanism=ldap3.KERBEROS,
user='ceod/admin')
sasl_credentials=(None, None, creds))
return conn
@ -378,10 +367,12 @@ def app_process(cfg, app, http_client):
proc.start()
try:
with krb5ccname_ctx('ctdalek'):
# Currently the HTTPClient uses SPNEGO for all requests,
# even GETs
with gssapi_creds_ctx('ctdalek'):
for i in range(5):
try:
http_client.get(hostname, '/ping')
http_client.get(hostname, '/ping', delegate=False)
except requests.exceptions.ConnectionError:
time.sleep(1)
continue

View File

@ -2,7 +2,7 @@ import os
import pytest
from .utils import krb5ccname_ctx
from .utils import gssapi_creds_ctx
@pytest.fixture(scope='module')
@ -14,5 +14,5 @@ def cli_setup(app_process):
# messy because they would be sharing the same environment variables,
# Kerberos cache, and registered utilities (via zope). So we're just
# going to start the app in a child process intead.
with krb5ccname_ctx('ctdalek'):
with gssapi_creds_ctx('ctdalek'):
yield

View File

@ -1,16 +1,14 @@
from base64 import b64encode
import json
import socket
from flask import g
import flask
from flask.testing import FlaskClient
import gssapi
import pytest
from requests import Request
from requests_gssapi import HTTPSPNEGOAuth
from ceo_common.krb5.utils import get_fwd_tgt
from .utils import krb5ccname_ctx
from .utils import gssapi_creds_ctx
__all__ = ['client']
@ -30,39 +28,30 @@ class CeodTestClient:
# for SPNEGO
self.target_name = gssapi.Name('ceod/' + socket.getfqdn())
def get_auth(self, principal):
def get_auth(self, principal: str, delegate: bool):
"""Acquire a HTTPSPNEGOAuth instance for the principal."""
name = gssapi.Name(principal)
# the 'store' arg doesn't seem to work for DIR ccaches
creds = gssapi.Credentials(name=name, usage='initiate')
auth = HTTPSPNEGOAuth(
opportunistic_auth=True,
target_name=self.target_name,
creds=creds,
)
return auth
with gssapi_creds_ctx(principal) as creds:
return HTTPSPNEGOAuth(
opportunistic_auth=True,
target_name=self.target_name,
creds=creds,
delegate=delegate,
)
def get_headers(self, principal: str, need_cred: bool):
with krb5ccname_ctx(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))
headers = list(req.prepare().headers.items())
if need_cred:
# Get the X-KRB5-CRED header (forwarded TGT).
cred = b64encode(get_fwd_tgt('ceod/' + socket.getfqdn())).decode()
headers.append(('X-KRB5-CRED', cred))
def get_headers(self, principal: str, delegate: bool):
# 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, delegate))
headers = list(req.prepare().headers.items())
return headers
def request(self, method: str, path: str, principal: str, need_cred: bool, **kwargs):
# Make sure that we're not already in a request context, otherwise
# g will get overridden
with pytest.raises(RuntimeError):
'' in g
def request(self, method: str, path: str, principal: str, delegate: bool, **kwargs):
# make sure that we're not already in a Flask context
assert not flask.has_app_context()
if principal is None:
principal = self.syscom_principal
headers = self.get_headers(principal, need_cred)
headers = self.get_headers(principal, delegate)
resp = self.client.open(path, method=method, headers=headers, **kwargs)
status = int(resp.status.split(' ', 1)[0])
if resp.headers['content-type'] == 'application/json':
@ -71,14 +60,14 @@ class CeodTestClient:
data = [json.loads(line) for line in resp.data.splitlines()]
return status, data
def get(self, path, principal=None, need_cred=True, **kwargs):
return self.request('GET', path, principal, need_cred, **kwargs)
def get(self, path, principal=None, delegate=True, **kwargs):
return self.request('GET', path, principal, delegate, **kwargs)
def post(self, path, principal=None, need_cred=True, **kwargs):
return self.request('POST', path, principal, need_cred, **kwargs)
def post(self, path, principal=None, delegate=True, **kwargs):
return self.request('POST', path, principal, delegate, **kwargs)
def patch(self, path, principal=None, need_cred=True, **kwargs):
return self.request('PATCH', path, principal, need_cred, **kwargs)
def patch(self, path, principal=None, delegate=True, **kwargs):
return self.request('PATCH', path, principal, delegate, **kwargs)
def delete(self, path, principal=None, need_cred=True, **kwargs):
return self.request('DELETE', path, principal, need_cred, **kwargs)
def delete(self, path, principal=None, delegate=True, **kwargs):
return self.request('DELETE', path, principal, delegate, **kwargs)

View File

@ -4,31 +4,44 @@ import subprocess
from subprocess import DEVNULL
import tempfile
import gssapi
import pytest
# map principals to files storing credentials
_ccaches = {}
# map principals to GSSAPI credentials
_cache = {}
@contextlib.contextmanager
def krb5ccname_ctx(principal: str):
def gssapi_creds_ctx(principal: str):
"""
Temporarily set KRB5CCNAME to a ccache storing credentials
for the specified user.
for the specified user, and yield the GSSAPI credentials.
"""
old_krb5ccname = os.environ['KRB5CCNAME']
try:
if principal not in _ccaches:
if principal not in _cache:
f = tempfile.NamedTemporaryFile()
os.environ['KRB5CCNAME'] = 'FILE:' + f.name
args = ['kinit', principal]
if principal == 'ceod/admin':
args = ['kinit', '-k', principal]
subprocess.run(
args, stdout=DEVNULL, text=True, input='krb5',
check=True)
_ccaches[principal] = f
args, stdout=DEVNULL, text=True, input='krb5', check=True)
creds = gssapi.Credentials(name=gssapi.Name(principal), usage='initiate')
# Keep the credential cache files around as long as the creds are
# used, otherwise we get a "Invalid credential was supplied" error
_cache[principal] = creds, f
else:
os.environ['KRB5CCNAME'] = 'FILE:' + _ccaches[principal].name
yield
creds, f = _cache[principal]
os.environ['KRB5CCNAME'] = 'FILE:' + f.name
yield creds
finally:
os.environ['KRB5CCNAME'] = old_krb5ccname
@pytest.fixture(scope='session', autouse=True)
def ccache_cleanup():
"""Make sure the ccache files get deleted at the end of the tests."""
yield
_cache.clear()