Skip to content

Commit

Permalink
Merge pull request #79 from romanchyla/scopes
Browse files Browse the repository at this point in the history
Scopes: added manage.py method to update existing tokens
  • Loading branch information
romanchyla committed Dec 11, 2015
2 parents 65d0f8f + e681d11 commit 97a56fe
Show file tree
Hide file tree
Showing 8 changed files with 195 additions and 16 deletions.
89 changes: 88 additions & 1 deletion adsws/accounts/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from adsws.core.users import User
from adsws.core import db
from adsws.accounts import create_app

from sqlalchemy import or_, exc
from flask.ext.script import Manager

accounts_manager = Manager(create_app())
Expand Down Expand Up @@ -127,7 +127,94 @@ def cleanup_clients(app_override=None, timedelta="days=31"):
db.session.rollback()
app.logger.error("Could not cleanup expired oauth2clients. "
"Database error; rolled back: {0}".format(e))
return
app.logger.info("Deleted {0} oauth2clients whose last_activity was "
"at least {1} old".format(deletions, timedelta))


@accounts_manager.command
def update_scopes(app_override=None, old_scopes='', new_scopes='',
force_token_update=False):
"""
Updates scopes for both the clients and active tokens in the
database defined in app.config['SQLALCHEMY_DATABASE_URI']
:param app_override: flask.app instance to use instead of manager.app
:param old_scopes: String representing the existing scopes, e.g.
"user store-preferences"
:type old_scopes: basestring
:param new_scopes: String representing the desired scopes, e.g.
"user store-preferences store-data"
:type new_scopes: basestring
:param force_token_update: Update any matching OauthToken even if
cannot be traced to an existing OAuthClient
:type force_token_update: boolean
:return: None
"""

app = accounts_manager.app if app_override is None else app_override

if set(old_scopes.split()) == set(new_scopes.split()):
app.logger.warn("Hmmm, useless scope replacement of {0) with {1}"
.format(old_scopes, new_scopes))

orig_old_scopes = old_scopes
old_scopes = set((old_scopes or '').split(' '))
new_scopes = ' '.join(sorted((new_scopes or '').split(' ')))


with app.app_context():
# first find all oauth clients that would be affected by this
# change (we could search the database to give us the clients,
# but since this is a maintenance routine, we'll go through
# them all and check their scopes manually)
clients = db.session.query(OAuthClient).all()
to_update = set()
total = 0

for client in clients:
total += 1
if old_scopes == set((client._default_scopes or '').split(' ')):
to_update.add(client.client_id)
db.session.begin_nested()
try:
client._default_scopes = new_scopes

# now update the existing tokens
total = 0
updated = 0
tokens = db.session.query(OAuthToken).filter_by(client_id=client.client_id).all()
for token in tokens:
if set((token._scopes or '').split(' ')) == old_scopes:
token._scopes = new_scopes
updated += 1
total += 1
db.session.commit()

app.logger.info("Updated {0} oauth2tokens (out of total: {1}) for {2}"
.format(updated, total, client.client_id))
except exc.IntegrityError, e:
db.session.rollback()
app.logger.error("Could not update scope of oauth2client: {0}. "
"Database error; rolled back: {1}"
.format(client.client_id, e))

if force_token_update:
tokens = db.session.query(OAuthToken).filter(or_(OAuthToken._scopes==orig_old_scopes,
OAuthToken._scopes==' '.join(sorted(list(old_scopes))))).all()
for token in tokens:
db.session.begin_nested()
try:
token._scopes = new_scopes
db.session.commit()
except exc.IntegrityError, e:
db.session.rollback()
app.logger.error("Could not update scope of oauth2token: {0}. "
"Database error; rolled back: {1}"
.format(token.id, e))


app.logger.info("Updated {0} oauth2clients (out of total: {1})"
.format(len(to_update), total))

