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/>.
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.
66 qparams
= parse_qs(query
)
69 for key
, values
in sorted(qparams
.items(), key
=lambda x
: x
[0].lower()):
72 for v
in sorted(values
):
73 sorted_params
.append((key
, v
))
76 return path
, sorted_params
and urllib
.urlencode(sorted_params
)
78 return '%s?%s' % (path
, urllib
.urlencode(sorted_params
))
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')
97 if apiMode
in {APIMode
.ONLYKEY
, APIMode
.ONLYKEY_SIGNED
, APIMode
.ALL_SIGNED
}:
98 raise HTTPAPIError('API key is missing', 403)
103 raise HTTPAPIError('Malformed API key', 400)
104 ak
= APIKey
.find_first(token
=apiKey
, is_active
=True)
106 raise HTTPAPIError('Invalid API key', 403)
108 raise HTTPAPIError('API key is blocked', 403)
109 # Signature validation
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
}:
117 return ak
, onlyPublic
120 def buildAW(ak
, onlyPublic
=False):
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
)
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
)
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()
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:
167 # Disable caching if we are not just retrieving data (or the hook requires it)
168 if request
.method
== 'POST' or hook
.NO_CACHE
:
171 ak
= error
= result
= None
172 ts
= int(time
.time())
174 responseUtil
= ResponseUtil()
178 used_session
= session
179 if not used_session
.user
: # ignore guest sessions
182 if apiKey
or oauthToken
or not used_session
:
184 # Validate the API key (and its signature)
185 ak
, enforceOnlyPublic
= checkAK(apiKey
, signature
, timestamp
, path
, query
)
186 if enforceOnlyPublic
:
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',
199 cacheKey
= normalizeQuery(path
, query
,
200 remove
=('_', 'signature', 'timestamp', 'nc', 'nocache', 'oa', 'onlyauthed'))
202 # in case the request was signed, store the result under a different key
203 cacheKey
= 'signed_' + cacheKey
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)
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',
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
)
226 obj
= cache
.get(cacheKey
)
228 result
, extra
, ts
, complete
, typeMap
= obj
231 ContextManager
.set("currentAW", aw
)
232 # Perform the actual exporting
234 if isinstance(res
, tuple) and len(res
) == 4:
235 result
, extra
, complete
, typeMap
= res
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
:
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
:
250 responseUtil
.status
= e
.getCode()
252 if result
is None and error
is None:
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
)
263 # No need to commit stuff if we didn't use an API key (nothing was written)
264 # XXX do we even need this?
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
)
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
)
281 if serializer
.encapsulate
:
282 result
= fossilize(HTTPAPIResult(result
, path
, query
, ts
, complete
, extra
), IHTTPAPIExportResultFossil
)
283 del result
['_fossil']
286 data
= serializer(result
)
287 serializer
.set_headers(responseUtil
)
288 return responseUtil
.make_response(data
)
290 logger
.exception('Serialization error in request %s?%s' % (path
, query
))