Index: /main/waeup.cas/trunk/setup.py
===================================================================
--- /main/waeup.cas/trunk/setup.py	(revision 10393)
+++ /main/waeup.cas/trunk/setup.py	(revision 10394)
@@ -22,8 +22,6 @@
     'setuptools',
     'webob',
+    'SQLAlchemy',
     ]
-
-if sys.version_info < (3, 1):
-    install_requires.append('pysqlite')  # included in Python >= 3.1
 
 tests_require = [
@@ -31,4 +29,5 @@
     'pytest-cov',
     'PasteDeploy',
+    'WebTest',
     ]
 
@@ -67,4 +66,6 @@
     entry_points="""[paste.app_factory]
     server = waeup.cas:make_cas_server
+    [waeup.cas.authenticators]
+    dummy = waeup.cas.authenticators:DummyAuthenticator
     """,
 )
Index: /main/waeup.cas/trunk/waeup/cas/authenticators.py
===================================================================
--- /main/waeup.cas/trunk/waeup/cas/authenticators.py	(revision 10394)
+++ /main/waeup.cas/trunk/waeup/cas/authenticators.py	(revision 10394)
@@ -0,0 +1,102 @@
+"""Components that do the real authentication stuff.
+"""
+from pkg_resources import iter_entry_points
+
+
+def get_all_authenticators():
+    """Get all authenticators registered via entry points.
+
+    To create an own authenticator, in the ``setup.py`` of your
+    package add a block like this::
+
+        entry_points='''
+           [waeup.cas.authenticators]
+           myname = my.pkg.my.module:MyAuthenticator
+           '''
+
+    where ``my.pkg.my.module`` must be a module importable at runtime
+    and ``MyAuthenticator`` must be some Authenticator class defined
+    in this module.
+
+    ``myname`` can be chosen as you like, but must not clash with
+    other ``waeup.cas.authenticators``. The name should contain ASCII
+    letters and numbers only, and start with a letter.
+    """
+    return dict(
+        [(x.name, x.load())
+         for x in iter_entry_points(group='waeup.cas.authenticators')])
+
+
+def get_authenticator(local_conf):
+    """Get an `Authenticator` configured by ``local_conf``.
+
+    `local_conf` must be a dictionary of settings.
+
+    An authenticator is identified by its entry-point name which must
+    be given as value of the ``auth`` key.
+
+    It is created passing all keys/values where key starts with
+    ``auth_``.
+
+    Returns a dict of remaining keys/values in `local_conf` with the
+    value of ``auth`` key replaced by an authenticator instance.
+    """
+    gen_opts, auth_opts = filter_auth_opts(local_conf)
+    if 'auth' in local_conf:
+        auth_dict = get_all_authenticators()
+        factory = auth_dict.get(local_conf['auth'])  # get authenticator
+        auth = factory(**auth_opts)
+        gen_opts.update(auth=auth)
+    return gen_opts
+
+
+def filter_auth_opts(local_conf):
+    """Filter out auth-related opts in `local_conf`, a dict.
+
+    Returns two dicts ``(<GENERAL_OPTS>, <AUTHENTICATOR_OPTS>)``
+    containing the general options and authenticator related options.
+
+    This is designed to work with typical paste.deploy config files
+    like this::
+
+      [foo]
+      foo = bar
+      auth = foo
+      auth_opt1 = bar
+
+    All settings not starting with `auth_` are put into the general
+    opts dict, while other are put into the authenticator opts dict.
+    """
+    auth_opts = {}
+    general_opts = {}
+    for name, value in local_conf.items():
+        if name.startswith('auth_'):
+            auth_opts[name] = value
+        else:
+            general_opts[name] = value
+    return general_opts, auth_opts
+
+
+class Authenticator(object):
+
+    #: The name of an authenticator
+    name = 'basic'
+
+    def __init__(self, **kw):
+        pass
+
+
+class DummyAuthenticator(Authenticator):
+    """A dummy authenticator for tests.
+
+    This authenticator does no real authentication. It simply approves
+    credentials ``('bird', 'bebop')`` and denies all other.
+    """
+
+    name = 'dummy'
+
+    def check_credentials(self, username='', password=''):
+        """If username is ``'bird'`` and password ``'bebop'`` return
+        ``True``, else ``False``.
+        """
+        return username == 'bird' and password == 'bebop'
Index: /main/waeup.cas/trunk/waeup/cas/db.py
===================================================================
--- /main/waeup.cas/trunk/waeup/cas/db.py	(revision 10393)
+++ /main/waeup.cas/trunk/waeup/cas/db.py	(revision 10394)
@@ -1,12 +1,130 @@
 # components to store tickets in a database
 # we use sqlite3
