From d6e74751fa156f3879ff2136caccf2a40d4b9e8c Mon Sep 17 00:00:00 2001 From: Todd Willey Date: Mon, 26 Jul 2010 15:01:42 -0400 Subject: Basic standup of SessionToken model for shortlived auth tokens. --- nova/compute/model.py | 36 ++++++++++++++++++++++++++++++ nova/exception.py | 3 +++ nova/tests/model_unittest.py | 52 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) diff --git a/nova/compute/model.py b/nova/compute/model.py index cda188183..331b68349 100644 --- a/nova/compute/model.py +++ b/nova/compute/model.py @@ -43,6 +43,7 @@ True import logging import time import redis +import uuid from nova import datastore from nova import exception @@ -228,6 +229,41 @@ class Daemon(datastore.BasicModel): for x in cls.associated_to("host", hostname): yield x +class SessionToken(datastore.BasicModel): + """This is a short-lived auth token that is passed through web requests""" + + def __init__(self, session_token): + self.token = session_token + super(SessionToken, self).__init__() + + @property + def identifier(self): + return self.token + + def default_state(self): + return {'user': None, 'session_type': None, 'token': self.token} + + @classmethod + def generate(cls, userid, session_type=None): + token = str(uuid.uuid4()) + while cls.lookup(token): + token = str(uuid.uuid4()) + instance = cls(token) + instance['user'] = userid + instance['session_type'] = session_type + instance.save() + return instance + + def save(self): + """Call into superclass to save object, then save associations""" + if not self['user']: + raise exception.Invalid("SessionToken requires a User association") + success = super(SessionToken, self).save() + if success: + self.associate_with("user", self['user']) + return True + + if __name__ == "__main__": import doctest doctest.testmod() diff --git a/nova/exception.py b/nova/exception.py index 2108123de..52497a19e 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -47,6 +47,9 @@ class NotAuthorized(Error): class NotEmpty(Error): pass +class Invalid(Error): + pass + def wrap_exception(f): def _wrap(*args, **kw): try: diff --git a/nova/tests/model_unittest.py b/nova/tests/model_unittest.py index 1bd7e527f..7823991b9 100644 --- a/nova/tests/model_unittest.py +++ b/nova/tests/model_unittest.py @@ -66,6 +66,12 @@ class ModelTestCase(test.TrialTestCase): daemon.save() return daemon + def create_session_token(self): + session_token = model.SessionToken('tk12341234') + session_token['user'] = 'testuser' + session_token.save() + return session_token + @defer.inlineCallbacks def test_create_instance(self): """store with create_instace, then test that a load finds it""" @@ -204,3 +210,49 @@ class ModelTestCase(test.TrialTestCase): if x.identifier == 'testhost:nova-testdaemon': found = True self.assertTrue(found) + + @defer.inlineCallbacks + def test_create_session_token(self): + """create""" + d = yield self.create_session_token() + d = model.SessionToken(d.token) + self.assertFalse(d.is_new_record()) + + @defer.inlineCallbacks + def test_delete_session_token(self): + """create, then destroy, then make sure loads a new record""" + instance = yield self.create_session_token() + yield instance.destroy() + newinst = yield model.SessionToken(instance.token) + self.assertTrue(newinst.is_new_record()) + + @defer.inlineCallbacks + def test_session_token_added_to_set(self): + """create, then check that it is included in list""" + instance = yield self.create_session_token() + found = False + for x in model.SessionToken.all(): + if x.identifier == instance.token: + found = True + self.assert_(found) + + @defer.inlineCallbacks + def test_session_token_associates_user(self): + """create, then check that it is listed for the user""" + instance = yield self.create_session_token() + found = False + for x in model.SessionToken.associated_to('user', 'testuser'): + if x.identifier == instance.identifier: + found = True + self.assertTrue(found) + + @defer.inlineCallbacks + def test_session_token_generation(self): + instance = yield model.SessionToken.generate('username', 'TokenType') + self.assertFalse(instance.is_new_record()) + + @defer.inlineCallbacks + def test_find_generated_session_token(self): + instance = yield model.SessionToken.generate('username', 'TokenType') + found = yield model.SessionToken.lookup(instance.identifier) + self.assert_(found) -- cgit From fd2d4e3f3dba426eedc22b326d2bb0cb6a19eb76 Mon Sep 17 00:00:00 2001 From: Todd Willey Date: Mon, 26 Jul 2010 17:00:50 -0400 Subject: Expiry awareness for SessionToken. --- nova/compute/model.py | 33 ++++++++++++++++++++++++--------- nova/tests/model_unittest.py | 9 +++++++++ nova/utils.py | 9 ++++++--- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/nova/compute/model.py b/nova/compute/model.py index 331b68349..3aa6fc841 100644 --- a/nova/compute/model.py +++ b/nova/compute/model.py @@ -40,6 +40,7 @@ True True """ +import datetime import logging import time import redis @@ -241,10 +242,24 @@ class SessionToken(datastore.BasicModel): return self.token def default_state(self): - return {'user': None, 'session_type': None, 'token': self.token} + now = datetime.datetime.utcnow() + diff = datetime.timedelta(hours=1) + expires = now + diff + return {'user': None, 'session_type': None, 'token': self.token, + 'expiry': expires.strftime(utils.TIME_FORMAT)} + + def save(self): + """Call into superclass to save object, then save associations""" + if not self['user']: + raise exception.Invalid("SessionToken requires a User association") + success = super(SessionToken, self).save() + if success: + self.associate_with("user", self['user']) + return True @classmethod def generate(cls, userid, session_type=None): + """make a new token for the given user""" token = str(uuid.uuid4()) while cls.lookup(token): token = str(uuid.uuid4()) @@ -254,14 +269,14 @@ class SessionToken(datastore.BasicModel): instance.save() return instance - def save(self): - """Call into superclass to save object, then save associations""" - if not self['user']: - raise exception.Invalid("SessionToken requires a User association") - success = super(SessionToken, self).save() - if success: - self.associate_with("user", self['user']) - return True + def update_expiry(self, **kwargs): + """updates the expirty attribute, but doesn't save""" + if not kwargs: + kwargs['hours'] = 1 + time = datetime.datetime.utcnow() + diff = datetime.timedelta(**kwargs) + expires = time + diff + self['expiry'] = expires.strftime(utils.TIME_FORMAT) if __name__ == "__main__": diff --git a/nova/tests/model_unittest.py b/nova/tests/model_unittest.py index 7823991b9..0755d8578 100644 --- a/nova/tests/model_unittest.py +++ b/nova/tests/model_unittest.py @@ -16,6 +16,7 @@ # License for the specific language governing permissions and limitations # under the License. +from datetime import datetime import logging import time from twisted.internet import defer @@ -256,3 +257,11 @@ class ModelTestCase(test.TrialTestCase): instance = yield model.SessionToken.generate('username', 'TokenType') found = yield model.SessionToken.lookup(instance.identifier) self.assert_(found) + + def test_update_expiry(self): + instance = model.SessionToken('tk12341234') + oldtime = datetime.utcnow() + instance['expiry'] = oldtime.strftime(utils.TIME_FORMAT) + instance.update_expiry() + expiry = utils.parse_isotime(instance['expiry']) + self.assert_(expiry > datetime.utcnow()) diff --git a/nova/utils.py b/nova/utils.py index 9ecceafe0..a1eb0a092 100644 --- a/nova/utils.py +++ b/nova/utils.py @@ -20,7 +20,7 @@ System-level utilities and helper functions. """ -from datetime import datetime +from datetime import datetime, timedelta import inspect import logging import os @@ -32,7 +32,7 @@ import sys from nova import flags FLAGS = flags.FLAGS - +TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" def fetchfile(url, target): logging.debug("Fetching %s" % url) @@ -118,4 +118,7 @@ def get_my_ip(): def isotime(at=None): if not at: at = datetime.utcnow() - return at.strftime("%Y-%m-%dT%H:%M:%SZ") + return at.strftime(TIME_FORMAT) + +def parse_isotime(timestr): + return datetime.strptime(timestr, TIME_FORMAT) -- cgit From 58b41fde4c8639577b738d0f57f10acda4c63c0e Mon Sep 17 00:00:00 2001 From: Todd Willey Date: Mon, 26 Jul 2010 18:00:39 -0400 Subject: Lookup should only not return expired tokens. --- nova/compute/model.py | 9 +++++++++ nova/tests/model_unittest.py | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/nova/compute/model.py b/nova/compute/model.py index 3aa6fc841..ab0bfeb83 100644 --- a/nova/compute/model.py +++ b/nova/compute/model.py @@ -257,6 +257,15 @@ class SessionToken(datastore.BasicModel): self.associate_with("user", self['user']) return True + @classmethod + def lookup(cls, key): + token = super(SessionToken, cls).lookup(key) + if token: + expires_at = utils.parse_isotime(token['expiry']) + if datetime.datetime.utcnow() >= expires_at: + return None + return token + @classmethod def generate(cls, userid, session_type=None): """make a new token for the given user""" diff --git a/nova/tests/model_unittest.py b/nova/tests/model_unittest.py index 0755d8578..10d3016f8 100644 --- a/nova/tests/model_unittest.py +++ b/nova/tests/model_unittest.py @@ -258,10 +258,24 @@ class ModelTestCase(test.TrialTestCase): found = yield model.SessionToken.lookup(instance.identifier) self.assert_(found) - def test_update_expiry(self): + def test_update_session_token_expiry(self): instance = model.SessionToken('tk12341234') oldtime = datetime.utcnow() instance['expiry'] = oldtime.strftime(utils.TIME_FORMAT) instance.update_expiry() expiry = utils.parse_isotime(instance['expiry']) self.assert_(expiry > datetime.utcnow()) + + @defer.inlineCallbacks + def test_session_token_lookup_when_expired(self): + instance = yield model.SessionToken.generate("testuser") + instance['expiry'] = datetime.utcnow().strftime(utils.TIME_FORMAT) + instance.save() + inst = model.SessionToken.lookup(instance.identifier) + self.assertFalse(inst) + + @defer.inlineCallbacks + def test_session_token_lookup_when_not_expired(self): + instance = yield model.SessionToken.generate("testuser") + inst = model.SessionToken.lookup(instance.identifier) + self.assert_(inst) -- cgit From 7588ae06e8d6a7d526b12e0f15f3e5be522f16d0 Mon Sep 17 00:00:00 2001 From: Todd Willey Date: Mon, 26 Jul 2010 18:02:00 -0400 Subject: In fact, it should delete them. --- nova/compute/model.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nova/compute/model.py b/nova/compute/model.py index ab0bfeb83..7335d2c79 100644 --- a/nova/compute/model.py +++ b/nova/compute/model.py @@ -263,6 +263,7 @@ class SessionToken(datastore.BasicModel): if token: expires_at = utils.parse_isotime(token['expiry']) if datetime.datetime.utcnow() >= expires_at: + token.destroy() return None return token -- cgit From 74ce3aef4dafca8b0fc6bf0404725afdefe335ec Mon Sep 17 00:00:00 2001 From: Todd Willey Date: Mon, 26 Jul 2010 23:49:49 -0400 Subject: Give SessionToken an is_expired method --- nova/compute/model.py | 5 +++++ nova/tests/model_unittest.py | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/nova/compute/model.py b/nova/compute/model.py index 7335d2c79..bae93b6c1 100644 --- a/nova/compute/model.py +++ b/nova/compute/model.py @@ -288,6 +288,11 @@ class SessionToken(datastore.BasicModel): expires = time + diff self['expiry'] = expires.strftime(utils.TIME_FORMAT) + def is_expired(self): + now = datetime.datetime.utcnow() + expires = utils.parse_isotime(self['expiry']) + return expires <= now + if __name__ == "__main__": import doctest diff --git a/nova/tests/model_unittest.py b/nova/tests/model_unittest.py index 10d3016f8..88ba5e6e9 100644 --- a/nova/tests/model_unittest.py +++ b/nova/tests/model_unittest.py @@ -279,3 +279,14 @@ class ModelTestCase(test.TrialTestCase): instance = yield model.SessionToken.generate("testuser") inst = model.SessionToken.lookup(instance.identifier) self.assert_(inst) + + @defer.inlineCallbacks + def test_session_token_is_expired_when_expired(self): + instance = yield model.SessionToken.generate("testuser") + instance['expiry'] = datetime.utcnow().strftime(utils.TIME_FORMAT) + self.assert_(instance.is_expired()) + + @defer.inlineCallbacks + def test_session_token_is_expired_when_not_expired(self): + instance = yield model.SessionToken.generate("testuser") + self.assertFalse(instance.is_expired()) -- cgit From ad7f099aefc17d04a2a04deb7fd3055adc8cd84a Mon Sep 17 00:00:00 2001 From: Todd Willey Date: Tue, 27 Jul 2010 01:03:05 -0400 Subject: Flag for SessionToken ttl setting. --- nova/compute/model.py | 12 ++++++++++-- nova/flags.py | 2 ++ nova/tests/model_unittest.py | 11 ++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/nova/compute/model.py b/nova/compute/model.py index bae93b6c1..212830d3c 100644 --- a/nova/compute/model.py +++ b/nova/compute/model.py @@ -235,6 +235,7 @@ class SessionToken(datastore.BasicModel): def __init__(self, session_token): self.token = session_token + self.default_ttl = FLAGS.auth_token_ttl super(SessionToken, self).__init__() @property @@ -243,7 +244,7 @@ class SessionToken(datastore.BasicModel): def default_state(self): now = datetime.datetime.utcnow() - diff = datetime.timedelta(hours=1) + diff = datetime.timedelta(seconds=self.default_ttl) expires = now + diff return {'user': None, 'session_type': None, 'token': self.token, 'expiry': expires.strftime(utils.TIME_FORMAT)} @@ -282,7 +283,7 @@ class SessionToken(datastore.BasicModel): def update_expiry(self, **kwargs): """updates the expirty attribute, but doesn't save""" if not kwargs: - kwargs['hours'] = 1 + kwargs['seconds'] = self.default_ttl time = datetime.datetime.utcnow() diff = datetime.timedelta(**kwargs) expires = time + diff @@ -293,6 +294,13 @@ class SessionToken(datastore.BasicModel): expires = utils.parse_isotime(self['expiry']) return expires <= now + def ttl(self): + """number of seconds remaining before expiration""" + now = datetime.datetime.utcnow() + expires = utils.parse_isotime(self['expiry']) + delta = expires - now + return (delta.seconds + (delta.days * 24 * 3600)) + if __name__ == "__main__": import doctest diff --git a/nova/flags.py b/nova/flags.py index 06ea1e007..3c1a0acaf 100644 --- a/nova/flags.py +++ b/nova/flags.py @@ -75,6 +75,8 @@ DEFINE_string('vpn_key_suffix', '-key', 'Suffix to add to project name for vpn key') +DEFINE_integer('auth_token_ttl', 3600, 'Seconds for auth tokens to linger') + # UNUSED DEFINE_string('node_availability_zone', 'nova', diff --git a/nova/tests/model_unittest.py b/nova/tests/model_unittest.py index 88ba5e6e9..24c08a908 100644 --- a/nova/tests/model_unittest.py +++ b/nova/tests/model_unittest.py @@ -16,7 +16,7 @@ # License for the specific language governing permissions and limitations # under the License. -from datetime import datetime +from datetime import datetime, timedelta import logging import time from twisted.internet import defer @@ -290,3 +290,12 @@ class ModelTestCase(test.TrialTestCase): def test_session_token_is_expired_when_not_expired(self): instance = yield model.SessionToken.generate("testuser") self.assertFalse(instance.is_expired()) + + @defer.inlineCallbacks + def test_session_token_ttl(self): + instance = yield model.SessionToken.generate("testuser") + now = datetime.utcnow() + delta = timedelta(hours=1) + instance['expiry'] = (now + delta).strftime(utils.TIME_FORMAT) + # give 5 seconds of fuzziness + self.assert_(abs(instance.ttl() - FLAGS.auth_token_ttl) < 5) -- cgit