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

Last change on this file since 10403 was 10403, checked in by uli, 11 years ago

Fix check_session_cookie().
Reorganize login() to be more readable.

File size: 7.9 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):
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)
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(response, db):
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):
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)
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    return resp
152
153
154def login_success_no_service(db, msg='', sso=False):
155    """Show logged-in screen after successful auth.
156
157    `sso` must be a boolean indicating whether login happened via
158    credentials (``False``) or via cookie (``True``).
159
160    Returns a response.
161    """
162    # show logged-in screen
163    html = get_template('login_successful.html')
164    html = html.replace('MSG_TEXT', msg)
165    resp = Response(html)
166    if not sso:
167        resp = set_session_cookie(resp, db)
168    return resp
169
170
171class CASServer(object):
172    """A WSGI CAS server.
173
174    This CAS server stores credential data (tickets, etc.) in a local
175    sqlite3 database file.
176
177    `db_path` -
178       The filesystem path to the database to use. If none is given
179       CAS server will create a new one in some new, temporary
180       directory. Please note that credentials will be lost after a
181       CAS server restart.
182
183       If the path is given and the file exists already, it will be
184       used.
185
186       If the database file does not exist, it will be created.
187    """
188    def __init__(self, db='sqlite:///:memory:', auth=None):
189        self.db_connection_string = db
190        self.db = DB(self.db_connection_string)
191        self.auth = auth
192
193    @wsgify
194    def __call__(self, req):
195        with DBSessionContext():
196            if req.path in ['/login', '/validate', '/logout']:
197                return getattr(self, req.path[1:])(req)
198        return exc.HTTPNotFound()
199
200    def _get_template(self, name):
201        path = os.path.join(template_dir, name)
202        if os.path.isfile(path):
203            return open(path, 'r').read()
204        return None
205
206    def login(self, req):
207        service = req.POST.get('service', req.GET.get('service', None))
208        service_field = ''
209        msg = ''
210        username = req.POST.get('username', None)
211        password = req.POST.get('password', None)
212        valid_lt = check_login_ticket(self.db, req.POST.get('lt'))
213        tgc = check_session_cookie(self.db, req.cookies.get('cas-tgc', None))
214        if username and password and valid_lt or tgc:
215            # act as credentials acceptor
216            if tgc:
217                cred_ok, reason = True, ''
218                if not service:
219                    msg = 'You logged in already.'
220            else:
221                cred_ok, reason = self.auth.check_credentials(
222                    username, password)
223            if cred_ok:
224                if service is None:
225                    # show logged-in screen
226                    sso = (tgc is not None)
227                    return login_success_no_service(self.db, msg, sso)
228                else:
229                    # safely redirect to service given
230                    return login_redirect_service(self.db, service)
231            else:
232                # login failed
233                msg = '<i>Login failed</i><br />Reason: %s' % reason
234        if service is not None:
235            service_field = (
236                '<input type="hidden" name="service" value="%s" />' % (
237                    service)
238                )
239        lt = create_login_ticket()
240        self.db.add(lt)
241        html = self._get_template('login.html')
242        html = html.replace('LT_VALUE', lt.ticket)
243        html = html.replace('SERVICE_FIELD_VALUE', service_field)
244        html = html.replace('MSG_TEXT', msg)
245        return Response(html)
246
247    def validate(self, req):
248        return exc.HTTPNotImplemented()
249
250    def logout(self, req):
251        return exc.HTTPNotImplemented()
252
253cas_server = CASServer
254
255
256def make_cas_server(global_conf, **local_conf):
257    local_conf = get_authenticator(local_conf)
258    return CASServer(**local_conf)
Note: See TracBrowser for help on using the repository browser.