-import os
-import sqlite3
+import time
+from sqlalchemy import (
+    create_engine, Column, Integer, Float, String, MetaData)
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker, scoped_session
 
 
-def create_db(path):
-    if os.path.exists(path):
-        return
-    conn = sqlite3.connect(path)
-    conn.close()
-    return
+Base = declarative_base()
+
+
+class ServiceTicket(Base):
+    __tablename__ = 'service_tickets'
+
+    id = Column(Integer, primary_key=True)
+    ticket = Column(String(255), nullable=False)
+    ts = Column(Float, nullable=False)
+    service = Column(String(2048), nullable=True)
+    user = Column(String(512), nullable=False)
+
+    def __init__(self, ticket, user, service=None, timestamp=None):
+        if timestamp is None:
+            timestamp = time.time()
+        self.ticket = ticket
+        self.ts = timestamp
+        self.service = service
+        self.user = user
+
+    def __repr__(self):
+        return "ServiceTicket('%s', '%s', '%s', %s)" % (
+            self.ticket, self.user, self.service, self.ts)
+
+
+class LoginTicket(Base):
+    __tablename__ = 'login_tickets'
+
+    ticket = Column(String(255), primary_key=True)
+    ts = Column(Float, nullable=False)
+
+    def __init__(self, ticket, timestamp=None):
+        if timestamp is None:
+            timestamp = time.time()
+        self.ticket = ticket
+        self.ts = timestamp
+
+    def __repr__(self):
+        return "LoginTicket('%s', %s)" % (self.ticket, self.ts)
+
+
+class TicketGrantingCookie(Base):
+    __tablename__ = 'ticket_granting_cookies'
+
+    value = Column(String(255), primary_key=True)
+    ts = Column(Float, nullable=False)
+
+    def __init__(self, value, timestamp=None):
+        if timestamp is None:
+            timestamp = time.time()
+        self.value = value
+        self.ts = timestamp
+
+    def __repr__(self):
+        return "TicketGrantingCookie('%s', %s)" % (self.value, self.ts)
+
+
+Session = scoped_session(sessionmaker())
+
+
+class DB(object):
+    """Abstract database to make data persistent.
+
+    Creates a database identified by `connection_string` if it does
+    not exist and initializes sessions.
+    """
+    @property
+    def session(self):
+        return Session
+
+    def __init__(self, connection_string):
+        self.engine = create_engine(connection_string)
+        self.metadata = MetaData()
+        Base.metadata.create_all(self.engine)
+        Session.configure(bind=self.engine)
+
+    def add(self, item):
+        """Insert `item` into database.
+
+        `item` must be an instance of the database content types
+        defined in this module, normally some `Ticket` instance.
+        """
+        Session.add(item)
+        Session.commit()
+
+    def delete(self, item):
+        """Delete `item` from database.
+
+        `item must be an instance of the database content types
+        defined in this module, normally some `Ticket` instance.
+        """
+        Session.delete(item)
+        Session.commit()
+
+    def query(self, *args, **kw):
+        return Session.query(*args, **kw)
+
+
+class DBSessionContext(object):
+    """A context manager providing database sessions.
+
+    Creates a new (threadlocal) database session when entering and
+    tears down the session after leaving the context.
+
+    Tearing down includes committing transactions and closing the
+    connection.
+
+    Meant to be used as a wrapper for web request handlers, such, that
+    a session is created when a request comes in and released when
+    the response is ready to be delivered.
+
+    This context manager does *not* catch any exceptions.
+    """
+    def __enter__(self):
+        return Session()
+
+    def __exit__(self, *args, **kw):
+        if args or kw:
+            Session.rollback()
+        Session.remove()
+        return False
Index: /main/waeup.cas/trunk/waeup/cas/server.py
===================================================================
--- /main/waeup.cas/trunk/waeup/cas/server.py	(revision 10393)
+++ /main/waeup.cas/trunk/waeup/cas/server.py	(revision 10394)
@@ -2,9 +2,100 @@
 """
 import os
-import tempfile
+import random
+import time
 from webob import exc, Response
 from webob.dec import wsgify
+from waeup.cas.authenticators import get_authenticator
+from waeup.cas.db import (
+    DB, DBSessionContext, LoginTicket, ServiceTicket, TicketGrantingCookie)
 
 template_dir = os.path.join(os.path.dirname(__file__), 'templates')
+
+RANDOM = random.SystemRandom(os.urandom(1024))
+
+#: The chars allowed by protocol specification for tickets and cookie
+#: values.
+ALPHABET = ('abcdefghijklmnopqrstuvwxyz'
+            'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
+            '01234567789-')
+
+
+def get_random_string(length):
+    """Get a random string of length `length`.
+
+    The returned string should be hard to guess but is not
+    neccessarily unique.
+    """
+    return ''.join([RANDOM.choice(ALPHABET) for x in range(length)])
+
+
+def get_unique_string():
+    """Get a unique string based on current time.
+
+    The returned string contains only chars from `ALPHABET`.
+
+    We try to be unique by using a timestamp in high resolution, so
+    that even tickets created shortly after another should differ. On
+    very fast machines, however, this might be not enough (currently
+    we use 16 decimal places).
+
+    This is fast because we don't have to fetch foreign data sources
+    nor have to do database lookups.
+
+    The returned string will be unique but it won't be hard to guess
+    for people able to read a clock.
+    """
+    return ('%.16f' % time.time()).replace('.', '-')
+
+
+def create_service_ticket(user, service=None):
+    """Get a service ticket.
+
+    Ticket length will be 32 chars, randomly picked from `ALPHABET`.
+    """
+    t_id = 'ST-' + get_random_string(29)
+    return ServiceTicket(t_id, user, service)
+
+
+def create_login_ticket():
+    """Create a unique login ticket.
+
+    Login tickets are required to be unique (but not neccessarily hard
+    to guess), according to protocol specification.
+    """
+    t_id = 'LT-%s' % get_unique_string()
+    return LoginTicket(t_id)
+
+
+def check_login_ticket(db, lt_string):
+    """Check whether `lt_string` represents a valid login ticket in `db`.
+    """
+    if lt_string is None:
+        return False
+    q = db.query(LoginTicket).filter(LoginTicket.ticket == lt_string)
+    result = [x for x in q]
+    if result:
+        db.delete(result[0])
+    return len(result) > 0
+
+
+def create_tgc_value():
+    """Get a ticket granting cookie value.
+    """
+    value = 'TGC-' + get_random_string(128)
+    return TicketGrantingCookie(value)
+
+
+def set_session_cookie(response, db):
+    """Create a session cookie (ticket granting cookie) on `response`.
+
+    The `db` database is used to make the created cookie value
+    persistent.
+    """
+    tgc = create_tgc_value()
+    db.add(tgc)
+    response.set_cookie(
+        'cas-tgc', tgc.value, path='/', secure=True, httponly=True)
+    return response
 
 
@@ -26,18 +117,63 @@
        If the database file does not exist, it will be created.
     """
