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

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

Support gateway param for login.

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