From 88a9662be4f97da5b04a3842c8d0caa2652be355 Mon Sep 17 00:00:00 2001 From: Joar Wandborg Date: Fri, 21 Sep 2012 13:02:35 +0200 Subject: [PATCH] Added client registration caps to OAuth plugin THE MIGRATIONS SUPPLIED WITH THIS COMMIT WILL DROP AND RE-CREATE YOUR oauth__tokens AND oauth__codes TABLES. ALL YOUR OAUTH CODES AND TOKENS WILL BE LOST. - Fixed pylint issues in db/sql/migrations. - Added __repr__ to the User model. - Added _disable_cors option to json_response. - Added crude error handling to the api.tools.api_auth decorator - Updated the OAuth README. - Added client registration, client overview, connection overview, client authorization views and templates. - Added error handling to the OAuthAuth Auth object. - Added AuthorizationForm, ClientRegistrationForm in oauth/forms. - Added migrations for OAuth, added client registration migration. - Added OAuthClient, OAuthUserClient models. - Added oauth/tools with require_client_auth decorator method. --- mediagoblin/db/sql/migrations.py | 8 +- mediagoblin/db/sql/models.py | 12 +- mediagoblin/plugins/api/tools.py | 20 ++- mediagoblin/plugins/oauth/README.rst | 7 +- mediagoblin/plugins/oauth/__init__.py | 43 ++++- mediagoblin/plugins/oauth/forms.py | 70 ++++++++ mediagoblin/plugins/oauth/migrations.py | 46 ++++++ mediagoblin/plugins/oauth/models.py | 99 ++++++++++- .../oauth/templates/oauth/authorize.html | 31 ++++ .../templates/oauth/client/connections.html | 34 ++++ .../oauth/templates/oauth/client/list.html | 45 +++++ .../templates/oauth/client/register.html | 34 ++++ mediagoblin/plugins/oauth/tools.py | 43 +++++ mediagoblin/plugins/oauth/views.py | 154 +++++++++++++++--- 14 files changed, 602 insertions(+), 44 deletions(-) create mode 100644 mediagoblin/plugins/oauth/forms.py create mode 100644 mediagoblin/plugins/oauth/migrations.py create mode 100644 mediagoblin/plugins/oauth/templates/oauth/authorize.html create mode 100644 mediagoblin/plugins/oauth/templates/oauth/client/connections.html create mode 100644 mediagoblin/plugins/oauth/templates/oauth/client/list.html create mode 100644 mediagoblin/plugins/oauth/templates/oauth/client/register.html create mode 100644 mediagoblin/plugins/oauth/tools.py diff --git a/mediagoblin/db/sql/migrations.py b/mediagoblin/db/sql/migrations.py index fb178285..e86109e9 100644 --- a/mediagoblin/db/sql/migrations.py +++ b/mediagoblin/db/sql/migrations.py @@ -16,15 +16,12 @@ import datetime -from sqlalchemy import (MetaData, Table, Column, Boolean, SmallInteger, +from sqlalchemy import (MetaData, Table, Column, Boolean, SmallInteger, Integer, Unicode, UnicodeText, DateTime, ForeignKey) - - from mediagoblin.db.sql.util import RegisterMigration from mediagoblin.db.sql.models import MediaEntry, Collection, User - MIGRATIONS = {} @@ -66,6 +63,7 @@ def add_transcoding_progress(db_conn): col.create(media_entry) db_conn.commit() + @RegisterMigration(4, MIGRATIONS) def add_collection_tables(db_conn): metadata = MetaData(bind=db_conn.bind) @@ -92,6 +90,7 @@ def add_collection_tables(db_conn): db_conn.commit() + @RegisterMigration(5, MIGRATIONS) def add_mediaentry_collected(db_conn): metadata = MetaData(bind=db_conn.bind) @@ -102,4 +101,3 @@ def add_mediaentry_collected(db_conn): col = Column('collected', Integer, default=0) col.create(media_entry) db_conn.commit() - diff --git a/mediagoblin/db/sql/models.py b/mediagoblin/db/sql/models.py index 5862f722..ccf03f32 100644 --- a/mediagoblin/db/sql/models.py +++ b/mediagoblin/db/sql/models.py @@ -85,6 +85,14 @@ class User(Base, UserMixin): _id = SimpleFieldAlias("id") + def __repr__(self): + return '<{0} #{1} {2} {3} "{4}">'.format( + self.__class__.__name__, + self.id, + 'verified' if self.email_verified else 'non-verified', + 'admin' if self.is_admin else 'user', + self.username) + class MediaEntry(Base, MediaEntryMixin): """ @@ -362,12 +370,12 @@ class Collection(Base, CollectionMixin): slug = Column(Unicode) created = Column(DateTime, nullable=False, default=datetime.datetime.now, index=True) - description = Column(UnicodeText) + description = Column(UnicodeText) creator = Column(Integer, ForeignKey(User.id), nullable=False) items = Column(Integer, default=0) get_creator = relationship(User) - + def get_collection_items(self, ascending=False): order_col = CollectionItem.position if not ascending: 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/oauth/README.rst b/mediagoblin/plugins/oauth/README.rst index e5a1dc3a..0c278e3e 100644 --- a/mediagoblin/plugins/oauth/README.rst +++ b/mediagoblin/plugins/oauth/README.rst @@ -122,20 +122,21 @@ 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 - `_ +- Only `bearer tokens`_ are issued. - `Access Token Scope`_ - `Implicit Grant`_ - ... -.. _`Client Registration`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-2 +.. _`bearer tokens`: http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-08 .. _`Access Token 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 diff --git a/mediagoblin/plugins/oauth/__init__.py b/mediagoblin/plugins/oauth/__init__.py index 33dcaf16..63bf49a8 100644 --- a/mediagoblin/plugins/oauth/__init__.py +++ b/mediagoblin/plugins/oauth/__init__.py @@ -18,11 +18,10 @@ 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__) @@ -39,8 +38,19 @@ def setup_plugin(): routes = [ Route('mediagoblin.plugins.oauth.authorize', '/oauth/authorize', controller='mediagoblin.plugins.oauth.views:authorize'), + Route('mediagoblin.plugins.oauth.authorize_client', '/oauth/client/authorize', + controller='mediagoblin.plugins.oauth.views:authorize_client'), Route('mediagoblin.plugins.oauth.access_token', '/oauth/access_token', - controller='mediagoblin.plugins.oauth.views:access_token')] + controller='mediagoblin.plugins.oauth.views:access_token'), + Route('mediagoblin.plugins.oauth.access_token', + '/oauth/client/connections', + controller='mediagoblin.plugins.oauth.views:list_connections'), + Route('mediagoblin.plugins.oauth.register_client', + '/oauth/client/register', + controller='mediagoblin.plugins.oauth.views:register_client'), + Route('mediagoblin.plugins.oauth.list_clients', + '/oauth/client/list', + controller='mediagoblin.plugins.oauth.views:list_clients')] pluginapi.register_routes(routes) pluginapi.register_template_path(os.path.join(PLUGIN_DIR, 'templates')) @@ -54,17 +64,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..35995373 --- /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 . + +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 visisble to users allowing your + appplication to authenticate as them.''')) + type = wtforms.SelectField(_('Type'), + [wtforms.validators.Required()], + choices=[ + ('confidential', 'Confidential'), + ('public', 'Public')], + description=_('''Confidential - The client can + make requests to the GNU MediaGoblin instance that can not be + intercepted by the user agent (e.g. server-side client).
+ Public - 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 required 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 . + +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..91bd0fc6 100644 --- a/mediagoblin/plugins/oauth/models.py +++ b/mediagoblin/plugins/oauth/models.py @@ -14,15 +14,84 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +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')) + + 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')) + + 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 +108,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 +134,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 . +-#} +{% extends "mediagoblin/base.html" %} +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} +
+
+ {{ csrf_token }} +

Authorize {{ client.name }}?

+

{{ client.description }}

+ {{ wtforms_util.render_divs(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 . +-#} +{% extends "mediagoblin/base.html" %} +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} +

{% trans %}OAuth client connections{% endtrans %}

+{% if connections %} +
    + {% for connection in connections %} +
  • {{ + connection.client.name }} - {{ connection.state }} +
  • + {% endfor %} +
+{% else %} +

You haven't connected using an OAuth client before.

+{% 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 . +-#} +{% extends "mediagoblin/base.html" %} +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} +

{% trans %}Your OAuth clients{% endtrans %}

+{% if clients %} +
    + {% for client in clients %} +
  • {{ client.name }} +
    +
    Type
    +
    {{ client.type }}
    +
    Description
    +
    {{ client.description }}
    +
    Identifier
    +
    {{ client.identifier }}
    +
    Secret
    +
    {{ client.secret }}
    +
    Redirect URI
    +
    {{ client.redirect_uri }}
    +
    +
  • + {% endfor %} +
+{% else %} +

You don't have any clients yet. Add one.

+{% 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 . +-#} +{% extends "mediagoblin/base.html" %} +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} +
+
+

Register OAuth client

+ {{ wtforms_util.render_divs(form) }} +
+ {{ csrf_token }} + +
+
+
+{% 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 . + +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..1c0d7f86 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.POST) - # 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.POST['name']) + client.description = unicode(request.POST['description']) + client.type = unicode(request.POST['type']) + client.owner_id = request.user.id + client.redirect_uri = unicode(request.POST['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.POST) + + 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,28 +169,34 @@ 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.POST) + 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: 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 + 'token_type': 'bearer', 'expires_in': - (token.expires - datetime.now()).total_seconds(), - 'refresh_token': 'This should probably be safe'} - return Response(json.dumps(access_token_data)) + (token.expires - datetime.now()).total_seconds()} + return json_response(access_token_data, _disable_cors=True) error_data = { 'error': 'Incorrect code'} -- 2.25.1