source: indico/indico/web/http_api/handlers.py @ 315708

hello-world-walkthroughipv6v0.98-seriesv0.98.2v0.98.3v0.98b2v0.99v1.0v1.1
Last change on this file since 315708 was 315708, checked in by Jose Benito <jose.benito.gonzalez@…>, 21 months ago

[IMP] Improve URL format for caching/signing

  • in both cases the query strign is sorted
  • the signature is now passed via &signature=...
  • the timestamp is included as &timestamp=...
  • the cache key now uses SHA-256 instead of hash()
  • Property mode set to 100644
File size: 10.0 KB
Line 
1# -*- coding: utf-8 -*-
2##
3##
4## This file is part of CDS Indico.
5## Copyright (C) 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010 CERN.
6##
7## CDS Indico is free software; you can redistribute it and/or
8## modify it under the terms of the GNU General Public License as
9## published by the Free Software Foundation; either version 2 of the
10## License, or (at your option) any later version.
11##
12## CDS Indico is distributed in the hope that it will be useful, but
13## WITHOUT ANY WARRANTY; without even the implied warranty of
14## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
15## General Public License for more details.
16##
17## You should have received a copy of the GNU General Public License
18## along with CDS Indico; if not, write to the Free Software Foundation, Inc.,
19## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
20
21"""
22HTTP API - Handlers
23"""
24
25# python stdlib imports
26import hashlib
27import hmac
28import re
29import time
30import urllib
31from urlparse import parse_qs
32from ZODB.POSException import ConflictError
33import pytz
34
35# indico imports
36from indico.web.http_api import ExportInterface, LimitExceededException
37from indico.web.http_api.auth import APIKeyHolder
38from indico.web.http_api.cache import RequestCache
39from indico.web.http_api.responses import HTTPAPIResult, HTTPAPIError
40from indico.web.http_api.util import remove_lists, get_query_parameter
41from indico.web.http_api import API_MODE_KEY, API_MODE_ONLYKEY, API_MODE_SIGNED, API_MODE_ONLYKEY_SIGNED, API_MODE_ALL_SIGNED
42from indico.web.wsgi import webinterface_handler_config as apache
43from indico.util.metadata.serializer import Serializer
44
45# indico legacy imports
46from MaKaC.common import DBMgr
47from MaKaC.common.fossilize import fossilizes, fossilize, Fossilizable
48from MaKaC.accessControl import AccessWrapper
49from MaKaC.common.info import HelperMaKaCInfo
50
51# Maximum number of records that will get exported for each detail level
52MAX_RECORDS = {
53    'events': 10000,
54    'contributions': 500,
55    'subcontributions': 500,
56    'sessions': 100,
57}
58
59# Valid URLs for export handlers. the last group has to be the response type
60EXPORT_URL_MAP = {
61    r'/export/(event|categ)/(\w+(?:-\w+)*)\.(\w+)$': 'handler_event_categ'
62}
63
64# Compile url regexps
65EXPORT_URL_MAP = dict((re.compile(pathRe), handlerFunc) for pathRe, handlerFunc in EXPORT_URL_MAP.iteritems())
66# Remove the extension at the end or before the querystring
67RE_REMOVE_EXTENSION = re.compile(r'\.(\w+)(?:$|(?=\?))')
68
69
70def normalizeQuery(path, query, ts=None, remove=('timestamp', 'signature')):
71    """Normalize request path and query so it can be used for caching and signing
72
73    Returns a string consisting of path and sorted query string.
74    Dynamic arguments like signature and timestamp are removed from the query string.
75    """
76    qdata = remove_lists(parse_qs(query))
77    if remove:
78        for key in remove:
79            qdata.pop(key, None)
80    if ts is not None:
81        qdata['timestamp'] = ts
82    sortedQuery = sorted(qdata.items(), key=lambda x: x[0].lower())
83    if sortedQuery:
84        return '%s?%s' % (path, urllib.urlencode(sortedQuery))
85    else:
86        return path
87
88
89def validateSignature(key, signature, path, query, timestamp=None):
90    if timestamp is None:
91        timestamp = int(time.time())
92    ts = timestamp / 300
93    candidates = []
94    for i in xrange(-1, 2):
95        h = hmac.new(key, normalizeQuery(path, query, ts + i), hashlib.sha1)
96        candidates.append(h.hexdigest())
97    if signature not in candidates:
98        raise HTTPAPIError('Signature invalid (check system clock)', apache.HTTP_FORBIDDEN)
99
100
101def getAK(apiKey, signature, path, query):
102    minfo = HelperMaKaCInfo.getMaKaCInfoInstance()
103    apiMode = minfo.getAPIMode()
104    if not apiKey:
105        if apiMode in (API_MODE_ONLYKEY, API_MODE_ONLYKEY_SIGNED, API_MODE_ALL_SIGNED):
106            raise HTTPAPIError('API key is missing', apache.HTTP_FORBIDDEN)
107        return None, True
108    akh = APIKeyHolder()
109    if not akh.hasKey(apiKey):
110        raise HTTPAPIError('Invalid API key', apache.HTTP_FORBIDDEN)
111    ak = akh.getById(apiKey)
112    if ak.isBlocked():
113        raise HTTPAPIError('API key is blocked', apache.HTTP_FORBIDDEN)
114    # Signature validation
115    onlyPublic = False
116    if signature:
117        validateSignature(ak.getSignKey(), signature, path, query)
118    elif apiMode in (API_MODE_SIGNED, API_MODE_ALL_SIGNED):
119        raise HTTPAPIError('Signature missing', apache.HTTP_FORBIDDEN)
120    elif apiMode == API_MODE_ONLYKEY_SIGNED:
121        onlyPublic = True
122    return ak, onlyPublic
123
124
125def buildAW(ak, req, onlyPublic=False):
126    aw = AccessWrapper()
127    if ak and not onlyPublic:
128        # If we have an authenticated request, require HTTPS
129        minfo = HelperMaKaCInfo.getMaKaCInfoInstance()
130        if not req.is_https() and minfo.isAPIHTTPSRequired():
131            raise HTTPAPIError('HTTPS is required', apache.HTTP_FORBIDDEN)
132        aw.setUser(ak.getUser())
133    return aw
134
135
136def getExportHandler(path):
137    """Get the export handler, handler args and return type from a path"""
138    func = None
139    match = None
140    for pathRe, handlerFunc in EXPORT_URL_MAP.iteritems():
141        match = pathRe.match(path)
142        if match:
143            func = handlerFunc
144            break
145
146    groups = match and match.groups()
147    if not match or groups[-1] not in ExportInterface.getAllowedFormats():
148        return None, None, None
149    return globals()[func], groups[:-1], groups[-1]
150
151
152def handler_event_categ(dbi, aw, qdata, dtype, idlist):
153    idlist = idlist.split('-')
154
155    expInt = ExportInterface(dbi, aw)
156    tzName = get_query_parameter(qdata, ['tz'], None)
157    detail = get_query_parameter(qdata, ['d', 'detail'], 'events')
158    userLimit = get_query_parameter(qdata, ['n', 'limit'], 0, integer=True)
159    offset = get_query_parameter(qdata, ['O', 'offset'], 0, integer=True)
160    orderBy = get_query_parameter(qdata, ['o', 'order'], 'start')
161    descending = get_query_parameter(qdata, ['c', 'descending'], False)
162
163    if tzName is None:
164        info = HelperMaKaCInfo.getMaKaCInfoInstance()
165        tzName = info.getTimezone()
166
167    tz = pytz.timezone(tzName)
168
169    max = MAX_RECORDS.get(detail, 10000)
170    if userLimit > max:
171        raise HTTPAPIError("You can only request up to %d records per request with the detail level '%s" %
172            (max, detail), apache.HTTP_BAD_REQUEST)
173
174    # impose a hard limit
175    limit = userLimit if userLimit > 0 else max
176
177    if dtype == 'categ':
178        iterator = expInt.category(idlist, tz, offset, limit, detail, orderBy, descending, qdata)
179    elif dtype == 'event':
180        iterator = expInt.event(idlist, tz, offset, limit, detail, orderBy, descending, qdata)
181
182    resultList = []
183    complete = True
184
185    try:
186        for obj in iterator:
187            resultList.append(obj)
188    except LimitExceededException:
189        complete = (limit == userLimit)
190
191    return resultList, complete
192
193def handler(req, **params):
194    path, query = req.URLFields['PATH_INFO'], req.URLFields['QUERY_STRING']
195    # Parse the actual query string
196    qdata = parse_qs(query)
197
198    dbi = DBMgr.getInstance()
199    dbi.startRequest()
200
201    cache = RequestCache(HelperMaKaCInfo.getMaKaCInfoInstance().getAPICacheTTL())
202
203    apiKey = get_query_parameter(qdata, ['ak', 'apikey'], None)
204    signature = get_query_parameter(qdata, ['signature'])
205    no_cache = get_query_parameter(qdata, ['nc', 'nocache'], 'no') == 'yes'
206    pretty = get_query_parameter(qdata, ['p', 'pretty'], 'no') == 'yes'
207    onlyPublic = get_query_parameter(qdata, ['op', 'onlypublic'], 'no') == 'yes'
208
209    # Get our handler function and its argument and response type
210    func, args, dformat = getExportHandler(path)
211    if func is None or dformat is None:
212        raise apache.SERVER_RETURN, apache.HTTP_NOT_FOUND
213
214    ak = error = result = None
215    ts = int(time.time())
216    try:
217        # Validate the API key (and its signature)
218        ak, enforceOnlyPublic = getAK(apiKey, signature, path, query)
219        if enforceOnlyPublic:
220            onlyPublic = True
221        # Create an access wrapper for the API key's user
222        aw = buildAW(ak, req, onlyPublic)
223        # Get rid of API key in cache key if we did not impersonate a user
224        if ak and aw.getUser() is None:
225            cache_key = normalizeQuery(path, query, remove=('ak', 'apiKey', 'signature', 'timestamp'))
226        else:
227            cache_key = normalizeQuery(path, query, remove=('signature', 'timestamp'))
228
229        obj = None
230        add_to_cache = True
231        cache_key = RE_REMOVE_EXTENSION.sub('', cache_key)
232        if not no_cache:
233            obj = cache.loadObject(cache_key)
234            if obj is not None:
235                result, complete = obj.getContent()
236                ts = obj.getTS()
237                add_to_cache = False
238        if result is None:
239            # Perform the actual exporting
240            result, complete = func(dbi, aw, qdata, *args)
241        if result is not None and add_to_cache:
242            cache.cacheObject(cache_key, (result, complete))
243    except HTTPAPIError, e:
244        error = e
245        if e.getCode():
246            req.status = e.getCode()
247
248    if result is None and error is None:
249        # TODO: usage page
250        raise apache.SERVER_RETURN, apache.HTTP_NOT_FOUND
251    else:
252        if ak and error is None:
253            # Commit only if there was an API key and no error
254            for _retry in xrange(10):
255                dbi.sync()
256                ak.used(req.remote_ip, path, query, not onlyPublic)
257                try:
258                    dbi.endRequest(True)
259                except ConflictError:
260                    pass # retry
261                else:
262                    break
263        else:
264            # No need to commit stuff if we didn't use an API key
265            # (nothing was written)
266            dbi.endRequest(False)
267
268        serializer = Serializer.create(dformat, pretty=pretty,
269                                       **remove_lists(qdata))
270
271        if error:
272            resultFossil = fossilize(error)
273        else:
274            resultFossil = fossilize(HTTPAPIResult(result, path, query, ts, complete))
275        del resultFossil['_fossil']
276
277        req.headers_out['Content-Type'] = serializer.getMIMEType()
278        return serializer(resultFossil)
Note: See TracBrowser for help on using the repository browser.