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

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

Remove more trash.

File size: 7.2 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    if cookie_value is None:
107        return False
108    try:
109        # turn value into unicode (py2.x) / str (py3.x)
110        cookie_value = cookie_value.decode('utf-8')
111    except AttributeError:                         # pragma: no cover
112        pass
113    q = db.query(TicketGrantingCookie).filter(
114        TicketGrantingCookie.value == cookie_value)
115    result = [x for x in q]
116    if len(result):
117        return result[0]
118    return None
119
120
121class CASServer(object):
122    """A WSGI CAS server.
123
124    This CAS server stores credential data (tickets, etc.) in a local
125    sqlite3 database file.
126
127    `db_path` -
128       The filesystem path to the database to use. If none is given
129       CAS server will create a new one in some new, temporary
130       directory. Please note that credentials will be lost after a
131       CAS server restart.
132
133       If the path is given and the file exists already, it will be
134       used.
135
136       If the database file does not exist, it will be created.
137    """
138    def __init__(self, db='sqlite:///:memory:', auth=None):
139        self.db_connection_string = db
140        self.db = DB(self.db_connection_string)
141        self.auth = auth
142
143    @wsgify
144    def __call__(self, req):
145        with DBSessionContext():
146            if req.path in ['/login', '/validate', '/logout']:
147                return getattr(self, req.path[1:])(req)
148        return exc.HTTPNotFound()
149
150    def _get_template(self, name):
151        path = os.path.join(template_dir, name)
152        if os.path.isfile(path):
153            return open(path, 'r').read()
154        return None
155
156    def login(self, req):
157        service = req.POST.get('service', req.GET.get('service', None))
158        service_field = ''
159        msg = ''
160        username = req.POST.get('username', None)
161        password = req.POST.get('password', None)
162        valid_lt = check_login_ticket(self.db, req.POST.get('lt'))
163        tgc = req.cookies.get('cas-tgc', None)
164        tgc = check_session_cookie(self.db, tgc)
165        if username and password and valid_lt or tgc:
166            # act as credentials acceptor
167            if tgc:
168                cred_ok, reason = True, ''
169                if not service:
170                    msg = 'You logged in already.'
171            else:
172                cred_ok, reason = self.auth.check_credentials(
173                    username, password)
174            if cred_ok:
175                if service is None:
176                    # show logged-in screen
177                    html = self._get_template('login_successful.html')
178                    html = html.replace('MSG_TEXT', msg)
179                    resp = Response(html)
180                    if not tgc:
181                        resp = set_session_cookie(resp, self.db)
182                    return resp
183                else:
184                    # safely redirect to service given
185                    st = create_service_ticket(service)
186                    self.db.add(st)
187                    service = '%s?ticket=%s' % (service, st.ticket)
188                    html = self._get_template('login_service_redirect.html')
189                    html = html.replace('SERVICE_URL', service)
190                    resp = exc.HTTPSeeOther(location=service)
191                    resp.cache_control = 'no-store'
192                    resp.pragma = 'no-cache'
193                    # some arbitrary date in the past
194                    resp.expires = 'Thu, 01 Dec 1994 16:00:00 GMT'
195                    resp.text = html
196                    return resp
197            else:
198                # login failed
199                msg = '<i>Login failed</i><br />Reason: %s' % reason
200        if service is not None:
201            service_field = (
202                '<input type="hidden" name="service" value="%s" />' % (
203                    service)
204                )
205        lt = create_login_ticket()
206        self.db.add(lt)
207        html = self._get_template('login.html')
208        html = html.replace('LT_VALUE', lt.ticket)
209        html = html.replace('SERVICE_FIELD_VALUE', service_field)
210        html = html.replace('MSG_TEXT', msg)
211        return Response(html)
212
213    def validate(self, req):
214        return exc.HTTPNotImplemented()
215
216    def logout(self, req):
217        return exc.HTTPNotImplemented()
218
219cas_server = CASServer
220
221
222def make_cas_server(global_conf, **local_conf):
223    local_conf = get_authenticator(local_conf)
224    return CASServer(**local_conf)
Note: See TracBrowser for help on using the repository browser.