-    def __init__(self, db_path=None):
-        if db_path is None:
-            db_path = os.path.join(tempfile.mkdtemp(), 'cas.db')
-        self.db_path = db_path
+    def __init__(self, db='sqlite:///:memory:', auth=None):
+        self.db_connection_string = db
+        self.db = DB(self.db_connection_string)
+        self.auth = auth
 
     @wsgify
     def __call__(self, req):
-        if req.path in ['/login', '/validate', '/logout']:
-            return getattr(self, req.path[1:])(req)
+        with DBSessionContext():
+            if req.path in ['/login', '/validate', '/logout']:
+                return getattr(self, req.path[1:])(req)
         return exc.HTTPNotFound()
 
+    def _get_template(self, name):
+        path = os.path.join(template_dir, name)
+        if os.path.isfile(path):
+            return open(path, 'r').read()
+        return None
+
     def login(self, req):
-        return Response(
-            open(os.path.join(template_dir, 'login.html'), 'r').read())
+        service = req.POST.get('service', req.GET.get('service', None))
+        service_field = ''
+        username = req.POST.get('username', None)
+        password = req.POST.get('password', None)
+        valid_lt = check_login_ticket(self.db, req.POST.get('lt'))
+        if username and password and valid_lt:
+            # act as credentials acceptor
+            cred_ok = self.auth.check_credentials(username, password)
+            if cred_ok:
+                if service is None:
+                    # show logged-in screen
+                    html = self._get_template('login_successful.html')
+                    resp = Response(html)
+                    resp = set_session_cookie(resp, self.db)
+                    return resp
+                else:
+                    # safely redirect to service given
+                    st = create_service_ticket(service)
+                    self.db.add(st)
+                    service = '%s?ticket=%s' % (service, st.ticket)
+                    html = self._get_template('login_service_redirect.html')
+                    html = html.replace('SERVICE_URL', service)
+                    resp = exc.HTTPSeeOther(location=service)
+                    resp.cache_control = 'no-store'
+                    resp.pragma = 'no-cache'
+                    # some arbitrary date in the past
+                    resp.expires = 'Thu, 01 Dec 1994 16:00:00 GMT'
+                    resp.text = html
+                    return resp
+        if service is not None:
+            service_field = (
+                '<input type="hidden" name="service" value="%s" />' % (
+                    service)
+                )
+        lt = create_login_ticket()
+        self.db.add(lt)
+        html = self._get_template('login.html')
+        html = html.replace('LT_VALUE', lt.ticket)
+        html = html.replace('SERVICE_FIELD_VALUE', service_field)
+        return Response(html)
 
     def validate(self, req):
