diff options
31 files changed, 1408 insertions, 50 deletions
diff --git a/docs/source/api/client_register.rst b/docs/source/api/client_register.rst new file mode 100644 index 00000000..4ad7908e --- /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 + <http://creativecommons.org/publicdomain/zero/1.0/>. + +==================== +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 <https://openid.net/specs/openid-connect-registration-1_0.html>`_, 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 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. + +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 <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 <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 <URI> is not a valid URI + This is when your URI is invalid. + + 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 + <http://creativecommons.org/publicdomain/zero/1.0/>. + +============== +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 <https://github.com/xray7224/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/db/migrations.py b/mediagoblin/db/migrations.py index fe4ffb3e..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 @@ -379,3 +381,82 @@ def pw_hash_nullable(db): constraint.create() db.commit() + + +# oauth1 migrations +class Client_v0(declarative_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 "<Client {0} - {1}>".format(self.application_name, self.id) + else: + return "<Client {0}>".format(self.id) + +class RequestToken_v0(declarative_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_v0.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(declarative_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_v0.token)) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + updated = Column(DateTime, nullable=False, default=datetime.datetime.now) + + +class NonceTimestamp_v0(declarative_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_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() diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index 7448f5ce..f0cbce2a 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -105,6 +105,72 @@ 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(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 "<Client {0} - {1}>".format(self.application_name, self.id) + else: + return "<Client {0}>".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=False, default=u"oob") + 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 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 @@ -580,10 +646,10 @@ with_polymorphic( [ProcessingNotification, CommentNotification]) MODELS = [ - User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, - MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData, - Notification, CommentNotification, ProcessingNotification, - CommentSubscription] + User, Client, RequestToken, AccessToken, NonceTimestamp, MediaEntry, Tag, + MediaTag, MediaComment, Collection, CollectionItem, MediaFile, FileKeynames, + MediaAttachmentFile, ProcessingMetaData, Notification, CommentNotification, + ProcessingNotification, CommentSubscription] """ Foundations are the default rows that are created immediately after the tables diff --git a/mediagoblin/decorators.py b/mediagoblin/decorators.py index ca7be53c..685d0d98 100644 --- a/mediagoblin/decorators.py +++ b/mediagoblin/decorators.py @@ -18,13 +18,16 @@ 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 from mediagoblin.db.models import MediaEntry, User -from mediagoblin.tools.response import redirect, render_404 +from mediagoblin.tools.response import json_response, redirect, render_404 from mediagoblin.tools.translate import pass_to_ugettext as _ +from mediagoblin.oauth.tools.request import decode_authorization_header +from mediagoblin.oauth.oauth import GMGRequestValidator def require_active_login(controller): """ @@ -268,3 +271,32 @@ def auth_enabled(controller): return controller(request, *args, **kwargs) return wrapper + +def oauth_required(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) + + + 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), + ) + + if not valid: + error = "Invalid oauth prarameter." + return json_response({"error": error}, status=400) + + return controller(request, *args, **kwargs) + + return wrapper diff --git a/mediagoblin/oauth/__init__.py b/mediagoblin/oauth/__init__.py new file mode 100644 index 00000000..719b56e7 --- /dev/null +++ b/mediagoblin/oauth/__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 <http://www.gnu.org/licenses/>. + diff --git a/mediagoblin/oauth/exceptions.py b/mediagoblin/oauth/exceptions.py new file mode 100644 index 00000000..5eccba34 --- /dev/null +++ b/mediagoblin/oauth/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 <http://www.gnu.org/licenses/>. + +class ValidationException(Exception): + pass diff --git a/mediagoblin/oauth/forms.py b/mediagoblin/oauth/forms.py new file mode 100644 index 00000000..94c7cb52 --- /dev/null +++ b/mediagoblin/oauth/forms.py @@ -0,0 +1,7 @@ +import wtforms + +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/oauth/oauth.py b/mediagoblin/oauth/oauth.py new file mode 100644 index 00000000..8229c47d --- /dev/null +++ b/mediagoblin/oauth/oauth.py @@ -0,0 +1,132 @@ +# 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 <http://www.gnu.org/licenses/>. + +from oauthlib.common import Request +from oauthlib.oauth1 import RequestValidator + +from mediagoblin.db.models import NonceTimestamp, Client, RequestToken, AccessToken + + + +class GMGRequestValidator(RequestValidator): + + enforce_ssl = False + + 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 """ + 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 + 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): + """ 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() + + def validate_timestamp_and_nonce(self, client_key, timestamp, + nonce, request, request_token=None, + access_token=None): + 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 """ + 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): + 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 + 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/oauth/routing.py b/mediagoblin/oauth/routing.py new file mode 100644 index 00000000..e45077bb --- /dev/null +++ b/mediagoblin/oauth/routing.py @@ -0,0 +1,43 @@ +# 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 <http://www.gnu.org/licenses/>. + +from mediagoblin.tools.routing import add_route + +# client registration & oauth +add_route( + "mediagoblin.oauth", + "/api/client/register", + "mediagoblin.oauth.views:client_register" + ) + +add_route( + "mediagoblin.oauth", + "/oauth/request_token", + "mediagoblin.oauth.views:request_token" + ) + +add_route( + "mediagoblin.oauth", + "/oauth/authorize", + "mediagoblin.oauth.views:authorize", + ) + +add_route( + "mediagoblin.oauth", + "/oauth/access_token", + "mediagoblin.oauth.views:access_token" + ) + diff --git a/mediagoblin/oauth/tools/__init__.py b/mediagoblin/oauth/tools/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/mediagoblin/oauth/tools/__init__.py diff --git a/mediagoblin/oauth/tools/forms.py b/mediagoblin/oauth/tools/forms.py new file mode 100644 index 00000000..e3eb3298 --- /dev/null +++ b/mediagoblin/oauth/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 <http://www.gnu.org/licenses/>. + +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/oauth/tools/request.py b/mediagoblin/oauth/tools/request.py new file mode 100644 index 00000000..5ce2da77 --- /dev/null +++ b/mediagoblin/oauth/tools/request.py @@ -0,0 +1,35 @@ +# 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 <http://www.gnu.org/licenses/>. + +def decode_authorization_header(header): + """ Decodes a HTTP Authorization Header to python dictionary """ + authorization = header.get("Authorization", "").lstrip(" ").lstrip("OAuth") + tokens = {} + + for param in authorization.split(","): + try: + key, value = param.split("=") + except ValueError: + continue + + key = key.lstrip(" ") + value = value.lstrip(" ").lstrip('"') + value = value.rstrip(" ").rstrip('"') + + tokens[key] = value + + return tokens + diff --git a/mediagoblin/oauth/views.py b/mediagoblin/oauth/views.py new file mode 100644 index 00000000..116eb023 --- /dev/null +++ b/mediagoblin/oauth/views.py @@ -0,0 +1,339 @@ +# 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 <http://www.gnu.org/licenses/>. + +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 +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.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 +client_types = ["web", "native"] # currently what pump supports + +@csrf_exempt +def client_register(request): + """ Endpoint for client registration """ + 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: + 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: + error = "client_id is requried to update." + return json_response({"error": error}, status=400) + elif "client_secret" not in data: + 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() + + if client is None: + 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 + ) + + app_name = ("application_type", client.application_name) + if app_name in client_types: + client.application_name = app_name + + elif client_type == "client_associate": + # registering + if "client_id" in data: + error = "Only set client_id for update." + return json_response({"error": error}, status=400) + elif "access_token" in data: + error = "access_token not needed for registration." + return json_response({"error": error}, status=400) + elif "client_secret" in data: + 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 + 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=application_type, + ) + + else: + 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): + 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 + + client.application_name = data.get("application_name", 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." + return json_response({"error": error}, status=400) + + contacts = contacts.split() + for contact in contacts: + if not validate_email(contact): + # not a valid email + error = "Email {0} is not a valid email.".format(contact) + return json_response({"error": error}, status=400) + + + client.contacts = contacts + + 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_response({"error": error}, status=400) + + redirect_uris = redirect_uris.split() + + 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.redirect_uri = redirect_uris + + + client.save() + + expirey = 0 if client.expirey is None else client.expirey + + return json_response( + { + "client_id": client.id, + "client_secret": client.secret, + "expires_at": expirey, + }) + +@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 == "": + error = "Unknown Content-Type" + return json_response({"error": error}, status=400) + + if not data and request.headers: + data = request.headers + + data = dict(data) # mutableifying + + 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) + + # check the client_id + client_id = authorization[u"oauth_consumer_key"] + client = Client.query.filter_by(id=client_id).first() + + 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 + request_validator = GMGRequestValidator(authorization) + 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(float(timestamp)) + + nc = NonceTimestamp(nonce=nonce, timestamp=timestamp) + nc.save() + + return form_response(tokens) + +@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.") + return render_400(request, err_msg=err_msg) + + 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 = GMGRequest(request) + 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 """ + 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) + + + 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/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..b7e74799 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.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 -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/__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 27ff32b4..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.plugins.api.tools 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 d6fd314f..de637d6b 100644 --- a/mediagoblin/plugins/oauth/views.py +++ b/mediagoblin/plugins/oauth/views.py @@ -21,7 +21,7 @@ from urllib import urlencode from werkzeug.exceptions import BadRequest -from mediagoblin.tools.response import render_to_response, redirect +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 _ @@ -31,7 +31,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/routing.py b/mediagoblin/routing.py index 986eb2ed..5961f33b 100644 --- a/mediagoblin/routing.py +++ b/mediagoblin/routing.py @@ -36,7 +36,8 @@ def get_url_map(): import mediagoblin.webfinger.routing import mediagoblin.listings.routing import mediagoblin.notifications.routing - + import mediagoblin.oauth.routing + for route in PluginManager().get_routes(): add_route(*route) diff --git a/mediagoblin/static/css/base.css b/mediagoblin/static/css/base.css index 84d274d1..d96b9200 100644 --- a/mediagoblin/static/css/base.css +++ b/mediagoblin/static/css/base.css @@ -757,3 +757,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 <http://www.gnu.org/licenses/>. +#} +{% extends "mediagoblin/base.html" %} + +{% block title -%} + {% trans %}Authorization{% endtrans %} — {{ super() }} +{%- endblock %} + +{% block mediagoblin_content %} + +<h1>{% trans %}Authorize{% endtrans %}</h1> + +<p> + {% trans %}You are logged in as{% endtrans %} + <strong>{{user.username}}</strong> + <br /><br /> + + {% trans %}Do you want to authorize {% endtrans %} + {% if client.application_name -%} + <em>{{ client.application_name }}</em> + {%- else -%} + <em>{% trans %}an unknown application{% endtrans %}</em> + {%- endif %} + {% trans %} to access your account? {% endtrans %} + <br /><br /> + {% trans %}Applications with access to your account can: {% endtrans %} + <ul> + <li>{% trans %}Post new media as you{% endtrans %}</li> + <li>{% trans %}See your information (e.g profile, meida, etc...){% endtrans %}</li> + <li>{% trans %}Change your information{% endtrans %}</li> + </ul> + <br /> + + <form method="POST"> + {{ csrf_token }} + {{ authorize_form.oauth_token }} + {{ authorize_form.oauth_verifier }} + <input type="submit" value="{% trans %}Authorize{% endtrans %}"> + </form> +</p> +{% 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 <http://www.gnu.org/licenses/>. +#} +{% extends "mediagoblin/base.html" %} + +{% block title -%} + {% trans %}Authorization Finished{% endtrans %} — {{ super() }} +{%- endblock %} + +{% block mediagoblin_content %} + +<h1>{% trans %}Authorization Complete{% endtrans %}</h1> + +<h4>{% trans %}Copy and paste this into your client:{% endtrans %}</h4> + +<p class="verifier"> + {{ oauth_request.verifier }} +</p> +{% endblock %} 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_oauth1.py b/mediagoblin/tests/test_oauth1.py new file mode 100644 index 00000000..073c2884 --- /dev/null +++ b/mediagoblin/tests/test_oauth1.py @@ -0,0 +1,166 @@ +# 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 <http://www.gnu.org/licenses/>. + +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 + + 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 = response.json + + 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 = response.json + + 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 = response.json + 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 = 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"] + 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 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"] + diff --git a/mediagoblin/tests/test_oauth.py b/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 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 <http://www.gnu.org/licenses/>. +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/request.py b/mediagoblin/tools/request.py index ee342eae..d4739039 100644 --- a/mediagoblin/tools/request.py +++ b/mediagoblin/tools/request.py @@ -14,12 +14,18 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +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 +42,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 or request.content_type == "": + data = request.form + else: + data = "" + return data diff --git a/mediagoblin/tools/response.py b/mediagoblin/tools/response.py index 0be1f835..b0401e08 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 <http://www.gnu.org/licenses/>. +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,6 +45,14 @@ 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""" @@ -106,3 +115,45 @@ 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 + +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 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 <http://www.gnu.org/licenses/>. + +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 + @@ -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 |