Added client registration caps to OAuth plugin
authorJoar Wandborg <git@wandborg.com>
Fri, 21 Sep 2012 11:02:35 +0000 (13:02 +0200)
committerJoar Wandborg <git@wandborg.com>
Fri, 21 Sep 2012 11:09:42 +0000 (13:09 +0200)
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.

14 files changed:
mediagoblin/db/sql/migrations.py
mediagoblin/db/sql/models.py
mediagoblin/plugins/api/tools.py
mediagoblin/plugins/oauth/README.rst
mediagoblin/plugins/oauth/__init__.py
mediagoblin/plugins/oauth/forms.py [new file with mode: 0644]
mediagoblin/plugins/oauth/migrations.py [new file with mode: 0644]
mediagoblin/plugins/oauth/models.py
mediagoblin/plugins/oauth/templates/oauth/authorize.html [new file with mode: 0644]
mediagoblin/plugins/oauth/templates/oauth/client/connections.html [new file with mode: 0644]
mediagoblin/plugins/oauth/templates/oauth/client/list.html [new file with mode: 0644]
mediagoblin/plugins/oauth/templates/oauth/client/register.html [new file with mode: 0644]
mediagoblin/plugins/oauth/tools.py [new file with mode: 0644]
mediagoblin/plugins/oauth/views.py

index fb17828513b86692a0fd6a1648b94639fab8f493..e86109e9a669048b72278730f5a3b45fb24b112e 100644 (file)
 
 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()
-
index 5862f722b2f362c6e472794b6ddd871a923b36d0..ccf03f320d74adfbdda88569dbb59fd41515aa04 100644 (file)
@@ -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:
index c4630ba7b4f3eef42fc490641ef0e0f2d40aebdd..ecc50364e8aed801fdcdbe5417a35f35e5bd6053 100644 (file)
@@ -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)
index e5a1dc3aabffd74288d7aba1e61accb7830119d1..0c278e3e1823860c2d3e569034039bf9b3e10aac 100644 (file)
@@ -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
-  <http://issues.mediagoblin.org/ticket/497>`_
+- 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
index 33dcaf16d6aa92d505fd59ce35c19859e09d6b78..63bf49a8a4bc7d4af503159a7bcb2dd9edc3b914 100644 (file)
@@ -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 (file)
index 0000000..3599537
--- /dev/null
@@ -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 visisble to users allowing your
+                appplication 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 (file)
index 0000000..f2af390
--- /dev/null
@@ -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()
index 295987c846e8098c34ce8443e6a335d3b5e355ab..91bd0fc63778ae137acf6dd06f7aa068d2c8fd39 100644 (file)
 # 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'))
+
+    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 (file)
index 0000000..647fa41
--- /dev/null
@@ -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 (file)
index 0000000..63b0230
--- /dev/null
@@ -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 (file)
index 0000000..21024bb
--- /dev/null
@@ -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 (file)
index 0000000..6fd700d
--- /dev/null
@@ -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 (file)
index 0000000..d21c8a5
--- /dev/null
@@ -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
index 70be303968cc0d9afaeb225a3066c58ba8e41f5d..1c0d7f86f3d865efd59c793893f767736aec4fc8 100644 (file)
@@ -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'}