@@ -51,3 +187,4 @@
 
 def make_cas_server(global_conf, **local_conf):
+    local_conf = get_authenticator(local_conf)
     return CASServer(**local_conf)
Index: /main/waeup.cas/trunk/waeup/cas/templates/login.html
===================================================================
--- /main/waeup.cas/trunk/waeup/cas/templates/login.html	(revision 10393)
+++ /main/waeup.cas/trunk/waeup/cas/templates/login.html	(revision 10394)
@@ -8,5 +8,7 @@
       <input type="text" name="username" />
       <input type="password" name="password" />
-      <input type="submit" value="Login" />
+      <input type="hidden" name="lt" value="LT_VALUE" />
+      SERVICE_FIELD_VALUE
+      <input type="submit" name="AUTHENTICATE" value="Login" />
     </form>
   </body>
Index: /main/waeup.cas/trunk/waeup/cas/templates/login_service_redirect.html
===================================================================
--- /main/waeup.cas/trunk/waeup/cas/templates/login_service_redirect.html	(revision 10394)
+++ /main/waeup.cas/trunk/waeup/cas/templates/login_service_redirect.html	(revision 10394)
@@ -0,0 +1,15 @@
+<html>
+  <head>
+    <title>WAeUP Central Authentication Service</title>
+    <script>
+      window.location.href="SERVICE_URL"
+    </script>
+  </head>
+  <body>
+    <noscript>
+      <p>CAS login successful</p>
+      <p>Click <a xhref="SERVICE_URL">SERVICE_URL</a>
+        to access the service you requested</p>
+    </noscript>
+  </body>
+</html>
Index: /main/waeup.cas/trunk/waeup/cas/templates/login_successful.html
===================================================================
--- /main/waeup.cas/trunk/waeup/cas/templates/login_successful.html	(revision 10394)
+++ /main/waeup.cas/trunk/waeup/cas/templates/login_successful.html	(revision 10394)
@@ -0,0 +1,9 @@
+<html>
+  <head>
+    <title>Single Sign-On initiated</title>
+  </head>
+  <body>
+    <h1>Logged in</h1>
+    <div>You successfully initiated single sign-on!</div>
+  </body>
+</html>
Index: /main/waeup.cas/trunk/waeup/cas/tests/sample2.ini
===================================================================
--- /main/waeup.cas/trunk/waeup/cas/tests/sample2.ini	(revision 10393)
+++ /main/waeup.cas/trunk/waeup/cas/tests/sample2.ini	(revision 10394)
@@ -3,4 +3,9 @@
 [app:main]
 use = egg:waeup.cas#server
