From 04e08d422ad179de881e4394b99f2231d1b65a90 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Thu, 27 Jun 2013 19:34:21 +0100 Subject: [PATCH 01/27] Moves json_response into tools/json.py --- mediagoblin/plugins/api/tools.py | 24 ----------------- mediagoblin/plugins/api/views.py | 4 +-- mediagoblin/plugins/oauth/tools.py | 2 +- mediagoblin/plugins/oauth/views.py | 2 +- mediagoblin/tools/json.py | 41 ++++++++++++++++++++++++++++++ 5 files changed, 45 insertions(+), 28 deletions(-) create mode 100644 mediagoblin/tools/json.py diff --git a/mediagoblin/plugins/api/tools.py b/mediagoblin/plugins/api/tools.py index 92411f4b..d1b3ebb1 100644 --- a/mediagoblin/plugins/api/tools.py +++ b/mediagoblin/plugins/api/tools.py @@ -51,30 +51,6 @@ class Auth(object): def __call__(self, request, *args, **kw): raise NotImplemented() - -def json_response(serializable, _disable_cors=False, *args, **kw): - ''' - Serializes a json objects and returns a werkzeug Response object with the - serialized value as the response body and Content-Type: application/json. - - :param serializable: A json-serializable object - - Any extra arguments and keyword arguments are passed to the - Response.__init__ method. - ''' - response = Response(json.dumps(serializable), *args, content_type='application/json', **kw) - - if not _disable_cors: - cors_headers = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With'} - for key, value in cors_headers.iteritems(): - response.headers.set(key, value) - - return response - - def get_entry_serializable(entry, urlgen): ''' Returns a serializable dict() of a MediaEntry instance. diff --git a/mediagoblin/plugins/api/views.py b/mediagoblin/plugins/api/views.py index 9159fe65..738ea25f 100644 --- a/mediagoblin/plugins/api/views.py +++ b/mediagoblin/plugins/api/views.py @@ -21,11 +21,11 @@ from os.path import splitext from werkzeug.exceptions import BadRequest, Forbidden from werkzeug.wrappers import Response +from mediagoblin.tools.json import json_response from mediagoblin.decorators import require_active_login from mediagoblin.meddleware.csrf import csrf_exempt from mediagoblin.media_types import sniff_media -from mediagoblin.plugins.api.tools import api_auth, get_entry_serializable, \ - json_response +from mediagoblin.plugins.api.tools import api_auth, get_entry_serializable from mediagoblin.submit.lib import check_file_field, prepare_queue_task, \ run_process_media, new_upload_entry diff --git a/mediagoblin/plugins/oauth/tools.py b/mediagoblin/plugins/oauth/tools.py index 27ff32b4..1e0fc6ef 100644 --- a/mediagoblin/plugins/oauth/tools.py +++ b/mediagoblin/plugins/oauth/tools.py @@ -23,7 +23,7 @@ from datetime import datetime from functools import wraps -from mediagoblin.plugins.api.tools import json_response +from mediagoblin.tools.json import json_response def require_client_auth(controller): diff --git a/mediagoblin/plugins/oauth/views.py b/mediagoblin/plugins/oauth/views.py index d6fd314f..a5d66111 100644 --- a/mediagoblin/plugins/oauth/views.py +++ b/mediagoblin/plugins/oauth/views.py @@ -22,6 +22,7 @@ from urllib import urlencode from werkzeug.exceptions import BadRequest from mediagoblin.tools.response import render_to_response, redirect +from mediagoblin.tools.json import json_response from mediagoblin.decorators import require_active_login from mediagoblin.messages import add_message, SUCCESS from mediagoblin.tools.translate import pass_to_ugettext as _ @@ -31,7 +32,6 @@ from mediagoblin.plugins.oauth.forms import ClientRegistrationForm, \ AuthorizationForm from mediagoblin.plugins.oauth.tools import require_client_auth, \ create_token -from mediagoblin.plugins.api.tools import json_response _log = logging.getLogger(__name__) diff --git a/mediagoblin/tools/json.py b/mediagoblin/tools/json.py new file mode 100644 index 00000000..a8437b82 --- /dev/null +++ b/mediagoblin/tools/json.py @@ -0,0 +1,41 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json + +from werkzeug.wrappers import Response + +def json_response(serializable, _disable_cors=False, *args, **kw): + ''' + Serializes a json objects and returns a werkzeug Response object with the + serialized value as the response body and Content-Type: application/json. + + :param serializable: A json-serializable object + + Any extra arguments and keyword arguments are passed to the + Response.__init__ method. + ''' + response = Response(json.dumps(serializable), *args, content_type='application/json', **kw) + + if not _disable_cors: + cors_headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With'} + for key, value in cors_headers.iteritems(): + response.headers.set(key, value) + + return response From c840cb66180a77e630c261c21967a6afc87411e9 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Thu, 27 Jun 2013 19:34:21 +0100 Subject: [PATCH 02/27] Moves json_response into tools/json.py --- mediagoblin/federation/__init__.py | 16 ++++++++++++++ mediagoblin/federation/routing.py | 19 +++++++++++++++++ mediagoblin/federation/views.py | 34 ++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 mediagoblin/federation/__init__.py create mode 100644 mediagoblin/federation/routing.py create mode 100644 mediagoblin/federation/views.py diff --git a/mediagoblin/federation/__init__.py b/mediagoblin/federation/__init__.py new file mode 100644 index 00000000..719b56e7 --- /dev/null +++ b/mediagoblin/federation/__init__.py @@ -0,0 +1,16 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + diff --git a/mediagoblin/federation/routing.py b/mediagoblin/federation/routing.py new file mode 100644 index 00000000..6a75628e --- /dev/null +++ b/mediagoblin/federation/routing.py @@ -0,0 +1,19 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from mediagoblin.tools.routing import add_route + +add_route("mediagoblin.federation", "/api/client/register", "mediagoblin.federation.views:client_register") diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py new file mode 100644 index 00000000..097dc625 --- /dev/null +++ b/mediagoblin/federation/views.py @@ -0,0 +1,34 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from mediagoblin.tools.json import json_response + +# possible client types +client_types = ["web", "native"] # currently what pump supports + +def client_register(request): + """ Endpoint for client registration """ + if request.method == "POST": + # new client registration + + return json_response({"dir":dir(request)}) + + # check they haven't given us client_id or client_type, they're only used for updating + pass + + elif request.method == "PUT": + # updating client + pass From 4990b47ce401dc86353a261825771a6811be4a8c Mon Sep 17 00:00:00 2001 From: xray7224 Date: Fri, 28 Jun 2013 17:59:32 +0100 Subject: [PATCH 03/27] Working client registration --- mediagoblin/db/models.py | 25 +++++++++++++++++- mediagoblin/federation/views.py | 46 ++++++++++++++++++++++++++------- mediagoblin/routing.py | 1 + mediagoblin/tools/crypto.py | 15 +++++++++++ mediagoblin/tools/json.py | 41 ----------------------------- mediagoblin/tools/response.py | 27 +++++++++++++++++-- 6 files changed, 101 insertions(+), 54 deletions(-) delete mode 100644 mediagoblin/tools/json.py diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index 826d47ba..4c39c025 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -105,6 +105,29 @@ class User(Base, UserMixin): _log.info('Deleted user "{0}" account'.format(self.username)) +class Client(Base): + """ + Model representing a client - Used for API Auth + """ + __tablename__ = "core__clients" + + id = Column(Unicode, nullable=True, primary_key=True) + secret = Column(Unicode, nullable=False) + expirey = Column(DateTime, nullable=True) + application_type = Column(Unicode, nullable=False) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + updated = Column(DateTime, nullable=False, default=datetime.datetime.now) + + # optional stuff + redirect_uri = Column(Unicode, nullable=True) + logo_uri = Column(Unicode, nullable=True) + application_name = Column(Unicode, nullable=True) + + def __repr__(self): + return "".format(self.id) + + + class MediaEntry(Base, MediaEntryMixin): """ TODO: Consider fetching the media_files using join @@ -580,7 +603,7 @@ with_polymorphic( [ProcessingNotification, CommentNotification]) MODELS = [ - User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, + User, Client, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData, Notification, CommentNotification, ProcessingNotification, CommentSubscription] diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index 097dc625..bfd58d27 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -14,21 +14,47 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from mediagoblin.tools.json import json_response +import json + +from mediagoblin.meddleware.csrf import csrf_exempt +from mediagoblin.tools.response import json_response +from mediagoblin.tools.crypto import random_string +from mediagoblin.db.models import Client # possible client types client_types = ["web", "native"] # currently what pump supports +@csrf_exempt def client_register(request): """ Endpoint for client registration """ - if request.method == "POST": - # new client registration + data = request.get_data() + if request.content_type == "application/json": + try: + data = json.loads(data) + except ValueError: + return json_response({"error":"Could not decode JSON"}) + else: + return json_response({"error":"Unknown Content-Type"}, status=400) - return json_response({"dir":dir(request)}) + if "type" not in data: + return json_response({"error":"No registration type provided"}, status=400) + + # generate the client_id and client_secret + client_id = random_string(22) # seems to be what pump uses + client_secret = random_string(43) # again, seems to be what pump uses + expirey = 0 # for now, lets not have it expire + expirey_db = None if expirey == 0 else expirey + client = Client( + id=client_id, + secret=client_secret, + expirey=expirey_db, + application_type=data["type"] + ) + client.save() - # check they haven't given us client_id or client_type, they're only used for updating - pass - - elif request.method == "PUT": - # updating client - pass + return json_response( + { + "client_id":client_id, + "client_secret":client_secret, + "expires_at":expirey, + }) diff --git a/mediagoblin/routing.py b/mediagoblin/routing.py index 986eb2ed..3a54aaa0 100644 --- a/mediagoblin/routing.py +++ b/mediagoblin/routing.py @@ -36,6 +36,7 @@ def get_url_map(): import mediagoblin.webfinger.routing import mediagoblin.listings.routing import mediagoblin.notifications.routing + import mediagoblin.federation.routing for route in PluginManager().get_routes(): add_route(*route) diff --git a/mediagoblin/tools/crypto.py b/mediagoblin/tools/crypto.py index 1379d21b..917e674c 100644 --- a/mediagoblin/tools/crypto.py +++ b/mediagoblin/tools/crypto.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import base64 +import string import errno import itsdangerous import logging @@ -24,6 +26,9 @@ from mediagoblin import mg_globals _log = logging.getLogger(__name__) +# produces base64 alphabet +alphabet = string.ascii_letters + "-_" +base = len(alphabet) # Use the system (hardware-based) random number generator if it exists. # -- this optimization is lifted from Django @@ -111,3 +116,13 @@ def get_timed_signer_url(namespace): assert __itsda_secret is not None return itsdangerous.URLSafeTimedSerializer(__itsda_secret, salt=namespace) + +def random_string(length): + """ Returns a URL safe base64 encoded crypographically strong string """ + rstring = "" + for i in range(length): + n = getrandbits(6) # 6 bytes = 2^6 = 64 + n = divmod(n, base)[1] + rstring += alphabet[n] + + return rstring diff --git a/mediagoblin/tools/json.py b/mediagoblin/tools/json.py deleted file mode 100644 index a8437b82..00000000 --- a/mediagoblin/tools/json.py +++ /dev/null @@ -1,41 +0,0 @@ -# GNU MediaGoblin -- federated, autonomous media hosting -# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import json - -from werkzeug.wrappers import Response - -def json_response(serializable, _disable_cors=False, *args, **kw): - ''' - Serializes a json objects and returns a werkzeug Response object with the - serialized value as the response body and Content-Type: application/json. - - :param serializable: A json-serializable object - - Any extra arguments and keyword arguments are passed to the - Response.__init__ method. - ''' - response = Response(json.dumps(serializable), *args, content_type='application/json', **kw) - - if not _disable_cors: - cors_headers = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With'} - for key, value in cors_headers.iteritems(): - response.headers.set(key, value) - - return response diff --git a/mediagoblin/tools/response.py b/mediagoblin/tools/response.py index 0be1f835..1fd242fb 100644 --- a/mediagoblin/tools/response.py +++ b/mediagoblin/tools/response.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import json + import werkzeug.utils from werkzeug.wrappers import Response as wz_Response from mediagoblin.tools.template import render_template @@ -31,7 +33,6 @@ def render_to_response(request, template, context, status=200): render_template(request, template, context), status=status) - def render_error(request, status=500, title=_('Oops!'), err_msg=_('An error occured')): """Render any error page with a given error code, title and text body @@ -44,7 +45,6 @@ def render_error(request, status=500, title=_('Oops!'), {'err_code': status, 'title': title, 'err_msg': err_msg}), status=status) - def render_403(request): """Render a standard 403 page""" _ = pass_to_ugettext @@ -106,3 +106,26 @@ def redirect_obj(request, obj): Requires obj to have a .url_for_self method.""" return redirect(request, location=obj.url_for_self(request.urlgen)) + +def json_response(serializable, _disable_cors=False, *args, **kw): + ''' + Serializes a json objects and returns a werkzeug Response object with the + serialized value as the response body and Content-Type: application/json. + + :param serializable: A json-serializable object + + Any extra arguments and keyword arguments are passed to the + Response.__init__ method. + ''' + + response = wz_Response(json.dumps(serializable), *args, content_type='application/json', **kw) + + if not _disable_cors: + cors_headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With'} + for key, value in cors_headers.iteritems(): + response.headers.set(key, value) + + return response From 54fbbf092310a3f1b29817dba90105732132e19b Mon Sep 17 00:00:00 2001 From: xray7224 Date: Fri, 28 Jun 2013 19:34:56 +0100 Subject: [PATCH 04/27] Adds more support to begin to deal with updates --- mediagoblin/federation/views.py | 34 +++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index bfd58d27..f16ae1df 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -38,17 +38,47 @@ def client_register(request): if "type" not in data: return json_response({"error":"No registration type provided"}, status=400) - + + if "application_type" not in data or data["application_type"] not in client_types: + return json_response({"error":"Unknown application_type."}, status=400) + + client_type = data["type"] + + if client_type == "client_update": + # updating a client + if "client_id" not in data: + return json_response({"error":"client_id is required to update."}, status=400) + elif "client_secret" not in data: + return json_response({"error":"client_secret is required to update."}, status=400) + + client = Client.query.filter_by(id=data["client_id"], secret=data["client_secret"]).all() + + if not client: + return json_response({"error":"Unauthorized.", status=403) + + elif client_type == "client_associate": + # registering + if "client_id" in data: + return json_response({"error":"Only set client_id for update."}, status=400) + elif "access_token" in data: + return json_response({"error":"access_token not needed for registration."}, status=400) + elif "client_secret" in data: + return json_response({"error":"Only set client_secret for update."}, status=400) + # generate the client_id and client_secret client_id = random_string(22) # seems to be what pump uses client_secret = random_string(43) # again, seems to be what pump uses expirey = 0 # for now, lets not have it expire expirey_db = None if expirey == 0 else expirey + + # save it client = Client( id=client_id, secret=client_secret, expirey=expirey_db, - application_type=data["type"] + application_type=data["type"], + logo_url=data.get("logo_url", None), + redirect_uri=data.get("redirect_uri", None) ) client.save() From 763e300d7c6d798056c629e24b22298691ccc02e Mon Sep 17 00:00:00 2001 From: xray7224 Date: Sun, 30 Jun 2013 15:26:49 +0100 Subject: [PATCH 05/27] Adds update ability --- mediagoblin/federation/views.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index f16ae1df..56bacbb1 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -56,6 +56,23 @@ def client_register(request): if not client: return json_response({"error":"Unauthorized.", status=403) + client.logo_url = data.get("logo_url", client.logo_url) + client.application_name = data.get("application_name", client.application_name) + app_name = ("application_type", client.application_name) + if app_name in client_types: + client.application_name = app_name + + client.save() + + expirey = 0 if client.expirey is None else client.expirey + + return json_response( + { + "client_id":client.id, + "client_secret":client.secret, + "expires":expirey, + }) + elif client_type == "client_associate": # registering if "client_id" in data: @@ -78,7 +95,8 @@ def client_register(request): expirey=expirey_db, application_type=data["type"], logo_url=data.get("logo_url", None), - redirect_uri=data.get("redirect_uri", None) + redirect_uri=data.get("redirect_uri", None), + application_type=data["application_type"] ) client.save() From c33a34d45964a7e49a5eeeabde0ef4a8132ac591 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Mon, 1 Jul 2013 17:50:39 +0100 Subject: [PATCH 06/27] Client registration now supports application/x-www-form-urlencoded now --- mediagoblin/db/models.py | 12 ++-- mediagoblin/federation/views.py | 98 +++++++++++++++++++++------------ mediagoblin/tools/validator.py | 46 ++++++++++++++++ 3 files changed, 118 insertions(+), 38 deletions(-) create mode 100644 mediagoblin/tools/validator.py diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index 4c39c025..daee9295 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -119,12 +119,16 @@ class Client(Base): updated = Column(DateTime, nullable=False, default=datetime.datetime.now) # optional stuff - redirect_uri = Column(Unicode, nullable=True) - logo_uri = Column(Unicode, nullable=True) + redirect_uri = Column(JSONEncoded, nullable=True) + logo_url = Column(Unicode, nullable=True) application_name = Column(Unicode, nullable=True) - + contacts = Column(JSONEncoded, nullable=True) + def __repr__(self): - return "".format(self.id) + if self.application_name: + return "".format(self.application_name, self.id) + else: + return "".format(self.id) diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index 56bacbb1..743fd142 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -19,6 +19,7 @@ import json from mediagoblin.meddleware.csrf import csrf_exempt from mediagoblin.tools.response import json_response from mediagoblin.tools.crypto import random_string +from mediagoblin.tools.validator import validate_email, validate_url from mediagoblin.db.models import Client # possible client types @@ -33,12 +34,13 @@ def client_register(request): data = json.loads(data) except ValueError: return json_response({"error":"Could not decode JSON"}) + elif request.content_type == "" or request.content_type == "application/x-www-form-urlencoded": + data = request.form else: return json_response({"error":"Unknown Content-Type"}, status=400) if "type" not in data: return json_response({"error":"No registration type provided"}, status=400) - if "application_type" not in data or data["application_type"] not in client_types: return json_response({"error":"Unknown application_type."}, status=400) @@ -51,27 +53,16 @@ def client_register(request): elif "client_secret" not in data: return json_response({"error":"client_secret is required to update."}, status=400) - client = Client.query.filter_by(id=data["client_id"], secret=data["client_secret"]).all() + client = Client.query.filter_by(id=data["client_id"], secret=data["client_secret"]).first() - if not client: - return json_response({"error":"Unauthorized.", status=403) + if client is None: + return json_response({"error":"Unauthorized."}, status=403) - client.logo_url = data.get("logo_url", client.logo_url) client.application_name = data.get("application_name", client.application_name) + client.application_type = data.get("application_type", client.application_type) app_name = ("application_type", client.application_name) if app_name in client_types: client.application_name = app_name - - client.save() - - expirey = 0 if client.expirey is None else client.expirey - - return json_response( - { - "client_id":client.id, - "client_secret":client.secret, - "expires":expirey, - }) elif client_type == "client_associate": # registering @@ -82,27 +73,66 @@ def client_register(request): elif "client_secret" in data: return json_response({"error":"Only set client_secret for update."}, status=400) - # generate the client_id and client_secret - client_id = random_string(22) # seems to be what pump uses - client_secret = random_string(43) # again, seems to be what pump uses - expirey = 0 # for now, lets not have it expire - expirey_db = None if expirey == 0 else expirey - - # save it - client = Client( - id=client_id, - secret=client_secret, - expirey=expirey_db, - application_type=data["type"], - logo_url=data.get("logo_url", None), - redirect_uri=data.get("redirect_uri", None), - application_type=data["application_type"] - ) + # generate the client_id and client_secret + client_id = random_string(22) # seems to be what pump uses + client_secret = random_string(43) # again, seems to be what pump uses + expirey = 0 # for now, lets not have it expire + expirey_db = None if expirey == 0 else expirey + + # save it + client = Client( + id=client_id, + secret=client_secret, + expirey=expirey_db, + application_type=data["application_type"], + ) + + else: + return json_response({"error":"Invalid registration type"}, status=400) + + logo_url = data.get("logo_url", client.logo_url) + if logo_url is not None and not validate_url(logo_url): + return json_response({"error":"Logo URL {0} is not a valid URL".format(logo_url)}, status=400) + else: + client.logo_url = logo_url + application_name=data.get("application_name", None) + + contacts = data.get("contact", None) + if contacts is not None: + if type(contacts) is not unicode: + return json_response({"error":"contacts must be a string of space-separated email addresses."}, status=400) + + contacts = contacts.split() + for contact in contacts: + if not validate_email(contact): + # not a valid email + return json_response({"error":"Email {0} is not a valid email".format(contact)}, status=400) + + + client.contacts = contacts + + request_uri = data.get("request_uris", None) + if request_uri is not None: + if type(request_uri) is not unicode: + return json_respinse({"error":"redirect_uris must be space-separated URLs."}, status=400) + + request_uri = request_uri.split() + + for uri in request_uri: + if not validate_url(uri): + # not a valid uri + return json_response({"error":"URI {0} is not a valid URI".format(uri)}, status=400) + + client.request_uri = request_uri + + client.save() + expirey = 0 if client.expirey is None else client.expirey + return json_response( { - "client_id":client_id, - "client_secret":client_secret, + "client_id":client.id, + "client_secret":client.secret, "expires_at":expirey, }) diff --git a/mediagoblin/tools/validator.py b/mediagoblin/tools/validator.py new file mode 100644 index 00000000..03598f9c --- /dev/null +++ b/mediagoblin/tools/validator.py @@ -0,0 +1,46 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from wtforms.validators import Email, URL + +def validate_email(email): + """ + Validates an email + + Returns True if valid and False if invalid + """ + + email_re = Email().regex + result = email_re.match(email) + if result is None: + return False + else: + return result.string + +def validate_url(url): + """ + Validates a url + + Returns True if valid and False if invalid + """ + + url_re = URL().regex + result = url_re.match(url) + if result is None: + return False + else: + return result.string + From be7f90b3f537190d199989625f75d334dbca7080 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Mon, 1 Jul 2013 19:13:07 +0100 Subject: [PATCH 07/27] Adds the docs for client registration --- docs/source/api/client_register.rst | 158 ++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/source/api/client_register.rst diff --git a/docs/source/api/client_register.rst b/docs/source/api/client_register.rst new file mode 100644 index 00000000..088eb51d --- /dev/null +++ b/docs/source/api/client_register.rst @@ -0,0 +1,158 @@ +.. MediaGoblin Documentation + + Written in 2011, 2012 by MediaGoblin contributors + + To the extent possible under law, the author(s) have dedicated all + copyright and related and neighboring rights to this software to + the public domain worldwide. This software is distributed without + any warranty. + + You should have received a copy of the CC0 Public Domain + Dedication along with this software. If not, see + . + +==================== +Registering a Client +==================== + +To use the GNU MediaGoblin API you need to use the dynamic client registration. This has been adapted from the `OpenID specification `_, this is the only part of OpenID that is being used to serve the purpose to provide the client registration which is used in OAuth. + +The endpoint is ``/api/client/register`` + +The parameters are: + +type + **required** - This must be either *client_associate* (for new registration) or *client_update* + +client_id + **update only** - This should only be used updating client information, this is the client_id given when you register + +client_secret + **update only** - This should only be used updating client information, this is the client_secret given when you register + +contacts + **optional** - This a space seporated list of email addresses to contact of people responsible for the client + +application_type + **required** - This is the type of client you are making, this must be either *web* or *native* + +application_name + **optional** - This is the name of your client + +logo_url + **optional** - This is a URL of the logo image for your client + +redirect_uri + **optional** - This is a space seporated list of pre-registered URLs for use at the Authorization Server + + +Response +-------- + +You will get back a response:: + +client_id + This identifies a client + +client_secret + This is the secret. + +expires_at + This is time that the client credentials expire. If this is 0 the client registration does not expire. + +======= +Example +======= + +Register Client +--------------- + +To register a client for the first time, this is the minimum you must supply:: + + { + "type": "client_associate", + "application_type": "native" + } + +A Response will look like:: + + { + "client_secret": "hJtfhaQzgKerlLVdaeRAgmbcstSOBLRfgOinMxBCHcb", + "expires_at": 0, + "client_id": "vwljdhUMhhNbdKizpjZlxv" + } + + +Updating Client +--------------- + +Using the response we got above we can update the information and add new information we may have opted not to supply:: + + { + "type": "client_update", + "client_id": "vwljdhUMhhNbdKizpjZlxv", + "client_secret": "hJtfhaQzgKerlLVdaeRAgmbcstSOBLRfgOinMxBCHcb", + "application_type": "web", + "application_name": "MyClient!", + "logo_url": "https://myclient.org/images/my_logo.png", + "contacts": "myemail@someprovider.com another_developer@provider.net", + } + +The response will just return back the client_id and client_secret you sent:: + + { + "client_id": "vwljdhUMhhNbdKizpjZlxv", + "client_secret": "hJtfhaQzgKerlLVdaeRAgmbcstSOBLRfgOinMxBCHcb", + "expires_at": 0 + } + + +====== +Errors +====== + +There are a number of errors you could get back, This explains what could cause some of them: + +Could not decode JSON + This is caused when you have an error in your JSON, you may want to use a JSON validator to ensure that your JSON is correct. + +Unknown Content-Type + You should sent a Content-Type header with when you make a request, this should be either application/json or www-form-urlencoded. This is caused when a unknown Content-Type is used. + +No registration type provided + This is when you leave out the ``type``. This should either be client_update or client_associate + +Unknown application_type. + This is when you have provided a ``type`` however this isn't one of the known types. + +client_id is required to update. + When you try and update you need to specify the client_id, this will be what you were given when you initially registered the client. + +client_secret is required to update. + When you try to update you need to specify the client_secrer, this will be what you were given when you initially register the client. + +Unauthorized. + This is when you are trying to update however the client_id and/or client_secret you have submitted are incorrect. + +Only set client_id for update. + This should only be given when you update. + +Only set client_secret for update. + This should only be given when you update. + +Logo URL is not a valid URL + This is when the URL specified did not meet the validation. + +contacts must be a string of space-separated email addresses. + ``contacts`` should be a string (not a list), ensure each email is seporated by a space + +Email is not a valid email + This is when you have submitted an invalid email address + +redirect_uris must be space-separated URLs. + ``redirect_uris`` should be a string (not a list), ensure each URL is seporated by a space + +URI is not a valid URI + This is when your URI is invalid. + + From d41c6a5349db0ac573e8f0d29d239febc705f7c9 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Mon, 8 Jul 2013 20:35:03 +0100 Subject: [PATCH 08/27] Adds oauth support up until authorization --- docs/source/api/client_register.rst | 4 +- mediagoblin/db/models.py | 37 +++++- mediagoblin/federation/routing.py | 26 +++- mediagoblin/federation/views.py | 177 ++++++++++++++++++++++------ mediagoblin/tools/request.py | 17 +++ mediagoblin/tools/response.py | 9 ++ setup.py | 2 + 7 files changed, 230 insertions(+), 42 deletions(-) diff --git a/docs/source/api/client_register.rst b/docs/source/api/client_register.rst index 088eb51d..4ad7908e 100644 --- a/docs/source/api/client_register.rst +++ b/docs/source/api/client_register.rst @@ -113,8 +113,8 @@ Errors There are a number of errors you could get back, This explains what could cause some of them: -Could not decode JSON - This is caused when you have an error in your JSON, you may want to use a JSON validator to ensure that your JSON is correct. +Could not decode data + This is caused when you have an error in the encoding of your data. Unknown Content-Type You should sent a Content-Type header with when you make a request, this should be either application/json or www-form-urlencoded. This is caused when a unknown Content-Type is used. diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index daee9295..8a71aa09 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -130,7 +130,36 @@ class Client(Base): else: return "".format(self.id) +class RequestToken(Base): + """ + Model for representing the request tokens + """ + __tablename__ = "core__request_tokens" + token = Column(Unicode, primary_key=True) + secret = Column(Unicode, nullable=False) + client = Column(Unicode, ForeignKey(Client.id)) + user = Column(Integer, ForeignKey(User.id), nullable=True) + used = Column(Boolean, default=False) + authenticated = Column(Boolean, default=False) + verifier = Column(Unicode, nullable=True) + callback = Column(Unicode, nullable=True) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + updated = Column(DateTime, nullable=False, default=datetime.datetime.now) + +class AccessToken(Base): + """ + Model for representing the access tokens + """ + __tablename__ = "core__access_tokens" + + token = Column(Unicode, nullable=False, primary_key=True) + secret = Column(Unicode, nullable=False) + user = Column(Integer, ForeignKey(User.id)) + request_token = Column(Unicode, ForeignKey(RequestToken.token)) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + updated = Column(DateTime, nullable=False, default=datetime.datetime.now) + class MediaEntry(Base, MediaEntryMixin): """ @@ -607,10 +636,10 @@ with_polymorphic( [ProcessingNotification, CommentNotification]) MODELS = [ - User, Client, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, - MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData, - Notification, CommentNotification, ProcessingNotification, - CommentSubscription] + User, Client, RequestToken, AccessToken, MediaEntry, Tag, MediaTag, + MediaComment, Collection, CollectionItem, MediaFile, FileKeynames, + MediaAttachmentFile, ProcessingMetaData, Notification, CommentNotification, + ProcessingNotification, CommentSubscription] ###################################################### diff --git a/mediagoblin/federation/routing.py b/mediagoblin/federation/routing.py index 6a75628e..f7e6f72c 100644 --- a/mediagoblin/federation/routing.py +++ b/mediagoblin/federation/routing.py @@ -16,4 +16,28 @@ from mediagoblin.tools.routing import add_route -add_route("mediagoblin.federation", "/api/client/register", "mediagoblin.federation.views:client_register") +# client registration & oauth +add_route( + "mediagoblin.federation", + "/api/client/register", + "mediagoblin.federation.views:client_register" + ) + + +add_route( + "mediagoblin.federation", + "/oauth/request_token", + "mediagoblin.federation.views:request_token" + ) + +add_route( + "mediagoblin.federation", + "/oauth/authorize", + "mediagoblin.federation.views:authorize", + ) + +add_route( + "mediagoblin.federation", + "/oauth/access_token", + "mediagoblin.federation.views:access_token" + ) diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index 743fd142..6c000855 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -14,13 +14,15 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json +from oauthlib.oauth1 import RequestValidator, RequestTokenEndpoint +from mediagoblin.tools.translate import pass_to_ugettext from mediagoblin.meddleware.csrf import csrf_exempt -from mediagoblin.tools.response import json_response +from mediagoblin.tools.request import decode_request +from mediagoblin.tools.response import json_response, render_400 from mediagoblin.tools.crypto import random_string from mediagoblin.tools.validator import validate_email, validate_url -from mediagoblin.db.models import Client +from mediagoblin.db.models import Client, RequestToken, AccessToken # possible client types client_types = ["web", "native"] # currently what pump supports @@ -28,38 +30,53 @@ client_types = ["web", "native"] # currently what pump supports @csrf_exempt def client_register(request): """ Endpoint for client registration """ - data = request.get_data() - if request.content_type == "application/json": - try: - data = json.loads(data) - except ValueError: - return json_response({"error":"Could not decode JSON"}) - elif request.content_type == "" or request.content_type == "application/x-www-form-urlencoded": - data = request.form - else: - return json_response({"error":"Unknown Content-Type"}, status=400) + try: + data = decode_request(request) + except ValueError: + error = "Could not decode data." + return json_response({"error": error}, status=400) + + if data is "": + error = "Unknown Content-Type" + return json_response({"error": error}, status=400) if "type" not in data: - return json_response({"error":"No registration type provided"}, status=400) - if "application_type" not in data or data["application_type"] not in client_types: - return json_response({"error":"Unknown application_type."}, status=400) + error = "No registration type provided." + return json_response({"error": error}, status=400) + if data.get("application_type", None) not in client_types: + error = "Unknown application_type." + return json_response({"error": error}, status=400) client_type = data["type"] if client_type == "client_update": # updating a client if "client_id" not in data: - return json_response({"error":"client_id is required to update."}, status=400) + error = "client_id is requried to update." + return json_response({"error": error}, status=400) elif "client_secret" not in data: - return json_response({"error":"client_secret is required to update."}, status=400) + error = "client_secret is required to update." + return json_response({"error": error}, status=400) - client = Client.query.filter_by(id=data["client_id"], secret=data["client_secret"]).first() + client = Client.query.filter_by( + id=data["client_id"], + secret=data["client_secret"] + ).first() if client is None: - return json_response({"error":"Unauthorized."}, status=403) + error = "Unauthorized." + return json_response({"error": error}, status=403) + + client.application_name = data.get( + "application_name", + client.application_name + ) + + client.application_type = data.get( + "application_type", + client.application_type + ) - client.application_name = data.get("application_name", client.application_name) - client.application_type = data.get("application_type", client.application_type) app_name = ("application_type", client.application_name) if app_name in client_types: client.application_name = app_name @@ -67,11 +84,14 @@ def client_register(request): elif client_type == "client_associate": # registering if "client_id" in data: - return json_response({"error":"Only set client_id for update."}, status=400) + error = "Only set client_id for update." + return json_response({"error": error}, status=400) elif "access_token" in data: - return json_response({"error":"access_token not needed for registration."}, status=400) + error = "access_token not needed for registration." + return json_response({"error": error}, status=400) elif "client_secret" in data: - return json_response({"error":"Only set client_secret for update."}, status=400) + error = "Only set client_secret for update." + return json_response({"error": error}, status=400) # generate the client_id and client_secret client_id = random_string(22) # seems to be what pump uses @@ -85,14 +105,19 @@ def client_register(request): secret=client_secret, expirey=expirey_db, application_type=data["application_type"], - ) + ) else: - return json_response({"error":"Invalid registration type"}, status=400) + error = "Invalid registration type" + return json_response({"error": error}, status=400) logo_url = data.get("logo_url", client.logo_url) if logo_url is not None and not validate_url(logo_url): - return json_response({"error":"Logo URL {0} is not a valid URL".format(logo_url)}, status=400) + error = "Logo URL {0} is not a valid URL.".format(logo_url) + return json_response( + {"error": error}, + status=400 + ) else: client.logo_url = logo_url application_name=data.get("application_name", None) @@ -100,13 +125,15 @@ def client_register(request): contacts = data.get("contact", None) if contacts is not None: if type(contacts) is not unicode: - return json_response({"error":"contacts must be a string of space-separated email addresses."}, status=400) + error = "Contacts must be a string of space-seporated email addresses." + return json_response({"error": error}, status=400) contacts = contacts.split() for contact in contacts: if not validate_email(contact): # not a valid email - return json_response({"error":"Email {0} is not a valid email".format(contact)}, status=400) + error = "Email {0} is not a valid email.".format(contact) + return json_response({"error": error}, status=400) client.contacts = contacts @@ -114,14 +141,16 @@ def client_register(request): request_uri = data.get("request_uris", None) if request_uri is not None: if type(request_uri) is not unicode: - return json_respinse({"error":"redirect_uris must be space-separated URLs."}, status=400) + error = "redirect_uris must be space-seporated URLs." + return json_respinse({"error": error}, status=400) request_uri = request_uri.split() for uri in request_uri: if not validate_url(uri): # not a valid uri - return json_response({"error":"URI {0} is not a valid URI".format(uri)}, status=400) + error = "URI {0} is not a valid URI".format(uri) + return json_response({"error": error}, status=400) client.request_uri = request_uri @@ -132,7 +161,85 @@ def client_register(request): return json_response( { - "client_id":client.id, - "client_secret":client.secret, - "expires_at":expirey, + "client_id": client.id, + "client_secret": client.secret, + "expires_at": expirey, }) + +class ValidationException(Exception): + pass + +class GMGRequestValidator(RequestValidator): + + def __init__(self, data): + self.POST = data + + def save_request_token(self, token, request): + """ Saves request token in db """ + client_id = self.POST[u"Authorization"][u"oauth_consumer_key"] + + request_token = RequestToken( + token=token["oauth_token"], + secret=token["oauth_token_secret"], + ) + request_token.client = client_id + request_token.save() + + +@csrf_exempt +def request_token(request): + """ Returns request token """ + try: + data = decode_request(request) + except ValueError: + error = "Could not decode data." + return json_response({"error": error}, status=400) + + if data is "": + error = "Unknown Content-Type" + return json_response({"error": error}, status=400) + + + # Convert 'Authorization' to a dictionary + authorization = {} + for item in data["Authorization"].split(","): + key, value = item.split("=", 1) + authorization[key] = value + data[u"Authorization"] = authorization + + # check the client_id + client_id = data[u"Authorization"][u"oauth_consumer_key"] + client = Client.query.filter_by(id=client_id).first() + if client is None: + # client_id is invalid + error = "Invalid client_id" + return json_response({"error": error}, status=400) + + request_validator = GMGRequestValidator(data) + rv = RequestTokenEndpoint(request_validator) + tokens = rv.create_request_token(request, {}) + + tokenized = {} + for t in tokens.split("&"): + key, value = t.split("=") + tokenized[key] = value + + # check what encoding to return them in + return json_response(tokenized) + +def authorize(request): + """ Displays a page for user to authorize """ + _ = pass_to_ugettext + token = request.args.get("oauth_token", None) + if token is None: + # no token supplied, display a html 400 this time + err_msg = _("Must provide an oauth_token") + return render_400(request, err_msg=err_msg) + + # AuthorizationEndpoint + + +@csrf_exempt +def access_token(request): + """ Provides an access token based on a valid verifier and request token """ + pass diff --git a/mediagoblin/tools/request.py b/mediagoblin/tools/request.py index ee342eae..ed903ce0 100644 --- a/mediagoblin/tools/request.py +++ b/mediagoblin/tools/request.py @@ -14,12 +14,17 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import json import logging from mediagoblin.db.models import User _log = logging.getLogger(__name__) +# MIME-Types +form_encoded = "application/x-www-form-urlencoded" +json_encoded = "application/json" + def setup_user_in_request(request): """ Examine a request and tack on a request.user parameter if that's @@ -36,3 +41,15 @@ def setup_user_in_request(request): # this session. _log.warn("Killing session for user id %r", request.session['user_id']) request.session.delete() + +def decode_request(request): + """ Decodes a request based on MIME-Type """ + data = request.get_data() + + if request.content_type == json_encoded: + data = json.loads(data) + elif request.content_type == form_encoded: + data = request.form + else: + data = "" + return data diff --git a/mediagoblin/tools/response.py b/mediagoblin/tools/response.py index 1fd242fb..db8fc388 100644 --- a/mediagoblin/tools/response.py +++ b/mediagoblin/tools/response.py @@ -45,6 +45,15 @@ def render_error(request, status=500, title=_('Oops!'), {'err_code': status, 'title': title, 'err_msg': err_msg}), status=status) +def render_400(request, err_msg=None): + """ Render a standard 400 page""" + _ = pass_to_ugettext + title = _("Bad Request") + if err_msg is None: + err_msg = _("The request sent to the server is invalid, please double check it") + + return render_error(request, 400, title, err_msg) + def render_403(request): """Render a standard 403 page""" _ = pass_to_ugettext diff --git a/setup.py b/setup.py index 6e026f30..b16f8d56 100644 --- a/setup.py +++ b/setup.py @@ -63,6 +63,8 @@ setup( 'itsdangerous', 'pytz', 'six', + 'oauthlib', + 'pypump', ## This is optional! # 'translitcodec', ## For now we're expecting that users will install this from From 405aa45adc14d3c67a120618ecc0ae792f5881de Mon Sep 17 00:00:00 2001 From: xray7224 Date: Wed, 10 Jul 2013 15:49:59 +0100 Subject: [PATCH 09/27] Adds more support for oauth - access_token & decorators still to do --- mediagoblin/db/models.py | 2 +- mediagoblin/federation/forms.py | 8 + mediagoblin/federation/views.py | 165 ++++++++++++++++-- mediagoblin/static/css/base.css | 7 + .../templates/mediagoblin/api/authorize.html | 56 ++++++ .../templates/mediagoblin/api/oob.html | 33 ++++ mediagoblin/tools/request.py | 2 +- 7 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 mediagoblin/federation/forms.py create mode 100644 mediagoblin/templates/mediagoblin/api/authorize.html create mode 100644 mediagoblin/templates/mediagoblin/api/oob.html diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index 8a71aa09..b6ae533e 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -143,7 +143,7 @@ class RequestToken(Base): used = Column(Boolean, default=False) authenticated = Column(Boolean, default=False) verifier = Column(Unicode, nullable=True) - callback = Column(Unicode, nullable=True) + callback = Column(Unicode, nullable=False, default=u"oob") created = Column(DateTime, nullable=False, default=datetime.datetime.now) updated = Column(DateTime, nullable=False, default=datetime.datetime.now) diff --git a/mediagoblin/federation/forms.py b/mediagoblin/federation/forms.py new file mode 100644 index 00000000..39d6fc27 --- /dev/null +++ b/mediagoblin/federation/forms.py @@ -0,0 +1,8 @@ +import wtforms +from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ + +class AuthorizeForm(wtforms.Form): + """ Form used to authorize the request token """ + + oauth_token = wtforms.HiddenField("oauth_token") + oauth_verifier = wtforms.HiddenField("oauth_verifier") diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index 6c000855..9559df10 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -14,15 +14,22 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from oauthlib.oauth1 import RequestValidator, RequestTokenEndpoint +import datetime +import oauthlib.common +from oauthlib.oauth1 import (AuthorizationEndpoint, RequestValidator, + RequestTokenEndpoint) + +from mediagoblin.decorators import require_active_login from mediagoblin.tools.translate import pass_to_ugettext from mediagoblin.meddleware.csrf import csrf_exempt from mediagoblin.tools.request import decode_request -from mediagoblin.tools.response import json_response, render_400 +from mediagoblin.tools.response import (render_to_response, redirect, + json_response, render_400) from mediagoblin.tools.crypto import random_string from mediagoblin.tools.validator import validate_email, validate_url -from mediagoblin.db.models import Client, RequestToken, AccessToken +from mediagoblin.db.models import User, Client, RequestToken, AccessToken +from mediagoblin.federation.forms import AuthorizeForm # possible client types client_types = ["web", "native"] # currently what pump supports @@ -120,7 +127,8 @@ def client_register(request): ) else: client.logo_url = logo_url - application_name=data.get("application_name", None) + + client.application_name = data.get("application_name", None) contacts = data.get("contact", None) if contacts is not None: @@ -171,7 +179,7 @@ class ValidationException(Exception): class GMGRequestValidator(RequestValidator): - def __init__(self, data): + def __init__(self, data=None): self.POST = data def save_request_token(self, token, request): @@ -183,8 +191,25 @@ class GMGRequestValidator(RequestValidator): secret=token["oauth_token_secret"], ) request_token.client = client_id + request_token.callback = token.get("oauth_callback", None) request_token.save() + def save_verifier(self, token, verifier, request): + """ Saves the oauth request verifier """ + request_token = RequestToken.query.filter_by(token=token).first() + request_token.verifier = verifier["oauth_verifier"] + request_token.save() + + + def save_access_token(self, token, request): + """ Saves access token in db """ + access_token = AccessToken( + token=token["oauth_token"], + secret=token["oauth_secret"], + ) + access_token.request_token = request.body["oauth_token"] + access_token.user = token["user"].id + access_token.save() @csrf_exempt def request_token(request): @@ -195,10 +220,16 @@ def request_token(request): error = "Could not decode data." return json_response({"error": error}, status=400) - if data is "": + if data == "": error = "Unknown Content-Type" return json_response({"error": error}, status=400) + print data + + if "Authorization" not in data: + error = "Missing required parameter." + return json_response({"error": error}, status=400) + # Convert 'Authorization' to a dictionary authorization = {} @@ -207,6 +238,10 @@ def request_token(request): authorization[key] = value data[u"Authorization"] = authorization + if "oauth_consumer_key" not in data[u"Authorization"]: + error = "Missing required parameter." + return json_respinse({"error": error}, status=400) + # check the client_id client_id = data[u"Authorization"][u"oauth_consumer_key"] client = Client.query.filter_by(id=client_id).first() @@ -217,29 +252,137 @@ def request_token(request): request_validator = GMGRequestValidator(data) rv = RequestTokenEndpoint(request_validator) - tokens = rv.create_request_token(request, {}) + tokens = rv.create_request_token(request, authorization) tokenized = {} for t in tokens.split("&"): key, value = t.split("=") tokenized[key] = value + print "[DEBUG] %s" % tokenized + # check what encoding to return them in return json_response(tokenized) - + +class WTFormData(dict): + """ + Provides a WTForm usable dictionary + """ + def getlist(self, key): + v = self[key] + if not isinstance(v, (list, tuple)): + v = [v] + return v + +@require_active_login def authorize(request): """ Displays a page for user to authorize """ + if request.method == "POST": + return authorize_finish(request) + _ = pass_to_ugettext token = request.args.get("oauth_token", None) if token is None: # no token supplied, display a html 400 this time - err_msg = _("Must provide an oauth_token") + err_msg = _("Must provide an oauth_token.") return render_400(request, err_msg=err_msg) - # AuthorizationEndpoint + oauth_request = RequestToken.query.filter_by(token=token).first() + if oauth_request is None: + err_msg = _("No request token found.") + return render_400(request, err_msg) + if oauth_request.used: + return authorize_finish(request) + + if oauth_request.verifier is None: + orequest = oauthlib.common.Request( + uri=request.url, + http_method=request.method, + body=request.get_data(), + headers=request.headers + ) + request_validator = GMGRequestValidator() + auth_endpoint = AuthorizationEndpoint(request_validator) + verifier = auth_endpoint.create_verifier(orequest, {}) + oauth_request.verifier = verifier["oauth_verifier"] + + oauth_request.user = request.user.id + oauth_request.save() + + # find client & build context + client = Client.query.filter_by(id=oauth_request.client).first() + + authorize_form = AuthorizeForm(WTFormData({ + "oauth_token": oauth_request.token, + "oauth_verifier": oauth_request.verifier + })) + + context = { + "user": request.user, + "oauth_request": oauth_request, + "client": client, + "authorize_form": authorize_form, + } + + + # AuthorizationEndpoint + return render_to_response( + request, + "mediagoblin/api/authorize.html", + context + ) + + +def authorize_finish(request): + """ Finishes the authorize """ + _ = pass_to_ugettext + token = request.form["oauth_token"] + verifier = request.form["oauth_verifier"] + oauth_request = RequestToken.query.filter_by(token=token, verifier=verifier) + oauth_request = oauth_request.first() + + if oauth_request is None: + # invalid token or verifier + err_msg = _("No request token found.") + return render_400(request, err_msg) + + oauth_request.used = True + oauth_request.updated = datetime.datetime.now() + oauth_request.save() + + if oauth_request.callback == "oob": + # out of bounds + context = {"oauth_request": oauth_request} + return render_to_response( + request, + "mediagoblin/api/oob.html", + context + ) + + # okay we need to redirect them then! + querystring = "?oauth_token={0}&oauth_verifier={1}".format( + oauth_request.token, + oauth_request.verifier + ) + + return redirect( + request, + querystring=querystring, + location=oauth_request.callback + ) @csrf_exempt def access_token(request): """ Provides an access token based on a valid verifier and request token """ - pass + try: + data = decode_request(request) + except ValueError: + error = "Could not decode data." + return json_response({"error": error}, status=400) + + if data == "": + error = "Unknown Content-Type" + return json_response({"error": error}, status=400) + + print "debug: %s" % data diff --git a/mediagoblin/static/css/base.css b/mediagoblin/static/css/base.css index 8b57584d..0d813bf5 100644 --- a/mediagoblin/static/css/base.css +++ b/mediagoblin/static/css/base.css @@ -753,3 +753,10 @@ pre { #exif_additional_info table tr { margin-bottom: 10px; } + +p.verifier { + text-align:center; + font-size:50px; + none repeat scroll 0% 0% rgb(221, 221, 221); + padding: 1em 0px; +} diff --git a/mediagoblin/templates/mediagoblin/api/authorize.html b/mediagoblin/templates/mediagoblin/api/authorize.html new file mode 100644 index 00000000..d0ec2616 --- /dev/null +++ b/mediagoblin/templates/mediagoblin/api/authorize.html @@ -0,0 +1,56 @@ +{# +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +#} +{% extends "mediagoblin/base.html" %} + +{% block title -%} + {% trans %}Authorization{% endtrans %} — {{ super() }} +{%- endblock %} + +{% block mediagoblin_content %} + +

{% trans %}Authorize{% endtrans %}

+ +

+ {% trans %}You are logged in as{% endtrans %} + {{user.username}} +

+ + {% trans %}Do you want to authorize {% endtrans %} + {% if client.application_name -%} + {{ client.application_name }} + {%- else -%} + {% trans %}an unknown application{% endtrans %} + {%- endif %} + {% trans %} to access your account? {% endtrans %} +

+ {% trans %}Applications with access to your account can: {% endtrans %} +

    +
  • {% trans %}Post new media as you{% endtrans %}
  • +
  • {% trans %}See your information (e.g profile, meida, etc...){% endtrans %}
  • +
  • {% trans %}Change your information{% endtrans %}
  • +
+
+ +
+ {{ csrf_token }} + {{ authorize_form.oauth_token }} + {{ authorize_form.oauth_verifier }} + +
+

+{% endblock %} diff --git a/mediagoblin/templates/mediagoblin/api/oob.html b/mediagoblin/templates/mediagoblin/api/oob.html new file mode 100644 index 00000000..d290472a --- /dev/null +++ b/mediagoblin/templates/mediagoblin/api/oob.html @@ -0,0 +1,33 @@ +{# +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +#} +{% extends "mediagoblin/base.html" %} + +{% block title -%} + {% trans %}Authorization Finished{% endtrans %} — {{ super() }} +{%- endblock %} + +{% block mediagoblin_content %} + +

{% trans %}Authorization Complete{% endtrans %}

+ +

{% trans %}Copy and paste this into your client:{% endtrans %}

+ +

+ {{ oauth_request.verifier }} +

+{% endblock %} diff --git a/mediagoblin/tools/request.py b/mediagoblin/tools/request.py index ed903ce0..2c9e609d 100644 --- a/mediagoblin/tools/request.py +++ b/mediagoblin/tools/request.py @@ -48,7 +48,7 @@ def decode_request(request): if request.content_type == json_encoded: data = json.loads(data) - elif request.content_type == form_encoded: + elif request.content_type == form_encoded or request.content_type == "": data = request.form else: data = "" From 2b60a56cbec44f789ee2efe71294979d7784515c Mon Sep 17 00:00:00 2001 From: xray7224 Date: Thu, 11 Jul 2013 17:58:58 +0100 Subject: [PATCH 10/27] Finishes most of oauth, just decorator to complete --- mediagoblin/decorators.py | 16 ++++++- mediagoblin/federation/views.py | 78 ++++++++++++++++----------------- mediagoblin/tools/request.py | 10 +++++ mediagoblin/tools/response.py | 19 ++++++++ 4 files changed, 81 insertions(+), 42 deletions(-) diff --git a/mediagoblin/decorators.py b/mediagoblin/decorators.py index ece222f5..ce26e46c 100644 --- a/mediagoblin/decorators.py +++ b/mediagoblin/decorators.py @@ -22,7 +22,8 @@ from werkzeug.exceptions import Forbidden, NotFound from mediagoblin import mg_globals as mgg from mediagoblin import messages from mediagoblin.db.models import MediaEntry, User -from mediagoblin.tools.response import redirect, render_404 +from mediagoblin.tools.request import decode_authorization_header +from mediagoblin.tools.response import json_response, redirect, render_404 from mediagoblin.tools.translate import pass_to_ugettext as _ @@ -268,3 +269,16 @@ def auth_enabled(controller): return controller(request, *args, **kwargs) return wrapper + +def oauth_requeired(controller): + """ Used to wrap API endpoints where oauth is required """ + @wraps(controller) + def wrapper(request, *args, **kwargs): + data = request.headers + authorization = decode_authorization_header(data) + + if authorization == dict(): + error = "Missing required parameter." + return json_response({"error": error}, status=400) + + diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index 9559df10..a6dcc79b 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -18,14 +18,15 @@ import datetime import oauthlib.common from oauthlib.oauth1 import (AuthorizationEndpoint, RequestValidator, - RequestTokenEndpoint) + RequestTokenEndpoint, AccessTokenEndpoint) from mediagoblin.decorators import require_active_login from mediagoblin.tools.translate import pass_to_ugettext from mediagoblin.meddleware.csrf import csrf_exempt -from mediagoblin.tools.request import decode_request +from mediagoblin.tools.request import decode_request, decode_authorization_header from mediagoblin.tools.response import (render_to_response, redirect, - json_response, render_400) + json_response, render_400, + form_response) from mediagoblin.tools.crypto import random_string from mediagoblin.tools.validator import validate_email, validate_url from mediagoblin.db.models import User, Client, RequestToken, AccessToken @@ -184,7 +185,7 @@ class GMGRequestValidator(RequestValidator): def save_request_token(self, token, request): """ Saves request token in db """ - client_id = self.POST[u"Authorization"][u"oauth_consumer_key"] + client_id = self.POST[u"oauth_consumer_key"] request_token = RequestToken( token=token["oauth_token"], @@ -200,17 +201,21 @@ class GMGRequestValidator(RequestValidator): request_token.verifier = verifier["oauth_verifier"] request_token.save() - def save_access_token(self, token, request): """ Saves access token in db """ access_token = AccessToken( token=token["oauth_token"], - secret=token["oauth_secret"], + secret=token["oauth_token_secret"], ) - access_token.request_token = request.body["oauth_token"] - access_token.user = token["user"].id + access_token.request_token = request.oauth_token + request_token = RequestToken.query.filter_by(token=request.oauth_token).first() + access_token.user = request_token.user access_token.save() + def get_realms(*args, **kwargs): + """ Currently a stub - called when making AccessTokens """ + return list() + @csrf_exempt def request_token(request): """ Returns request token """ @@ -224,45 +229,32 @@ def request_token(request): error = "Unknown Content-Type" return json_response({"error": error}, status=400) - print data + if not data and request.headers: + data = request.headers + + data = dict(data) # mutableifying - if "Authorization" not in data: + authorization = decode_authorization_header(data) + + + if authorization == dict() or u"oauth_consumer_key" not in authorization: error = "Missing required parameter." return json_response({"error": error}, status=400) - - # Convert 'Authorization' to a dictionary - authorization = {} - for item in data["Authorization"].split(","): - key, value = item.split("=", 1) - authorization[key] = value - data[u"Authorization"] = authorization - - if "oauth_consumer_key" not in data[u"Authorization"]: - error = "Missing required parameter." - return json_respinse({"error": error}, status=400) - # check the client_id - client_id = data[u"Authorization"][u"oauth_consumer_key"] + client_id = authorization[u"oauth_consumer_key"] client = Client.query.filter_by(id=client_id).first() if client is None: # client_id is invalid error = "Invalid client_id" return json_response({"error": error}, status=400) - request_validator = GMGRequestValidator(data) + # make request token and return to client + request_validator = GMGRequestValidator(authorization) rv = RequestTokenEndpoint(request_validator) tokens = rv.create_request_token(request, authorization) - tokenized = {} - for t in tokens.split("&"): - key, value = t.split("=") - tokenized[key] = value - - print "[DEBUG] %s" % tokenized - - # check what encoding to return them in - return json_response(tokenized) + return form_response(tokens) class WTFormData(dict): """ @@ -375,14 +367,18 @@ def authorize_finish(request): @csrf_exempt def access_token(request): """ Provides an access token based on a valid verifier and request token """ - try: - data = decode_request(request) - except ValueError: - error = "Could not decode data." + data = request.headers + + parsed_tokens = decode_authorization_header(data) + + if parsed_tokens == dict() or "oauth_token" not in parsed_tokens: + error = "Missing required parameter." return json_response({"error": error}, status=400) - if data == "": - error = "Unknown Content-Type" - return json_response({"error": error}, status=400) - print "debug: %s" % data + request.oauth_token = parsed_tokens["oauth_token"] + request_validator = GMGRequestValidator(data) + av = AccessTokenEndpoint(request_validator) + tokens = av.create_access_token(request, {}) + return form_response(tokens) + diff --git a/mediagoblin/tools/request.py b/mediagoblin/tools/request.py index 2c9e609d..0c0fc557 100644 --- a/mediagoblin/tools/request.py +++ b/mediagoblin/tools/request.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import re import json import logging from mediagoblin.db.models import User @@ -25,6 +26,9 @@ _log = logging.getLogger(__name__) form_encoded = "application/x-www-form-urlencoded" json_encoded = "application/json" +# Regex for Authorization header +auth_header_re = re.compile('(\w+)[:=] ?"?(\w+)"?') + def setup_user_in_request(request): """ Examine a request and tack on a request.user parameter if that's @@ -53,3 +57,9 @@ def decode_request(request): else: data = "" return data + +def decode_authorization_header(header): + """ Decodes a HTTP Authorization Header to python dictionary """ + authorization = header.get("Authorization", "") + tokens = dict(auth_header_re.findall(authorization)) + return tokens diff --git a/mediagoblin/tools/response.py b/mediagoblin/tools/response.py index db8fc388..b0401e08 100644 --- a/mediagoblin/tools/response.py +++ b/mediagoblin/tools/response.py @@ -138,3 +138,22 @@ def json_response(serializable, _disable_cors=False, *args, **kw): response.headers.set(key, value) return response + +def form_response(data, *args, **kwargs): + """ + Responds using application/x-www-form-urlencoded and returns a werkzeug + Response object with the data argument as the body + and 'application/x-www-form-urlencoded' as the Content-Type. + + Any extra arguments and keyword arguments are passed to the + Response.__init__ method. + """ + + response = wz_Response( + data, + content_type="application/x-www-form-urlencoded", + *args, + **kwargs + ) + + return response From 786bbd79e8d77c06a9d86aee00edc4dd3e89d651 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Thu, 11 Jul 2013 19:43:00 +0100 Subject: [PATCH 11/27] Cleans up some of the OAuth code --- mediagoblin/decorators.py | 4 +- mediagoblin/federation/exceptions.py | 18 ++++++ mediagoblin/federation/oauth.py | 80 ++++++++++++++++++++++++ mediagoblin/federation/tools/__init__.py | 0 mediagoblin/federation/tools/request.py | 27 ++++++++ mediagoblin/federation/views.py | 56 ++--------------- mediagoblin/tools/request.py | 9 --- 7 files changed, 134 insertions(+), 60 deletions(-) create mode 100644 mediagoblin/federation/exceptions.py create mode 100644 mediagoblin/federation/oauth.py create mode 100644 mediagoblin/federation/tools/__init__.py create mode 100644 mediagoblin/federation/tools/request.py diff --git a/mediagoblin/decorators.py b/mediagoblin/decorators.py index ce26e46c..1fdb78d7 100644 --- a/mediagoblin/decorators.py +++ b/mediagoblin/decorators.py @@ -22,10 +22,11 @@ from werkzeug.exceptions import Forbidden, NotFound from mediagoblin import mg_globals as mgg from mediagoblin import messages from mediagoblin.db.models import MediaEntry, User -from mediagoblin.tools.request import decode_authorization_header from mediagoblin.tools.response import json_response, redirect, render_404 from mediagoblin.tools.translate import pass_to_ugettext as _ +from mediagoblin.federation.tools.request import decode_authorization_header +from mediagoblin.federation.oauth import GMGRequestValidator def require_active_login(controller): """ @@ -282,3 +283,4 @@ def oauth_requeired(controller): return json_response({"error": error}, status=400) + diff --git a/mediagoblin/federation/exceptions.py b/mediagoblin/federation/exceptions.py new file mode 100644 index 00000000..5eccba34 --- /dev/null +++ b/mediagoblin/federation/exceptions.py @@ -0,0 +1,18 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +class ValidationException(Exception): + pass diff --git a/mediagoblin/federation/oauth.py b/mediagoblin/federation/oauth.py new file mode 100644 index 00000000..c94b0a9d --- /dev/null +++ b/mediagoblin/federation/oauth.py @@ -0,0 +1,80 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from oauthlib.common import Request +from oauthlib.oauth1 import (AuthorizationEndpoint, RequestValidator, + RequestTokenEndpoint, AccessTokenEndpoint) + +from mediagoblin.db.models import Client, RequestToken, AccessToken + + + +class GMGRequestValidator(RequestValidator): + + def __init__(self, data=None): + self.POST = data + + def save_request_token(self, token, request): + """ Saves request token in db """ + client_id = self.POST[u"oauth_consumer_key"] + + request_token = RequestToken( + token=token["oauth_token"], + secret=token["oauth_token_secret"], + ) + request_token.client = client_id + request_token.callback = token.get("oauth_callback", None) + request_token.save() + + def save_verifier(self, token, verifier, request): + """ Saves the oauth request verifier """ + request_token = RequestToken.query.filter_by(token=token).first() + request_token.verifier = verifier["oauth_verifier"] + request_token.save() + + def save_access_token(self, token, request): + """ Saves access token in db """ + access_token = AccessToken( + token=token["oauth_token"], + secret=token["oauth_token_secret"], + ) + access_token.request_token = request.oauth_token + request_token = RequestToken.query.filter_by(token=request.oauth_token).first() + access_token.user = request_token.user + access_token.save() + + def get_realms(*args, **kwargs): + """ Currently a stub - called when making AccessTokens """ + return list() + +class GMGRequest(Request): + """ + Fills in data to produce a oauth.common.Request object from a + werkzeug Request object + """ + + def __init__(self, request, *args, **kwargs): + """ + :param request: werkzeug request object + + any extra params are passed to oauthlib.common.Request object + """ + kwargs["uri"] = kwargs.get("uri", request.url) + kwargs["http_method"] = kwargs.get("http_method", request.method) + kwargs["body"] = kwargs.get("body", request.get_data()) + kwargs["headers"] = kwargs.get("headers", dict(request.headers)) + + super(GMGRequest, self).__init__(*args, **kwargs) diff --git a/mediagoblin/federation/tools/__init__.py b/mediagoblin/federation/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mediagoblin/federation/tools/request.py b/mediagoblin/federation/tools/request.py new file mode 100644 index 00000000..4f5be277 --- /dev/null +++ b/mediagoblin/federation/tools/request.py @@ -0,0 +1,27 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import re + +# Regex for parsing Authorization string +auth_header_re = re.compile('(\w+)[:=] ?"?(\w+)"?') + +def decode_authorization_header(header): + """ Decodes a HTTP Authorization Header to python dictionary """ + authorization = header.get("Authorization", "") + tokens = dict(auth_header_re.findall(authorization)) + return tokens + diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index a6dcc79b..29b5647e 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -16,21 +16,23 @@ import datetime -import oauthlib.common from oauthlib.oauth1 import (AuthorizationEndpoint, RequestValidator, RequestTokenEndpoint, AccessTokenEndpoint) from mediagoblin.decorators import require_active_login from mediagoblin.tools.translate import pass_to_ugettext from mediagoblin.meddleware.csrf import csrf_exempt -from mediagoblin.tools.request import decode_request, decode_authorization_header +from mediagoblin.tools.request import decode_request from mediagoblin.tools.response import (render_to_response, redirect, json_response, render_400, form_response) from mediagoblin.tools.crypto import random_string from mediagoblin.tools.validator import validate_email, validate_url -from mediagoblin.db.models import User, Client, RequestToken, AccessToken from mediagoblin.federation.forms import AuthorizeForm +from mediagoblin.federation.exceptions import ValidationException +from mediagoblin.federation.oauth import GMGRequestValidator, GMGRequest +from mediagoblin.federation.tools.request import decode_authorization_header +from mediagoblin.db.models import Client, RequestToken, AccessToken # possible client types client_types = ["web", "native"] # currently what pump supports @@ -175,47 +177,6 @@ def client_register(request): "expires_at": expirey, }) -class ValidationException(Exception): - pass - -class GMGRequestValidator(RequestValidator): - - def __init__(self, data=None): - self.POST = data - - def save_request_token(self, token, request): - """ Saves request token in db """ - client_id = self.POST[u"oauth_consumer_key"] - - request_token = RequestToken( - token=token["oauth_token"], - secret=token["oauth_token_secret"], - ) - request_token.client = client_id - request_token.callback = token.get("oauth_callback", None) - request_token.save() - - def save_verifier(self, token, verifier, request): - """ Saves the oauth request verifier """ - request_token = RequestToken.query.filter_by(token=token).first() - request_token.verifier = verifier["oauth_verifier"] - request_token.save() - - def save_access_token(self, token, request): - """ Saves access token in db """ - access_token = AccessToken( - token=token["oauth_token"], - secret=token["oauth_token_secret"], - ) - access_token.request_token = request.oauth_token - request_token = RequestToken.query.filter_by(token=request.oauth_token).first() - access_token.user = request_token.user - access_token.save() - - def get_realms(*args, **kwargs): - """ Currently a stub - called when making AccessTokens """ - return list() - @csrf_exempt def request_token(request): """ Returns request token """ @@ -288,12 +249,7 @@ def authorize(request): return authorize_finish(request) if oauth_request.verifier is None: - orequest = oauthlib.common.Request( - uri=request.url, - http_method=request.method, - body=request.get_data(), - headers=request.headers - ) + orequest = GMGRequest(request) request_validator = GMGRequestValidator() auth_endpoint = AuthorizationEndpoint(request_validator) verifier = auth_endpoint.create_verifier(orequest, {}) diff --git a/mediagoblin/tools/request.py b/mediagoblin/tools/request.py index 0c0fc557..d4739039 100644 --- a/mediagoblin/tools/request.py +++ b/mediagoblin/tools/request.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import re import json import logging from mediagoblin.db.models import User @@ -26,8 +25,6 @@ _log = logging.getLogger(__name__) form_encoded = "application/x-www-form-urlencoded" json_encoded = "application/json" -# Regex for Authorization header -auth_header_re = re.compile('(\w+)[:=] ?"?(\w+)"?') def setup_user_in_request(request): """ @@ -57,9 +54,3 @@ def decode_request(request): else: data = "" return data - -def decode_authorization_header(header): - """ Decodes a HTTP Authorization Header to python dictionary """ - authorization = header.get("Authorization", "") - tokens = dict(auth_header_re.findall(authorization)) - return tokens From 1e2675b0c0ee2bf35705b538ec94978fe4f005d4 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Thu, 11 Jul 2013 20:24:20 +0100 Subject: [PATCH 12/27] Adds the decorator --- mediagoblin/decorators.py | 20 +++++++++++++++++--- mediagoblin/federation/oauth.py | 2 ++ mediagoblin/federation/routing.py | 6 ++++++ mediagoblin/federation/views.py | 8 ++++++-- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/mediagoblin/decorators.py b/mediagoblin/decorators.py index 1fdb78d7..ad36f376 100644 --- a/mediagoblin/decorators.py +++ b/mediagoblin/decorators.py @@ -18,6 +18,7 @@ from functools import wraps from urlparse import urljoin from werkzeug.exceptions import Forbidden, NotFound +from oauthlib.oauth1 import ResourceEndpoint from mediagoblin import mg_globals as mgg from mediagoblin import messages @@ -271,7 +272,7 @@ def auth_enabled(controller): return wrapper -def oauth_requeired(controller): +def oauth_required(controller): """ Used to wrap API endpoints where oauth is required """ @wraps(controller) def wrapper(request, *args, **kwargs): @@ -282,5 +283,18 @@ def oauth_requeired(controller): error = "Missing required parameter." return json_response({"error": error}, status=400) - - + + request_validator = GMGRequestValidator() + resource_endpoint = ResourceEndpoint(request_validator) + valid, request = resource_endpoint.validate_protected_resource_request( + uri=request.url, + http_method=request.method, + body=request.get_data(), + headers=dict(request.headers), + ) + #print "[VALID] %s" % valid + #print "[REQUEST] %s" % request + + return controller(request, *args, **kwargs) + + return wrapper diff --git a/mediagoblin/federation/oauth.py b/mediagoblin/federation/oauth.py index c94b0a9d..ff45882d 100644 --- a/mediagoblin/federation/oauth.py +++ b/mediagoblin/federation/oauth.py @@ -24,6 +24,8 @@ from mediagoblin.db.models import Client, RequestToken, AccessToken class GMGRequestValidator(RequestValidator): + enforce_ssl = False + def __init__(self, data=None): self.POST = data diff --git a/mediagoblin/federation/routing.py b/mediagoblin/federation/routing.py index f7e6f72c..5dc71456 100644 --- a/mediagoblin/federation/routing.py +++ b/mediagoblin/federation/routing.py @@ -41,3 +41,9 @@ add_route( "/oauth/access_token", "mediagoblin.federation.views:access_token" ) + +add_route( + "mediagoblin.federation", + "/api/test", + "mediagoblin.federation.views:test" + ) diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index 29b5647e..c538f4cb 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -19,7 +19,7 @@ import datetime from oauthlib.oauth1 import (AuthorizationEndpoint, RequestValidator, RequestTokenEndpoint, AccessTokenEndpoint) -from mediagoblin.decorators import require_active_login +from mediagoblin.decorators import require_active_login, oauth_required from mediagoblin.tools.translate import pass_to_ugettext from mediagoblin.meddleware.csrf import csrf_exempt from mediagoblin.tools.request import decode_request @@ -337,4 +337,8 @@ def access_token(request): av = AccessTokenEndpoint(request_validator) tokens = av.create_access_token(request, {}) return form_response(tokens) - + +@csrf_exempt +@oauth_required +def test(request): + return json_response({"check":"OK"}) From 49a47ec991152a5dd25a7460e1d3d11afb73d32d Mon Sep 17 00:00:00 2001 From: xray7224 Date: Thu, 11 Jul 2013 20:55:08 +0100 Subject: [PATCH 13/27] Ensures endpoint queries with @oauth_required are validated --- mediagoblin/decorators.py | 6 +++-- mediagoblin/federation/oauth.py | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/mediagoblin/decorators.py b/mediagoblin/decorators.py index ad36f376..bb2ba7a5 100644 --- a/mediagoblin/decorators.py +++ b/mediagoblin/decorators.py @@ -292,8 +292,10 @@ def oauth_required(controller): body=request.get_data(), headers=dict(request.headers), ) - #print "[VALID] %s" % valid - #print "[REQUEST] %s" % request + + if not valid: + error = "Invalid oauth prarameter." + return json_response({"error": error}, status=400) return controller(request, *args, **kwargs) diff --git a/mediagoblin/federation/oauth.py b/mediagoblin/federation/oauth.py index ff45882d..846b0794 100644 --- a/mediagoblin/federation/oauth.py +++ b/mediagoblin/federation/oauth.py @@ -62,6 +62,51 @@ class GMGRequestValidator(RequestValidator): """ Currently a stub - called when making AccessTokens """ return list() + def validate_timestamp_and_nonce(self, client_key, timestamp, + nonce, request, request_token=None, + access_token=None): + return True # TODO!!! - SECURITY RISK IF NOT DONE + + def validate_client_key(self, client_key, request): + """ Verifies client exists with id of client_key """ + client = Client.query.filter_by(id=client_key).first() + if client is None: + return False + + return True + + def validate_access_token(self, client_key, token, request): + """ Verifies token exists for client with id of client_key """ + client = Client.query.filter_by(id=client_key).first() + token = AccessToken.query.filter_by(token=token) + token = token.first() + + if token is None: + return False + + request_token = RequestToken.query.filter_by(token=token.request_token) + request_token = request_token.first() + + if client.id != request_token.client: + return False + + return True + + def validate_realms(self, *args, **kwargs): + """ Would validate reals however not using these yet. """ + return True # implement when realms are implemented + + + def get_client_secret(self, client_key, request): + """ Retrives a client secret with from a client with an id of client_key """ + client = Client.query.filter_by(id=client_key).first() + return client.secret + + def get_access_token_secret(self, client_key, token, request): + client = Client.query.filter_by(id=client_key).first() + access_token = AccessToken.query.filter_by(token=token).first() + return access_token.secret + class GMGRequest(Request): """ Fills in data to produce a oauth.common.Request object from a From cfe7054c13880657fdcb95068a734554ff847cea Mon Sep 17 00:00:00 2001 From: xray7224 Date: Sun, 14 Jul 2013 16:24:04 +0100 Subject: [PATCH 14/27] Using nonce now, preventing OAuth replay attacks --- mediagoblin/db/models.py | 14 ++++++++++++-- mediagoblin/federation/oauth.py | 9 +++++++-- mediagoblin/federation/views.py | 10 +++++++++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index b6ae533e..74dea44e 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -161,6 +161,16 @@ class AccessToken(Base): updated = Column(DateTime, nullable=False, default=datetime.datetime.now) +class NonceTimestamp(Base): + """ + A place the timestamp and nonce can be stored - this is for OAuth1 + """ + __tablename__ = "core__nonce_timestamps" + + nonce = Column(Unicode, nullable=False, primary_key=True) + timestamp = Column(DateTime, nullable=False, primary_key=True) + + class MediaEntry(Base, MediaEntryMixin): """ TODO: Consider fetching the media_files using join @@ -636,8 +646,8 @@ with_polymorphic( [ProcessingNotification, CommentNotification]) MODELS = [ - User, Client, RequestToken, AccessToken, MediaEntry, Tag, MediaTag, - MediaComment, Collection, CollectionItem, MediaFile, FileKeynames, + User, Client, RequestToken, AccessToken, NonceTimestamp, MediaEntry, Tag, + MediaTag, MediaComment, Collection, CollectionItem, MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData, Notification, CommentNotification, ProcessingNotification, CommentSubscription] diff --git a/mediagoblin/federation/oauth.py b/mediagoblin/federation/oauth.py index 846b0794..ea0fea2c 100644 --- a/mediagoblin/federation/oauth.py +++ b/mediagoblin/federation/oauth.py @@ -18,7 +18,7 @@ from oauthlib.common import Request from oauthlib.oauth1 import (AuthorizationEndpoint, RequestValidator, RequestTokenEndpoint, AccessTokenEndpoint) -from mediagoblin.db.models import Client, RequestToken, AccessToken +from mediagoblin.db.models import NonceTimestamp, Client, RequestToken, AccessToken @@ -65,7 +65,12 @@ class GMGRequestValidator(RequestValidator): def validate_timestamp_and_nonce(self, client_key, timestamp, nonce, request, request_token=None, access_token=None): - return True # TODO!!! - SECURITY RISK IF NOT DONE + nc = NonceTimestamp.query.filter_by(timestamp=timestamp, nonce=nonce) + nc = nc.first() + if nc is None: + return True + + return False def validate_client_key(self, client_key, request): """ Verifies client exists with id of client_key """ diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index c538f4cb..aae9d55a 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -32,7 +32,7 @@ from mediagoblin.federation.forms import AuthorizeForm from mediagoblin.federation.exceptions import ValidationException from mediagoblin.federation.oauth import GMGRequestValidator, GMGRequest from mediagoblin.federation.tools.request import decode_authorization_header -from mediagoblin.db.models import Client, RequestToken, AccessToken +from mediagoblin.db.models import NonceTimestamp, Client, RequestToken, AccessToken # possible client types client_types = ["web", "native"] # currently what pump supports @@ -215,6 +215,14 @@ def request_token(request): rv = RequestTokenEndpoint(request_validator) tokens = rv.create_request_token(request, authorization) + # store the nonce & timestamp before we return back + nonce = authorization[u"oauth_nonce"] + timestamp = authorization[u"oauth_timestamp"] + timestamp = datetime.datetime.fromtimestamp(int(timestamp)) + + nc = NonceTimestamp(nonce=nonce, timestamp=timestamp) + nc.save() + return form_response(tokens) class WTFormData(dict): From 1c694fbec5daf567a8ec49baf4df2abfa408442a Mon Sep 17 00:00:00 2001 From: xray7224 Date: Sun, 14 Jul 2013 19:00:52 +0100 Subject: [PATCH 15/27] Fixes tests --- mediagoblin/federation/routing.py | 6 ------ mediagoblin/federation/views.py | 4 ---- mediagoblin/plugins/api/views.py | 2 +- mediagoblin/plugins/oauth/__init__.py | 12 ++++++------ mediagoblin/plugins/oauth/tools.py | 2 +- mediagoblin/plugins/oauth/views.py | 3 +-- mediagoblin/tests/test_http_callback.py | 4 ++-- mediagoblin/tests/{test_oauth.py => test_oauth2.py} | 12 ++++++------ 8 files changed, 17 insertions(+), 28 deletions(-) rename mediagoblin/tests/{test_oauth.py => test_oauth2.py} (95%) diff --git a/mediagoblin/federation/routing.py b/mediagoblin/federation/routing.py index 5dc71456..bc3a7a7e 100644 --- a/mediagoblin/federation/routing.py +++ b/mediagoblin/federation/routing.py @@ -23,7 +23,6 @@ add_route( "mediagoblin.federation.views:client_register" ) - add_route( "mediagoblin.federation", "/oauth/request_token", @@ -42,8 +41,3 @@ add_route( "mediagoblin.federation.views:access_token" ) -add_route( - "mediagoblin.federation", - "/api/test", - "mediagoblin.federation.views:test" - ) diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index aae9d55a..94eb9886 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -346,7 +346,3 @@ def access_token(request): tokens = av.create_access_token(request, {}) return form_response(tokens) -@csrf_exempt -@oauth_required -def test(request): - return json_response({"check":"OK"}) diff --git a/mediagoblin/plugins/api/views.py b/mediagoblin/plugins/api/views.py index 738ea25f..b7e74799 100644 --- a/mediagoblin/plugins/api/views.py +++ b/mediagoblin/plugins/api/views.py @@ -21,7 +21,7 @@ from os.path import splitext from werkzeug.exceptions import BadRequest, Forbidden from werkzeug.wrappers import Response -from mediagoblin.tools.json import json_response +from mediagoblin.tools.response import json_response from mediagoblin.decorators import require_active_login from mediagoblin.meddleware.csrf import csrf_exempt from mediagoblin.media_types import sniff_media diff --git a/mediagoblin/plugins/oauth/__init__.py b/mediagoblin/plugins/oauth/__init__.py index 5762379d..82c1f380 100644 --- a/mediagoblin/plugins/oauth/__init__.py +++ b/mediagoblin/plugins/oauth/__init__.py @@ -35,22 +35,22 @@ def setup_plugin(): routes = [ ('mediagoblin.plugins.oauth.authorize', - '/oauth/authorize', + '/oauth-2/authorize', 'mediagoblin.plugins.oauth.views:authorize'), ('mediagoblin.plugins.oauth.authorize_client', - '/oauth/client/authorize', + '/oauth-2/client/authorize', 'mediagoblin.plugins.oauth.views:authorize_client'), ('mediagoblin.plugins.oauth.access_token', - '/oauth/access_token', + '/oauth-2/access_token', 'mediagoblin.plugins.oauth.views:access_token'), ('mediagoblin.plugins.oauth.list_connections', - '/oauth/client/connections', + '/oauth-2/client/connections', 'mediagoblin.plugins.oauth.views:list_connections'), ('mediagoblin.plugins.oauth.register_client', - '/oauth/client/register', + '/oauth-2/client/register', 'mediagoblin.plugins.oauth.views:register_client'), ('mediagoblin.plugins.oauth.list_clients', - '/oauth/client/list', + '/oauth-2/client/list', 'mediagoblin.plugins.oauth.views:list_clients')] pluginapi.register_routes(routes) diff --git a/mediagoblin/plugins/oauth/tools.py b/mediagoblin/plugins/oauth/tools.py index 1e0fc6ef..af0a3305 100644 --- a/mediagoblin/plugins/oauth/tools.py +++ b/mediagoblin/plugins/oauth/tools.py @@ -23,7 +23,7 @@ from datetime import datetime from functools import wraps -from mediagoblin.tools.json import json_response +from mediagoblin.tools.response import json_response def require_client_auth(controller): diff --git a/mediagoblin/plugins/oauth/views.py b/mediagoblin/plugins/oauth/views.py index a5d66111..de637d6b 100644 --- a/mediagoblin/plugins/oauth/views.py +++ b/mediagoblin/plugins/oauth/views.py @@ -21,8 +21,7 @@ from urllib import urlencode from werkzeug.exceptions import BadRequest -from mediagoblin.tools.response import render_to_response, redirect -from mediagoblin.tools.json import json_response +from mediagoblin.tools.response import render_to_response, redirect, json_response from mediagoblin.decorators import require_active_login from mediagoblin.messages import add_message, SUCCESS from mediagoblin.tools.translate import pass_to_ugettext as _ diff --git a/mediagoblin/tests/test_http_callback.py b/mediagoblin/tests/test_http_callback.py index a0511af7..64b7ee8f 100644 --- a/mediagoblin/tests/test_http_callback.py +++ b/mediagoblin/tests/test_http_callback.py @@ -23,7 +23,7 @@ from mediagoblin import mg_globals from mediagoblin.tools import processing from mediagoblin.tests.tools import fixture_add_user from mediagoblin.tests.test_submission import GOOD_PNG -from mediagoblin.tests import test_oauth as oauth +from mediagoblin.tests import test_oauth2 as oauth class TestHTTPCallback(object): @@ -44,7 +44,7 @@ class TestHTTPCallback(object): 'password': self.user_password}) def get_access_token(self, client_id, client_secret, code): - response = self.test_app.get('/oauth/access_token', { + response = self.test_app.get('/oauth-2/access_token', { 'code': code, 'client_id': client_id, 'client_secret': client_secret}) diff --git a/mediagoblin/tests/test_oauth.py b/mediagoblin/tests/test_oauth2.py similarity index 95% rename from mediagoblin/tests/test_oauth.py rename to mediagoblin/tests/test_oauth2.py index ea3bd798..86f9e8cc 100644 --- a/mediagoblin/tests/test_oauth.py +++ b/mediagoblin/tests/test_oauth2.py @@ -51,7 +51,7 @@ class TestOAuth(object): def register_client(self, name, client_type, description=None, redirect_uri=''): return self.test_app.post( - '/oauth/client/register', { + '/oauth-2/client/register', { 'name': name, 'description': description, 'type': client_type, @@ -115,7 +115,7 @@ class TestOAuth(object): client_identifier = client.identifier redirect_uri = 'https://foo.example' - response = self.test_app.get('/oauth/authorize', { + response = self.test_app.get('/oauth-2/authorize', { 'client_id': client.identifier, 'scope': 'all', 'redirect_uri': redirect_uri}) @@ -129,7 +129,7 @@ class TestOAuth(object): # Short for client authorization post reponse capr = self.test_app.post( - '/oauth/client/authorize', { + '/oauth-2/client/authorize', { 'client_id': form.client_id.data, 'allow': 'Allow', 'next': form.next.data}) @@ -155,7 +155,7 @@ class TestOAuth(object): client = self.db.OAuthClient.query.filter( self.db.OAuthClient.identifier == unicode(client_id)).first() - token_res = self.test_app.get('/oauth/access_token?client_id={0}&\ + token_res = self.test_app.get('/oauth-2/access_token?client_id={0}&\ code={1}&client_secret={2}'.format(client_id, code, client.secret)) assert token_res.status_int == 200 @@ -183,7 +183,7 @@ code={1}&client_secret={2}'.format(client_id, code, client.secret)) client = self.db.OAuthClient.query.filter( self.db.OAuthClient.identifier == unicode(client_id)).first() - token_res = self.test_app.get('/oauth/access_token?\ + token_res = self.test_app.get('/oauth-2/access_token?\ code={0}&client_secret={1}'.format(code, client.secret)) assert token_res.status_int == 200 @@ -204,7 +204,7 @@ code={0}&client_secret={1}'.format(code, client.secret)) client = self.db.OAuthClient.query.filter( self.db.OAuthClient.identifier == client_id).first() - token_res = self.test_app.get('/oauth/access_token', + token_res = self.test_app.get('/oauth-2/access_token', {'refresh_token': token_data['refresh_token'], 'client_id': client_id, 'client_secret': client.secret From 86ba41688332e3f71779f76c486889a7a099fa91 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Tue, 16 Jul 2013 19:19:49 +0100 Subject: [PATCH 16/27] Adds some tests for the OAuth and some docs --- docs/source/api/oauth.rst | 36 +++++++++ mediagoblin/federation/views.py | 19 ++--- mediagoblin/tests/test_oauth1.py | 122 +++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 docs/source/api/oauth.rst create mode 100644 mediagoblin/tests/test_oauth1.py diff --git a/docs/source/api/oauth.rst b/docs/source/api/oauth.rst new file mode 100644 index 00000000..003ad492 --- /dev/null +++ b/docs/source/api/oauth.rst @@ -0,0 +1,36 @@ +.. MediaGoblin Documentation + + Written in 2011, 2012 by MediaGoblin contributors + + To the extent possible under law, the author(s) have dedicated all + copyright and related and neighboring rights to this software to + the public domain worldwide. This software is distributed without + any warranty. + + You should have received a copy of the CC0 Public Domain + Dedication along with this software. If not, see + . + +============== +Authentication +============== + +GNU MediaGoblin uses OAuth1 to authenticate requests to the API. There are many +libraries out there for OAuth1, you're likely not going to have to do much. There +is a library for the GNU MediaGoblin called `PyPump `_. +We are not using OAuth2 as we want to stay completely compatable with GNU MediaGoblin. + + +We use :doc:`client_register` to get the client ID and secret. + +Endpoints +--------- + +These are the endpoints you need to use for the oauth requests: + +`/oauth/request_token` is for getting the request token. + +`/oauth/authorize` is to send the user to to authorize your application. + +`/oauth/access_token` is for getting the access token to use in requests. + diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index 94eb9886..7eb9f148 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -108,13 +108,14 @@ def client_register(request): client_secret = random_string(43) # again, seems to be what pump uses expirey = 0 # for now, lets not have it expire expirey_db = None if expirey == 0 else expirey - + application_type = data["application_type"] + # save it client = Client( id=client_id, secret=client_secret, expirey=expirey_db, - application_type=data["application_type"], + application_type=application_type, ) else: @@ -133,7 +134,7 @@ def client_register(request): client.application_name = data.get("application_name", None) - contacts = data.get("contact", None) + contacts = data.get("contacts", None) if contacts is not None: if type(contacts) is not unicode: error = "Contacts must be a string of space-seporated email addresses." @@ -149,21 +150,21 @@ def client_register(request): client.contacts = contacts - request_uri = data.get("request_uris", None) - if request_uri is not None: - if type(request_uri) is not unicode: + redirect_uris = data.get("redirect_uris", None) + if redirect_uris is not None: + if type(redirect_uris) is not unicode: error = "redirect_uris must be space-seporated URLs." return json_respinse({"error": error}, status=400) - request_uri = request_uri.split() + redirect_uris = redirect_uris.split() - for uri in request_uri: + for uri in redirect_uris: if not validate_url(uri): # not a valid uri error = "URI {0} is not a valid URI".format(uri) return json_response({"error": error}, status=400) - client.request_uri = request_uri + client.redirect_uri = redirect_uris client.save() diff --git a/mediagoblin/tests/test_oauth1.py b/mediagoblin/tests/test_oauth1.py new file mode 100644 index 00000000..f3b44850 --- /dev/null +++ b/mediagoblin/tests/test_oauth1.py @@ -0,0 +1,122 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json + +import pytest +from urlparse import parse_qs, urlparse + +from mediagoblin import mg_globals +from mediagoblin.tools import template, pluginapi +from mediagoblin.tests.tools import fixture_add_user + + +class TestOAuth(object): + @pytest.fixture(autouse=True) + def setup(self, test_app): + self.test_app = test_app + + self.db = mg_globals.database + + self.pman = pluginapi.PluginManager() + + self.user_password = "AUserPassword123" + self.user = fixture_add_user("OAuthy", self.user_password) + + self.login() + + def login(self): + self.test_app.post( + "/auth/login/", { + "username": self.user.username, + "password": self.user_password}) + + def register_client(self, **kwargs): + """ Regiters a client with the API """ + + kwargs["type"] = "client_associate" + kwargs["application_type"] = kwargs.get("application_type", "native") + return self.test_app.post("/api/client/register", kwargs) + + def test_client_client_register_limited_info(self): + """ Tests that a client can be registered with limited information """ + response = self.register_client() + client_info = json.loads(response.body) + + client = self.db.Client.query.filter_by(id=client_info["client_id"]).first() + + assert response.status_int == 200 + assert client is not None + + def test_client_register_full_info(self): + """ Provides every piece of information possible to register client """ + query = { + "application_name": "Testificate MD", + "application_type": "web", + "contacts": "someone@someplace.com tuteo@tsengeo.lu", + "logo_url": "http://ayrel.com/utral.png", + "redirect_uris": "http://navi-kosman.lu http://gmg-yawne-oeru.lu", + } + + response = self.register_client(**query) + client_info = json.loads(response.body) + + client = self.db.Client.query.filter_by(id=client_info["client_id"]).first() + + assert client is not None + assert client.secret == client_info["client_secret"] + assert client.application_type == query["application_type"] + assert client.redirect_uri == query["redirect_uris"].split() + assert client.logo_url == query["logo_url"] + assert client.contacts == query["contacts"].split() + + + def test_client_update(self): + """ Tests that you can update a client """ + # first we need to register a client + response = self.register_client() + + client_info = json.loads(response.body) + client = self.db.Client.query.filter_by(id=client_info["client_id"]).first() + + # Now update + update_query = { + "type": "client_update", + "application_name": "neytiri", + "contacts": "someone@someplace.com abc@cba.com", + "logo_url": "http://place.com/picture.png", + "application_type": "web", + "redirect_uris": "http://blah.gmg/whatever https://inboxen.org/", + } + + update_response = self.register_client(**update_query) + + assert update_response.status_int == 200 + client_info = json.loads(update_response.body) + client = self.Client.query.filter_by(id=client_info["client_id"]).first() + + assert client.secret == client_info["client_secret"] + assert client.application_type == update_query["application_type"] + assert client.application_name == update_query["application_name"] + assert client.contacts == update_query["contacts"].split() + assert client.logo_url == update_query["logo_url"] + assert client.redirect_uri == update_query["redirect_uris"].split() + + def request_token(self): + """ Test a request for a request token """ + response = self.register_client() + + From 89d5b44e0aee5845f816a89a9f8b3364940daea3 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Thu, 18 Jul 2013 19:15:05 +0100 Subject: [PATCH 17/27] Adds test for request_tokens --- mediagoblin/federation/oauth.py | 6 ++- mediagoblin/federation/tools/request.py | 19 +++++--- mediagoblin/federation/views.py | 8 ++-- mediagoblin/tests/test_oauth1.py | 58 ++++++++++++++++++++++--- 4 files changed, 71 insertions(+), 20 deletions(-) diff --git a/mediagoblin/federation/oauth.py b/mediagoblin/federation/oauth.py index ea0fea2c..764b8535 100644 --- a/mediagoblin/federation/oauth.py +++ b/mediagoblin/federation/oauth.py @@ -26,8 +26,9 @@ class GMGRequestValidator(RequestValidator): enforce_ssl = False - def __init__(self, data=None): + def __init__(self, data=None, *args, **kwargs): self.POST = data + super(GMGRequestValidator, self).__init__(*args, **kwargs) def save_request_token(self, token, request): """ Saves request token in db """ @@ -38,7 +39,8 @@ class GMGRequestValidator(RequestValidator): secret=token["oauth_token_secret"], ) request_token.client = client_id - request_token.callback = token.get("oauth_callback", None) + if u"oauth_callback" in self.POST: + request_token.callback = self.POST[u"oauth_callback"] request_token.save() def save_verifier(self, token, verifier, request): diff --git a/mediagoblin/federation/tools/request.py b/mediagoblin/federation/tools/request.py index 4f5be277..6e484bb6 100644 --- a/mediagoblin/federation/tools/request.py +++ b/mediagoblin/federation/tools/request.py @@ -14,14 +14,19 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import re - -# Regex for parsing Authorization string -auth_header_re = re.compile('(\w+)[:=] ?"?(\w+)"?') - def decode_authorization_header(header): """ Decodes a HTTP Authorization Header to python dictionary """ - authorization = header.get("Authorization", "") - tokens = dict(auth_header_re.findall(authorization)) + authorization = header.get("Authorization", "").lstrip(" ").lstrip("OAuth") + tokens = {} + + for param in authorization.split(","): + key, value = param.split("=") + + key = key.lstrip(" ") + value = value.lstrip(" ").lstrip('"') + value = value.rstrip(" ").rstrip('"') + + tokens[key] = value + return tokens diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index 7eb9f148..633a19d4 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -198,7 +198,6 @@ def request_token(request): authorization = decode_authorization_header(data) - if authorization == dict() or u"oauth_consumer_key" not in authorization: error = "Missing required parameter." return json_response({"error": error}, status=400) @@ -206,12 +205,13 @@ def request_token(request): # check the client_id client_id = authorization[u"oauth_consumer_key"] client = Client.query.filter_by(id=client_id).first() - if client is None: + + if client == None: # client_id is invalid error = "Invalid client_id" return json_response({"error": error}, status=400) - # make request token and return to client + # make request token and return to client request_validator = GMGRequestValidator(authorization) rv = RequestTokenEndpoint(request_validator) tokens = rv.create_request_token(request, authorization) @@ -219,7 +219,7 @@ def request_token(request): # store the nonce & timestamp before we return back nonce = authorization[u"oauth_nonce"] timestamp = authorization[u"oauth_timestamp"] - timestamp = datetime.datetime.fromtimestamp(int(timestamp)) + timestamp = datetime.datetime.fromtimestamp(float(timestamp)) nc = NonceTimestamp(nonce=nonce, timestamp=timestamp) nc.save() diff --git a/mediagoblin/tests/test_oauth1.py b/mediagoblin/tests/test_oauth1.py index f3b44850..073c2884 100644 --- a/mediagoblin/tests/test_oauth1.py +++ b/mediagoblin/tests/test_oauth1.py @@ -14,17 +14,23 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json +import cgi import pytest from urlparse import parse_qs, urlparse +from oauthlib.oauth1 import Client + from mediagoblin import mg_globals from mediagoblin.tools import template, pluginapi from mediagoblin.tests.tools import fixture_add_user class TestOAuth(object): + + MIME_FORM = "application/x-www-form-urlencoded" + MIME_JSON = "application/json" + @pytest.fixture(autouse=True) def setup(self, test_app): self.test_app = test_app @@ -54,7 +60,7 @@ class TestOAuth(object): def test_client_client_register_limited_info(self): """ Tests that a client can be registered with limited information """ response = self.register_client() - client_info = json.loads(response.body) + client_info = response.json client = self.db.Client.query.filter_by(id=client_info["client_id"]).first() @@ -72,7 +78,7 @@ class TestOAuth(object): } response = self.register_client(**query) - client_info = json.loads(response.body) + client_info = response.json client = self.db.Client.query.filter_by(id=client_info["client_id"]).first() @@ -89,7 +95,7 @@ class TestOAuth(object): # first we need to register a client response = self.register_client() - client_info = json.loads(response.body) + client_info = response.json client = self.db.Client.query.filter_by(id=client_info["client_id"]).first() # Now update @@ -105,8 +111,8 @@ class TestOAuth(object): update_response = self.register_client(**update_query) assert update_response.status_int == 200 - client_info = json.loads(update_response.body) - client = self.Client.query.filter_by(id=client_info["client_id"]).first() + client_info = update_response.json + client = self.db.Client.query.filter_by(id=client_info["client_id"]).first() assert client.secret == client_info["client_secret"] assert client.application_type == update_query["application_type"] @@ -115,8 +121,46 @@ class TestOAuth(object): assert client.logo_url == update_query["logo_url"] assert client.redirect_uri == update_query["redirect_uris"].split() - def request_token(self): + def to_authorize_headers(self, data): + headers = "" + for key, value in data.items(): + headers += '{0}="{1}",'.format(key, value) + return {"Authorization": "OAuth " + headers[:-1]} + + def test_request_token(self): """ Test a request for a request token """ response = self.register_client() + client_id = response.json["client_id"] + + endpoint = "/oauth/request_token" + request_query = { + "oauth_consumer_key": client_id, + "oauth_nonce": "abcdefghij", + "oauth_timestamp": 123456789.0, + "oauth_callback": "https://some.url/callback", + } + + headers = self.to_authorize_headers(request_query) + + headers["Content-Type"] = self.MIME_FORM + + response = self.test_app.post(endpoint, headers=headers) + response = cgi.parse_qs(response.body) + + # each element is a list, reduce it to a string + for key, value in response.items(): + response[key] = value[0] + + request_token = self.db.RequestToken.query.filter_by( + token=response["oauth_token"] + ).first() + + client = self.db.Client.query.filter_by(id=client_id).first() + + assert request_token is not None + assert request_token.secret == response["oauth_token_secret"] + assert request_token.client == client.id + assert request_token.used == False + assert request_token.callback == request_query["oauth_callback"] From 8ddd7769de7a90f71d8dd3e0cc2c491e51d76d47 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Thu, 18 Jul 2013 20:21:35 +0100 Subject: [PATCH 18/27] Adds migration for OAuth1 tables --- mediagoblin/db/migrations.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index fe4ffb3e..4673e0ce 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -26,7 +26,9 @@ from sqlalchemy.sql import and_ from migrate.changeset.constraint import UniqueConstraint from mediagoblin.db.migration_tools import RegisterMigration, inspect_table -from mediagoblin.db.models import MediaEntry, Collection, User, MediaComment +from mediagoblin.db.models import (MediaEntry, Collection, User, MediaComment, + Client, RequestToken, AccessToken, + NonceTimestamp) MIGRATIONS = {} @@ -379,3 +381,15 @@ def pw_hash_nullable(db): constraint.create() db.commit() + + +@RegisterMigration(14, MIGRATIONS) +def create_oauth1_tables(db): + """ Creates the OAuth1 tables """ + + Client.__table__.create(db.bind) + RequestToken.__table__.create(db.bind) + AccessToken.__table__.create(db.bind) + NonceTimestamp.__table__.create(db.bind) + + db.commit() From 7271b062821ab012a774e813e61a35401f3ed7d7 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Thu, 18 Jul 2013 20:39:15 +0100 Subject: [PATCH 19/27] Moves first versions of the the models to migrations --- mediagoblin/db/migrations.py | 79 ++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index 4673e0ce..015dbff0 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -26,9 +26,7 @@ from sqlalchemy.sql import and_ from migrate.changeset.constraint import UniqueConstraint from mediagoblin.db.migration_tools import RegisterMigration, inspect_table -from mediagoblin.db.models import (MediaEntry, Collection, User, MediaComment, - Client, RequestToken, AccessToken, - NonceTimestamp) +from mediagoblin.db.models import MediaEntry, Collection, User, MediaComment MIGRATIONS = {} @@ -383,13 +381,80 @@ def pw_hash_nullable(db): db.commit() +# oauth1 migrations +class Client_v0(Base): + """ + Model representing a client - Used for API Auth + """ + __tablename__ = "core__clients" + + id = Column(Unicode, nullable=True, primary_key=True) + secret = Column(Unicode, nullable=False) + expirey = Column(DateTime, nullable=True) + application_type = Column(Unicode, nullable=False) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + updated = Column(DateTime, nullable=False, default=datetime.datetime.now) + + # optional stuff + redirect_uri = Column(JSONEncoded, nullable=True) + logo_url = Column(Unicode, nullable=True) + application_name = Column(Unicode, nullable=True) + contacts = Column(JSONEncoded, nullable=True) + + def __repr__(self): + if self.application_name: + return "".format(self.application_name, self.id) + else: + return "".format(self.id) + +class RequestToken_v0(Base): + """ + Model for representing the request tokens + """ + __tablename__ = "core__request_tokens" + + token = Column(Unicode, primary_key=True) + secret = Column(Unicode, nullable=False) + client = Column(Unicode, ForeignKey(Client.id)) + user = Column(Integer, ForeignKey(User.id), nullable=True) + used = Column(Boolean, default=False) + authenticated = Column(Boolean, default=False) + verifier = Column(Unicode, nullable=True) + callback = Column(Unicode, nullable=False, default=u"oob") + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + updated = Column(DateTime, nullable=False, default=datetime.datetime.now) + +class AccessToken_v0(Base): + """ + Model for representing the access tokens + """ + __tablename__ = "core__access_tokens" + + token = Column(Unicode, nullable=False, primary_key=True) + secret = Column(Unicode, nullable=False) + user = Column(Integer, ForeignKey(User.id)) + request_token = Column(Unicode, ForeignKey(RequestToken.token)) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + updated = Column(DateTime, nullable=False, default=datetime.datetime.now) + + +class NonceTimestamp_v0(Base): + """ + A place the timestamp and nonce can be stored - this is for OAuth1 + """ + __tablename__ = "core__nonce_timestamps" + + nonce = Column(Unicode, nullable=False, primary_key=True) + timestamp = Column(DateTime, nullable=False, primary_key=True) + + @RegisterMigration(14, MIGRATIONS) def create_oauth1_tables(db): """ Creates the OAuth1 tables """ - Client.__table__.create(db.bind) - RequestToken.__table__.create(db.bind) - AccessToken.__table__.create(db.bind) - NonceTimestamp.__table__.create(db.bind) + Client_v0.__table__.create(db.bind) + RequestToken_v0.__table__.create(db.bind) + AccessToken_v0.__table__.create(db.bind) + NonceTimestamp_v0.__table__.create(db.bind) db.commit() From 617bff18301e5b51612ae9fca4022593b6ec9413 Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Sat, 20 Jul 2013 19:08:02 +0100 Subject: [PATCH 20/27] Fixes some typo's and removes unused imports --- mediagoblin/federation/views.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index 633a19d4..8c26799f 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -16,10 +16,10 @@ import datetime -from oauthlib.oauth1 import (AuthorizationEndpoint, RequestValidator, - RequestTokenEndpoint, AccessTokenEndpoint) +from oauthlib.oauth1 import (RequestTokenEndpoint, AuthorizationEndpoint, + AccessTokenEndpoint) -from mediagoblin.decorators import require_active_login, oauth_required +from mediagoblin.decorators import require_active_login from mediagoblin.tools.translate import pass_to_ugettext from mediagoblin.meddleware.csrf import csrf_exempt from mediagoblin.tools.request import decode_request @@ -29,10 +29,9 @@ from mediagoblin.tools.response import (render_to_response, redirect, from mediagoblin.tools.crypto import random_string from mediagoblin.tools.validator import validate_email, validate_url from mediagoblin.federation.forms import AuthorizeForm -from mediagoblin.federation.exceptions import ValidationException from mediagoblin.federation.oauth import GMGRequestValidator, GMGRequest from mediagoblin.federation.tools.request import decode_authorization_header -from mediagoblin.db.models import NonceTimestamp, Client, RequestToken, AccessToken +from mediagoblin.db.models import NonceTimestamp, Client, RequestToken # possible client types client_types = ["web", "native"] # currently what pump supports @@ -154,7 +153,7 @@ def client_register(request): if redirect_uris is not None: if type(redirect_uris) is not unicode: error = "redirect_uris must be space-seporated URLs." - return json_respinse({"error": error}, status=400) + return json_response({"error": error}, status=400) redirect_uris = redirect_uris.split() From 8e3bf97821b7057920286aca16c649e48f3275a1 Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Mon, 22 Jul 2013 17:17:01 +0100 Subject: [PATCH 21/27] Fix problem with migration - OAuth --- mediagoblin/db/migrations.py | 14 ++++++++------ mediagoblin/federation/forms.py | 1 - mediagoblin/federation/oauth.py | 4 +--- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index 015dbff0..374ab4c8 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -25,6 +25,8 @@ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.sql import and_ from migrate.changeset.constraint import UniqueConstraint + +from mediagoblin.db.extratypes import JSONEncoded from mediagoblin.db.migration_tools import RegisterMigration, inspect_table from mediagoblin.db.models import MediaEntry, Collection, User, MediaComment @@ -382,7 +384,7 @@ def pw_hash_nullable(db): # oauth1 migrations -class Client_v0(Base): +class Client_v0(declarative_base()): """ Model representing a client - Used for API Auth """ @@ -407,7 +409,7 @@ class Client_v0(Base): else: return "".format(self.id) -class RequestToken_v0(Base): +class RequestToken_v0(declarative_base()): """ Model for representing the request tokens """ @@ -415,7 +417,7 @@ class RequestToken_v0(Base): token = Column(Unicode, primary_key=True) secret = Column(Unicode, nullable=False) - client = Column(Unicode, ForeignKey(Client.id)) + client = Column(Unicode, ForeignKey(Client_v0.id)) user = Column(Integer, ForeignKey(User.id), nullable=True) used = Column(Boolean, default=False) authenticated = Column(Boolean, default=False) @@ -424,7 +426,7 @@ class RequestToken_v0(Base): created = Column(DateTime, nullable=False, default=datetime.datetime.now) updated = Column(DateTime, nullable=False, default=datetime.datetime.now) -class AccessToken_v0(Base): +class AccessToken_v0(declarative_base()): """ Model for representing the access tokens """ @@ -433,12 +435,12 @@ class AccessToken_v0(Base): token = Column(Unicode, nullable=False, primary_key=True) secret = Column(Unicode, nullable=False) user = Column(Integer, ForeignKey(User.id)) - request_token = Column(Unicode, ForeignKey(RequestToken.token)) + request_token = Column(Unicode, ForeignKey(RequestToken_v0.token)) created = Column(DateTime, nullable=False, default=datetime.datetime.now) updated = Column(DateTime, nullable=False, default=datetime.datetime.now) -class NonceTimestamp_v0(Base): +class NonceTimestamp_v0(declarative_base()): """ A place the timestamp and nonce can be stored - this is for OAuth1 """ diff --git a/mediagoblin/federation/forms.py b/mediagoblin/federation/forms.py index 39d6fc27..94c7cb52 100644 --- a/mediagoblin/federation/forms.py +++ b/mediagoblin/federation/forms.py @@ -1,5 +1,4 @@ import wtforms -from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ class AuthorizeForm(wtforms.Form): """ Form used to authorize the request token """ diff --git a/mediagoblin/federation/oauth.py b/mediagoblin/federation/oauth.py index 764b8535..8229c47d 100644 --- a/mediagoblin/federation/oauth.py +++ b/mediagoblin/federation/oauth.py @@ -15,8 +15,7 @@ # along with this program. If not, see . from oauthlib.common import Request -from oauthlib.oauth1 import (AuthorizationEndpoint, RequestValidator, - RequestTokenEndpoint, AccessTokenEndpoint) +from oauthlib.oauth1 import RequestValidator from mediagoblin.db.models import NonceTimestamp, Client, RequestToken, AccessToken @@ -110,7 +109,6 @@ class GMGRequestValidator(RequestValidator): return client.secret def get_access_token_secret(self, client_key, token, request): - client = Client.query.filter_by(id=client_key).first() access_token = AccessToken.query.filter_by(token=token).first() return access_token.secret From 657263abdf9b47f1598a7633c1e0039d0eb7b043 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Mon, 22 Jul 2013 16:56:22 +0100 Subject: [PATCH 22/27] Refactor WTFormData --- mediagoblin/federation/tools/forms.py | 25 +++++++++++++++++++++++++ mediagoblin/federation/views.py | 11 +---------- 2 files changed, 26 insertions(+), 10 deletions(-) create mode 100644 mediagoblin/federation/tools/forms.py diff --git a/mediagoblin/federation/tools/forms.py b/mediagoblin/federation/tools/forms.py new file mode 100644 index 00000000..e3eb3298 --- /dev/null +++ b/mediagoblin/federation/tools/forms.py @@ -0,0 +1,25 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +class WTFormData(dict): + """ + Provides a WTForm usable dictionary + """ + def getlist(self, key): + v = self[key] + if not isinstance(v, (list, tuple)): + v = [v] + return v diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index 8c26799f..5bb93a68 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -31,6 +31,7 @@ from mediagoblin.tools.validator import validate_email, validate_url from mediagoblin.federation.forms import AuthorizeForm from mediagoblin.federation.oauth import GMGRequestValidator, GMGRequest from mediagoblin.federation.tools.request import decode_authorization_header +from mediagoblin.federation.tools.forms import WTFormData from mediagoblin.db.models import NonceTimestamp, Client, RequestToken # possible client types @@ -225,16 +226,6 @@ def request_token(request): return form_response(tokens) -class WTFormData(dict): - """ - Provides a WTForm usable dictionary - """ - def getlist(self, key): - v = self[key] - if not isinstance(v, (list, tuple)): - v = [v] - return v - @require_active_login def authorize(request): """ Displays a page for user to authorize """ From 005181b1663a05e55faa56a474cf6f5d81a255c9 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Mon, 22 Jul 2013 17:06:00 +0100 Subject: [PATCH 23/27] Renames OAuth1 code to federation --- mediagoblin/{federation => oauth}/__init__.py | 0 mediagoblin/{federation => oauth}/exceptions.py | 0 mediagoblin/{federation => oauth}/forms.py | 0 mediagoblin/{federation => oauth}/oauth.py | 0 mediagoblin/{federation => oauth}/routing.py | 16 ++++++++-------- .../{federation => oauth}/tools/__init__.py | 0 mediagoblin/{federation => oauth}/tools/forms.py | 0 .../{federation => oauth}/tools/request.py | 0 mediagoblin/{federation => oauth}/views.py | 10 +++++----- 9 files changed, 13 insertions(+), 13 deletions(-) rename mediagoblin/{federation => oauth}/__init__.py (100%) rename mediagoblin/{federation => oauth}/exceptions.py (100%) rename mediagoblin/{federation => oauth}/forms.py (100%) rename mediagoblin/{federation => oauth}/oauth.py (100%) rename mediagoblin/{federation => oauth}/routing.py (75%) rename mediagoblin/{federation => oauth}/tools/__init__.py (100%) rename mediagoblin/{federation => oauth}/tools/forms.py (100%) rename mediagoblin/{federation => oauth}/tools/request.py (100%) rename mediagoblin/{federation => oauth}/views.py (97%) diff --git a/mediagoblin/federation/__init__.py b/mediagoblin/oauth/__init__.py similarity index 100% rename from mediagoblin/federation/__init__.py rename to mediagoblin/oauth/__init__.py diff --git a/mediagoblin/federation/exceptions.py b/mediagoblin/oauth/exceptions.py similarity index 100% rename from mediagoblin/federation/exceptions.py rename to mediagoblin/oauth/exceptions.py diff --git a/mediagoblin/federation/forms.py b/mediagoblin/oauth/forms.py similarity index 100% rename from mediagoblin/federation/forms.py rename to mediagoblin/oauth/forms.py diff --git a/mediagoblin/federation/oauth.py b/mediagoblin/oauth/oauth.py similarity index 100% rename from mediagoblin/federation/oauth.py rename to mediagoblin/oauth/oauth.py diff --git a/mediagoblin/federation/routing.py b/mediagoblin/oauth/routing.py similarity index 75% rename from mediagoblin/federation/routing.py rename to mediagoblin/oauth/routing.py index bc3a7a7e..e45077bb 100644 --- a/mediagoblin/federation/routing.py +++ b/mediagoblin/oauth/routing.py @@ -18,26 +18,26 @@ from mediagoblin.tools.routing import add_route # client registration & oauth add_route( - "mediagoblin.federation", + "mediagoblin.oauth", "/api/client/register", - "mediagoblin.federation.views:client_register" + "mediagoblin.oauth.views:client_register" ) add_route( - "mediagoblin.federation", + "mediagoblin.oauth", "/oauth/request_token", - "mediagoblin.federation.views:request_token" + "mediagoblin.oauth.views:request_token" ) add_route( - "mediagoblin.federation", + "mediagoblin.oauth", "/oauth/authorize", - "mediagoblin.federation.views:authorize", + "mediagoblin.oauth.views:authorize", ) add_route( - "mediagoblin.federation", + "mediagoblin.oauth", "/oauth/access_token", - "mediagoblin.federation.views:access_token" + "mediagoblin.oauth.views:access_token" ) diff --git a/mediagoblin/federation/tools/__init__.py b/mediagoblin/oauth/tools/__init__.py similarity index 100% rename from mediagoblin/federation/tools/__init__.py rename to mediagoblin/oauth/tools/__init__.py diff --git a/mediagoblin/federation/tools/forms.py b/mediagoblin/oauth/tools/forms.py similarity index 100% rename from mediagoblin/federation/tools/forms.py rename to mediagoblin/oauth/tools/forms.py diff --git a/mediagoblin/federation/tools/request.py b/mediagoblin/oauth/tools/request.py similarity index 100% rename from mediagoblin/federation/tools/request.py rename to mediagoblin/oauth/tools/request.py diff --git a/mediagoblin/federation/views.py b/mediagoblin/oauth/views.py similarity index 97% rename from mediagoblin/federation/views.py rename to mediagoblin/oauth/views.py index 5bb93a68..116eb023 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/oauth/views.py @@ -18,7 +18,7 @@ import datetime from oauthlib.oauth1 import (RequestTokenEndpoint, AuthorizationEndpoint, AccessTokenEndpoint) - + from mediagoblin.decorators import require_active_login from mediagoblin.tools.translate import pass_to_ugettext from mediagoblin.meddleware.csrf import csrf_exempt @@ -28,10 +28,10 @@ from mediagoblin.tools.response import (render_to_response, redirect, form_response) from mediagoblin.tools.crypto import random_string from mediagoblin.tools.validator import validate_email, validate_url -from mediagoblin.federation.forms import AuthorizeForm -from mediagoblin.federation.oauth import GMGRequestValidator, GMGRequest -from mediagoblin.federation.tools.request import decode_authorization_header -from mediagoblin.federation.tools.forms import WTFormData +from mediagoblin.oauth.forms import AuthorizeForm +from mediagoblin.oauth.oauth import GMGRequestValidator, GMGRequest +from mediagoblin.oauth.tools.request import decode_authorization_header +from mediagoblin.oauth.tools.forms import WTFormData from mediagoblin.db.models import NonceTimestamp, Client, RequestToken # possible client types From 0ec89cb29fbd4b1b31534e5bc66c914c381837c5 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Mon, 29 Jul 2013 17:25:10 +0100 Subject: [PATCH 24/27] Fixes problem with headers pointing to old federation dir --- mediagoblin/decorators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mediagoblin/decorators.py b/mediagoblin/decorators.py index 302ab247..685d0d98 100644 --- a/mediagoblin/decorators.py +++ b/mediagoblin/decorators.py @@ -26,8 +26,8 @@ from mediagoblin.db.models import MediaEntry, User from mediagoblin.tools.response import json_response, redirect, render_404 from mediagoblin.tools.translate import pass_to_ugettext as _ -from mediagoblin.federation.tools.request import decode_authorization_header -from mediagoblin.federation.oauth import GMGRequestValidator +from mediagoblin.oauth.tools.request import decode_authorization_header +from mediagoblin.oauth.oauth import GMGRequestValidator def require_active_login(controller): """ From 4554d6e0140fab80a3b6e0d3ac5fc015d769e152 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Mon, 29 Jul 2013 17:28:50 +0100 Subject: [PATCH 25/27] Fix problem with routing to oauth --- mediagoblin/routing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mediagoblin/routing.py b/mediagoblin/routing.py index 3a54aaa0..c2b2304d 100644 --- a/mediagoblin/routing.py +++ b/mediagoblin/routing.py @@ -36,6 +36,7 @@ def get_url_map(): import mediagoblin.webfinger.routing import mediagoblin.listings.routing import mediagoblin.notifications.routing + import mediagoblin.oauth.routing import mediagoblin.federation.routing for route in PluginManager().get_routes(): From cae55705b154c9c78380678ca340a124220c2774 Mon Sep 17 00:00:00 2001 From: xray7224 Date: Mon, 29 Jul 2013 17:48:53 +0100 Subject: [PATCH 26/27] Fix problem causing exception when invalid Authentication header provided --- mediagoblin/oauth/tools/request.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mediagoblin/oauth/tools/request.py b/mediagoblin/oauth/tools/request.py index 6e484bb6..5ce2da77 100644 --- a/mediagoblin/oauth/tools/request.py +++ b/mediagoblin/oauth/tools/request.py @@ -20,8 +20,11 @@ def decode_authorization_header(header): tokens = {} for param in authorization.split(","): - key, value = param.split("=") - + try: + key, value = param.split("=") + except ValueError: + continue + key = key.lstrip(" ") value = value.lstrip(" ").lstrip('"') value = value.rstrip(" ").rstrip('"') From 89909bd6921efb4ec3296c908c5d57eba35ebd21 Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Tue, 13 Aug 2013 10:45:09 +0100 Subject: [PATCH 27/27] Fix import errors when running tests --- mediagoblin/routing.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mediagoblin/routing.py b/mediagoblin/routing.py index c2b2304d..5961f33b 100644 --- a/mediagoblin/routing.py +++ b/mediagoblin/routing.py @@ -37,8 +37,7 @@ def get_url_map(): import mediagoblin.listings.routing import mediagoblin.notifications.routing import mediagoblin.oauth.routing - import mediagoblin.federation.routing - + for route in PluginManager().get_routes(): add_route(*route)