#28 Protect resource by requiring login
Merged 6 years ago by cqi. Opened 6 years ago by cqi.

file modified
+5
@@ -89,6 +89,11 @@ 

      except:

          pass

  

+     # Disable login_required in development environment

+     LOGIN_DISABLED = True

+     # Disable authorize in development environment

+     AUTHORIZE_DISABLED = True

+ 

      AUTH_BACKEND = 'noauth'

      AUTH_OPENIDC_USERINFO_URI = 'https://iddev.fedorainfracloud.org/openidc/UserInfo'

  

file modified
+5 -1
@@ -25,6 +25,7 @@ 

  

  from flask import Flask, jsonify

  from flask_sqlalchemy import SQLAlchemy

+ from flask_login import LoginManager

  

  from odcs.logger import init_logging

  from odcs.config import init_config
@@ -40,10 +41,13 @@ 

  init_logging(conf)

  log = getLogger(__name__)

  

+ login_manager = LoginManager()

+ login_manager.init_app(app)

+ 

  from odcs import views

  

  from odcs.auth import init_auth

- init_auth(app, backend=conf.auth_backend)

+ init_auth(login_manager, conf.auth_backend)

  

  def json_error(status, error, message):

      response = jsonify(

file modified
+10 -7
@@ -24,12 +24,12 @@ 

  

  import requests

  import ldap

+ import flask

  

  from itertools import chain

  

  from flask import abort

  from flask import g

- from flask import request

  

  from odcs import conf, log

  from odcs.models import User
@@ -37,7 +37,7 @@ 

  

  

  @commit_on_success

- def load_krb_user_from_request():

+ def load_krb_user_from_request(request):

      """Load Kerberos user from current request

  

      REMOTE_USER needs to be set in environment variable, that is set by
@@ -62,6 +62,7 @@ 

  

      g.groups = groups

      g.user = user

+     return user

  

  

  def query_ldap_groups(uid):
@@ -82,7 +83,7 @@ 

  

  

  @commit_on_success

- def load_openidc_user():

+ def load_openidc_user(request):

      """Load FAS user from current request"""

      username = request.environ.get('REMOTE_USER')

      if not username:
@@ -105,6 +106,7 @@ 

  

      g.groups = user_info.get('groups', [])

      g.user = user

+     return user

  

  

  def validate_scopes(scope):
@@ -132,7 +134,7 @@ 

      return r.json()

  

  

- def init_auth(app, backend):

+ def init_auth(login_manager, backend):

      """Initialize authentication backend

  

      Enable and initialize authentication backend to work with frontend
@@ -144,10 +146,11 @@ 

          return

      if backend == 'kerberos':

          global load_krb_user_from_request

-         load_krb_user_from_request = app.before_request(load_krb_user_from_request)

+         load_krb_user_from_request = login_manager.request_loader(

+             load_krb_user_from_request)

      elif backend == 'openidc':

          global load_openidc_user

-         load_openidc_user = app.before_request(load_openidc_user)

+         load_openidc_user = login_manager.request_loader(load_openidc_user)

      else:

          raise ValueError('Unknown backend name {0}.'.format(backend))

  
@@ -159,4 +162,4 @@ 

          returned.

      :rtype: bool

      """

-     return bool(set(g.groups) & set(conf.allowed_groups))

+     return bool(set(flask.g.groups) & set(conf.allowed_groups))

file modified
+4
@@ -179,6 +179,10 @@ 

              'type': list,

              'default': [],

              'desc': 'Required scopes for submitting request to run new compose.'},

+         'authorize_disabled': {

+             'type': bool,

+             'default': False,

+             'desc': 'Disable group based authorization.'},

      }

  

      def __init__(self, conf_section_obj):

file modified
+2 -1
@@ -29,6 +29,7 @@ 

  from datetime import datetime, timedelta

  from odcs import conf

  from sqlalchemy.orm import validates

+ from flask_login import UserMixin

  

  from odcs import db

  
@@ -79,7 +80,7 @@ 

      __abstract__ = True

  

  

- class User(ODCSBase):

+ class User(ODCSBase, UserMixin):

      """User information table"""

  

      __tablename__ = 'users'

file modified
+21 -1
@@ -24,14 +24,30 @@ 

  import datetime

  import json

  

+ import flask

+ 

  from flask.views import MethodView

- from flask import request, jsonify

+ from flask import request, jsonify, abort

+ from flask_login import login_required

  

  from odcs import app, db, log, conf

  from odcs.errors import NotFound, BadRequest

  from odcs.models import Compose, COMPOSE_RESULTS, COMPOSE_FLAGS, COMPOSE_STATES

  from odcs.pungi import PungiSourceType

  from odcs.api_utils import pagination_metadata, filter_composes

+ from odcs.auth import user_in_allowed_groups as _user_in_allowed_groups

+ 

+ 

+ def user_in_allowed_groups(func):

+     """Only allow user who is in allowed groups to call endpoint"""

+     def _decorator(*args, **kwargs):

+         if conf.authorize_disabled:

+             return func(*args, **kwargs)

+         if _user_in_allowed_groups():

+             return func(*args, **kwargs)

+         abort(401, 'User {0} is not in allowed groups.'.format(

+             flask.g.user.username))

+     return _decorator

  

  

  api_v1 = {
@@ -82,6 +98,8 @@ 

              else:

                  raise NotFound('No such compose found.')

  

+     @login_required

+     @user_in_allowed_groups

      def post(self):

          owner = "Unknown"  # TODO

  
@@ -168,6 +186,8 @@ 

  

          return jsonify(compose.json()), 200

  

+     @login_required

+     @user_in_allowed_groups

      def delete(self, id):

          compose = Compose.query.filter_by(id=id).first()

          if compose:

file modified
+1
@@ -15,6 +15,7 @@ 

  Flask-Migrate

  Flask-SQLAlchemy

  Flask-Script

+ Flask-Login==0.4.0

  requests

  jinja2

  pyldap

file modified
+55 -27
@@ -57,7 +57,7 @@ 

          }

  

          with app.test_request_context(environ_base=environ_base):

-             load_krb_user_from_request()

+             load_krb_user_from_request(flask.request)

  

              expected_user = db.session.query(User).filter(

                  User.username == 'newuser')[0]
@@ -79,19 +79,17 @@ 

          }

  

          with app.test_request_context(environ_base=environ_base):

-             load_krb_user_from_request()

+             load_krb_user_from_request(flask.request)

  

              self.assertEqual(original_users_count, db.session.query(User.id).count())

              self.assertEqual(self.user.id, flask.g.user.id)

              self.assertEqual(self.user.username, flask.g.user.username)

              self.assertEqual(['admins', 'devel'], sorted(flask.g.groups))

  

-     @patch('odcs.auth.request')

-     @patch('odcs.auth.g')

-     def test_401_if_remote_user_not_present(self, g, request):

-         request.environ.get.return_value = None

- 

-         self.assertRaises(Unauthorized, load_krb_user_from_request)

+     def test_401_if_remote_user_not_present(self):

+         with app.test_request_context():

+             self.assertRaises(Unauthorized,

+                               load_krb_user_from_request, flask.request)

  

  

  class TestLoadOpenIDCUserFromRequest(ModelsBaseTest):
@@ -119,7 +117,7 @@ 

          }

  

          with app.test_request_context(environ_base=environ_base):

-             load_openidc_user()

+             load_openidc_user(flask.request)

  

              new_user = db.session.query(User).filter(User.username == 'new_user')[0]

  
@@ -145,7 +143,7 @@ 

          with app.test_request_context(environ_base=environ_base):

              original_users_count = db.session.query(User.id).count()

  

-             load_openidc_user()

+             load_openidc_user(flask.request)

  

              users_count = db.session.query(User.id).count()

              self.assertEqual(original_users_count, users_count)
@@ -162,7 +160,7 @@ 

              'OIDC_CLAIM_scope': 'openid https://id.fedoraproject.org/scope/groups',

          }

          with app.test_request_context(environ_base=environ_base):

-             self.assertRaises(Unauthorized, load_openidc_user)

+             self.assertRaises(Unauthorized, load_openidc_user, flask.request)

  

      def test_401_if_access_token_not_present(self):

          environ_base = {
@@ -172,7 +170,7 @@ 

              'OIDC_CLAIM_scope': 'openid https://id.fedoraproject.org/scope/groups',

          }

          with app.test_request_context(environ_base=environ_base):

-             self.assertRaises(Unauthorized, load_openidc_user)

+             self.assertRaises(Unauthorized, load_openidc_user, flask.request)

  

      def test_401_if_scope_not_present(self):

          environ_base = {
@@ -182,7 +180,7 @@ 

              # Missing OIDC_CLAIM_scope here

          }

          with app.test_request_context(environ_base=environ_base):

-             self.assertRaises(Unauthorized, load_openidc_user)

+             self.assertRaises(Unauthorized, load_openidc_user, flask.request)

  

      def test_401_if_required_scope_not_present_in_token_scope(self):

          environ_base = {
@@ -198,7 +196,7 @@ 

                  self.assertRaisesRegexp(

                      Unauthorized,

                      'Required OIDC scope new-compose not present.',

-                     load_openidc_user)

+                     load_openidc_user, flask.request)

  

  

  class TestQueryLdapGroups(unittest.TestCase):
@@ -223,23 +221,53 @@ 

  class TestInitAuth(unittest.TestCase):

      """Test init_auth"""

  

+     def setUp(self):

+         self.login_manager = Mock()

+ 

      def test_select_kerberos_auth_backend(self):

-         app = Mock()

-         init_auth(app, 'kerberos')

-         app.before_request.assert_called_once_with(load_krb_user_from_request)

+         init_auth(self.login_manager, 'kerberos')

+         self.login_manager.request_loader.assert_called_once_with(load_krb_user_from_request)

  

      def test_select_openidc_auth_backend(self):

-         app = Mock()

-         init_auth(app, 'openidc')

-         app.before_request.assert_called_once_with(load_openidc_user)

+         init_auth(self.login_manager, 'openidc')

+         self.login_manager.request_loader.assert_called_once_with(load_openidc_user)

  

      def test_not_use_auth_backend(self):

-         app = Mock()

-         init_auth(app, 'noauth')

-         app.before_request.assert_not_called()

+         init_auth(self.login_manager, 'noauth')

+         self.login_manager.request_loader.assert_not_called()

  

      def test_error_if_select_an_unknown_backend(self):

-         app = Mock()

-         self.assertRaises(ValueError, init_auth, app, 'xxx')

-         self.assertRaises(ValueError, init_auth, app, '')

-         self.assertRaises(ValueError, init_auth, app, None)

+         self.assertRaises(ValueError, init_auth, self.login_manager, 'xxx')

+         self.assertRaises(ValueError, init_auth, self.login_manager, '')

+         self.assertRaises(ValueError, init_auth, self.login_manager, None)

+ 

+ 

+ class TestUserInAllowedGroups(unittest.TestCase):

+     """Test user_in_allowed_groups"""

+ 

+     @patch.object(odcs.auth.conf, 'allowed_groups', new=[])

+     def test_not_allow_when_allowed_groups_is_empty(self):

+         with app.test_request_context():

+             flask.g.groups = []

+             self.assertFalse(odcs.auth.user_in_allowed_groups())

+ 

+             flask.g.groups = ['testers']

+             self.assertFalse(odcs.auth.user_in_allowed_groups())

+ 

+     @patch.object(odcs.auth.conf, 'allowed_groups', new=['admins', 'testers'])

+     def test_not_allow_when_not_in_allowed_groups(self):

+         with app.test_request_context():

+             flask.g.groups = ['common']

+             self.assertFalse(odcs.auth.user_in_allowed_groups())

+ 

+     @patch.object(odcs.auth.conf, 'allowed_groups', new=['admins', 'testers'])

+     def test_allow_when_in_allowed_groups(self):

+         with app.test_request_context():

+             flask.g.groups = ['common', 'testers']

+             self.assertTrue(odcs.auth.user_in_allowed_groups())

+ 

+             flask.g.groups = ['common', 'testers', 'admins']

+             self.assertTrue(odcs.auth.user_in_allowed_groups())

+ 

+             flask.g.groups = ['testers', 'admins']

+             self.assertTrue(odcs.auth.user_in_allowed_groups())

file modified
+145 -23
@@ -23,17 +23,35 @@ 

  import datetime

  import unittest

  import json

+ 

+ import flask

+ 

  from freezegun import freeze_time

+ from mock import Mock, patch

+ from werkzeug.exceptions import Unauthorized

  

- from odcs import db, app

- from odcs.models import Compose, COMPOSE_STATES, COMPOSE_RESULTS

+ import odcs.auth

+ 

+ from odcs import db, app, login_manager

+ from odcs.models import Compose, COMPOSE_STATES, COMPOSE_RESULTS, User

  from odcs.pungi import PungiSourceType

+ from odcs.views import user_in_allowed_groups

+ 

+ 

+ @login_manager.user_loader

+ def user_loader(username):

+     return User(id=1, username=username)

  

  

  class TestViews(unittest.TestCase):

      maxDiff = None

  

      def setUp(self):

+         self.patch_allowed_groups = patch.object(odcs.auth.conf,

+                                                  'allowed_groups',

+                                                  new=['tester'])

+         self.patch_allowed_groups.start()

+ 

          self.client = app.test_client()

          db.session.remove()

          db.drop_all()
@@ -58,10 +76,22 @@ 

          db.drop_all()

          db.session.commit()

  

+         self.patch_allowed_groups.stop()

+ 

+     def login_user(self):

+         with self.client.session_transaction() as sess:

+             sess['user_id'] = 'tester'

+             sess['_fresh'] = True

+ 

      def test_submit_build(self):

-         rv = self.client.post('/odcs/1/composes/', data=json.dumps(

-             {'source_type': 'module', 'source': 'testmodule-master'}))

-         data = json.loads(rv.data.decode('utf8'))

+         self.login_user()

+ 

+         with app.test_request_context():

+             flask.g.groups = ['tester']

+ 

+             rv = self.client.post('/odcs/1/composes/', data=json.dumps(

+                 {'source_type': 'module', 'source': 'testmodule-master'}))

+             data = json.loads(rv.data.decode('utf8'))

  

          expected_json = {'source_type': 2, 'state': 0, 'time_done': None,

                           'state_name': 'wait', 'source': u'testmodule-master',
@@ -78,10 +108,15 @@ 

          self.assertEqual(c.state, COMPOSE_STATES["wait"])

  

      def test_submit_build_nodeps(self):

-         rv = self.client.post('/odcs/1/composes/', data=json.dumps(

-             {'source_type': 'tag', 'source': 'f26', 'packages': ['ed'],

-              'flags': ['no_deps']}))

-         data = json.loads(rv.data.decode('utf8'))

+         self.login_user()

+ 

+         with app.test_request_context():

+             flask.g.groups = ['tester']

+ 

+             rv = self.client.post('/odcs/1/composes/', data=json.dumps(

+                 {'source_type': 'tag', 'source': 'f26', 'packages': ['ed'],

+                  'flags': ['no_deps']}))

+             data = json.loads(rv.data.decode('utf8'))

  

          self.assertEqual(data['flags'], ['no_deps'])

  
@@ -93,8 +128,14 @@ 

          self.c1.state = COMPOSE_STATES["removed"]

          self.c1.reused_id = 1

          db.session.commit()

-         rv = self.client.post('/odcs/1/composes/', data=json.dumps({'id': 1}))

-         data = json.loads(rv.data.decode('utf8'))

+ 

+         self.login_user()

+ 

+         with app.test_request_context():

+             flask.g.groups = ['tester']

+ 

+             rv = self.client.post('/odcs/1/composes/', data=json.dumps({'id': 1}))

+             data = json.loads(rv.data.decode('utf8'))

  

          self.assertEqual(data['id'], 3)

          self.assertEqual(data['state_name'], 'wait')
@@ -108,8 +149,14 @@ 

          self.c1.state = COMPOSE_STATES["failed"]

          self.c1.reused_id = 1

          db.session.commit()

-         rv = self.client.post('/odcs/1/composes/', data=json.dumps({'id': 1}))

-         data = json.loads(rv.data.decode('utf8'))

+ 

+         self.login_user()

+ 

+         with app.test_request_context():

+             flask.g.groups = ['tester']

+ 

+             rv = self.client.post('/odcs/1/composes/', data=json.dumps({'id': 1}))

+             data = json.loads(rv.data.decode('utf8'))

  

          self.assertEqual(data['id'], 3)

          self.assertEqual(data['state_name'], 'wait')
@@ -121,15 +168,25 @@ 

  

      def test_submit_build_resurrection_no_removed(self):

          db.session.commit()

-         rv = self.client.post('/odcs/1/composes/', data=json.dumps({'id': 1}))

-         data = json.loads(rv.data.decode('utf8'))

+         self.login_user()

+ 

+         with app.test_request_context():

+             flask.g.groups = ['tester']

+ 

+             rv = self.client.post('/odcs/1/composes/', data=json.dumps({'id': 1}))

+             data = json.loads(rv.data.decode('utf8'))

  

          self.assertEqual(data['message'], 'No expired or failed compose with id 1')

  

      def test_submit_build_resurrection_not_found(self):

+         self.login_user()

          db.session.commit()

-         rv = self.client.post('/odcs/1/composes/', data=json.dumps({'id': 100}))

-         data = json.loads(rv.data.decode('utf8'))

+ 

+         with app.test_request_context():

+             flask.g.groups = ['tester']

+ 

+             rv = self.client.post('/odcs/1/composes/', data=json.dumps({'id': 100}))

+             data = json.loads(rv.data.decode('utf8'))

  

          self.assertEqual(data['message'], 'No expired or failed compose with id 100')

  
@@ -175,6 +232,8 @@ 

          self.assertEqual(len(evs), 1)

  

      def test_delete_compose(self):

+         self.login_user()

+ 

          with freeze_time(self.initial_datetime) as frozen_datetime:

              c3 = Compose.create(

                  db.session, "unknown", PungiSourceType.MODULE, "testmodule-master",
@@ -185,11 +244,14 @@ 

  

              self.assertEqual(len(Compose.composes_to_expire()), 0)

  

-             resp = self.client.delete("/odcs/1/composes/%s" % c3.id)

+             with app.test_request_context():

+                 flask.g.groups = ['tester']

+ 

+                 resp = self.client.delete("/odcs/1/composes/%s" % c3.id)

+                 data = json.loads(resp.data.decode('utf8'))

  

              self.assertEqual(resp.status, '202 ACCEPTED')

  

-             data = json.loads(resp.data.decode('utf8'))

              self.assertEqual(data['status'], 202)

              self.assertEqual(data['message'],

                               "The delete request for compose (id=%s) has been accepted and will be processed by backend later." % c3.id)
@@ -202,6 +264,8 @@ 

              self.assertEqual(expired_compose.id, c3.id)

  

      def test_delete_not_allowed_states_compose(self):

+         self.login_user()

+ 

          for state in COMPOSE_STATES.keys():

              if state not in ['done', 'failed']:

                  new_c = Compose.create(
@@ -211,8 +275,12 @@ 

                  db.session.add(new_c)

                  db.session.commit()

  

-                 resp = self.client.delete("/odcs/1/composes/%s" % new_c.id)

-                 data = json.loads(resp.data.decode('utf8'))

+                 with app.test_request_context():

+                     flask.g.groups = ['tester']

+ 

+                     resp = self.client.delete("/odcs/1/composes/%s" % new_c.id)

+                     data = json.loads(resp.data.decode('utf8'))

+ 

                  self.assertEqual(resp.status, '400 BAD REQUEST')

                  self.assertEqual(data['status'], 400)

                  self.assertRegexpMatches(data['message'],
@@ -220,10 +288,64 @@ 

                  self.assertEqual(data['error'], 'Bad Request')

  

      def test_delete_non_exist_compose(self):

-         resp = self.client.delete("/odcs/1/composes/999999")

-         data = json.loads(resp.data.decode('utf8'))

+         self.login_user()

+ 

+         with app.test_request_context():

+             flask.g.groups = ['tester']

+ 

+             resp = self.client.delete("/odcs/1/composes/999999")

+             data = json.loads(resp.data.decode('utf8'))

  

          self.assertEqual(resp.status, '404 NOT FOUND')

          self.assertEqual(data['status'], 404)

          self.assertEqual(data['message'], "No such compose found.")

          self.assertEqual(data['error'], 'Not Found')

+ 

+ 

+ class TestUserInAllowedGroupsDecorator(unittest.TestCase):

+     """Test decorator user_in_allowed_groups"""

+ 

+     def setUp(self):

+         self.mock_func = Mock()

+         self.decorated_func = user_in_allowed_groups(self.mock_func)

+ 

+         self.patch_allowed_groups = patch.object(odcs.auth.conf,

+                                                  'allowed_groups',

+                                                  new=['testers'])

+         self.patch_allowed_groups.start()

+ 

+     def tearDown(self):

+         self.patch_allowed_groups.stop()

+ 

+     def test_401_if_not_in_allowed_groups(self):

+         with app.test_request_context():

+             flask.g.groups = ['another_group']

+             flask.g.user = User(id=1, username='tester')

+ 

+             self.assertRaises(Unauthorized, self.decorated_func, 1, 2, 3)

+             self.mock_func.assert_not_called()

+ 

+     def test_authorized_if_in_allowed_groups(self):

+         with app.test_request_context():

+             flask.g.groups = ['testers']

+             flask.g.user = User(id=1, username='tester')

+ 

+             self.decorated_func(1, 2, 3)

+             self.mock_func.assert_called_once_with(1, 2, 3)

+ 

+     @patch.object(odcs.views.conf, 'authorize_disabled', new=True)

+     def test_no_authorize_when_disable_authorize(self):

+         with app.test_request_context():

+             flask.g.groups = ['testers']

+             flask.g.user = User(id=1, username='tester')

+ 

+             self.decorated_func(1, 2, 3)

+             self.mock_func.assert_called_once_with(1, 2, 3)

+ 

+         with app.test_request_context():

+             flask.g.groups = ['another_groups']

+             flask.g.user = User(id=1, username='tester')

+ 

+             self.decorated_func(1, 2, 3)

+             self.assertEqual(2, self.mock_func.call_count)

+             self.mock_func.assert_called_with(1, 2, 3)

Signed-off-by: Chenxiong Qi cqi@redhat.com

Originally, each request is required to be authenticated whatever the request method is. That is not flexible and can't work with frontend either kerberos or openidc authentication in apache that is not enabled for GET. Meanwhile, it would be easy to implement simple authentication based on username/password in ODCS by using Flask-Login and its login_require decorator, if it is needed.

Looks good. Note that, this requires authentication, but there's not authorization check on the DELETE endpoint yet (which we want eventually). It doesn't have to be a part of this change.

Any possibility to add tests here?

there's not authorization check on the DELETE endpoint yet (which we want eventually)

Yes. Authorization has not been added to both POST and DELETE endpoint.

This change makes ODCS work with frontend authentication done in Apache. For an instance running in development environment without Aapche, I think a simple authentication mechanism would be needed as well so that both POST and DELETE are accessible by an authenticated user.

I'll propose separate PRs for both of them.

rebased

6 years ago

Above are fixed and updated in this PR.

In development environment, LOGIN_DISABLED can be used to disable flask_login.login_required.

Tests are updated according to the changes by using Flask-Login.

A separate commit is added to support group-based authorization on each request.

Pull-Request has been merged by cqi

6 years ago