-# what file to use to store tickets, credentials, etc.
-db_path = /a/path/to/cas.db
+# SQLAlchemy connection string
+# By default we get an in-memory SQLite database
+# Samples:
+#   sqlite in-memory db:  sqlite://:memory:
+#   sqlite file-based db: sqlite:////path/to/db
+db = sqlite:///:memory:
+auth = dummy
Index: /main/waeup.cas/trunk/waeup/cas/tests/test_authenticators.py
===================================================================
--- /main/waeup.cas/trunk/waeup/cas/tests/test_authenticators.py	(revision 10394)
+++ /main/waeup.cas/trunk/waeup/cas/tests/test_authenticators.py	(revision 10394)
@@ -0,0 +1,55 @@
+import unittest
+from waeup.cas.authenticators import (
+    get_all_authenticators, get_authenticator, filter_auth_opts,
+    Authenticator, DummyAuthenticator,
+    )
+
+
+class TestHelpers(unittest.TestCase):
+
+    def test_get_all_authenticators(self):
+        # we can get authenticators via entry points
+        auths = get_all_authenticators()
+        assert 'dummy' in auths
+        assert auths['dummy'] is DummyAuthenticator
+
+    def test_filter_auth_opts(self):
+        # we can filter auth opts
+        result = filter_auth_opts(
+            dict(auth='foo', auth_bar='baz', auth_baz='blah')
+            )
+        assert result == (
+            {'auth': 'foo'},
+            {'auth_bar': 'baz', 'auth_baz': 'blah'}
+            )
+
+    def test_get_authenticator(self):
+        # we can parse a paste.deploy config dict for auth
+        result = get_authenticator({})
+        assert result == {}
+        result = get_authenticator({'auth': 'dummy'})
+        assert isinstance(result['auth'], DummyAuthenticator)
+
+
+class AuthenticatorTests(unittest.TestCase):
+
+    def test_create(self):
+        # we can create Authenticator instances
+        auth = Authenticator()
+        assert isinstance(auth, Authenticator)
+
+
+class DummyAuthenticatorTests(unittest.TestCase):
+
+    def test_create(self):
+        # we can create DummyAuthenticator instances
+        auth = DummyAuthenticator()
+        assert isinstance(auth, DummyAuthenticator)
+
+    def test_check_credentials(self):
+        # we can succeed with 'bird'/'bebop'. Others will fail.
+        auth = DummyAuthenticator()
+        result1 = auth.check_credentials('bird', 'bebop')
+        assert result1 is True
+        result2 = auth.check_credentials('foo', 'bar')
+        assert result2 is False
Index: /main/waeup.cas/trunk/waeup/cas/tests/test_db.py
===================================================================
--- /main/waeup.cas/trunk/waeup/cas/tests/test_db.py	(revision 10393)
+++ /main/waeup.cas/trunk/waeup/cas/tests/test_db.py	(revision 10394)
@@ -5,5 +5,7 @@
 import tempfile
 import unittest
-from waeup.cas.db import create_db
+from sqlalchemy.engine import Engine
+from waeup.cas.db import (
+    DB, LoginTicket, ServiceTicket, TicketGrantingCookie)
 
 
@@ -17,25 +19,114 @@
     def test_create_db(self):
         # we can create a database
-        db_path = os.path.join(self.workdir, 'sample-cas.db')
-        create_db(db_path)
-        assert os.path.isfile(db_path)
-        conn = sqlite3.connect(db_path)
-        assert conn is not None
-        conn.close()
+        conn_string = 'sqlite:///'
+        db = DB(conn_string)
+        assert hasattr(db, 'engine')
+        assert isinstance(db.engine, Engine)
 
     def test_create_db_exists(self):
         # an already existing db will be left untouched
         db_path = os.path.join(self.workdir, 'sample-cas.db')
-        create_db(db_path)
+        conn_string = 'sqlite:///%s' % db_path
+        assert os.path.isdir(self.workdir)
+        DB(conn_string)  # create database
         conn = sqlite3.connect(db_path)
-        cursor = conn.cursor()
-        cursor.execute('''CREATE TABLE mytest (name text, forname text)''')
-        cursor.execute('''INSERT INTO mytest VALUES ("Foo", "Bar")''')
-        conn.commit()
+        with conn:
+            conn.execute('''CREATE TABLE mytest (name text, forname text)''')
+            conn.execute('''INSERT INTO mytest VALUES ("Foo", "Bar")''')
         conn.close()
 
-        create_db(db_path)
         conn = sqlite3.connect(db_path)
