diff options
Diffstat (limited to 'mediagoblin/plugins')
-rw-r--r-- | mediagoblin/plugins/api/__init__.py | 17 | ||||
-rw-r--r-- | mediagoblin/plugins/api/tools.py | 20 | ||||
-rw-r--r-- | mediagoblin/plugins/api/views.py | 29 | ||||
-rw-r--r-- | mediagoblin/plugins/flatpagesfile/__init__.py | 3 | ||||
-rw-r--r-- | mediagoblin/plugins/httpapiauth/__init__.py | 58 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/README.rst | 13 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/__init__.py | 54 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/forms.py | 70 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/migrations.py | 46 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/models.py | 101 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/templates/oauth/authorize.html | 31 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/templates/oauth/client/connections.html | 34 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/templates/oauth/client/list.html | 45 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/templates/oauth/client/register.html | 34 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/tools.py | 43 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/views.py | 196 |
16 files changed, 722 insertions, 72 deletions
diff --git a/mediagoblin/plugins/api/__init__.py b/mediagoblin/plugins/api/__init__.py index 40722088..d3fdf2ef 100644 --- a/mediagoblin/plugins/api/__init__.py +++ b/mediagoblin/plugins/api/__init__.py @@ -17,8 +17,6 @@ import os import logging -from routes.route import Route - from mediagoblin.tools import pluginapi _log = logging.getLogger(__name__) @@ -33,12 +31,15 @@ def setup_plugin(): _log.debug('API config: {0}'.format(config)) routes = [ - Route('mediagoblin.plugins.api.test', '/api/test', - controller='mediagoblin.plugins.api.views:api_test'), - Route('mediagoblin.plugins.api.entries', '/api/entries', - controller='mediagoblin.plugins.api.views:get_entries'), - Route('mediagoblin.plugins.api.post_entry', '/api/submit', - controller='mediagoblin.plugins.api.views:post_entry')] + ('mediagoblin.plugins.api.test', + '/api/test', + 'mediagoblin.plugins.api.views:api_test'), + ('mediagoblin.plugins.api.entries', + '/api/entries', + 'mediagoblin.plugins.api.views:get_entries'), + ('mediagoblin.plugins.api.post_entry', + '/api/submit', + 'mediagoblin.plugins.api.views:post_entry')] pluginapi.register_routes(routes) diff --git a/mediagoblin/plugins/api/tools.py b/mediagoblin/plugins/api/tools.py index c4630ba7..ecc50364 100644 --- a/mediagoblin/plugins/api/tools.py +++ b/mediagoblin/plugins/api/tools.py @@ -52,7 +52,7 @@ class Auth(object): raise NotImplemented() -def json_response(serializable, *args, **kw): +def json_response(serializable, _disable_cors=False, *args, **kw): ''' Serializes a json objects and returns a webob.Response object with the serialized value as the response body and Content-Type: application/json. @@ -64,11 +64,14 @@ def json_response(serializable, *args, **kw): ''' response = Response(json.dumps(serializable), *args, **kw) response.headers['Content-Type'] = 'application/json' - cors_headers = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With'} - response.headers.update(cors_headers) + + 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'} + response.headers.update(cors_headers) + return response @@ -149,6 +152,11 @@ def api_auth(controller): auth, request.url)) if not auth(request, *args, **kw): + if getattr(auth, 'errors', []): + return json_response({ + 'status': 403, + 'errors': auth.errors}) + return exc.HTTPForbidden() return controller(request, *args, **kw) diff --git a/mediagoblin/plugins/api/views.py b/mediagoblin/plugins/api/views.py index d537ec6e..a1b1bcac 100644 --- a/mediagoblin/plugins/api/views.py +++ b/mediagoblin/plugins/api/views.py @@ -20,8 +20,8 @@ import uuid from os.path import splitext from webob import exc, Response -from cgi import FieldStorage from werkzeug.utils import secure_filename +from werkzeug.datastructures import FileStorage from celery import registry from mediagoblin.db.util import ObjectId @@ -29,13 +29,10 @@ from mediagoblin.decorators import require_active_login from mediagoblin.processing import mark_entry_failed from mediagoblin.processing.task import ProcessMedia from mediagoblin.meddleware.csrf import csrf_exempt -from mediagoblin.media_types import sniff_media, InvalidFileType, \ - FileTypeNotSupported +from mediagoblin.media_types import sniff_media from mediagoblin.plugins.api.tools import api_auth, get_entry_serializable, \ json_response -from mediagoblin.plugins.api import config - _log = logging.getLogger(__name__) @@ -52,24 +49,24 @@ def post_entry(request): _log.debug('Must POST against post_entry') return exc.HTTPBadRequest() - if not 'file' in request.POST \ - or not isinstance(request.POST['file'], FieldStorage) \ - or not request.POST['file'].file: + if not 'file' in request.files \ + or not isinstance(request.files['file'], FileStorage) \ + or not request.files['file'].stream: _log.debug('File field not found') return exc.HTTPBadRequest() - media_file = request.POST['file'] + media_file = request.files['file'] media_type, media_manager = sniff_media(media_file) entry = request.db.MediaEntry() entry.id = ObjectId() entry.media_type = unicode(media_type) - entry.title = unicode(request.POST.get('title') + entry.title = unicode(request.form.get('title') or splitext(media_file.filename)[0]) - entry.description = unicode(request.POST.get('description')) - entry.license = unicode(request.POST.get('license', '')) + entry.description = unicode(request.form.get('description')) + entry.license = unicode(request.form.get('license', '')) entry.uploader = request.user.id @@ -88,7 +85,7 @@ def post_entry(request): queue_filepath, 'wb') with queue_file: - queue_file.write(request.POST['file'].file.read()) + queue_file.write(request.files['file'].stream.read()) # Add queued filename to the entry entry.queued_media_file = queue_filepath @@ -98,6 +95,12 @@ def post_entry(request): # Save now so we have this data before kicking off processing entry.save(validate=True) + if request.form.get('callback_url'): + metadata = request.db.ProcessingMetaData() + metadata.media_entry = entry + metadata.callback_url = unicode(request.form['callback_url']) + metadata.save() + # Pass off to processing # # (... don't change entry after this point to avoid race diff --git a/mediagoblin/plugins/flatpagesfile/__init__.py b/mediagoblin/plugins/flatpagesfile/__init__.py index b9b52012..3d797809 100644 --- a/mediagoblin/plugins/flatpagesfile/__init__.py +++ b/mediagoblin/plugins/flatpagesfile/__init__.py @@ -19,7 +19,6 @@ import logging import os import jinja2 -from routes.route import Route from mediagoblin.tools import pluginapi from mediagoblin.tools.response import render_to_response @@ -68,7 +67,7 @@ def setup_plugin(): name = 'flatpagesfile.%s' % name.strip() controller = flatpage_handler_builder(template) routes.append( - Route(name, url, controller=controller)) + (name, url, controller)) pluginapi.register_routes(routes) _log.info('Done setting up flatpagesfile!') diff --git a/mediagoblin/plugins/httpapiauth/__init__.py b/mediagoblin/plugins/httpapiauth/__init__.py new file mode 100644 index 00000000..d3d2065e --- /dev/null +++ b/mediagoblin/plugins/httpapiauth/__init__.py @@ -0,0 +1,58 @@ +# 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 logging +import base64 + +from werkzeug.exceptions import BadRequest, Unauthorized + +from mediagoblin.plugins.api.tools import Auth + +_log = logging.getLogger(__name__) + + +def setup_http_api_auth(): + _log.info('Setting up HTTP API Auth...') + + +class HTTPAuth(Auth): + def trigger(self, request): + if request.authorization: + return True + + return False + + def __call__(self, request, *args, **kw): + _log.debug('Trying to authorize the user agent via HTTP Auth') + if not request.authorization: + return False + + user = request.db.User.query.filter_by( + username=request.authorization['username']).first() + + if user.check_login(request.authorization['password']): + request.user = user + return True + else: + raise Unauthorized() + + return False + + + +hooks = { + 'setup': setup_http_api_auth, + 'auth': HTTPAuth()} diff --git a/mediagoblin/plugins/oauth/README.rst b/mediagoblin/plugins/oauth/README.rst index e5a1dc3a..405a67e2 100644 --- a/mediagoblin/plugins/oauth/README.rst +++ b/mediagoblin/plugins/oauth/README.rst @@ -122,20 +122,23 @@ Capabilities - `Authorization endpoint`_ - Located at ``/oauth/authorize`` - `Token endpoint`_ - Located at ``/oauth/access_token`` - `Authorization Code Grant`_ +- `Client Registration`_ .. _`Authorization endpoint`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-3.1 .. _`Token endpoint`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-3.2 .. _`Authorization Code Grant`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-4.1 +.. _`Client Registration`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-2 Incapabilities ============== -- `Client Registration`_ - `planned feature - <http://issues.mediagoblin.org/ticket/497>`_ -- `Access Token Scope`_ +- Only `bearer tokens`_ are issued. - `Implicit Grant`_ +- `Force TLS for token endpoint`_ - This one is up the the siteadmin +- Authorization `scope`_ and `state` - ... -.. _`Client Registration`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-2 -.. _`Access Token Scope`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-3.3 +.. _`bearer tokens`: http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-08 +.. _`scope`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-3.3 .. _`Implicit Grant`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-4.2 +.. _`Force TLS for token endpoint`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-3.2 diff --git a/mediagoblin/plugins/oauth/__init__.py b/mediagoblin/plugins/oauth/__init__.py index 33dcaf16..4714d95d 100644 --- a/mediagoblin/plugins/oauth/__init__.py +++ b/mediagoblin/plugins/oauth/__init__.py @@ -17,12 +17,9 @@ import os import logging -from routes.route import Route -from webob import exc - from mediagoblin.tools import pluginapi -from mediagoblin.tools.response import render_to_response -from mediagoblin.plugins.oauth.models import OAuthToken +from mediagoblin.plugins.oauth.models import OAuthToken, OAuthClient, \ + OAuthUserClient from mediagoblin.plugins.api.tools import Auth _log = logging.getLogger(__name__) @@ -37,10 +34,24 @@ def setup_plugin(): _log.debug('OAuth config: {0}'.format(config)) routes = [ - Route('mediagoblin.plugins.oauth.authorize', '/oauth/authorize', - controller='mediagoblin.plugins.oauth.views:authorize'), - Route('mediagoblin.plugins.oauth.access_token', '/oauth/access_token', - controller='mediagoblin.plugins.oauth.views:access_token')] + ('mediagoblin.plugins.oauth.authorize', + '/oauth/authorize', + 'mediagoblin.plugins.oauth.views:authorize'), + ('mediagoblin.plugins.oauth.authorize_client', + '/oauth/client/authorize', + 'mediagoblin.plugins.oauth.views:authorize_client'), + ('mediagoblin.plugins.oauth.access_token', + '/oauth/access_token', + 'mediagoblin.plugins.oauth.views:access_token'), + ('mediagoblin.plugins.oauth.list_connections', + '/oauth/client/connections', + 'mediagoblin.plugins.oauth.views:list_connections'), + ('mediagoblin.plugins.oauth.register_client', + '/oauth/client/register', + 'mediagoblin.plugins.oauth.views:register_client'), + ('mediagoblin.plugins.oauth.list_clients', + '/oauth/client/list', + 'mediagoblin.plugins.oauth.views:list_clients')] pluginapi.register_routes(routes) pluginapi.register_template_path(os.path.join(PLUGIN_DIR, 'templates')) @@ -54,17 +65,42 @@ class OAuthAuth(Auth): return False def __call__(self, request, *args, **kw): + self.errors = [] + # TODO: Add suport for client credentials authorization + client_id = request.GET.get('client_id') # TODO: Not used + client_secret = request.GET.get('client_secret') # TODO: Not used access_token = request.GET.get('access_token') + + _log.debug('Authorizing request {0}'.format(request.url)) + if access_token: token = OAuthToken.query.filter(OAuthToken.token == access_token)\ .first() if not token: + self.errors.append('Invalid access token') + return False + + _log.debug('Access token: {0}'.format(token)) + _log.debug('Client: {0}'.format(token.client)) + + relation = OAuthUserClient.query.filter( + (OAuthUserClient.user == token.user) + & (OAuthUserClient.client == token.client) + & (OAuthUserClient.state == u'approved')).first() + + _log.debug('Relation: {0}'.format(relation)) + + if not relation: + self.errors.append( + u'Client has not been approved by the resource owner') return False request.user = token.user return True + self.errors.append(u'No access_token specified') + return False hooks = { diff --git a/mediagoblin/plugins/oauth/forms.py b/mediagoblin/plugins/oauth/forms.py new file mode 100644 index 00000000..2a956dad --- /dev/null +++ b/mediagoblin/plugins/oauth/forms.py @@ -0,0 +1,70 @@ +# 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 wtforms + +from urlparse import urlparse + +from mediagoblin.tools.extlib.wtf_html5 import URLField +from mediagoblin.tools.translate import fake_ugettext_passthrough as _ + + +class AuthorizationForm(wtforms.Form): + client_id = wtforms.HiddenField(_(u'Client ID'), + [wtforms.validators.Required()]) + next = wtforms.HiddenField(_(u'Next URL'), + [wtforms.validators.Required()]) + allow = wtforms.SubmitField(_(u'Allow')) + deny = wtforms.SubmitField(_(u'Deny')) + + +class ClientRegistrationForm(wtforms.Form): + name = wtforms.TextField(_('Name'), [wtforms.validators.Required()], + description=_('The name of the OAuth client')) + description = wtforms.TextAreaField(_('Description'), + [wtforms.validators.Length(min=0, max=500)], + description=_('''This will be visible to users allowing your + application to authenticate as them.''')) + type = wtforms.SelectField(_('Type'), + [wtforms.validators.Required()], + choices=[ + ('confidential', 'Confidential'), + ('public', 'Public')], + description=_('''<strong>Confidential</strong> - The client can + make requests to the GNU MediaGoblin instance that can not be + intercepted by the user agent (e.g. server-side client).<br /> + <strong>Public</strong> - The client can't make confidential + requests to the GNU MediaGoblin instance (e.g. client-side + JavaScript client).''')) + + redirect_uri = URLField(_('Redirect URI'), + [wtforms.validators.Optional(), wtforms.validators.URL()], + description=_('''The redirect URI for the applications, this field + is <strong>required</strong> for public clients.''')) + + def __init__(self, *args, **kw): + wtforms.Form.__init__(self, *args, **kw) + + def validate(self): + if not wtforms.Form.validate(self): + return False + + if self.type.data == 'public' and not self.redirect_uri.data: + self.redirect_uri.errors.append( + _('This field is required for public clients')) + return False + + return True diff --git a/mediagoblin/plugins/oauth/migrations.py b/mediagoblin/plugins/oauth/migrations.py new file mode 100644 index 00000000..f2af3907 --- /dev/null +++ b/mediagoblin/plugins/oauth/migrations.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 sqlalchemy import MetaData, Table + +from mediagoblin.db.sql.util import RegisterMigration + +from mediagoblin.plugins.oauth.models import OAuthClient, OAuthToken, \ + OAuthUserClient, OAuthCode + +MIGRATIONS = {} + + +@RegisterMigration(1, MIGRATIONS) +def remove_and_replace_token_and_code(db): + metadata = MetaData(bind=db.bind) + + token_table = Table('oauth__tokens', metadata, autoload=True, + autoload_with=db.bind) + + token_table.drop() + + code_table = Table('oauth__codes', metadata, autoload=True, + autoload_with=db.bind) + + code_table.drop() + + OAuthClient.__table__.create(db.bind) + OAuthUserClient.__table__.create(db.bind) + OAuthToken.__table__.create(db.bind) + OAuthCode.__table__.create(db.bind) + + db.commit() diff --git a/mediagoblin/plugins/oauth/models.py b/mediagoblin/plugins/oauth/models.py index 295987c8..7e247c1a 100644 --- a/mediagoblin/plugins/oauth/models.py +++ b/mediagoblin/plugins/oauth/models.py @@ -14,15 +14,86 @@ # 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 uuid +import bcrypt + from datetime import datetime, timedelta from mediagoblin.db.sql.base import Base from mediagoblin.db.sql.models import User from sqlalchemy import ( - Column, Unicode, Integer, DateTime, ForeignKey) + Column, Unicode, Integer, DateTime, ForeignKey, Enum) from sqlalchemy.orm import relationship +# Don't remove this, I *think* it applies sqlalchemy-migrate functionality onto +# the models. +from migrate import changeset + + +class OAuthClient(Base): + __tablename__ = 'oauth__client' + + id = Column(Integer, primary_key=True) + created = Column(DateTime, nullable=False, + default=datetime.now) + + name = Column(Unicode) + description = Column(Unicode) + + identifier = Column(Unicode, unique=True, index=True) + secret = Column(Unicode, index=True) + + owner_id = Column(Integer, ForeignKey(User.id)) + owner = relationship(User, backref='registered_clients') + + redirect_uri = Column(Unicode) + + type = Column(Enum( + u'confidential', + u'public', + name=u'oauth__client_type')) + + def generate_identifier(self): + self.identifier = unicode(uuid.uuid4()) + + def generate_secret(self): + self.secret = unicode( + bcrypt.hashpw( + unicode(uuid.uuid4()), + bcrypt.gensalt())) + + def __repr__(self): + return '<{0} {1}:{2} ({3})>'.format( + self.__class__.__name__, + self.id, + self.name.encode('ascii', 'replace'), + self.owner.username.encode('ascii', 'replace')) + + +class OAuthUserClient(Base): + __tablename__ = 'oauth__user_client' + id = Column(Integer, primary_key=True) + + user_id = Column(Integer, ForeignKey(User.id)) + user = relationship(User, backref='oauth_clients') + + client_id = Column(Integer, ForeignKey(OAuthClient.id)) + client = relationship(OAuthClient, backref='users') + + state = Column(Enum( + u'approved', + u'rejected', + name=u'oauth__relation_state')) + + def __repr__(self): + return '<{0} #{1} {2} [{3}, {4}]>'.format( + self.__class__.__name__, + self.id, + self.state.encode('ascii', 'replace'), + self.user, + self.client) + class OAuthToken(Base): __tablename__ = 'oauth__tokens' @@ -39,6 +110,17 @@ class OAuthToken(Base): index=True) user = relationship(User) + client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False) + client = relationship(OAuthClient) + + def __repr__(self): + return '<{0} #{1} expires {2} [{3}, {4}]>'.format( + self.__class__.__name__, + self.id, + self.expires.isoformat(), + self.user, + self.client) + class OAuthCode(Base): __tablename__ = 'oauth__codes' @@ -54,5 +136,20 @@ class OAuthCode(Base): index=True) user = relationship(User) + client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False) + client = relationship(OAuthClient) + + def __repr__(self): + return '<{0} #{1} expires {2} [{3}, {4}]>'.format( + self.__class__.__name__, + self.id, + self.expires.isoformat(), + self.user, + self.client) + -MODELS = [OAuthToken, OAuthCode] +MODELS = [ + OAuthToken, + OAuthCode, + OAuthClient, + OAuthUserClient] diff --git a/mediagoblin/plugins/oauth/templates/oauth/authorize.html b/mediagoblin/plugins/oauth/templates/oauth/authorize.html new file mode 100644 index 00000000..647fa41f --- /dev/null +++ b/mediagoblin/plugins/oauth/templates/oauth/authorize.html @@ -0,0 +1,31 @@ +{# +# 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. +#, se, seee +# 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" %} +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} +<form action="{{ request.urlgen('mediagoblin.plugins.oauth.authorize_client') }}" + method="POST"> + <div class="form_box_xl"> + {{ csrf_token }} + <h2>Authorize {{ client.name }}?</h2> + <p class="client-description">{{ client.description }}</p> + {{ wtforms_util.render_divs(form) }} + </div> +</form> +{% endblock %} diff --git a/mediagoblin/plugins/oauth/templates/oauth/client/connections.html b/mediagoblin/plugins/oauth/templates/oauth/client/connections.html new file mode 100644 index 00000000..63b0230a --- /dev/null +++ b/mediagoblin/plugins/oauth/templates/oauth/client/connections.html @@ -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 <http://www.gnu.org/licenses/>. +-#} +{% extends "mediagoblin/base.html" %} +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} +<h1>{% trans %}OAuth client connections{% endtrans %}</h1> +{% if connections %} +<ul> + {% for connection in connections %} + <li><span title="{{ connection.client.description }}">{{ + connection.client.name }}</span> - {{ connection.state }} + </li> + {% endfor %} +</ul> +{% else %} +<p>You haven't connected using an OAuth client before.</p> +{% endif %} +{% endblock %} diff --git a/mediagoblin/plugins/oauth/templates/oauth/client/list.html b/mediagoblin/plugins/oauth/templates/oauth/client/list.html new file mode 100644 index 00000000..21024bb7 --- /dev/null +++ b/mediagoblin/plugins/oauth/templates/oauth/client/list.html @@ -0,0 +1,45 @@ +{# +# 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" %} +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} +<h1>{% trans %}Your OAuth clients{% endtrans %}</h1> +{% if clients %} +<ul> + {% for client in clients %} + <li>{{ client.name }} + <dl> + <dt>Type</dt> + <dd>{{ client.type }}</dd> + <dt>Description</dt> + <dd>{{ client.description }}</dd> + <dt>Identifier</dt> + <dd>{{ client.identifier }}</dd> + <dt>Secret</dt> + <dd>{{ client.secret }}</dd> + <dt>Redirect URI<dt> + <dd>{{ client.redirect_uri }}</dd> + </dl> + </li> + {% endfor %} +</ul> +{% else %} +<p>You don't have any clients yet. <a href="{{ request.urlgen('mediagoblin.plugins.oauth.register_client') }}">Add one</a>.</p> +{% endif %} +{% endblock %} diff --git a/mediagoblin/plugins/oauth/templates/oauth/client/register.html b/mediagoblin/plugins/oauth/templates/oauth/client/register.html new file mode 100644 index 00000000..6fd700d3 --- /dev/null +++ b/mediagoblin/plugins/oauth/templates/oauth/client/register.html @@ -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 <http://www.gnu.org/licenses/>. +-#} +{% extends "mediagoblin/base.html" %} +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} +<form action="{{ request.urlgen('mediagoblin.plugins.oauth.register_client') }}" + method="POST"> + <div class="form_box_xl"> + <h1>Register OAuth client</h1> + {{ wtforms_util.render_divs(form) }} + <div class="form_submit_buttons"> + {{ csrf_token }} + <input type="submit" value="{% trans %}Add{% endtrans %}" + class="button_form" /> + </div> + </div> +</form> +{% endblock %} diff --git a/mediagoblin/plugins/oauth/tools.py b/mediagoblin/plugins/oauth/tools.py new file mode 100644 index 00000000..d21c8a5b --- /dev/null +++ b/mediagoblin/plugins/oauth/tools.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 functools import wraps + +from mediagoblin.plugins.oauth.models import OAuthClient +from mediagoblin.plugins.api.tools import json_response + + +def require_client_auth(controller): + @wraps(controller) + def wrapper(request, *args, **kw): + if not request.GET.get('client_id'): + return json_response({ + 'status': 400, + 'errors': [u'No client identifier in URL']}, + _disable_cors=True) + + client = OAuthClient.query.filter( + OAuthClient.identifier == request.GET.get('client_id')).first() + + if not client: + return json_response({ + 'status': 400, + 'errors': [u'No such client identifier']}, + _disable_cors=True) + + return controller(request, client) + + return wrapper diff --git a/mediagoblin/plugins/oauth/views.py b/mediagoblin/plugins/oauth/views.py index 70be3039..cf605fd2 100644 --- a/mediagoblin/plugins/oauth/views.py +++ b/mediagoblin/plugins/oauth/views.py @@ -21,38 +21,142 @@ from webob import exc, Response from urllib import urlencode from uuid import uuid4 from datetime import datetime -from functools import wraps -from mediagoblin.tools import pluginapi -from mediagoblin.tools.response import render_to_response +from mediagoblin.tools.response import render_to_response, redirect from mediagoblin.decorators import require_active_login from mediagoblin.messages import add_message, SUCCESS, ERROR from mediagoblin.tools.translate import pass_to_ugettext as _ -from mediagoblin.plugins.oauth.models import OAuthCode, OAuthToken +from mediagoblin.plugins.oauth.models import OAuthCode, OAuthToken, \ + OAuthClient, OAuthUserClient +from mediagoblin.plugins.oauth.forms import ClientRegistrationForm, \ + AuthorizationForm +from mediagoblin.plugins.oauth.tools import require_client_auth +from mediagoblin.plugins.api.tools import json_response _log = logging.getLogger(__name__) @require_active_login -def authorize(request): - # TODO: Check if allowed +def register_client(request): + ''' + Register an OAuth client + ''' + form = ClientRegistrationForm(request.form) - # Client is allowed by the user - if True or already_authorized: - # Generate a code - # Save the code, the client will later use it to obtain an access token - # Redirect the user agent to the redirect_uri with the code + if request.method == 'POST' and form.validate(): + client = OAuthClient() + client.name = unicode(request.form['name']) + client.description = unicode(request.form['description']) + client.type = unicode(request.form['type']) + client.owner_id = request.user.id + client.redirect_uri = unicode(request.form['redirect_uri']) - if not 'redirect_uri' in request.GET: - add_message(request, ERROR, _('No redirect_uri found')) + client.generate_identifier() + client.generate_secret() + + client.save() + + add_message(request, SUCCESS, _('The client {0} has been registered!')\ + .format( + client.name)) + + return redirect(request, 'mediagoblin.plugins.oauth.list_clients') + + return render_to_response( + request, + 'oauth/client/register.html', + {'form': form}) + + +@require_active_login +def list_clients(request): + clients = request.db.OAuthClient.query.filter( + OAuthClient.owner_id == request.user.id).all() + return render_to_response(request, 'oauth/client/list.html', + {'clients': clients}) + + +@require_active_login +def list_connections(request): + connections = OAuthUserClient.query.filter( + OAuthUserClient.user == request.user).all() + return render_to_response(request, 'oauth/client/connections.html', + {'connections': connections}) + + +@require_active_login +def authorize_client(request): + form = AuthorizationForm(request.form) + + client = OAuthClient.query.filter(OAuthClient.id == + form.client_id.data).first() + + if not client: + _log.error('''No such client id as received from client authorization + form.''') + return exc.HTTPBadRequest() + + if form.validate(): + relation = OAuthUserClient() + relation.user_id = request.user.id + relation.client_id = form.client_id.data + if form.allow.data: + relation.state = u'approved' + elif form.deny.data: + relation.state = u'rejected' + else: + return exc.HTTPBadRequest + + relation.save() + + return exc.HTTPFound(location=form.next.data) + + return render_to_response( + request, + 'oauth/authorize.html', + {'form': form, + 'client': client}) + + +@require_client_auth +@require_active_login +def authorize(request, client): + # TODO: Get rid of the JSON responses in this view, it's called by the + # user-agent, not the client. + user_client_relation = OAuthUserClient.query.filter( + (OAuthUserClient.user == request.user) + & (OAuthUserClient.client == client)) + + if user_client_relation.filter(OAuthUserClient.state == + u'approved').count(): + redirect_uri = None + + if client.type == u'public': + if not client.redirect_uri: + return json_response({ + 'status': 400, + 'errors': + [u'Public clients MUST have a redirect_uri pre-set']}, + _disable_cors=True) + + redirect_uri = client.redirect_uri + + if client.type == u'confidential': + redirect_uri = request.GET.get('redirect_uri', client.redirect_uri) + if not redirect_uri: + return json_response({ + 'status': 400, + 'errors': [u'Can not find a redirect_uri for client: {0}'\ + .format(client.name)]}, _disable_cors=True) code = OAuthCode() code.code = unicode(uuid4()) code.user = request.user + code.client = client code.save() redirect_uri = ''.join([ - request.GET.get('redirect_uri'), + redirect_uri, '?', urlencode({'code': code.code})]) @@ -65,29 +169,67 @@ def authorize(request): # code parameter # - on deny: send the user agent back to the redirect uri with error # information - pass - return render_to_response(request, 'oauth/base.html', {}) + form = AuthorizationForm(request.form) + form.client_id.data = client.id + form.next.data = request.url + return render_to_response( + request, + 'oauth/authorize.html', + {'form': form, + 'client': client}) def access_token(request): if request.GET.get('code'): - code = OAuthCode.query.filter(OAuthCode.code == request.GET.get('code'))\ - .first() + code = OAuthCode.query.filter(OAuthCode.code == + request.GET.get('code')).first() if code: + if code.client.type == u'confidential': + client_identifier = request.GET.get('client_id') + + if not client_identifier: + return json_response({ + 'error': 'invalid_request', + 'error_description': + 'Missing client_id in request'}) + + client_secret = request.GET.get('client_secret') + + if not client_secret: + return json_response({ + 'error': 'invalid_request', + 'error_description': + 'Missing client_secret in request'}) + + if not client_secret == code.client.secret or \ + not client_identifier == code.client.identifier: + return json_response({ + 'error': 'invalid_client', + 'error_description': + 'The client_id or client_secret does not match the' + ' code'}) + token = OAuthToken() token.token = unicode(uuid4()) token.user = code.user + token.client = code.client token.save() access_token_data = { 'access_token': token.token, - 'token_type': 'what_do_i_use_this_for', # TODO - 'expires_in': - (token.expires - datetime.now()).total_seconds(), - 'refresh_token': 'This should probably be safe'} - return Response(json.dumps(access_token_data)) - - error_data = { - 'error': 'Incorrect code'} - return Response(json.dumps(error_data)) + 'token_type': 'bearer', + 'expires_in': int( + round( + (token.expires - datetime.now()).total_seconds()))} + return json_response(access_token_data, _disable_cors=True) + else: + return json_response({ + 'error': 'invalid_request', + 'error_description': + 'Invalid code'}) + else: + return json_response({ + 'error': 'invalid_request', + 'error_descriptin': + 'Missing `code` parameter in request'}) |