Skip to content

Commit

Permalink
Add support for both OIDC and Kerberos auth
Browse files Browse the repository at this point in the history
Authentication would fall back to Kerberos/Negotiate if OpenID Bearer
token is not provided. This can be configured with the following
settings which overrides `AUTH_METHOD` option:

    AUTH_METHODS = ["OIDC", "Kerberos"]

JIRA: RHELWF-7346
  • Loading branch information
hluk committed Nov 2, 2022
1 parent d0b45fc commit 752ae58
Show file tree
Hide file tree
Showing 6 changed files with 107 additions and 24 deletions.
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ def enable_kerberos(app, monkeypatch):
monkeypatch.setitem(app.config, 'AUTH_METHOD', 'Kerberos')


@pytest.fixture()
def enable_kerberos_oidc_fallback(app, monkeypatch):
monkeypatch.setitem(app.config, 'AUTH_METHODS', ['Kerberos', 'OIDC'])


@pytest.fixture()
def enable_ssl(app, monkeypatch):
monkeypatch.setitem(app.config, 'AUTH_METHOD', 'SSL')
Expand Down
45 changes: 35 additions & 10 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@

@pytest.mark.usefixtures('enable_kerberos')
class TestGSSAPIAuthentication(object):
waiver_data = {
'subject': {'type': 'koji_build', 'item': 'glibc-2.26-27.fc27'},
'testcase': 'testcase1',
'product_version': 'fool-1',
'waived': True,
'comment': 'it broke',
}