-        cursor = conn.cursor()
-        cursor.execute('''SELECT * FROM mytest''')
-        assert cursor.fetchall() == [('Foo', 'Bar')]
+        with conn:
+            result = [x for x in conn.execute('''SELECT * FROM mytest''')]
+        conn.close()
+        assert result == [('Foo', 'Bar')]
+
+    def test_create_db_populates_db(self):
+        # created DBs will be populated with the required tables
+        db_path = os.path.join(self.workdir, 'sample-cas.db')
+        conn_string = 'sqlite:///%s' % db_path
+        DB(conn_string)  # creates database
+        conn = sqlite3.connect(db_path)
+        with conn:
+            result = [x[:2] for x in conn.execute(
+                '''SELECT * FROM sqlite_master ORDER BY type''')]
+        conn.close()
+        assert ('table', 'service_tickets') in result
+        assert ('table', 'login_tickets') in result
+
+
+class TicketTests(unittest.TestCase):
+
+    def setUp(self):
+        self.db = DB('sqlite:///')
+        self.db.session()
+
+    def tearDown(self):
+        self.db.session.remove()
+
+    def test_login_ticket_add(self):
+        # we can add login tickets
+        assert self.db.engine.has_table('login_tickets')
+
+        self.db.add(LoginTicket('foo'))
+        assert [x.ticket for x in self.db.query(LoginTicket)] == ['foo']
+
+    def test_login_ticket_delete(self):
+        # we can delete single login tickets
+        assert self.db.engine.has_table('login_tickets')
+
+        self.db.add(LoginTicket('foo'))
+        contents = [x for x in self.db.query(LoginTicket)]
+        assert len(contents) == 1
+        lt = contents[0]
+        self.db.delete(lt)
+        assert [x.ticket for x in self.db.query(LoginTicket)] == []
+
+    def test_login_ticket_repr(self):
+        # we can get a proper LoginTicket representation
+        # (i.e. one that can be fed to `eval`)
+        ticket = LoginTicket('foo', 12.1)
+        assert ticket.__repr__() == "LoginTicket('foo', 12.1)"
+
+    def test_login_ticket_timestamp(self):
+        # we get a timestamp stored, if none is passed to init
+        lticket = LoginTicket('foo')
+        assert isinstance(lticket.ts, float)
+
+    def test_add_service_ticket(self):
+        # we can add service tickets
+        self.db.add(ServiceTicket('foo', 'bar', 'baz', 12.1))
+        result = [(x.ticket, x.user, x.service, x.ts)
+                  for x in self.db.query(ServiceTicket)]
+        assert result == [('foo', 'bar', 'baz', 12.1)]
+
+    def test_service_ticket_repr(self):
+        # we can get a proper ServiceTicket representation
+        # (i.e. one that can be fed to `eval`)
+        sticket = ServiceTicket('foo', 'bar', 'baz', 12.1)
+        st_repr = sticket.__repr__()
+        assert st_repr == "ServiceTicket('foo', 'bar', 'baz', 12.1)"
+
+    def test_service_ticket_timestamp(self):
+        # we get a timestamp stored, if none is passed to init
+        sticket = ServiceTicket('foo', 'bar', 'baz')
+        assert isinstance(sticket.ts, float)
+
+    def test_ticket_granting_cookie_add(self):
+        # we can add ticket granting cookies
+        assert self.db.engine.has_table('ticket_granting_cookies')
+
+        self.db.add(TicketGrantingCookie('foo'))
+        assert [x.value for x in self.db.query(
+            TicketGrantingCookie)] == ['foo']
+
+    def test_ticket_granting_cookie_repr(self):
+        # we can get a proper ticket-granting cookie representation
+        # (i.e. one that can be fed to `eval`)
+        tgc = TicketGrantingCookie('foo', 12.1)
+        assert tgc.__repr__() == "TicketGrantingCookie('foo', 12.1)"
+
+    def test_ticket_granting_cookie_timestamp(self):
+        # we get a timestamp stored, if none is passed to init
+        tgc = TicketGrantingCookie('foo')
+        assert isinstance(tgc.ts, float)
Index: /main/waeup.cas/trunk/waeup/cas/tests/test_server.py
===================================================================
--- /main/waeup.cas/trunk/waeup/cas/tests/test_server.py	(revision 10393)
+++ /main/waeup.cas/trunk/waeup/cas/tests/test_server.py	(revision 10394)
@@ -1,9 +1,20 @@
 import os
+import re
 import shutil
 import tempfile
 import unittest
 from paste.deploy import loadapp
-from webob import Request
-from waeup.cas.server import CASServer
+from webob import Request, Response
+from webtest import TestApp as WebTestApp  # avoid py.test skip message
+from waeup.cas.authenticators import DummyAuthenticator
+from waeup.cas.db import DB, LoginTicket, ServiceTicket, TicketGrantingCookie
+from waeup.cas.server import (
+    CASServer, create_service_ticket, create_login_ticket,
+    create_tgc_value, check_login_ticket, set_session_cookie,
+    )
+
+
+RE_ALPHABET = re.compile('^[a-zA-Z0-9\-]*$')
+RE_COOKIE = re.compile('^cas-tgc=[A-Za-z0-9\-]+; Path=/; secure; HttpOnly$')
 
 
@@ -19,4 +30,5 @@
         self.workdir = os.path.join(self._new_tmpdir, 'home')
         self.db_path = os.path.join(self.workdir, 'mycas.db')
