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

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

Support /validate (CAS 1.0).

File size: 10.6 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 check_service_ticket(db, ticket, service, renew=False):
62    """Check whether (`ticket`, `service`) represents a valid service
63    ticket in `db`.
64
65    Returns a database set or ``None``.
66    """
67    if None in (ticket, service):
68        return None
69    ticket, service = str(ticket), str(service)
70    q = db.query(ServiceTicket).filter(
71        ServiceTicket.ticket == ticket).filter(
72        ServiceTicket.service == service).first()
73    if renew and q.sso:
74        return None
75    return q
76
77
78def create_login_ticket():
79    """Create a unique login ticket.
80
81    Login tickets are required to be unique (but not neccessarily hard
82    to guess), according to protocol specification.
83    """
84    t_id = 'LT-%s' % get_unique_string()
85    return LoginTicket(t_id)
86
87
88def check_login_ticket(db, lt_string):
89    """Check whether `lt_string` represents a valid login ticket in `db`.
90    """
91    if lt_string is None:
92        return False
93    q = db.query(LoginTicket).filter(LoginTicket.ticket == str(lt_string))
94    result = [x for x in q]
95    if result:
96        db.delete(result[0])
97    return len(result) > 0
98
99
100def create_tgc_value():
101    """Get a ticket granting cookie value.
102    """
103    value = 'TGC-' + get_random_string(128)
104    return TicketGrantingCookie(value)
105
106
107def set_session_cookie(db, response):
108    """Create a session cookie (ticket granting cookie) on `response`.
109
110    The `db` database is used to make the created cookie value
111    persistent.
112    """
113    tgc = create_tgc_value()
114    db.add(tgc)
115    response.set_cookie(
116        'cas-tgc', tgc.value, path='/', secure=True, httponly=True)
117    return response
118
119
120def delete_session_cookie(db, response, old_value=None):
121    """Delete session cookie.
122
123    Sets cookie with expiration date in past and deletes respective
124    entry from database.
125    """
126    if old_value is not None:
127        # delete old tgc from db
128        q = db.query(TicketGrantingCookie).filter(
129            TicketGrantingCookie.value == old_value)
130        result = list(q)
131        if len(result) == 1:
132            db.delete(result[0])
133    response.set_cookie(
134        'cas-tgc', '', path='/', secure=True, httponly=True,
135        expires=datetime.datetime(1970, 1, 1, 0, 0, 0))
136    return response
137
138
139def check_session_cookie(db, cookie_value):
140    """Check whether `cookie_value` represents a valid ticket granting
141    ticket in `db`.
142
143    `cookie_value` is a string representing a ticket granting ticket
144    maybe stored in `db`.
145
146    If a respective cookie can be found, a
147    :class:`waeup.cas.db.TicketGrantingCookie` is returend. Else
148    ``None`` is returned.
149    """
150    if cookie_value is None:
151        return None
152    try:
153        # turn value into unicode (py2.x) / str (py3.x)
154        cookie_value = cookie_value.decode('utf-8')
155    except AttributeError:                         # pragma: no cover
156        pass
157    q = db.query(TicketGrantingCookie).filter(
158        TicketGrantingCookie.value == cookie_value)
159    result = [x for x in q]
160    if len(result):
161        return result[0]
162    return None
163
164
165def get_template(name):
166    path = os.path.join(template_dir, name)
167    if os.path.isfile(path):
168        return open(path, 'r').read()
169    return None
170
171
172def login_redirect_service(db, service, sso=True, create_ticket=True,
173                           warn=False):
174    """Return a response redirecting to a service via HTTP 303 See Other.
175    """
176    if create_ticket:
177        st = create_service_ticket(service, sso)
178        db.add(st)
179        service = '%s?ticket=%s' % (service, st.ticket)
180    html = get_template('login_service_redirect.html')
181    if warn:
182        html = get_template('login_service_confirm.html')
183    html = html.replace('SERVICE_URL', service)
184    resp = exc.HTTPSeeOther(location=service)
185    if warn:
186        resp = Response()
187    # try to forbid caching of any type
188    resp.cache_control = 'no-store'
189    resp.pragma = 'no-cache'
190    # some arbitrary date in the past
191    resp.expires = 'Thu, 01 Dec 1994 16:00:00 GMT'
192    resp.text = html
193    if not sso:
194        resp = set_session_cookie(db, resp)
195    return resp
196
197
198def login_success_no_service(db, msg='', sso=False):
199    """Show logged-in screen after successful auth.
200
201    `sso` must be a boolean indicating whether login happened via
202    credentials (``False``) or via cookie (``True``).
203
204    Returns a response.
205    """
206    # show logged-in screen
207    html = get_template('login_successful.html')
208    html = html.replace('MSG_TEXT', msg)
209    resp = Response(html)
210    if not sso:
211        resp = set_session_cookie(db, resp)
212    return resp
213
214
215class CASServer(object):
216    """A WSGI CAS server.
217
218    This CAS server stores credential data (tickets, etc.) in a local
219    sqlite3 database file.
220
221    `db_path` -
222       The filesystem path to the database to use. If none is given
223       CAS server will create a new one in some new, temporary
224       directory. Please note that credentials will be lost after a
225       CAS server restart.
226
227       If the path is given and the file exists already, it will be
228       used.
229
230       If the database file does not exist, it will be created.
231    """
232    def __init__(self, db='sqlite:///:memory:', auth=None):
233        self.db_connection_string = db
234        self.db = DB(self.db_connection_string)
235        self.auth = auth
236
237    @wsgify
238    def __call__(self, req):
239        with DBSessionContext():
240            if req.path in ['/login', '/validate', '/logout']:
241                return getattr(self, req.path[1:])(req)
242        return exc.HTTPNotFound()
243
244    def _get_template(self, name):
245        path = os.path.join(template_dir, name)
246        if os.path.isfile(path):
247            return open(path, 'r').read()
248        return None
249
250    def login(self, req):
251        service = req.POST.get('service', req.GET.get('service', None))
252        renew = req.POST.get('renew', req.GET.get('renew', None))
253        warn = req.POST.get('warn', req.GET.get('warn', False))
254        gateway = req.POST.get('gateway', req.GET.get('gateway', None))
255        if renew is not None and gateway is not None:
256            gateway = None
257        service_field = ''
258        msg = ''
259        username = req.POST.get('username', None)
260        password = req.POST.get('password', None)
261        valid_lt = check_login_ticket(self.db, req.POST.get('lt'))
262        tgc = check_session_cookie(self.db, req.cookies.get('cas-tgc', None))
263        if gateway and (not tgc) and service:
264            return login_redirect_service(
265                self.db, service, sso=True, create_ticket=False)
266        if tgc and (renew is None):
267            if service:
268                return login_redirect_service(
269                    self.db, service, sso=True, warn=warn)
270            else:
271                return login_success_no_service(
272                    self.db, 'You logged in already.', True)
273        if username and password and valid_lt:
274            # act as credentials acceptor
275            cred_ok, reason = self.auth.check_credentials(
276                username, password)
277            if cred_ok:
278                if service is None:
279                    # show logged-in screen
280                    return login_success_no_service(self.db, msg, False)
281                else:
282                    # safely redirect to service given
283                    return login_redirect_service(
284                        self.db, service, sso=False, warn=warn)
285            else:
286                # login failed
287                msg = '<i>Login failed</i><br />Reason: %s' % reason
288        if service is not None:
289            service_field = (
290                '<input type="hidden" name="service" value="%s" />' % (
291                    service)
292                )
293        lt = create_login_ticket()
294        self.db.add(lt)
295        html = self._get_template('login.html')
296        html = html.replace('LT_VALUE', lt.ticket)
297        html = html.replace('SERVICE_FIELD_VALUE', service_field)
298        html = html.replace('MSG_TEXT', msg)
299        return Response(html)
300
301    def validate(self, req):
302        service = req.POST.get('service', req.GET.get('service', None))
303        ticket = req.POST.get('ticket', req.GET.get('ticket', None))
304        renew = req.POST.get('renew', req.GET.get('renew', None))
305        renew = renew is not None
306        st = check_service_ticket(self.db, ticket, service, renew)
307        if st is not None:
308            return Response('yes' + chr(0x0a) + st.user + chr(0x0a))
309        return Response('no' + chr(0x0a) + chr(0x0a))
310
311    def logout(self, req):
312        url = req.GET.get('url', req.POST.get('url', None))
313        old_val = req.cookies.get('cas-tgc', None)
314        html = self._get_template('logout.html')
315        if url is not None:
316            html = self._get_template('logout_url.html')
317            html = html.replace('URL_HREF', url)
318        resp = Response(html)
319        delete_session_cookie(self.db, resp, old_val)
320        return resp
321
322
323cas_server = CASServer
324
325
326def make_cas_server(global_conf, **local_conf):
327    local_conf = get_authenticator(local_conf)
328    return CASServer(**local_conf)
Note: See TracBrowser for help on using the repository browser.