source: main/waeup.cas/trunk/waeup/cas/server.py @ 10409

Last change on this file since 10409 was 10409, checked in by uli, 12 years ago

Test and fix login behaviour in case we send service params.

File size: 8.0 KB
Line 
1"""A WSGI app for serving CAS.
2"""
3import os
4import random
5import time
6from webob import exc, Response
7from webob.dec import wsgify
8from waeup.cas.authenticators import get_authenticator
9from waeup.cas.db import (
10    DB, DBSessionContext, LoginTicket, ServiceTicket, TicketGrantingCookie)
11
12template_dir = os.path.join(os.path.dirname(__file__), 'templates')
13
14RANDOM = random.SystemRandom(os.urandom(1024))
15
16#: The chars allowed by protocol specification for tickets and cookie
17#: values.
18ALPHABET = ('abcdefghijklmnopqrstuvwxyz'
19            'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
20            '01234567789-')
21
22
23def get_random_string(length):
24    """Get a random string of length `length`.
25
26    The returned string should be hard to guess but is not
27    neccessarily unique.
28    """
29    return ''.join([RANDOM.choice(ALPHABET) for x in range(length)])
30
31
32def get_unique_string():
33    """Get a unique string based on current time.
34
35    The returned string contains only chars from `ALPHABET`.
36
37    We try to be unique by using a timestamp in high resolution, so
38    that even tickets created shortly after another should differ. On
39    very fast machines, however, this might be not enough (currently
40    we use 16 decimal places).
41
42    This is fast because we don't have to fetch foreign data sources
43    nor have to do database lookups.
44
45    The returned string will be unique but it won't be hard to guess
46    for people able to read a clock.
47    """
48    return ('%.16f' % time.time()).replace('.', '-')
49
50
51def create_service_ticket(user, service=None, sso=True):
52    """Get a service ticket.
53
54    Ticket length will be 32 chars, randomly picked from `ALPHABET`.
55    """
56    t_id = 'ST-' + get_random_string(29)
57    return ServiceTicket(t_id, user, service, sso)
58
59
60def create_login_ticket():
61    """Create a unique login ticket.
62
63    Login tickets are required to be unique (but not neccessarily hard
64    to guess), according to protocol specification.
65    """
66    t_id = 'LT-%s' % get_unique_string()
67    return LoginTicket(t_id)
68
69
70def check_login_ticket(db, lt_string):
71    """Check whether `lt_string` represents a valid login ticket in `db`.
72    """
73    if lt_string is None:
74        return False
75    q = db.query(LoginTicket).filter(LoginTicket.ticket == lt_string)
76    result = [x for x in q]
77    if result:
78        db.delete(result[0])
79    return len(result) > 0
80
81
82def create_tgc_value():
83    """Get a ticket granting cookie value.
84    """
85    value = 'TGC-' + get_random_string(128)
86    return TicketGrantingCookie(value)
87
88
89def set_session_cookie(db, response):
90    """Create a session cookie (ticket granting cookie) on `response`.
91
92    The `db` database is used to make the created cookie value
93    persistent.
94    """
95    tgc = create_tgc_value()
96    db.add(tgc)
97    response.set_cookie(
98        'cas-tgc', tgc.value, path='/', secure=True, httponly=True)
99    return response
100
101
102def check_session_cookie(db, cookie_value):
103    """Check whether `cookie_value` represents a valid ticket granting
104    ticket in `db`.
105
106    `cookie_value` is a string representing a ticket granting ticket
107    maybe stored in `db`.
108
109    If a respective cookie can be found, a
110    :class:`waeup.cas.db.TicketGrantingCookie` is returend. Else
111    ``None`` is returned.
112    """
113    if cookie_value is None:
114        return None
115    try:
116        # turn value into unicode (py2.x) / str (py3.x)
117        cookie_value = cookie_value.decode('utf-8')
118    except AttributeError:                         # pragma: no cover
119        pass
120    q = db.query(TicketGrantingCookie).filter(
121        TicketGrantingCookie.value == cookie_value)
122    result = [x for x in q]
123    if len(result):
124        return result[0]
125    return None
126
127
128def get_template(name):
129    path = os.path.join(template_dir, name)
130    if os.path.isfile(path):
131        return open(path, 'r').read()
132    return None
133
134
135def login_redirect_service(db, service, sso=True):
136    """Return a response redirecting to a service via HTTP 303 See Other.
137    """
138    # safely redirect to service given
139    st = create_service_ticket(service, sso)
140    db.add(st)
141    service = '%s?ticket=%s' % (service, st.ticket)
142    html = get_template('login_service_redirect.html')
143    html = html.replace('SERVICE_URL', service)
144    resp = exc.HTTPSeeOther(location=service)
145    # try to forbid caching of any type
146    resp.cache_control = 'no-store'
147    resp.pragma = 'no-cache'
148    # some arbitrary date in the past
149    resp.expires = 'Thu, 01 Dec 1994 16:00:00 GMT'
150    resp.text = html
151    if not sso:
152        resp = set_session_cookie(db, resp)
153    return resp
154
155
156def login_success_no_service(db, msg='', sso=False):
157    """Show logged-in screen after successful auth.
158
159    `sso` must be a boolean indicating whether login happened via
160    credentials (``False``) or via cookie (``True``).
161
162    Returns a response.
163    """
164    # show logged-in screen
165    html = get_template('login_successful.html')
166    html = html.replace('MSG_TEXT', msg)
167    resp = Response(html)
168    if not sso:
169        resp = set_session_cookie(db, resp)
170    return resp
171
172
173class CASServer(object):
174    """A WSGI CAS server.
175
176    This CAS server stores credential data (tickets, etc.) in a local
177    sqlite3 database file.
178
179    `db_path` -
180       The filesystem path to the database to use. If none is given
181       CAS server will create a new one in some new, temporary
182       directory. Please note that credentials will be lost after a
183       CAS server restart.
184
185       If the path is given and the file exists already, it will be
186       used.
187
188       If the database file does not exist, it will be created.
189    """
190    def __init__(self, db='sqlite:///:memory:', auth=None):
191        self.db_connection_string = db
192        self.db = DB(self.db_connection_string)
193        self.auth = auth
194
195    @wsgify
196    def __call__(self, req):
197        with DBSessionContext():
198            if req.path in ['/login', '/validate', '/logout']:
199                return getattr(self, req.path[1:])(req)
200        return exc.HTTPNotFound()
201
202    def _get_template(self, name):
203        path = os.path.join(template_dir, name)
204        if os.path.isfile(path):
205            return open(path, 'r').read()
206        return None
207
208    def login(self, req):
209        service = req.POST.get('service', req.GET.get('service', None))
210        service_field = ''
211        msg = ''
212        username = req.POST.get('username', None)
213        password = req.POST.get('password', None)
214        valid_lt = check_login_ticket(self.db, req.POST.get('lt'))
215        tgc = check_session_cookie(self.db, req.cookies.get('cas-tgc', None))
216        if tgc:
217            if service:
218                return login_redirect_service(self.db, service, sso=True)
219            else:
220                return login_success_no_service(
221                    self.db, 'You logged in already.', True)
222        if username and password and valid_lt:
223            # act as credentials acceptor
224            cred_ok, reason = self.auth.check_credentials(
225                username, password)
226            if cred_ok:
227                if service is None:
228                    # show logged-in screen
229                    return login_success_no_service(self.db, msg, False)
230                else:
231                    # safely redirect to service given
232                    return login_redirect_service(self.db, service, sso=False)
233            else:
234                # login failed
235                msg = '<i>Login failed</i><br />Reason: %s' % reason
236        if service is not None:
237            service_field = (
238                '<input type="hidden" name="service" value="%s" />' % (
239                    service)
240                )
241        lt = create_login_ticket()
242        self.db.add(lt)
243        html = self._get_template('login.html')
244        html = html.replace('LT_VALUE', lt.ticket)
245        html = html.replace('SERVICE_FIELD_VALUE', service_field)
246        html = html.replace('MSG_TEXT', msg)
247        return Response(html)
248
249    def validate(self, req):
250        return exc.HTTPNotImplemented()
251
252    def logout(self, req):
253        return exc.HTTPNotImplemented()
254
255cas_server = CASServer
256
257
258def make_cas_server(global_conf, **local_conf):
259    local_conf = get_authenticator(local_conf)
260    return CASServer(**local_conf)
Note: See TracBrowser for help on using the repository browser.