+        os.mkdir(self.workdir)
         self.paste_conf1 = os.path.join(
             os.path.dirname(__file__), 'sample1.ini')
@@ -34,6 +46,7 @@
         app = loadapp('config:%s' % self.paste_conf1)
         assert isinstance(app, CASServer)
-        assert hasattr(app, 'db_path')
-        assert app.db_path is not None
+        assert hasattr(app, 'db')
+        assert isinstance(app.db, DB)
+        assert hasattr(app, 'auth')
 
     def test_paste_deploy_options(self):
@@ -41,18 +54,24 @@
         app = loadapp('config:%s' % self.paste_conf2)
         assert isinstance(app, CASServer)
-        assert app.db_path == '/a/path/to/cas.db'
+        assert app.db_connection_string == 'sqlite:///:memory:'
+        assert isinstance(app.auth, DummyAuthenticator)
 
     def test_init(self):
-        # we get a db dir set automatically
-        app = CASServer()
-        assert hasattr(app, 'db_path')
-        assert app.db_path is not None
-        assert os.path.exists(os.path.dirname(app.db_path))
+        # we get a `DB` instance created automatically
+        app = CASServer()
+        assert hasattr(app, 'db')
+        assert app.db is not None
 
     def test_init_explicit_db_path(self):
         # we can set a db_path explicitly
-        app = CASServer(db_path=self.db_path)
-        assert hasattr(app, 'db_path')
-        assert app.db_path == self.db_path
+        app = CASServer(db='sqlite:///%s' % self.db_path)
+        assert hasattr(app, 'db')
+        assert isinstance(app.db, DB)
+        assert os.path.isfile(self.db_path)
+
+    def test_get_template(self):
+        app = CASServer()
+        assert app._get_template('login.html') is not None
+        assert app._get_template('not-existent.html') is None
 
     def test_call_root(self):
@@ -93,2 +112,149 @@
         assert resp.content_type == 'text/html'
         assert b'<form ' in resp.body
