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

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

Add function to delete set cookie.

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