use GSSAPI delegation
continuous-integration/drone/push Build was killed
Details
continuous-integration/drone/push Build was killed
Details
This commit is contained in:
parent
95e167578f
commit
e011e98026
11
README.md
11
README.md
|
@ -61,17 +61,6 @@ pip install -r requirements.txt
|
||||||
pip install -r dev-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
|
## Running the application
|
||||||
ceod is a distributed application, with instances on different hosts offering
|
ceod is a distributed application, with instances on different hosts offering
|
||||||
different services.
|
different services.
|
||||||
|
|
|
@ -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
|
Therefore, we will use unconstrained delegation. The client essentially
|
||||||
forwards their TGT to ceod, which uses it to access other services over GSSAPI
|
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,
|
on the client's behalf. We accomplish this using GSSAPI delegation (i.e. set
|
||||||
base64-encoded, and placed in an HTTP header named 'X-KRB5-CRED'.
|
the GSS_C_DELEG_FLAG when creating a security context).
|
||||||
|
|
||||||
Since the client's credentials are used when interacting with LDAP, this means
|
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.
|
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
|
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
|
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.
|
directory.
|
||||||
|
|
||||||
## Web UI
|
## Web UI
|
||||||
|
|
|
@ -11,14 +11,13 @@ def http_request(method: str, path: str, **kwargs) -> requests.Response:
|
||||||
cfg = component.getUtility(IConfig)
|
cfg = component.getUtility(IConfig)
|
||||||
if path.startswith('/api/db'):
|
if path.startswith('/api/db'):
|
||||||
host = cfg.get('ceod_db_host')
|
host = cfg.get('ceod_db_host')
|
||||||
need_cred = False
|
delegate = False
|
||||||
else:
|
else:
|
||||||
host = cfg.get('ceod_admin_host')
|
host = cfg.get('ceod_admin_host')
|
||||||
# The forwarded TGT is only needed for endpoints which write to LDAP
|
# The forwarded TGT is only needed for endpoints which write to LDAP
|
||||||
need_cred = method != 'GET'
|
delegate = method != 'GET'
|
||||||
return client.request(
|
return client.request(
|
||||||
host, path, method, principal=None, need_cred=need_cred,
|
host, path, method, delegate=delegate, stream=True, **kwargs)
|
||||||
stream=True, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def http_get(path: str, **kwargs) -> requests.Response:
|
def http_get(path: str, **kwargs) -> requests.Response:
|
||||||
|
|
|
@ -1,27 +1,24 @@
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from zope.interface import Interface
|
from zope.interface import Interface
|
||||||
|
|
||||||
|
|
||||||
class IHTTPClient(Interface):
|
class IHTTPClient(Interface):
|
||||||
"""A helper class for HTTP requests to ceod."""
|
"""A helper class for HTTP requests to ceod."""
|
||||||
|
|
||||||
def request(host: str, api_path: str, method: str, principal: str,
|
def request(host: str, api_path: str, method: str, delegate: bool, **kwargs):
|
||||||
need_cred: bool, **kwargs):
|
"""
|
||||||
"""Make an HTTP request."""
|
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,
|
def get(host: str, api_path: str, delegate: bool = True, **kwargs):
|
||||||
need_cred: bool = True, **kwargs):
|
|
||||||
"""Make a GET request."""
|
"""Make a GET request."""
|
||||||
|
|
||||||
def post(host: str, api_path: str, principal: Union[str, None] = None,
|
def post(host: str, api_path: str, delegate: bool = True, **kwargs):
|
||||||
need_cred: bool = True, **kwargs):
|
|
||||||
"""Make a POST request."""
|
"""Make a POST request."""
|
||||||
|
|
||||||
def patch(host: str, api_path: str, principal: Union[str, None] = None,
|
def patch(host: str, api_path: str, delegate: bool = True, **kwargs):
|
||||||
need_cred: bool = True, **kwargs):
|
|
||||||
"""Make a PATCH request."""
|
"""Make a PATCH request."""
|
||||||
|
|
||||||
def delete(host: str, api_path: str, principal: Union[str, None] = None,
|
def delete(host: str, api_path: str, delegate: bool = True, **kwargs):
|
||||||
need_cred: bool = True, **kwargs):
|
|
||||||
"""Make a DELETE request."""
|
"""Make a DELETE request."""
|
||||||
|
|
|
@ -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)
|
|
|
@ -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)
|
|
|
@ -1,13 +1,10 @@
|
||||||
from base64 import b64encode
|
import flask
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
import gssapi
|
import gssapi
|
||||||
import requests
|
import requests
|
||||||
from requests_gssapi import HTTPSPNEGOAuth
|
from requests_gssapi import HTTPSPNEGOAuth
|
||||||
from zope import component
|
from zope import component
|
||||||
from zope.interface import implementer
|
from zope.interface import implementer
|
||||||
|
|
||||||
from ceo_common.krb5.utils import get_fwd_tgt
|
|
||||||
from ceo_common.interfaces import IConfig, IHTTPClient
|
from ceo_common.interfaces import IConfig, IHTTPClient
|
||||||
|
|
||||||
|
|
||||||
|
@ -23,48 +20,40 @@ class HTTPClient:
|
||||||
self.ceod_port = cfg.get('ceod_port')
|
self.ceod_port = cfg.get('ceod_port')
|
||||||
self.base_domain = cfg.get('base_domain')
|
self.base_domain = cfg.get('base_domain')
|
||||||
|
|
||||||
def request(self, host: str, api_path: str, method: str, principal: str,
|
def request(self, host: str, api_path: str, method: str, delegate: bool, **kwargs):
|
||||||
need_cred: bool, **kwargs):
|
|
||||||
# always use the FQDN
|
# always use the FQDN
|
||||||
if '.' not in host:
|
if '.' not in host:
|
||||||
host = host + '.' + self.base_domain
|
host = host + '.' + self.base_domain
|
||||||
|
|
||||||
# SPNEGO
|
# SPNEGO
|
||||||
if principal is not None:
|
spnego_kwargs = {
|
||||||
gssapi_name = gssapi.Name(principal)
|
'opportunistic_auth': True,
|
||||||
creds = gssapi.Credentials(name=gssapi_name, usage='initiate')
|
'target_name': gssapi.Name('ceod/' + host),
|
||||||
else:
|
}
|
||||||
creds = None
|
if flask.has_request_context() and 'client_creds' in flask.g:
|
||||||
auth = HTTPSPNEGOAuth(
|
# This is reached when we are the server and the client has forwarded
|
||||||
opportunistic_auth=True,
|
# their credentials to us.
|
||||||
target_name=gssapi.Name('ceod/' + host),
|
spnego_kwargs['creds'] = flask.g.client_creds
|
||||||
creds=creds,
|
if delegate:
|
||||||
)
|
# This is reached when we are the client and we want to forward our
|
||||||
|
# credentials to the server.
|
||||||
# Forwarded TGT (X-KRB5-CRED)
|
spnego_kwargs['delegate'] = True
|
||||||
headers = {}
|
auth = HTTPSPNEGOAuth(**spnego_kwargs)
|
||||||
if need_cred:
|
|
||||||
b = get_fwd_tgt('ceod/' + host)
|
|
||||||
headers['X-KRB5-CRED'] = b64encode(b).decode()
|
|
||||||
|
|
||||||
return requests.request(
|
return requests.request(
|
||||||
method,
|
method,
|
||||||
f'{self.scheme}://{host}:{self.ceod_port}{api_path}',
|
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,
|
def get(self, host: str, api_path: str, delegate: bool = True, **kwargs):
|
||||||
need_cred: bool = False, **kwargs):
|
return self.request(host, api_path, 'GET', delegate, **kwargs)
|
||||||
return self.request(host, api_path, 'GET', principal, need_cred, **kwargs)
|
|
||||||
|
|
||||||
def post(self, host: str, api_path: str, principal: Union[str, None] = None,
|
def post(self, host: str, api_path: str, delegate: bool = True, **kwargs):
|
||||||
need_cred: bool = False, **kwargs):
|
return self.request(host, api_path, 'POST', delegate, **kwargs)
|
||||||
return self.request(host, api_path, 'POST', principal, need_cred, **kwargs)
|
|
||||||
|
|
||||||
def patch(self, host: str, api_path: str, principal: Union[str, None] = None,
|
def patch(self, host: str, api_path: str, delegate: bool = True, **kwargs):
|
||||||
need_cred: bool = False, **kwargs):
|
return self.request(host, api_path, 'PATCH', delegate, **kwargs)
|
||||||
return self.request(host, api_path, 'PATCH', principal, need_cred, **kwargs)
|
|
||||||
|
|
||||||
def delete(self, host: str, api_path: str, principal: Union[str, None] = None,
|
def delete(self, host: str, api_path: str, delegate: bool = True, **kwargs):
|
||||||
need_cred: bool = False, **kwargs):
|
return self.request(host, api_path, 'DELETE', delegate, **kwargs)
|
||||||
return self.request(host, api_path, 'DELETE', principal, need_cred, **kwargs)
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
from flask import g
|
|
||||||
from zope import component
|
from zope import component
|
||||||
from zope.interface import implementer
|
from zope.interface import implementer
|
||||||
|
|
||||||
|
@ -17,7 +16,7 @@ class RemoteMailmanService:
|
||||||
def subscribe(self, address: str, mailing_list: str):
|
def subscribe(self, address: str, mailing_list: str):
|
||||||
resp = self.http_client.post(
|
resp = self.http_client.post(
|
||||||
self.mailman_host, f'/api/mailman/{mailing_list}/{address}',
|
self.mailman_host, f'/api/mailman/{mailing_list}/{address}',
|
||||||
principal=g.sasl_user)
|
delegate=False)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
if resp.status_code == 409:
|
if resp.status_code == 409:
|
||||||
raise UserAlreadySubscribedError()
|
raise UserAlreadySubscribedError()
|
||||||
|
@ -28,7 +27,7 @@ class RemoteMailmanService:
|
||||||
def unsubscribe(self, address: str, mailing_list: str):
|
def unsubscribe(self, address: str, mailing_list: str):
|
||||||
resp = self.http_client.delete(
|
resp = self.http_client.delete(
|
||||||
self.mailman_host, f'/api/mailman/{mailing_list}/{address}',
|
self.mailman_host, f'/api/mailman/{mailing_list}/{address}',
|
||||||
principal=g.sasl_user)
|
delegate=False)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
if resp.status_code == 404:
|
if resp.status_code == 404:
|
||||||
raise UserNotSubscribedError()
|
raise UserNotSubscribedError()
|
||||||
|
|
|
@ -6,7 +6,6 @@ from flask import Flask
|
||||||
from zope import component
|
from zope import component
|
||||||
|
|
||||||
from .error_handlers import register_error_handlers
|
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, \
|
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \
|
||||||
IMailmanService, IMailService, IUWLDAPService, IHTTPClient
|
IMailmanService, IMailService, IUWLDAPService, IHTTPClient
|
||||||
from ceo_common.model import Config, HTTPClient, RemoteMailmanService
|
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')
|
app.register_blueprint(uwldap.bp, url_prefix='/api/uwldap')
|
||||||
|
|
||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
app.before_request(before_request)
|
|
||||||
app.teardown_request(teardown_request)
|
|
||||||
|
|
||||||
@app.route('/ping')
|
@app.route('/ping')
|
||||||
def ping():
|
def ping():
|
||||||
|
|
|
@ -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())
|
|
|
@ -3,8 +3,9 @@ import functools
|
||||||
import socket
|
import socket
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
from flask import request, Response, make_response
|
from flask import request, Response, make_response, g
|
||||||
import gssapi
|
import gssapi
|
||||||
|
from gssapi.raw import RequirementFlag
|
||||||
|
|
||||||
_server_name = None
|
_server_name = None
|
||||||
|
|
||||||
|
@ -45,7 +46,16 @@ def requires_authentication(f):
|
||||||
# necessary?)
|
# necessary?)
|
||||||
assert ctx.complete, 'only one round trip expected'
|
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:
|
# RFC 2744, section 5.1:
|
||||||
# "If no token need be sent, gss_accept_sec_context will indicate this
|
# "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."
|
# by setting the length field of the output_token argument to zero."
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
@ -7,9 +6,6 @@ from zope import component
|
||||||
from zope.interface import implementer
|
from zope.interface import implementer
|
||||||
|
|
||||||
from ceo_common.interfaces import IKerberosService, IConfig
|
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)
|
@implementer(IKerberosService)
|
||||||
|
@ -21,35 +17,10 @@ class KerberosService:
|
||||||
cfg = component.getUtility(IConfig)
|
cfg = component.getUtility(IConfig)
|
||||||
|
|
||||||
self.admin_principal = admin_principal
|
self.admin_principal = admin_principal
|
||||||
self.cache_dir = cfg.get('ceod_krb5_cache_dir')
|
|
||||||
self.realm = cfg.get('ldap_sasl_realm')
|
self.realm = cfg.get('ldap_sasl_realm')
|
||||||
self._initialize_cache()
|
# We don't need a credentials cache because the client forwards
|
||||||
|
# their credentials to us
|
||||||
def _initialize_cache(self, **kwargs):
|
os.environ['KRB5CCNAME'] = 'FILE:/dev/null'
|
||||||
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 _run(self, args: List[str]):
|
def _run(self, args: List[str]):
|
||||||
subprocess.run(args, check=True)
|
subprocess.run(args, check=True)
|
||||||
|
|
|
@ -34,10 +34,11 @@ class LDAPService:
|
||||||
if 'ldap_conn' in g:
|
if 'ldap_conn' in g:
|
||||||
return g.ldap_conn
|
return g.ldap_conn
|
||||||
kwargs = {'auto_bind': True, 'raise_exceptions': True}
|
kwargs = {'auto_bind': True, 'raise_exceptions': True}
|
||||||
if 'sasl_user' in g:
|
if 'client_creds' in g:
|
||||||
kwargs['authentication'] = ldap3.SASL
|
kwargs['authentication'] = ldap3.SASL
|
||||||
kwargs['sasl_mechanism'] = ldap3.KERBEROS
|
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)
|
conn = ldap3.Connection(self.ldap_server_url, **kwargs)
|
||||||
# cache the connection for a single request
|
# cache the connection for a single request
|
||||||
g.ldap_conn = conn
|
g.ldap_conn = conn
|
||||||
|
|
|
@ -65,8 +65,7 @@ class MailService:
|
||||||
|
|
||||||
def announce_new_user(self, user: IUser, operations: List[str]):
|
def announce_new_user(self, user: IUser, operations: List[str]):
|
||||||
# The person who added the new user
|
# The person who added the new user
|
||||||
# TODO: store the auth_user from SPNEGO into flask.g
|
auth_user = g.auth_user
|
||||||
auth_user = g.sasl_user
|
|
||||||
if '@' in auth_user:
|
if '@' in auth_user:
|
||||||
auth_user = auth_user[:auth_user.index('@')]
|
auth_user = auth_user[:auth_user.index('@')]
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
flake8==3.9.2
|
flake8==3.9.2
|
||||||
setuptools==40.8.0
|
setuptools==40.8.0
|
||||||
wheel==0.36.2
|
wheel==0.36.2
|
||||||
cffi==1.14.6
|
|
||||||
pytest==6.2.4
|
pytest==6.2.4
|
||||||
aiosmtpd==1.4.2
|
aiosmtpd==1.4.2
|
||||||
aiohttp==3.7.4.post0
|
aiohttp==3.7.4.post0
|
||||||
|
|
|
@ -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()
|
|
|
@ -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
|
# If we're syscom but we don't pass credentials, the request should fail
|
||||||
_, data = client.post('/api/members', json={
|
_, data = client.post('/api/members', json={
|
||||||
'uid': 'test_1', 'cn': 'Test One', 'terms': ['s2021'],
|
'uid': 'test_1', 'cn': 'Test One', 'terms': ['s2021'],
|
||||||
}, principal='ctdalek', need_cred=False)
|
}, principal='ctdalek', delegate=False)
|
||||||
assert data[-1]['status'] == 'aborted'
|
assert data[-1]['status'] == 'aborted'
|
||||||
|
|
|
@ -7,7 +7,6 @@ admin_host = phosphoric-acid
|
||||||
# this is the host with NFS no_root_squash
|
# this is the host with NFS no_root_squash
|
||||||
fs_root_host = phosphoric-acid
|
fs_root_host = phosphoric-acid
|
||||||
mailman_host = mail
|
mailman_host = mail
|
||||||
krb5_cache_dir = /run/ceod/krb5_cache
|
|
||||||
use_https = false
|
use_https = false
|
||||||
port = 9987
|
port = 9987
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,6 @@ uw_domain = uwaterloo.internal
|
||||||
admin_host = phosphoric-acid
|
admin_host = phosphoric-acid
|
||||||
fs_root_host = phosphoric-acid
|
fs_root_host = phosphoric-acid
|
||||||
mailman_host = phosphoric-acid
|
mailman_host = phosphoric-acid
|
||||||
krb5_cache_dir = /tmp/ceod_test_krb5_cache
|
|
||||||
use_https = false
|
use_https = false
|
||||||
port = 9987
|
port = 9987
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ import requests
|
||||||
import socket
|
import socket
|
||||||
from zope import component
|
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, \
|
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, \
|
||||||
IFileService, IMailmanService, IHTTPClient, IUWLDAPService, IMailService
|
IFileService, IMailmanService, IHTTPClient, IUWLDAPService, IMailService
|
||||||
from ceo_common.model import Config, HTTPClient
|
from ceo_common.model import Config, HTTPClient
|
||||||
|
@ -51,18 +51,6 @@ def cfg(_drone_hostname_mock):
|
||||||
return _cfg
|
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):
|
def delete_test_princs(krb_srv):
|
||||||
proc = subprocess.run([
|
proc = subprocess.run([
|
||||||
'kadmin', '-k', '-p', krb_srv.admin_principal, 'listprincs', 'test_*',
|
'kadmin', '-k', '-p', krb_srv.admin_principal, 'listprincs', 'test_*',
|
||||||
|
@ -82,14 +70,12 @@ def krb_srv(cfg):
|
||||||
principal = 'ceod/admin'
|
principal = 'ceod/admin'
|
||||||
else:
|
else:
|
||||||
principal = 'ceod/' + socket.getfqdn()
|
principal = 'ceod/' + socket.getfqdn()
|
||||||
cache_dir = cfg.get('ceod_krb5_cache_dir')
|
|
||||||
krb = KerberosService(principal)
|
krb = KerberosService(principal)
|
||||||
component.provideUtility(krb, IKerberosService)
|
component.provideUtility(krb, IKerberosService)
|
||||||
|
|
||||||
delete_test_princs(krb)
|
delete_test_princs(krb)
|
||||||
yield krb
|
yield krb
|
||||||
delete_test_princs(krb)
|
delete_test_princs(krb)
|
||||||
shutil.rmtree(cache_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_subtree(conn: ldap3.Connection, base_dn: str):
|
def delete_subtree(conn: ldap3.Connection, base_dn: str):
|
||||||
|
@ -105,7 +91,7 @@ def delete_subtree(conn: ldap3.Connection, base_dn: str):
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def g_admin_ctx(app):
|
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
|
This context manager should be used any time LDAP is modified via the
|
||||||
LDAPService, and we are not in a request context.
|
LDAPService, and we are not in a request context.
|
||||||
This should NOT be active when CeodTestClient is making a request, since
|
This should NOT be active when CeodTestClient is making a request, since
|
||||||
|
@ -113,28 +99,31 @@ def g_admin_ctx(app):
|
||||||
"""
|
"""
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def wrapper():
|
def wrapper():
|
||||||
with krb5ccname_ctx('ceod/admin'), app.app_context():
|
with gssapi_creds_ctx('ceod/admin') as creds, app.app_context():
|
||||||
try:
|
try:
|
||||||
flask.g.sasl_user = 'ceod/admin'
|
flask.g.auth_user = 'ceod/admin'
|
||||||
|
flask.g.client_creds = creds
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
flask.g.pop('sasl_user')
|
flask.g.pop('client_creds')
|
||||||
|
flask.g.pop('auth_user')
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def g_syscom(app):
|
def g_syscom(app):
|
||||||
"""
|
"""
|
||||||
Store the principal for the syscom member in flask.g, and point
|
Store the credentials for ctdalek in flask.g and override KRB5CCNAME.
|
||||||
KRB5CCNAME to the file where the TGT is stored.
|
|
||||||
Use this fixture if you need syscom credentials for an HTTP request
|
Use this fixture if you need syscom credentials for an HTTP request
|
||||||
to a different process.
|
to a different process.
|
||||||
"""
|
"""
|
||||||
with krb5ccname_ctx('ctdalek'), app.app_context():
|
with gssapi_creds_ctx('ctdalek') as creds, app.app_context():
|
||||||
try:
|
try:
|
||||||
flask.g.sasl_user = 'ctdalek'
|
flask.g.sasl_user = 'ctdalek'
|
||||||
|
flask.g.client_creds = creds
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
flask.g.pop('client_creds')
|
||||||
flask.g.pop('sasl_user')
|
flask.g.pop('sasl_user')
|
||||||
|
|
||||||
|
|
||||||
|
@ -146,11 +135,11 @@ def ldap_conn(cfg) -> ldap3.Connection:
|
||||||
server_url = cfg.get('ldap_server_url')
|
server_url = cfg.get('ldap_server_url')
|
||||||
# sanity check
|
# sanity check
|
||||||
assert server_url == cfg.get('uwldap_server_url')
|
assert server_url == cfg.get('uwldap_server_url')
|
||||||
with krb5ccname_ctx('ceod/admin'):
|
with gssapi_creds_ctx('ceod/admin') as creds:
|
||||||
conn = ldap3.Connection(
|
conn = ldap3.Connection(
|
||||||
server_url, auto_bind=True, raise_exceptions=True,
|
server_url, auto_bind=True, raise_exceptions=True,
|
||||||
authentication=ldap3.SASL, sasl_mechanism=ldap3.KERBEROS,
|
authentication=ldap3.SASL, sasl_mechanism=ldap3.KERBEROS,
|
||||||
user='ceod/admin')
|
sasl_credentials=(None, None, creds))
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@ -378,10 +367,12 @@ def app_process(cfg, app, http_client):
|
||||||
proc.start()
|
proc.start()
|
||||||
|
|
||||||
try:
|
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):
|
for i in range(5):
|
||||||
try:
|
try:
|
||||||
http_client.get(hostname, '/ping')
|
http_client.get(hostname, '/ping', delegate=False)
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
|
@ -2,7 +2,7 @@ import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from .utils import krb5ccname_ctx
|
from .utils import gssapi_creds_ctx
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='module')
|
@pytest.fixture(scope='module')
|
||||||
|
@ -14,5 +14,5 @@ def cli_setup(app_process):
|
||||||
# messy because they would be sharing the same environment variables,
|
# messy because they would be sharing the same environment variables,
|
||||||
# Kerberos cache, and registered utilities (via zope). So we're just
|
# Kerberos cache, and registered utilities (via zope). So we're just
|
||||||
# going to start the app in a child process intead.
|
# going to start the app in a child process intead.
|
||||||
with krb5ccname_ctx('ctdalek'):
|
with gssapi_creds_ctx('ctdalek'):
|
||||||
yield
|
yield
|
||||||
|
|
|
@ -1,16 +1,14 @@
|
||||||
from base64 import b64encode
|
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
from flask import g
|
import flask
|
||||||
from flask.testing import FlaskClient
|
from flask.testing import FlaskClient
|
||||||
import gssapi
|
import gssapi
|
||||||
import pytest
|
import pytest
|
||||||
from requests import Request
|
from requests import Request
|
||||||
from requests_gssapi import HTTPSPNEGOAuth
|
from requests_gssapi import HTTPSPNEGOAuth
|
||||||
|
|
||||||
from ceo_common.krb5.utils import get_fwd_tgt
|
from .utils import gssapi_creds_ctx
|
||||||
from .utils import krb5ccname_ctx
|
|
||||||
|
|
||||||
__all__ = ['client']
|
__all__ = ['client']
|
||||||
|
|
||||||
|
@ -30,39 +28,30 @@ class CeodTestClient:
|
||||||
# for SPNEGO
|
# for SPNEGO
|
||||||
self.target_name = gssapi.Name('ceod/' + socket.getfqdn())
|
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."""
|
"""Acquire a HTTPSPNEGOAuth instance for the principal."""
|
||||||
name = gssapi.Name(principal)
|
with gssapi_creds_ctx(principal) as creds:
|
||||||
# the 'store' arg doesn't seem to work for DIR ccaches
|
return HTTPSPNEGOAuth(
|
||||||
creds = gssapi.Credentials(name=name, usage='initiate')
|
opportunistic_auth=True,
|
||||||
auth = HTTPSPNEGOAuth(
|
target_name=self.target_name,
|
||||||
opportunistic_auth=True,
|
creds=creds,
|
||||||
target_name=self.target_name,
|
delegate=delegate,
|
||||||
creds=creds,
|
)
|
||||||
)
|
|
||||||
return auth
|
|
||||||
|
|
||||||
def get_headers(self, principal: str, need_cred: bool):
|
def get_headers(self, principal: str, delegate: bool):
|
||||||
with krb5ccname_ctx(principal):
|
# Get the Authorization header (SPNEGO).
|
||||||
# Get the Authorization header (SPNEGO).
|
# The method doesn't matter here because we just need to extract
|
||||||
# The method doesn't matter here because we just need to extract
|
# the header using req.prepare().
|
||||||
# the header using req.prepare().
|
req = Request('GET', self.base_url, auth=self.get_auth(principal, delegate))
|
||||||
req = Request('GET', self.base_url, auth=self.get_auth(principal))
|
headers = list(req.prepare().headers.items())
|
||||||
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))
|
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
def request(self, method: str, path: str, principal: str, need_cred: bool, **kwargs):
|
def request(self, method: str, path: str, principal: str, delegate: bool, **kwargs):
|
||||||
# Make sure that we're not already in a request context, otherwise
|
# make sure that we're not already in a Flask context
|
||||||
# g will get overridden
|
assert not flask.has_app_context()
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
'' in g
|
|
||||||
if principal is None:
|
if principal is None:
|
||||||
principal = self.syscom_principal
|
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)
|
resp = self.client.open(path, method=method, headers=headers, **kwargs)
|
||||||
status = int(resp.status.split(' ', 1)[0])
|
status = int(resp.status.split(' ', 1)[0])
|
||||||
if resp.headers['content-type'] == 'application/json':
|
if resp.headers['content-type'] == 'application/json':
|
||||||
|
@ -71,14 +60,14 @@ class CeodTestClient:
|
||||||
data = [json.loads(line) for line in resp.data.splitlines()]
|
data = [json.loads(line) for line in resp.data.splitlines()]
|
||||||
return status, data
|
return status, data
|
||||||
|
|
||||||
def get(self, path, principal=None, need_cred=True, **kwargs):
|
def get(self, path, principal=None, delegate=True, **kwargs):
|
||||||
return self.request('GET', path, principal, need_cred, **kwargs)
|
return self.request('GET', path, principal, delegate, **kwargs)
|
||||||
|
|
||||||
def post(self, path, principal=None, need_cred=True, **kwargs):
|
def post(self, path, principal=None, delegate=True, **kwargs):
|
||||||
return self.request('POST', path, principal, need_cred, **kwargs)
|
return self.request('POST', path, principal, delegate, **kwargs)
|
||||||
|
|
||||||
def patch(self, path, principal=None, need_cred=True, **kwargs):
|
def patch(self, path, principal=None, delegate=True, **kwargs):
|
||||||
return self.request('PATCH', path, principal, need_cred, **kwargs)
|
return self.request('PATCH', path, principal, delegate, **kwargs)
|
||||||
|
|
||||||
def delete(self, path, principal=None, need_cred=True, **kwargs):
|
def delete(self, path, principal=None, delegate=True, **kwargs):
|
||||||
return self.request('DELETE', path, principal, need_cred, **kwargs)
|
return self.request('DELETE', path, principal, delegate, **kwargs)
|
||||||
|
|
|
@ -4,31 +4,44 @@ import subprocess
|
||||||
from subprocess import DEVNULL
|
from subprocess import DEVNULL
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
import gssapi
|
||||||
|
import pytest
|
||||||
|
|
||||||
# map principals to files storing credentials
|
|
||||||
_ccaches = {}
|
# map principals to GSSAPI credentials
|
||||||
|
_cache = {}
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def krb5ccname_ctx(principal: str):
|
def gssapi_creds_ctx(principal: str):
|
||||||
"""
|
"""
|
||||||
Temporarily set KRB5CCNAME to a ccache storing credentials
|
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']
|
old_krb5ccname = os.environ['KRB5CCNAME']
|
||||||
try:
|
try:
|
||||||
if principal not in _ccaches:
|
if principal not in _cache:
|
||||||
f = tempfile.NamedTemporaryFile()
|
f = tempfile.NamedTemporaryFile()
|
||||||
os.environ['KRB5CCNAME'] = 'FILE:' + f.name
|
os.environ['KRB5CCNAME'] = 'FILE:' + f.name
|
||||||
args = ['kinit', principal]
|
args = ['kinit', principal]
|
||||||
if principal == 'ceod/admin':
|
if principal == 'ceod/admin':
|
||||||
args = ['kinit', '-k', principal]
|
args = ['kinit', '-k', principal]
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
args, stdout=DEVNULL, text=True, input='krb5',
|
args, stdout=DEVNULL, text=True, input='krb5', check=True)
|
||||||
check=True)
|
creds = gssapi.Credentials(name=gssapi.Name(principal), usage='initiate')
|
||||||
_ccaches[principal] = f
|
# 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:
|
else:
|
||||||
os.environ['KRB5CCNAME'] = 'FILE:' + _ccaches[principal].name
|
creds, f = _cache[principal]
|
||||||
yield
|
os.environ['KRB5CCNAME'] = 'FILE:' + f.name
|
||||||
|
yield creds
|
||||||
finally:
|
finally:
|
||||||
os.environ['KRB5CCNAME'] = old_krb5ccname
|
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()
|
||||||
|
|
Loading…
Reference in New Issue