c840cb66 |
1 | # GNU MediaGoblin -- federated, autonomous media hosting |
2 | # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. |
3 | # |
4 | # This program is free software: you can redistribute it and/or modify |
5 | # it under the terms of the GNU Affero General Public License as published by |
6 | # the Free Software Foundation, either version 3 of the License, or |
7 | # (at your option) any later version. |
8 | # |
9 | # This program is distributed in the hope that it will be useful, |
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
12 | # GNU Affero General Public License for more details. |
13 | # |
14 | # You should have received a copy of the GNU Affero General Public License |
15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | |
405aa45a |
17 | import datetime |
4990b47c |
18 | |
405aa45a |
19 | from oauthlib.oauth1 import (AuthorizationEndpoint, RequestValidator, |
2b60a56c |
20 | RequestTokenEndpoint, AccessTokenEndpoint) |
405aa45a |
21 | |
1e2675b0 |
22 | from mediagoblin.decorators import require_active_login, oauth_required |
d41c6a53 |
23 | from mediagoblin.tools.translate import pass_to_ugettext |
4990b47c |
24 | from mediagoblin.meddleware.csrf import csrf_exempt |
786bbd79 |
25 | from mediagoblin.tools.request import decode_request |
405aa45a |
26 | from mediagoblin.tools.response import (render_to_response, redirect, |
2b60a56c |
27 | json_response, render_400, |
28 | form_response) |
4990b47c |
29 | from mediagoblin.tools.crypto import random_string |
c33a34d4 |
30 | from mediagoblin.tools.validator import validate_email, validate_url |
405aa45a |
31 | from mediagoblin.federation.forms import AuthorizeForm |
786bbd79 |
32 | from mediagoblin.federation.exceptions import ValidationException |
33 | from mediagoblin.federation.oauth import GMGRequestValidator, GMGRequest |
34 | from mediagoblin.federation.tools.request import decode_authorization_header |
cfe7054c |
35 | from mediagoblin.db.models import NonceTimestamp, Client, RequestToken, AccessToken |
c840cb66 |
36 | |
37 | # possible client types |
38 | client_types = ["web", "native"] # currently what pump supports |
39 | |
4990b47c |
40 | @csrf_exempt |
c840cb66 |
41 | def client_register(request): |
42 | """ Endpoint for client registration """ |
d41c6a53 |
43 | try: |
44 | data = decode_request(request) |
45 | except ValueError: |
46 | error = "Could not decode data." |
47 | return json_response({"error": error}, status=400) |
48 | |
49 | if data is "": |
50 | error = "Unknown Content-Type" |
51 | return json_response({"error": error}, status=400) |
c840cb66 |
52 | |
4990b47c |
53 | if "type" not in data: |
d41c6a53 |
54 | error = "No registration type provided." |
55 | return json_response({"error": error}, status=400) |
56 | if data.get("application_type", None) not in client_types: |
57 | error = "Unknown application_type." |
58 | return json_response({"error": error}, status=400) |
54fbbf09 |
59 | |
60 | client_type = data["type"] |
61 | |
62 | if client_type == "client_update": |
63 | # updating a client |
64 | if "client_id" not in data: |
d41c6a53 |
65 | error = "client_id is requried to update." |
66 | return json_response({"error": error}, status=400) |
54fbbf09 |
67 | elif "client_secret" not in data: |
d41c6a53 |
68 | error = "client_secret is required to update." |
69 | return json_response({"error": error}, status=400) |
54fbbf09 |
70 | |
d41c6a53 |
71 | client = Client.query.filter_by( |
72 | id=data["client_id"], |
73 | secret=data["client_secret"] |
74 | ).first() |
54fbbf09 |
75 | |
c33a34d4 |
76 | if client is None: |
d41c6a53 |
77 | error = "Unauthorized." |
78 | return json_response({"error": error}, status=403) |
79 | |
80 | client.application_name = data.get( |
81 | "application_name", |
82 | client.application_name |
83 | ) |
84 | |
85 | client.application_type = data.get( |
86 | "application_type", |
87 | client.application_type |
88 | ) |
54fbbf09 |
89 | |
763e300d |
90 | app_name = ("application_type", client.application_name) |
91 | if app_name in client_types: |
92 | client.application_name = app_name |
763e300d |
93 | |
54fbbf09 |
94 | elif client_type == "client_associate": |
95 | # registering |
96 | if "client_id" in data: |
d41c6a53 |
97 | error = "Only set client_id for update." |
98 | return json_response({"error": error}, status=400) |
54fbbf09 |
99 | elif "access_token" in data: |
d41c6a53 |
100 | error = "access_token not needed for registration." |
101 | return json_response({"error": error}, status=400) |
54fbbf09 |
102 | elif "client_secret" in data: |
d41c6a53 |
103 | error = "Only set client_secret for update." |
104 | return json_response({"error": error}, status=400) |
54fbbf09 |
105 | |
c33a34d4 |
106 | # generate the client_id and client_secret |
107 | client_id = random_string(22) # seems to be what pump uses |
108 | client_secret = random_string(43) # again, seems to be what pump uses |
109 | expirey = 0 # for now, lets not have it expire |
110 | expirey_db = None if expirey == 0 else expirey |
111 | |
112 | # save it |
113 | client = Client( |
114 | id=client_id, |
115 | secret=client_secret, |
116 | expirey=expirey_db, |
117 | application_type=data["application_type"], |
d41c6a53 |
118 | ) |
c33a34d4 |
119 | |
120 | else: |
d41c6a53 |
121 | error = "Invalid registration type" |
122 | return json_response({"error": error}, status=400) |
c33a34d4 |
123 | |
124 | logo_url = data.get("logo_url", client.logo_url) |
125 | if logo_url is not None and not validate_url(logo_url): |
d41c6a53 |
126 | error = "Logo URL {0} is not a valid URL.".format(logo_url) |
127 | return json_response( |
128 | {"error": error}, |
129 | status=400 |
130 | ) |
c33a34d4 |
131 | else: |
132 | client.logo_url = logo_url |
405aa45a |
133 | |
134 | client.application_name = data.get("application_name", None) |
c33a34d4 |
135 | |
136 | contacts = data.get("contact", None) |
137 | if contacts is not None: |
138 | if type(contacts) is not unicode: |
d41c6a53 |
139 | error = "Contacts must be a string of space-seporated email addresses." |
140 | return json_response({"error": error}, status=400) |
c33a34d4 |
141 | |
142 | contacts = contacts.split() |
143 | for contact in contacts: |
144 | if not validate_email(contact): |
145 | # not a valid email |
d41c6a53 |
146 | error = "Email {0} is not a valid email.".format(contact) |
147 | return json_response({"error": error}, status=400) |
c33a34d4 |
148 | |
149 | |
150 | client.contacts = contacts |
151 | |
152 | request_uri = data.get("request_uris", None) |
153 | if request_uri is not None: |
154 | if type(request_uri) is not unicode: |
d41c6a53 |
155 | error = "redirect_uris must be space-seporated URLs." |
156 | return json_respinse({"error": error}, status=400) |
c33a34d4 |
157 | |
158 | request_uri = request_uri.split() |
159 | |
160 | for uri in request_uri: |
161 | if not validate_url(uri): |
162 | # not a valid uri |
d41c6a53 |
163 | error = "URI {0} is not a valid URI".format(uri) |
164 | return json_response({"error": error}, status=400) |
c33a34d4 |
165 | |
166 | client.request_uri = request_uri |
167 | |
168 | |
4990b47c |
169 | client.save() |
c840cb66 |
170 | |
c33a34d4 |
171 | expirey = 0 if client.expirey is None else client.expirey |
172 | |
4990b47c |
173 | return json_response( |
174 | { |
d41c6a53 |
175 | "client_id": client.id, |
176 | "client_secret": client.secret, |
177 | "expires_at": expirey, |
4990b47c |
178 | }) |
d41c6a53 |
179 | |
d41c6a53 |
180 | @csrf_exempt |
181 | def request_token(request): |
182 | """ Returns request token """ |
183 | try: |
184 | data = decode_request(request) |
185 | except ValueError: |
186 | error = "Could not decode data." |
187 | return json_response({"error": error}, status=400) |
188 | |
405aa45a |
189 | if data == "": |
d41c6a53 |
190 | error = "Unknown Content-Type" |
191 | return json_response({"error": error}, status=400) |
192 | |
2b60a56c |
193 | if not data and request.headers: |
194 | data = request.headers |
195 | |
196 | data = dict(data) # mutableifying |
405aa45a |
197 | |
2b60a56c |
198 | authorization = decode_authorization_header(data) |
d41c6a53 |
199 | |
d41c6a53 |
200 | |
2b60a56c |
201 | if authorization == dict() or u"oauth_consumer_key" not in authorization: |
405aa45a |
202 | error = "Missing required parameter." |
2b60a56c |
203 | return json_response({"error": error}, status=400) |
405aa45a |
204 | |
d41c6a53 |
205 | # check the client_id |
2b60a56c |
206 | client_id = authorization[u"oauth_consumer_key"] |
d41c6a53 |
207 | client = Client.query.filter_by(id=client_id).first() |
208 | if client is None: |
209 | # client_id is invalid |
210 | error = "Invalid client_id" |
211 | return json_response({"error": error}, status=400) |
212 | |
2b60a56c |
213 | # make request token and return to client |
214 | request_validator = GMGRequestValidator(authorization) |
d41c6a53 |
215 | rv = RequestTokenEndpoint(request_validator) |
405aa45a |
216 | tokens = rv.create_request_token(request, authorization) |
d41c6a53 |
217 | |
cfe7054c |
218 | # store the nonce & timestamp before we return back |
219 | nonce = authorization[u"oauth_nonce"] |
220 | timestamp = authorization[u"oauth_timestamp"] |
221 | timestamp = datetime.datetime.fromtimestamp(int(timestamp)) |
222 | |
223 | nc = NonceTimestamp(nonce=nonce, timestamp=timestamp) |
224 | nc.save() |
225 | |
2b60a56c |
226 | return form_response(tokens) |
405aa45a |
227 | |
228 | class WTFormData(dict): |
229 | """ |
230 | Provides a WTForm usable dictionary |
231 | """ |
232 | def getlist(self, key): |
233 | v = self[key] |
234 | if not isinstance(v, (list, tuple)): |
235 | v = [v] |
236 | return v |
237 | |
238 | @require_active_login |
d41c6a53 |
239 | def authorize(request): |
240 | """ Displays a page for user to authorize """ |
405aa45a |
241 | if request.method == "POST": |
242 | return authorize_finish(request) |
243 | |
d41c6a53 |
244 | _ = pass_to_ugettext |
245 | token = request.args.get("oauth_token", None) |
246 | if token is None: |
247 | # no token supplied, display a html 400 this time |
405aa45a |
248 | err_msg = _("Must provide an oauth_token.") |
d41c6a53 |
249 | return render_400(request, err_msg=err_msg) |
250 | |
405aa45a |
251 | oauth_request = RequestToken.query.filter_by(token=token).first() |
252 | if oauth_request is None: |
253 | err_msg = _("No request token found.") |
254 | return render_400(request, err_msg) |
255 | |
256 | if oauth_request.used: |
257 | return authorize_finish(request) |
258 | |
259 | if oauth_request.verifier is None: |
786bbd79 |
260 | orequest = GMGRequest(request) |
405aa45a |
261 | request_validator = GMGRequestValidator() |
262 | auth_endpoint = AuthorizationEndpoint(request_validator) |
263 | verifier = auth_endpoint.create_verifier(orequest, {}) |
264 | oauth_request.verifier = verifier["oauth_verifier"] |
265 | |
266 | oauth_request.user = request.user.id |
267 | oauth_request.save() |
268 | |
269 | # find client & build context |
270 | client = Client.query.filter_by(id=oauth_request.client).first() |
271 | |
272 | authorize_form = AuthorizeForm(WTFormData({ |
273 | "oauth_token": oauth_request.token, |
274 | "oauth_verifier": oauth_request.verifier |
275 | })) |
276 | |
277 | context = { |
278 | "user": request.user, |
279 | "oauth_request": oauth_request, |
280 | "client": client, |
281 | "authorize_form": authorize_form, |
282 | } |
283 | |
284 | |
d41c6a53 |
285 | # AuthorizationEndpoint |
405aa45a |
286 | return render_to_response( |
287 | request, |
288 | "mediagoblin/api/authorize.html", |
289 | context |
290 | ) |
291 | |
292 | |
293 | def authorize_finish(request): |
294 | """ Finishes the authorize """ |
295 | _ = pass_to_ugettext |
296 | token = request.form["oauth_token"] |
297 | verifier = request.form["oauth_verifier"] |
298 | oauth_request = RequestToken.query.filter_by(token=token, verifier=verifier) |
299 | oauth_request = oauth_request.first() |
d41c6a53 |
300 | |
405aa45a |
301 | if oauth_request is None: |
302 | # invalid token or verifier |
303 | err_msg = _("No request token found.") |
304 | return render_400(request, err_msg) |
305 | |
306 | oauth_request.used = True |
307 | oauth_request.updated = datetime.datetime.now() |
308 | oauth_request.save() |
309 | |
310 | if oauth_request.callback == "oob": |
311 | # out of bounds |
312 | context = {"oauth_request": oauth_request} |
313 | return render_to_response( |
314 | request, |
315 | "mediagoblin/api/oob.html", |
316 | context |
317 | ) |
318 | |
319 | # okay we need to redirect them then! |
320 | querystring = "?oauth_token={0}&oauth_verifier={1}".format( |
321 | oauth_request.token, |
322 | oauth_request.verifier |
323 | ) |
324 | |
325 | return redirect( |
326 | request, |
327 | querystring=querystring, |
328 | location=oauth_request.callback |
329 | ) |
d41c6a53 |
330 | |
331 | @csrf_exempt |
332 | def access_token(request): |
333 | """ Provides an access token based on a valid verifier and request token """ |
2b60a56c |
334 | data = request.headers |
405aa45a |
335 | |
2b60a56c |
336 | parsed_tokens = decode_authorization_header(data) |
337 | |
338 | if parsed_tokens == dict() or "oauth_token" not in parsed_tokens: |
339 | error = "Missing required parameter." |
405aa45a |
340 | return json_response({"error": error}, status=400) |
341 | |
2b60a56c |
342 | |
343 | request.oauth_token = parsed_tokens["oauth_token"] |
344 | request_validator = GMGRequestValidator(data) |
345 | av = AccessTokenEndpoint(request_validator) |
346 | tokens = av.create_access_token(request, {}) |
347 | return form_response(tokens) |
1e2675b0 |
348 | |