+
+
+class BrowserTests(unittest.TestCase):
+
+    def setUp(self):
+        self.raw_app = CASServer(auth=DummyAuthenticator())
+        self.app = WebTestApp(self.raw_app)
+
+    def test_login(self):
+        resp = self.app.get('/login')
+        assert resp.status == '200 OK'
+        form = resp.forms[0]
+        # 2.1.3: form must be submitted by POST
+        assert form.method == 'post'
+        fieldnames = form.fields.keys()
+        # 2.1.3: form must contain: username, password, lt
+        assert 'username' in fieldnames
+        assert 'password' in fieldnames
+        assert 'lt' in fieldnames
+        assert RE_ALPHABET.match(form['lt'].value)
+
+    def test_login_no_service(self):
+        # w/o a service passed in, the form should not contain service
+        # (not a strict protocol requirement, but handy)
+        resp = self.app.get('/login')
+        assert 'service' not in resp.forms[0].fields.keys()
+
+    def test_login_service_replayed(self):
+        # 2.1.3: the login form must contain the service param sent
+        resp = self.app.get('/login?service=http%3A%2F%2Fwww.service.com')
+        form = resp.forms[0]
+        assert resp.status == '200 OK'
+        assert 'service' in form.fields.keys()
+        assert form['service'].value == 'http://www.service.com'
+
+    def test_login_cred_acceptor_valid_no_service(self):
+        # 2.2.4: successful login w/o service yields a message
+        lt = create_login_ticket()
+        self.raw_app.db.add(lt)
+        lt_string = lt.ticket
+        resp = self.app.post('/login', dict(
+            username='bird', password='bebop', lt=lt_string))
+        assert resp.status == '200 OK'
+        assert b'successful' in resp.body
+        # single-sign-on session initiated
+        assert 'Set-Cookie' in resp.headers
+        cookie = resp.headers['Set-Cookie']
+        assert cookie.startswith('cas-tgc=')
+
+    def test_login_cred_acceptor_valid_w_service(self):
+        # 2.2.4: successful login with service makes a redirect
+        # Appendix B: safe redirect
+        lt = create_login_ticket()
+        self.raw_app.db.add(lt)
+        lt_string = lt.ticket
+        resp = self.app.post('/login', dict(
+            username='bird', password='bebop', lt=lt_string,
+            service='http://example.com/Login'))
+        assert resp.status == '303 See Other'
+        assert 'Location' in resp.headers
+        assert resp.headers['Location'].startswith(
+            'http://example.com/Login?ticket=ST-')
+        assert 'Pragma' in resp.headers
+        assert resp.headers['Pragma'] == 'no-cache'
+        assert 'Cache-Control' in resp.headers
+        assert resp.headers['Cache-Control'] == 'no-store'
+        assert 'Expires' in resp.headers
+        assert resp.headers['Expires'] == 'Thu, 01 Dec 1994 16:00:00 GMT'
+        assert b'window.location.href' in resp.body
+        assert b'noscript' in resp.body
+        assert b'ticket=ST-' in resp.body
+
+
+class CASServerHelperTests(unittest.TestCase):
+
+    def setUp(self):
+        self.workdir = tempfile.mkdtemp()
+        self.db_file = os.path.join(self.workdir, 'mycas.db')
+        self.conn_string = 'sqlite:///%s' % self.db_file
+        self.db = DB(self.conn_string)
+
+    def tearDown(self):
+        shutil.rmtree(self.workdir)
+
+    def test_create_service_ticket(self):
+        # we can create service tickets
+        st = create_service_ticket(
+            user='bob', service='http://www.example.com')
+        assert isinstance(st, ServiceTicket)
+        # 3.1.1: service not part of ticket
+        assert 'example.com' not in st.ticket
+        # 3.1.1: ticket must start with 'ST-'
+        assert st.ticket.startswith('ST-')
+        # 3.1.1: min. ticket length clients must be able to process is 32
+        assert len(st.ticket) < 33
+        # 3.7: allowed character set == [a-zA-Z0-9\-]
+        assert RE_ALPHABET.match(st.ticket), (
+            'Ticket contains forbidden chars: %s' % st)
+
+    def test_create_login_ticket(self):
+        # we can create login tickets
+        lt = create_login_ticket()
+        # 3.5.1: ticket should start with 'LT-'
+        assert lt.ticket.startswith('LT-')
+        # 3.7: allowed character set == [a-zA-Z0-9\-]
+        assert RE_ALPHABET.match(lt.ticket), (
+            'Ticket contains forbidden chars: %s' % lt)
+
+    def test_create_login_ticket_unique(self):
+        # 3.5.1: login tickets are unique (although not hard to guess)
+        ticket_num = 1000  # increase to test more thoroughly
+        lt_list = [create_login_ticket() for x in range(ticket_num)]
+        assert len(set(lt_list)) == ticket_num
+
+    def test_create_tgc_value(self):
+        # we can create ticket granting cookies
+        tgc = create_tgc_value()
+        assert isinstance(tgc, TicketGrantingCookie)
+        # 3.6.1: cookie value should start with 'TGC-'
+        assert tgc.value.startswith('TGC-')
+        # 3.7: allowed character set == [a-zA-Z0-9\-]
+        assert RE_ALPHABET.match(tgc.value), (
+            'Cookie value contains forbidden chars: %s' % tgc)
+
+    def test_check_login_ticket(self):
+        db = DB('sqlite:///')
+        lt = LoginTicket('LT-123456')
+        db.add(lt)
+        assert check_login_ticket(db, None) is False
+        assert check_login_ticket(db, 'LT-123456') is True
+        # the ticket will be removed after check
+        assert check_login_ticket(db, 'LT-123456') is False
+        assert check_login_ticket(db, 'LT-654321') is False
+
+    def test_set_session_cookie(self):
+        # make sure we can add session cookies to responses
+        db = DB('sqlite:///')
+        resp = set_session_cookie(Response(), db)
+        assert 'Set-Cookie' in resp.headers
+        cookie = resp.headers['Set-Cookie']
+        assert RE_COOKIE.match(cookie), (
+            'Cookie in unexpected format: %s' % cookie)
+        # the cookie is stored in database
+        value = cookie.split('=')[1].split(';')[0]
+        q = db.query(TicketGrantingCookie).filter(
+            TicketGrantingCookie.value == value)
+        assert len(list(q)) == 1
