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

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

Support warn parameter for login.

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