# per PEP-0249 a transaction is always in progress
db.session.commit()
2 changes: 1 addition & 1 deletion adsws/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def create_app(app_name=None, instance_path=None, static_path=None,
app.errorhandler(401)(on_401)
app.errorhandler(429)(on_429)
app.errorhandler(405)(on_405)

@oauth2.after_request
def set_adsws_uid_header(valid, oauth):
"""
Expand Down
4 changes: 3 additions & 1 deletion adsws/modules/oauth2server/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,13 @@ def save_token(token, request, *args, **kwargs):
expires_in = token.pop('expires_in')
expires = datetime.utcnow() + timedelta(seconds=int(expires_in))

# scopes are sorted alphabetically before writing to a database
# this makes administrative tasks easier
tok = OAuthToken(
access_token=token['access_token'],
refresh_token=token.get('refresh_token'),
token_type=token['token_type'],
_scopes=token['scope'],
_scopes=' '.join(sorted((token['scope'] or '').split(' '))),
expires=expires,
client_id=request.client.client_id,
user_id=uid,
Expand Down
15 changes: 10 additions & 5 deletions adsws/tests/test_accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import httpretty

RATELIMITER_KEY_PREFIX = 'unittest.{0}'.format(datetime.datetime.now())



class TestUtils(UnitTestCase):
Expand Down Expand Up @@ -52,11 +53,7 @@ def test_validate_password(self):
self.assertTrue(func("123Aabc"))


class TestAccounts(TestCase):
"""
Tests for accounts endpoints and workflows
"""

class AccountsSetup(TestCase):
def tearDown(self):
httpretty.disable()
httpretty.reset()
Expand Down Expand Up @@ -95,9 +92,16 @@ def create_app(self):
MAIL_SUPPRESS_SEND=True,
RATELIMITER_KEY_PREFIX=RATELIMITER_KEY_PREFIX,
SECRET_KEY="unittests-secret-key",
SQLALCHEMY_ECHO=False
)
return app


class TestAccounts(AccountsSetup):
"""
Tests for accounts endpoints and workflows
"""

def setup_google_recaptcha_response(self):
"""Set up a mock google recaptcha api"""

Expand Down Expand Up @@ -819,6 +823,7 @@ def test_when_no_session(self, mocked):
self.assertTrue(mocked.called)



TESTSUITE = make_test_suite(TestAccounts, TestUtils)

if __name__ == '__main__':
Expand Down
96 changes: 90 additions & 6 deletions adsws/tests/test_commands.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,102 @@
import time
import datetime
import random
from werkzeug.security import gen_salt

from flask.ext.testing import TestCase

from adsws.modules.oauth2server.models import OAuthClient, OAuthToken
from adsws.modules.oauth2server.models import OAuthClient, Scope, OAuthToken
from adsws.tests.test_accounts import AccountsSetup
from adsws.core.users import User
from adsws.core import db, user_manipulator
from adsws import factory
from adsws.testsuite import make_test_suite, run_test_suite
from adsws.accounts.manage import cleanup_tokens, cleanup_clients, \
cleanup_users, parse_timedelta


cleanup_users, parse_timedelta, update_scopes

class TestManageScopes(AccountsSetup):

def _create_client(self, client_id='test', user_id=0, scopes='adsws:internal'):
# create a client in the database
c1 = OAuthClient(
client_id=client_id,
client_secret='client secret %s' % random.random(),
name='bumblebee',
description='',
is_confidential=False,
user_id=user_id,
_redirect_uris='%s/client/authorized' % self.app.config.get('SITE_SECURE_URL'),
_default_scopes=scopes
)
db.session.add(c1)
db.session.commit()
return OAuthClient.query.filter_by(client_secret=c1.client_secret).one()

def _create_token(self, client_id='test', user_id=0, scopes='adsws:internal'):
token = OAuthToken(
client_id=client_id,
user_id=user_id,
access_token='access token %s' % random.random(),
refresh_token='refresh token %s' % random.random(),
expires=datetime.datetime(2500, 1, 1),
_scopes=scopes,
is_personal=False,
is_internal=True,
)
db.session.add(token)
db.session.commit()
return OAuthToken.query.filter_by(id=token.id).one()

def test_update_scopes_forced(self):
"""Verify that scopes are updated without clients"""

self._create_client('test0', 0, scopes='')
self._create_token('test0', 0, scopes='adsws:foo one')
self._create_token('test0', 1, scopes='adsws:foo one two')

update_scopes(self.app, 'adsws:foo one', 'foo bar', force_token_update=True)

self.assertTrue(len(OAuthClient.query.filter_by(_default_scopes='bar foo').all()) == 0)
self.assertTrue(len(OAuthToken.query.filter_by(_scopes='bar foo').all()) == 1)

def test_update_scopes(self):
"""Verify that scopes are updated properly"""

self._create_client('test0', 0, scopes='adsws:foo')
self._create_client('test1', 1, scopes='adsws:foo one two')
self._create_client('test2', 2, scopes='adsws:foo two one')
self._create_client('test3', 3, scopes='adsws:foo')
self._create_client('test4', 4, scopes='adsws:foo')

self._create_token('test0', 0, scopes='adsws:foo one')
self._create_token('test1', 1, scopes='adsws:foo one two')
self._create_token('test1', 1, scopes='adsws:foo two one')
self._create_token('test1', 1, scopes='foo bar')

# normally, scopes will be sorted alphabetically (but we fake it here)
self.assertIsNotNone(OAuthClient.query.filter_by(_default_scopes= 'adsws:foo one two').one())
self.assertIsNotNone(OAuthClient.query.filter_by(_default_scopes= 'adsws:foo two one').one())

update_scopes(self.app, 'adsws:foo one two', 'foo bar')

# manager will save tokens alphab sorted
self.assertTrue(len(OAuthClient.query.filter_by(_default_scopes='bar foo').all()) == 2)
self.assertTrue(len(OAuthClient.query.filter_by(_default_scopes='adsws:foo').all()) == 3)

self.assertTrue(len(OAuthToken.query.filter_by(_scopes='bar foo').all()) == 2)
self.assertTrue(len(OAuthToken.query.filter_by(_scopes='adsws:foo one').all()) == 1)
self.assertTrue(len(OAuthToken.query.filter_by(_scopes='foo bar').all()) == 1)

update_scopes(self.app, 'foo bar', 'xxx')

self.assertTrue(len(OAuthClient.query.filter_by(_default_scopes='bar foo').all()) == 0)
self.assertTrue(len(OAuthClient.query.filter_by(_default_scopes='xxx').all()) == 2)
self.assertTrue(len(OAuthClient.query.filter_by(_default_scopes='adsws:foo').all()) == 3)

self.assertTrue(len(OAuthToken.query.filter_by(_scopes='bar foo').all()) == 0)
self.assertTrue(len(OAuthToken.query.filter_by(_scopes='adsws:foo one').all()) == 1)
self.assertTrue(len(OAuthToken.query.filter_by(_scopes='foo bar').all()) == 0)
self.assertTrue(len(OAuthToken.query.filter_by(_scopes='xxx').all()) == 3)

class TestManage_Accounts(TestCase):
"""
Tests for manage.py/flask.ext.script commands
Expand Down Expand Up @@ -234,7 +318,7 @@ def test_cleanup_client(self):
self.assertIsNone(current_clients[0].last_activity)


TEST_SUITE = make_test_suite(TestManage_Accounts)
TEST_SUITE = make_test_suite(TestManage_Accounts, TestManageScopes)


if __name__ == "__main__":
Expand Down
1 change: 1 addition & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ coveralls
pytest
coverage
pytest-cov
Flask-Testing==0.4.2
2 changes: 1 addition & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[pytest]
addopts = --cov=adsws --cov-report=term-missing
testpaths=adsws/tests
testpaths=adsws/
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ alembic==0.8.1
six>=1.10.0
redis==2.10.3
ConcurrentLogHandler==0.9.1
flask-consulate==0.1.2
flask-consulate==0.1.2

0 comments on commit 97a56fe

Please sign in to comment.