Get rid of IP in AccessWrapper
[cds-indico.git] / indico / web / http_api / handlers.py
blob4747fc37a674176d50b4d4aba1e56faf547ff803
1 # This file is part of Indico.
2 # Copyright (C) 2002 - 2015 European Organization for Nuclear Research (CERN).
4 # Indico is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License as
6 # published by the Free Software Foundation; either version 3 of the
7 # License, or (at your option) any later version.
9 # Indico is distributed in the hope that it will be useful, but
10 # WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12 # General Public License for more details.
14 # You should have received a copy of the GNU General Public License
15 # along with Indico; if not, see <http://www.gnu.org/licenses/>.
17 """
18 HTTP API - Handlers
19 """
21 import hashlib
22 import hmac
23 import posixpath
24 import re
25 import time
26 import urllib
27 from uuid import UUID
29 import transaction
30 from flask import request, session
31 from urlparse import parse_qs
32 from werkzeug.exceptions import NotFound
34 from indico.core.db import DBMgr
35 from indico.core.config import Config
36 from indico.core.logger import Logger
37 from indico.modules.api import APIMode
38 from indico.modules.api import settings as api_settings
39 from indico.modules.api.models.keys import APIKey
40 from indico.modules.oauth.errors import OAuthError
41 from indico.modules.oauth.components import OAuthUtils
42 from indico.util.contextManager import ContextManager
43 from indico.util.string import to_unicode
44 from indico.web.http_api import HTTPAPIHook
45 from indico.web.http_api.responses import HTTPAPIResult, HTTPAPIError
46 from indico.web.http_api.util import get_query_parameter
47 from indico.web.http_api.fossils import IHTTPAPIExportResultFossil
48 from indico.web.http_api.metadata.serializer import Serializer
49 from indico.web.flask.util import ResponseUtil
51 from MaKaC.common.fossilize import fossilize, clearCache
52 from MaKaC.accessControl import AccessWrapper
53 from MaKaC.common.cache import GenericCache
56 # Remove the extension at the end or before the querystring
57 RE_REMOVE_EXTENSION = re.compile(r'\.(\w+)(?:$|(?=\?))')
60 def normalizeQuery(path, query, remove=('signature',), separate=False):
61 """Normalize request path and query so it can be used for caching and signing
63 Returns a string consisting of path and sorted query string.
64 Dynamic arguments like signature and timestamp are removed from the query string.
65 """
66 qparams = parse_qs(query)
67 sorted_params = []
69 for key, values in sorted(qparams.items(), key=lambda x: x[0].lower()):
70 key = key.lower()
71 if key not in remove:
72 for v in sorted(values):
73 sorted_params.append((key, v))
75 if separate:
76 return path, sorted_params and urllib.urlencode(sorted_params)
77 elif sorted_params:
78 return '%s?%s' % (path, urllib.urlencode(sorted_params))
79 else:
80 return path
83 def validateSignature(ak, signature, timestamp, path, query):
84 ttl = api_settings.get('signature_ttl')
85 if not timestamp and not (ak.is_persistent_allowed and api_settings.get('allow_persistent')):
86 raise HTTPAPIError('Signature invalid (no timestamp)', 403)
87 elif timestamp and abs(timestamp - int(time.time())) > ttl:
88 raise HTTPAPIError('Signature invalid (bad timestamp)', 403)
89 digest = hmac.new(ak.secret, normalizeQuery(path, query), hashlib.sha1).hexdigest()
90 if signature != digest:
91 raise HTTPAPIError('Signature invalid', 403)
94 def checkAK(apiKey, signature, timestamp, path, query):
95 apiMode = api_settings.get('security_mode')
96 if not apiKey:
97 if apiMode in {APIMode.ONLYKEY, APIMode.ONLYKEY_SIGNED, APIMode.ALL_SIGNED}:
98 raise HTTPAPIError('API key is missing', 403)
99 return None, True
100 try:
101 UUID(hex=apiKey)
102 except ValueError:
103 raise HTTPAPIError('Malformed API key', 400)
104 ak = APIKey.find_first(token=apiKey, is_active=True)
105 if not ak:
106 raise HTTPAPIError('Invalid API key', 403)
107 if ak.is_blocked:
108 raise HTTPAPIError('API key is blocked', 403)
109 # Signature validation
110 onlyPublic = False
111 if signature:
112 validateSignature(ak, signature, timestamp, path, query)
113 elif apiMode == APIMode.ALL_SIGNED:
114 raise HTTPAPIError('Signature missing', 403)
115 elif apiMode in {APIMode.SIGNED, APIMode.ONLYKEY_SIGNED}:
116 onlyPublic = True
117 return ak, onlyPublic
120 def buildAW(ak, onlyPublic=False):
121 aw = AccessWrapper()
122 if ak and not onlyPublic:
123 # If we have an authenticated request, require HTTPS
124 # Dirty hack: Google calendar converts HTTP API requests from https to http
125 # Therefore, not working with Indico setup (requiring https for HTTP API authenticated)
126 if not request.is_secure and api_settings.get('require_https') and request.user_agent.browser != 'google':
127 raise HTTPAPIError('HTTPS is required', 403)
128 aw.setUser(ak.user.as_avatar)
129 return aw
132 def handler(prefix, path):
133 path = posixpath.join('/', prefix, path)
134 ContextManager.destroy()
135 clearCache() # init fossil cache
136 logger = Logger.get('httpapi')
137 if request.method == 'POST':
138 # Convert POST data to a query string
139 queryParams = dict((key, value.encode('utf-8')) for key, value in request.form.iteritems())
140 query = urllib.urlencode(queryParams)
141 else:
142 # Parse the actual query string
143 queryParams = dict((key, value.encode('utf-8')) for key, value in request.args.iteritems())
144 query = request.query_string
146 dbi = DBMgr.getInstance()
147 dbi.startRequest()
149 apiKey = get_query_parameter(queryParams, ['ak', 'apikey'], None)
150 cookieAuth = get_query_parameter(queryParams, ['ca', 'cookieauth'], 'no') == 'yes'
151 signature = get_query_parameter(queryParams, ['signature'])
152 timestamp = get_query_parameter(queryParams, ['timestamp'], 0, integer=True)
153 noCache = get_query_parameter(queryParams, ['nc', 'nocache'], 'no') == 'yes'
154 pretty = get_query_parameter(queryParams, ['p', 'pretty'], 'no') == 'yes'
155 onlyPublic = get_query_parameter(queryParams, ['op', 'onlypublic'], 'no') == 'yes'
156 onlyAuthed = get_query_parameter(queryParams, ['oa', 'onlyauthed'], 'no') == 'yes'
157 oauthToken = 'oauth_token' in queryParams
158 # Check if OAuth data is supplied in the Authorization header
159 if not oauthToken and request.headers.get('Authorization') is not None:
160 oauthToken = 'oauth_token' in request.headers.get('Authorization')
162 # Get our handler function and its argument and response type
163 hook, dformat = HTTPAPIHook.parseRequest(path, queryParams)
164 if hook is None or dformat is None:
165 raise NotFound
167 # Disable caching if we are not just retrieving data (or the hook requires it)
168 if request.method == 'POST' or hook.NO_CACHE:
169 noCache = True
171 ak = error = result = None
172 ts = int(time.time())
173 typeMap = {}
174 responseUtil = ResponseUtil()
175 try:
176 used_session = None
177 if cookieAuth:
178 used_session = session
179 if not used_session.user: # ignore guest sessions
180 used_session = None
182 if apiKey or oauthToken or not used_session:
183 if not oauthToken:
184 # Validate the API key (and its signature)
185 ak, enforceOnlyPublic = checkAK(apiKey, signature, timestamp, path, query)
186 if enforceOnlyPublic:
187 onlyPublic = True
188 # Create an access wrapper for the API key's user
189 aw = buildAW(ak, onlyPublic)
190 else: # Access Token (OAuth)
191 at = OAuthUtils.OAuthCheckAccessResource()
192 aw = buildAW(at, onlyPublic)
193 # Get rid of API key in cache key if we did not impersonate a user
194 if ak and aw.getUser() is None:
195 cacheKey = normalizeQuery(path, query,
196 remove=('_', 'ak', 'apiKey', 'signature', 'timestamp', 'nc', 'nocache',
197 'oa', 'onlyauthed'))
198 else:
199 cacheKey = normalizeQuery(path, query,
200 remove=('_', 'signature', 'timestamp', 'nc', 'nocache', 'oa', 'onlyauthed'))
201 if signature:
202 # in case the request was signed, store the result under a different key
203 cacheKey = 'signed_' + cacheKey
204 else:
205 # We authenticated using a session cookie.
206 if Config.getInstance().getCSRFLevel() >= 2:
207 token = request.headers.get('X-CSRF-Token', get_query_parameter(queryParams, ['csrftoken']))
208 if used_session.csrf_protected and used_session.csrf_token != token:
209 raise HTTPAPIError('Invalid CSRF token', 403)
210 aw = AccessWrapper()
211 if not onlyPublic:
212 aw.setUser(used_session.avatar)
213 userPrefix = 'user-{}_'.format(used_session.user.id)
214 cacheKey = userPrefix + normalizeQuery(path, query,
215 remove=('_', 'nc', 'nocache', 'ca', 'cookieauth', 'oa', 'onlyauthed',
216 'csrftoken'))
218 # Bail out if the user requires authentication but is not authenticated
219 if onlyAuthed and not aw.getUser():
220 raise HTTPAPIError('Not authenticated', 403)
222 addToCache = not hook.NO_CACHE
223 cache = GenericCache('HTTPAPI')
224 cacheKey = RE_REMOVE_EXTENSION.sub('', cacheKey)
225 if not noCache:
226 obj = cache.get(cacheKey)
227 if obj is not None:
228 result, extra, ts, complete, typeMap = obj
229 addToCache = False
230 if result is None:
231 ContextManager.set("currentAW", aw)
232 # Perform the actual exporting
233 res = hook(aw)
234 if isinstance(res, tuple) and len(res) == 4:
235 result, extra, complete, typeMap = res
236 else:
237 result, extra, complete, typeMap = res, {}, True, {}
238 if result is not None and addToCache:
239 ttl = api_settings.get('cache_ttl')
240 cache.set(cacheKey, (result, extra, ts, complete, typeMap), ttl)
241 except HTTPAPIError, e:
242 error = e
243 if e.getCode():
244 responseUtil.status = e.getCode()
245 if responseUtil.status == 405:
246 responseUtil.headers['Allow'] = 'GET' if request.method == 'POST' else 'POST'
247 except OAuthError, e:
248 error = e
249 if e.getCode():
250 responseUtil.status = e.getCode()
252 if result is None and error is None:
253 # TODO: usage page
254 raise NotFound
255 else:
256 if ak and error is None:
257 # Commit only if there was an API key and no error
258 norm_path, norm_query = normalizeQuery(path, query, remove=('signature', 'timestamp'), separate=True)
259 uri = to_unicode('?'.join(filter(None, (norm_path, norm_query))))
260 ak.register_used(request.remote_addr, uri, not onlyPublic)
261 transaction.commit()
262 else:
263 # No need to commit stuff if we didn't use an API key (nothing was written)
264 # XXX do we even need this?
265 transaction.abort()
267 # Log successful POST api requests
268 if error is None and request.method == 'POST':
269 logger.info('API request: %s?%s' % (path, query))
271 serializer = Serializer.create(dformat, query_params=queryParams, pretty=pretty, typeMap=typeMap,
272 **hook.serializer_args)
273 if error:
274 if not serializer.schemaless:
275 # if our serializer has a specific schema (HTML, ICAL, etc...)
276 # use JSON, since it is universal
277 serializer = Serializer.create('json')
279 result = fossilize(error)
280 else:
281 if serializer.encapsulate:
282 result = fossilize(HTTPAPIResult(result, path, query, ts, complete, extra), IHTTPAPIExportResultFossil)
283 del result['_fossil']
285 try:
286 data = serializer(result)
287 serializer.set_headers(responseUtil)
288 return responseUtil.make_response(data)
289 except:
290 logger.exception('Serialization error in request %s?%s' % (path, query))
291 raise