def test_unauthorized(self, client, monkeypatch):
monkeypatch.setenv('KRB5_KTNAME', '/etc/foo.keytab')
r = client.post('/api/v1.0/waivers/', content_type='application/json')
Expand All @@ -27,31 +35,34 @@ def test_unauthorized(self, client, monkeypatch):
__new__=mock.Mock(return_value=None))
def test_authorized(self, client, monkeypatch):
monkeypatch.setenv('KRB5_KTNAME', '/etc/foo.keytab')
data = {
'subject': {'type': 'koji_build', 'item': 'glibc-2.26-27.fc27'},
'testcase': 'testcase1',
'product_version': 'fool-1',
'waived': True,
'comment': 'it broke',
}
headers = {'Authorization':
'Negotiate %s' % b64encode(b"CTOKEN").decode()}
r = client.post('/api/v1.0/waivers/', data=json.dumps(data),
r = client.post('/api/v1.0/waivers/', data=json.dumps(self.waiver_data),
content_type='application/json', headers=headers)
assert r.status_code == 201
assert r.headers.get('WWW-Authenticate') == \
'negotiate %s' % b64encode(b"STOKEN").decode()
res_data = json.loads(r.data.decode('utf-8'))
assert res_data['username'] == 'foo'

def test_invalid_token(self, client, monkeypatch):
monkeypatch.setenv('KRB5_KTNAME', '/etc/foo.keytab')
headers = {'Authorization': 'Negotiate INVALID'}
r = client.post('/api/v1.0/waivers/', data=json.dumps(self.waiver_data),
content_type='application/json', headers=headers)
assert r.status_code == 401
assert r.json == {"message": "Invalid authentication token"}


class TestOIDCAuthentication(object):
invalid_token_error = "Token required but invalid"
auth_missing_error = "No 'Authorization' header found"

def test_get_user_without_token(self, session):
with pytest.raises(Unauthorized) as excinfo:
request = mock.MagicMock()
waiverdb.auth.get_user(request)
assert "No 'Authorization' header found" in str(excinfo.value)
assert self.auth_missing_error in str(excinfo.value)

@mock.patch.object(flask_oidc.OpenIDConnect, '_get_token_info')
def test_get_user_with_invalid_token(self, mocked_get_token, session):
Expand All @@ -67,7 +78,7 @@ def test_get_user_with_invalid_token(self, mocked_get_token, session):
request.headers.__contains__.side_effect = headers.__contains__
with pytest.raises(Unauthorized) as excinfo:
waiverdb.auth.get_user(request)
assert 'Token required but invalid' in excinfo.value.get_description()
assert self.invalid_token_error in excinfo.value.get_description()

@mock.patch.object(flask_oidc.OpenIDConnect, '_get_token_info')
def test_get_user_good(self, mocked_get_token, session):
Expand Down Expand Up @@ -110,3 +121,17 @@ def test_good_ssl_cert(self):
request = mock.MagicMock(environ=ssl)
user, header = waiverdb.auth.get_user(request)
assert user == name


@pytest.mark.usefixtures('enable_kerberos_oidc_fallback')
class TestKerberosWithFallbackAuthentication(TestGSSAPIAuthentication):
def test_unauthorized(self, client, monkeypatch):
monkeypatch.setenv('KRB5_KTNAME', '/etc/foo.keytab')
r = client.post('/api/v1.0/waivers/', content_type='application/json')
assert r.status_code == 401


@pytest.mark.usefixtures('enable_kerberos_oidc_fallback')
class TestOIDCWithFallbackAuthentication(TestOIDCAuthentication):
invalid_token_error = ""
auth_missing_error = ""
20 changes: 16 additions & 4 deletions waiverdb/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from flask import Flask, current_app
from flask_cors import CORS
from flask_migrate import Migrate
from flask_oidc import OpenIDConnect
from sqlalchemy import event
from sqlalchemy.exc import ProgrammingError
import requests
Expand All @@ -18,11 +19,12 @@
from waiverdb.logger import init_logging
from waiverdb.api_v1 import api_v1
from waiverdb.models import db
from waiverdb.utils import json_error
from flask_oidc import OpenIDConnect
from waiverdb.utils import auth_methods, json_error
from werkzeug.exceptions import default_exceptions
from waiverdb.monitor import db_hook_event_listeners

oidc = OpenIDConnect()


def enable_cors(app):
"""
Expand Down Expand Up @@ -105,8 +107,9 @@ def create_app(config_obj=None):
app.register_error_handler(requests.Timeout, json_error)

populate_db_config(app)
if app.config['AUTH_METHOD'] == 'OIDC':
app.oidc = OpenIDConnect(app)
if 'OIDC' in auth_methods(app):
oidc.init_app(app)
app.oidc = oidc
# initialize logging
init_logging(app)
# initialize db
Expand All @@ -118,6 +121,7 @@ def create_app(config_obj=None):
# register blueprints
app.register_blueprint(api_v1, url_prefix="/api/v1.0")
app.add_url_rule('/healthcheck', view_func=healthcheck)
app.add_url_rule('/auth/oidclogin', view_func=login)
register_event_handlers(app)

# initialize DB event listeners from the monitor module
Expand Down Expand Up @@ -145,6 +149,14 @@ def healthcheck():
return ('Health check OK', 200, [('Content-Type', 'text/plain')])


@oidc.require_login
def login():
return {
'email': oidc.user_getfield('email'),
'token': oidc.get_access_token(),
}


def register_event_handlers(app):
"""
Register SQLAlchemy event handlers with the application's session factory.
Expand Down
41 changes: 32 additions & 9 deletions waiverdb/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@


import base64
import binascii
import os
if not os.getenv('DOCS'): # installing gssapi causing a problem for documentation building
import gssapi
from flask import current_app, Response, g
from werkzeug.exceptions import Unauthorized, Forbidden

from waiverdb.utils import auth_methods

OIDC_AUTH_HEADER_PREFIX = "Bearer "


# Inspired by https://github.com/mkomitee/flask-kerberos/blob/master/flask_kerberos.py
# Later cleaned and ported to python-gssapi
Expand Down Expand Up @@ -40,16 +45,31 @@ def process_gssapi_request(token):


def get_user(request):
methods = auth_methods(current_app)
if not methods:
raise Unauthorized("Authenticated user required")

if len(methods) > 1:
auth_header = request.headers.get("Authorization", "").strip()
if "OIDC" in methods and auth_header.startswith(OIDC_AUTH_HEADER_PREFIX):
return get_user_by_method(request, "OIDC")
if "Kerberos" in methods and not auth_header.startswith(OIDC_AUTH_HEADER_PREFIX):
return get_user_by_method(request, "Kerberos")

return get_user_by_method(request, methods[0])


def get_user_by_method(request, auth_method):
user = None
headers = dict()
if current_app.config['AUTH_METHOD'] == 'OIDC':
if auth_method == 'OIDC':
if 'Authorization' not in request.headers:
raise Unauthorized("No 'Authorization' header found.")
token = request.headers.get("Authorization").strip()
prefix = 'Bearer '
if not token.startswith(prefix):
raise Unauthorized('Authorization headers must start with %r' % prefix)
token = token[len(prefix):].strip()
if not token.startswith(OIDC_AUTH_HEADER_PREFIX):
raise Unauthorized(
f"Authorization headers must start with {OIDC_AUTH_HEADER_PREFIX}")
token = token[len(OIDC_AUTH_HEADER_PREFIX):].strip()
required_scopes = [
'openid',
current_app.config['OIDC_REQUIRED_SCOPE'],
Expand All @@ -58,18 +78,21 @@ def get_user(request):
if validity is not True:
raise Unauthorized(validity)
user = g.oidc_token_info['username']
elif current_app.config['AUTH_METHOD'] == 'Kerberos':
elif auth_method == 'Kerberos':
if 'Authorization' not in request.headers:
response = Response('Unauthorized', 401, {'WWW-Authenticate': 'Negotiate'})
raise Unauthorized(response=response)
header = request.headers.get("Authorization")
token = ''.join(header.strip().split()[1:])
user, token = process_gssapi_request(base64.b64decode(token))
try:
user, token = process_gssapi_request(base64.b64decode(token))
except binascii.Error:
raise Unauthorized("Invalid authentication token")
# remove realm
user = user.split("@")[0]
headers = {'WWW-Authenticate': ' '.join(
['negotiate', base64.b64encode(token).decode()])}
elif current_app.config['AUTH_METHOD'] == 'SSL':
elif auth_method == 'SSL':
# Nginx sets SSL_CLIENT_VERIFY and SSL_CLIENT_S_DN in request.environ
# when doing SSL authentication.
ssl_client_verify = request.environ.get('SSL_CLIENT_VERIFY')
Expand All @@ -78,7 +101,7 @@ def get_user(request):
if not request.environ.get('SSL_CLIENT_S_DN'):
raise Unauthorized('Unable to get user information (DN) from the client certificate')
user = request.environ.get('SSL_CLIENT_S_DN')
elif current_app.config['AUTH_METHOD'] == 'dummy':
elif auth_method == 'dummy':
# Blindly accept any username. For testing purposes only of course!
if not request.authorization:
response = Response('Unauthorized', 401, {'WWW-Authenticate': 'Basic realm="dummy"'})
Expand Down
8 changes: 7 additions & 1 deletion waiverdb/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,10 +271,16 @@ def cli(username, comment, waived, product_version, testcase, scenario, subject,
oidc_client_secret = None
if config.has_option('waiverdb', 'oidc_client_secret'):
oidc_client_secret = config.get('waiverdb', 'oidc_client_secret')
id_provider_mapping = {
'Token': config.get(
'waiverdb', 'oidc_token_endpoint', fallback='Token'),
'Authorization': config.get(
'waiverdb', 'oidc_auth_endpoint', fallback='Authorization'),
}
oidc = openidc_client.OpenIDCClient(
'waiverdb',
config.get('waiverdb', 'oidc_id_provider'),
{'Token': 'Token', 'Authorization': 'Authorization'},
id_provider_mapping,
config.get('waiverdb', 'oidc_client_id'),
oidc_client_secret)
scopes = config.get('waiverdb', 'oidc_scopes').strip().splitlines()
Expand Down
12 changes: 12 additions & 0 deletions waiverdb/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,15 @@ def stomp_connection():
else:
raise RuntimeError('stomp was configured to publish messages, '
'but STOMP_CONFIGS is not configured')


def auth_methods(app):
methods = app.config.get('AUTH_METHODS')
if methods:
return methods

method = app.config.get('AUTH_METHOD')
if method:
return [method]

return []

0 comments on commit 752ae58

Please sign in to comment.