From 7d3852b4cfcf7ccd85adb1cedefb9102180f1c3c Mon Sep 17 00:00:00 2001 From: Matt Jia Date: Mar 14 2017 06:05:50 +0000 Subject: support kerberos authentication --- diff --git a/README.md b/README.md index 8fc8b65..4584239 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Set up a python virtualenv: $ sudo dnf install python-virtualenv $ virtualenv env_waiverdb - $ source env_resultsdb/bin/activate + $ source env_waiverdb/bin/activate $ pip install -r requirements.txt Install the project: diff --git a/requirements.txt b/requirements.txt index 0203797..0682ff2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,7 @@ Flask Flask-RESTful Flask-SQLAlchemy SQLAlchemy +kerberos >= 1.1.1 pytest >= 2.4.2 +mock diff --git a/tests/test_api_v10.py b/tests/test_api_v10.py index 00226dc..fbd0a2b 100644 --- a/tests/test_api_v10.py +++ b/tests/test_api_v10.py @@ -13,8 +13,10 @@ import pytest import json from .utils import create_waiver import datetime +from mock import patch -def test_create_waiver(client, session): +@patch('waiverdb.auth.get_user', return_value=('foo', {})) +def test_create_waiver(mocked_get_user, client, session, monkeypatch): data = { 'result_id': 123, 'product_version': 'fool-1', @@ -25,13 +27,14 @@ def test_create_waiver(client, session): content_type='application/json') res_data = json.loads(r.data) assert r.status_code == 201 - assert res_data['username'] == 'mjia' + assert res_data['username'] == 'foo' assert res_data['result_id'] == 123 assert res_data['product_version'] == 'fool-1' assert res_data['waived'] == True assert res_data['comment'] == 'it broke' -def test_create_waiver_with_malformed_data(client): +@patch('waiverdb.auth.get_user', return_value=('foo', {})) +def test_create_waiver_with_malformed_data(mocked_get_user, client): data = { 'result_id': 'wrong id', } diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..fbee55b --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,53 @@ + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +import pytest +import kerberos +import mock +import json +from werkzeug.exceptions import Unauthorized +import waiverdb.auth + +class TestKerberosAuthentication(object): + + def test_keytab_file_is_not_set_should_raise_error(self): + with pytest.raises(Unauthorized): + request = mock.MagicMock() + waiverdb.auth.get_user(request) + + 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 + assert r.headers.get('www-authenticate') == 'Negotiate' + + @mock.patch('kerberos.authGSSServerInit', return_value=(kerberos.AUTH_GSS_COMPLETE, object())) + @mock.patch('kerberos.authGSSServerStep', return_value=kerberos.AUTH_GSS_COMPLETE) + @mock.patch('kerberos.authGSSServerResponse', return_value='STOKEN') + @mock.patch('kerberos.authGSSServerUserName', return_value='foo@EXAMPLE.ORG') + @mock.patch('kerberos.authGSSServerClean') + @mock.patch('kerberos.getServerPrincipalDetails') + def test_authorized(self, principal, clean, name, response, step, init, + client, monkeypatch, session): + monkeypatch.setenv('KRB5_KTNAME', '/etc/foo.keytab') + data = { + 'result_id': 123, + 'product_version': 'fool-1', + 'waived': True, + 'comment': 'it broke', + } + r = client.post('/api/v1.0/waivers/', data=json.dumps(data), + content_type='application/json', + headers={'Authorization': 'Negotiate CTOKEN'}) + assert r.status_code == 201 + assert r.headers.get('WWW-Authenticate') == 'negotiate STOKEN' + res_data = json.loads(r.data) + assert res_data['username'] == 'foo' diff --git a/waiverdb/api_v1.py b/waiverdb/api_v1.py index 89f23e7..09927ff 100644 --- a/waiverdb/api_v1.py +++ b/waiverdb/api_v1.py @@ -10,7 +10,7 @@ # GNU General Public License for more details. # -from flask import Blueprint +from flask import Blueprint, request from flask_restful import Resource, Api, reqparse, marshal_with from werkzeug.exceptions import HTTPException, BadRequest, NotFound from sqlalchemy.sql.expression import func @@ -18,6 +18,7 @@ from sqlalchemy.sql.expression import func from waiverdb.models import db, Waiver from waiverdb.utils import reqparse_since, json_collection, jsonp from waiverdb.fields import waiver_fields +import waiverdb.auth api_v1 = (Blueprint('api_v1', __name__)) api = Api(api_v1) @@ -71,14 +72,13 @@ class WaiversResource(Resource): @jsonp @marshal_with(waiver_fields) def post(self): + user, headers = waiverdb.auth.get_user(request) args = RP['create_waiver'].parse_args() - # hardcode the username for now - username = 'mjia' - waiver = Waiver(args['result_id'], username, args['product_version'], args['waived'], + waiver = Waiver(args['result_id'], user, args['product_version'], args['waived'], args['comment']) db.session.add(waiver) db.session.commit() - return waiver, 201 + return waiver, 201, headers class WaiverResource(Resource): diff --git a/waiverdb/auth.py b/waiverdb/auth.py new file mode 100644 index 0000000..364eaa9 --- /dev/null +++ b/waiverdb/auth.py @@ -0,0 +1,107 @@ + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +import os +import kerberos +from flask import current_app, Response +# Starting with Flask 0.9, the _app_ctx_stack is the correct one, +# before that we need to use the _request_ctx_stack. +try: + from flask import _app_ctx_stack as stack +except ImportError: + from flask import _request_ctx_stack as stack +from socket import gethostname +from werkzeug.exceptions import Unauthorized, Forbidden + +#Inspired by https://github.com/mkomitee/flask-kerberos/blob/master/flask_kerberos.py +class KerberosAuthenticate(object): + + def __init__(self): + if current_app.config['KERBEROS_HTTP_HOST']: + hostname = current_app.config['KERBEROS_HTTP_HOST'] + else: + hostname = gethostname() + self.service_name = "HTTP@%s" % (hostname) + if 'KRB5_KTNAME' in os.environ: + try: + principal = kerberos.getServerPrincipalDetails('HTTP', hostname) + except kerberos.KrbError as exc: + raise Unauthorized("Authentication Kerberos Failure: %s" % exc.message[0]) + else: + current_app.logger.debug("Kerberos: server is identifying as %s" % principal) + else: + raise Unauthorized("Kerberos: set KRB5_KTNAME to your keytab file") + + def _gssapi_authenticate(self, token): + ''' + Performs GSSAPI Negotiate Authentication + On success also stashes the server response token for mutual authentication + at the top of request context with the name kerberos_token, along with the + authenticated user principal with the name kerberos_user. + ''' + state = None + ctx = stack.top + try: + rc, state = kerberos.authGSSServerInit(self.service_name) + if rc != kerberos.AUTH_GSS_COMPLETE: + current_app.logger.error('Unable to initialize server context') + return None + rc = kerberos.authGSSServerStep(state, token) + if rc == kerberos.AUTH_GSS_COMPLETE: + current_app.logger.debug('Completed GSSAPI negotiation') + ctx.kerberos_token = kerberos.authGSSServerResponse(state) + ctx.kerberos_user = kerberos.authGSSServerUserName(state) + return rc + elif rc == kerberos.AUTH_GSS_CONTINUE: + current_app.logger.debug('Continuing GSSAPI negotiation') + return kerberos.AUTH_GSS_CONTINUE + else: + current_app.logger.debug('Unable to step server context') + return None + except kerberos.GSSError as e: + current_app.logger.error('Unable to authenticate: %s', e) + return None + finally: + if state: + kerberos.authGSSServerClean(state) + + def process_request(self, token): + """ + Authenticates the current request using Kerberos. + """ + kerberos_user = None + kerberos_token = None + ctx = stack.top + rc = self._gssapi_authenticate(token) + if rc == kerberos.AUTH_GSS_COMPLETE: + kerberos_user = ctx.kerberos_user + kerberos_token = ctx.kerberos_token + elif rc != kerberos.AUTH_GSS_CONTINUE: + raise Forbidden("Invalid Kerberos ticket") + return kerberos_user, kerberos_token + +def get_user(request): + user = None + headers = dict() + if 'KRB5_KTNAME' in os.environ: + header = request.headers.get("Authorization") + if not header: + response = Response('Unauthorized', 401, {'WWW-Authenticate': 'Negotiate'}) + raise Unauthorized(response=response) + token = ''.join(header.split()[1:]) + user, kerberos_token = KerberosAuthenticate().process_request(token) + # remove realm + user = user.split("@")[0] + if kerberos_token is not None: + headers = {'WWW-Authenticate': ' '.join(['negotiate', kerberos_token])} + else: + raise Unauthorized("Authenticated user required") + return user, headers diff --git a/waiverdb/config.py b/waiverdb/config.py index de70424..4c8a7df 100644 --- a/waiverdb/config.py +++ b/waiverdb/config.py @@ -22,6 +22,8 @@ class Config(object): # need to explicitly turn this off # https://github.com/flask-restful/flask-restful/issues/449 ERROR_404_HELP = False + # Change it if the Kerberos service is not running on which the waiverdb is run. + KERBEROS_HTTP_HOST = None class ProductionConfig(Config):