#15 support kerberos authentication
Merged 7 years ago by mjia. Opened 7 years ago by mjia.
mjia/waiverdb support_kerberos  into  master

file modified
+1 -1
@@ -10,7 +10,7 @@ 

  

      $ 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:

file modified
+2
@@ -5,5 +5,7 @@ 

  Flask-RESTful

  Flask-SQLAlchemy

  SQLAlchemy

+ kerberos >= 1.1.1

  

  pytest >= 2.4.2

+ mock

file modified
+6 -3
@@ -13,8 +13,10 @@ 

  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 @@ 

              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',

      }

file added
+53
@@ -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'

file modified
+5 -5
@@ -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 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 @@ 

      @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):

file added
+107
@@ -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()

It should really just default to using the default service principal in the keytab, with an option to use a different principal in case the keytab has keys for multiple principals for some reason.

mjia commented 7 years ago

Yeah, we could have a new configuration for this, something like:
KRB_AUTH_PRINCIPAL = 'HTTP/hostname@EXMAPLE.COM'
Then we do not need to call getServerPrincipalDetails here.

+         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

file modified
+2
@@ -22,6 +22,8 @@ 

      # 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):

no initial comment

Is there any kerberos environment I can use for testing?

Is there any kerberos environment I can use for testing?

Okay, it turns out that I can use vagrant/docker to set up one.

By and large, this looks good to me. @mjia can you let us know how functional testing goes with vagrant/docker?

By and large, this looks good to me. @mjia can you let us know how functional testing goes >with vagrant/docker?

Nah, I would not use vagrant/docker in the functional testings. It sounds like so complicated to me, :-). The thing I want to do is to set up a KDC server in order to manually verify that piece of code actually works.

Nice! :+1: to merge.

Nice! :+1: to merge.

rebased

7 years ago

Pull-Request has been merged by mjia

7 years ago

It should really just default to using the default service principal in the keytab, with an option to use a different principal in case the keytab has keys for multiple principals for some reason.

Yeah, we could have a new configuration for this, something like:
KRB_AUTH_PRINCIPAL = 'HTTP/hostname@EXMAPLE.COM'
Then we do not need to call getServerPrincipalDetails here.