aboutsummaryrefslogtreecommitdiffstats
path: root/mediagoblin/plugins/oauth
diff options
context:
space:
mode:
Diffstat (limited to 'mediagoblin/plugins/oauth')
-rw-r--r--mediagoblin/plugins/oauth/README.rst148
-rw-r--r--mediagoblin/plugins/oauth/__init__.py109
-rw-r--r--mediagoblin/plugins/oauth/forms.py69
-rw-r--r--mediagoblin/plugins/oauth/migrations.py158
-rw-r--r--mediagoblin/plugins/oauth/models.py192
-rw-r--r--mediagoblin/plugins/oauth/templates/oauth/authorize.html31
-rw-r--r--mediagoblin/plugins/oauth/templates/oauth/client/connections.html34
-rw-r--r--mediagoblin/plugins/oauth/templates/oauth/client/list.html45
-rw-r--r--mediagoblin/plugins/oauth/templates/oauth/client/register.html34
-rw-r--r--mediagoblin/plugins/oauth/tools.py114
-rw-r--r--mediagoblin/plugins/oauth/views.py254
11 files changed, 1188 insertions, 0 deletions
diff --git a/mediagoblin/plugins/oauth/README.rst b/mediagoblin/plugins/oauth/README.rst
new file mode 100644
index 00000000..753b180f
--- /dev/null
+++ b/mediagoblin/plugins/oauth/README.rst
@@ -0,0 +1,148 @@
+==============
+ OAuth plugin
+==============
+
+.. warning::
+ In its current state. This plugin has received no security audit.
+ Development has been entirely focused on Making It Work(TM). Use this
+ plugin with caution.
+
+ Additionally, this and the API may break... consider it pre-alpha.
+ There's also a known issue that the OAuth client doesn't do
+ refresh tokens so this might result in issues for users.
+
+The OAuth plugin enables third party web applications to authenticate as one or
+more GNU MediaGoblin users in a safe way in order retrieve, create and update
+content stored on the GNU MediaGoblin instance.
+
+The OAuth plugin is based on the `oauth v2.25 draft`_ and is pointing by using
+the ``oauthlib.oauth2.draft25.WebApplicationClient`` from oauthlib_ to a
+mediagoblin instance and building the OAuth 2 provider logic around the client.
+
+There are surely some aspects of the OAuth v2.25 draft that haven't made it
+into this plugin due to the technique used to develop it.
+
+.. _`oauth v2.25 draft`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25
+.. _oauthlib: http://pypi.python.org/pypi/oauthlib
+
+
+Set up the OAuth plugin
+=======================
+
+1. Add the following to your MediaGoblin .ini file in the ``[plugins]`` section::
+
+ [[mediagoblin.plugins.oauth]]
+
+2. Run::
+
+ gmg dbupdate
+
+ in order to create and apply migrations to any database tables that the
+ plugin requires.
+
+.. note::
+ This only enables the OAuth plugin. To be able to let clients fetch data
+ from the MediaGoblin instance you should also enable the API plugin or some
+ other plugin that supports authenticating with OAuth credentials.
+
+
+Authenticate against GNU MediaGoblin
+====================================
+
+.. note::
+ As mentioned in `capabilities`_ GNU MediaGoblin currently only supports the
+ `Authorization Code Grant`_ procedure for obtaining an OAuth access token.
+
+Authorization Code Grant
+------------------------
+
+.. note::
+ As mentioned in `incapabilities`_ GNU MediaGoblin currently does not
+ support `client registration`_
+
+The `authorization code grant`_ works in the following way:
+
+`Definitions`
+
+ Authorization server
+ The GNU MediaGoblin instance
+ Resource server
+ Also the GNU MediaGoblin instance ;)
+ Client
+ The web application intended to use the data
+ Redirect uri
+ An URI pointing to a page controlled by the *client*
+ Resource owner
+ The GNU MediaGoblin user who's resources the client requests access to
+ User agent
+ Commonly the GNU MediaGoblin user's web browser
+ Authorization code
+ An intermediate token that is exchanged for an *access token*
+ Access token
+ A secret token that the *client* uses to authenticate itself agains the
+ *resource server* as a specific *resource owner*.
+
+
+Brief description of the procedure
+++++++++++++++++++++++++++++++++++
+
+1. The *client* requests an *authorization code* from the *authorization
+ server* by redirecting the *user agent* to the `Authorization Endpoint`_.
+ Which parameters should be included in the redirect are covered later in
+ this document.
+2. The *authorization server* authenticates the *resource owner* and redirects
+ the *user agent* back to the *redirect uri* (covered later in this
+ document).
+3. The *client* receives the request from the *user agent*, attached is the
+ *authorization code*.
+4. The *client* requests an *access token* from the *authorization server*
+5. \?\?\?\?\?
+6. Profit!
+
+
+Detailed description of the procedure
++++++++++++++++++++++++++++++++++++++
+
+TBD, in the meantime here is a proof-of-concept GNU MediaGoblin client:
+
+https://github.com/jwandborg/omgmg/
+
+and here are some detailed descriptions from other OAuth 2
+providers:
+
+- https://developers.google.com/accounts/docs/OAuth2WebServer
+- https://developers.facebook.com/docs/authentication/server-side/
+
+and if you're unsure about anything, there's the `OAuth v2.25 draft
+<http://tools.ietf.org/html/draft-ietf-oauth-v2-25>`_, the `OAuth plugin
+source code
+<http://gitorious.org/mediagoblin/mediagoblin/trees/master/mediagoblin/plugins/oauth>`_
+and the `#mediagoblin IRC channel <http://mediagoblin.org/pages/join.html#irc>`_.
+
+
+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
+==============
+
+- Only `bearer tokens`_ are issued.
+- `Implicit Grant`_
+- `Force TLS for token endpoint`_ - This one is up the the siteadmin
+- Authorization `scope`_ and `state`
+- ...
+
+.. _`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
new file mode 100644
index 00000000..5762379d
--- /dev/null
+++ b/mediagoblin/plugins/oauth/__init__.py
@@ -0,0 +1,109 @@
+# 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 os
+import logging
+
+from mediagoblin.tools import pluginapi
+from mediagoblin.plugins.oauth.models import OAuthToken, OAuthClient, \
+ OAuthUserClient
+from mediagoblin.plugins.api.tools import Auth
+
+_log = logging.getLogger(__name__)
+
+PLUGIN_DIR = os.path.dirname(__file__)
+
+
+def setup_plugin():
+ config = pluginapi.get_config('mediagoblin.plugins.oauth')
+
+ _log.info('Setting up OAuth...')
+ _log.debug('OAuth config: {0}'.format(config))
+
+ routes = [
+ ('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'))
+
+
+class OAuthAuth(Auth):
+ def trigger(self, request):
+ if 'access_token' in request.GET:
+ return True
+
+ 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 = {
+ 'setup': setup_plugin,
+ 'auth': OAuthAuth()
+ }
diff --git a/mediagoblin/plugins/oauth/forms.py b/mediagoblin/plugins/oauth/forms.py
new file mode 100644
index 00000000..5edd992a
--- /dev/null
+++ b/mediagoblin/plugins/oauth/forms.py
@@ -0,0 +1,69 @@
+# 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 lazy_pass_to_ugettext as _
+
+
+class AuthorizationForm(wtforms.Form):
+ client_id = wtforms.HiddenField(u'',
+ validators=[wtforms.validators.Required()])
+ next = wtforms.HiddenField(u'', validators=[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..d7b89da3
--- /dev/null
+++ b/mediagoblin/plugins/oauth/migrations.py
@@ -0,0 +1,158 @@
+# 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 datetime import datetime, timedelta
+from sqlalchemy import (MetaData, Table, Column,
+ Integer, Unicode, Enum, DateTime, ForeignKey)
+from sqlalchemy.ext.declarative import declarative_base
+
+from mediagoblin.db.migration_tools import RegisterMigration
+from mediagoblin.db.models import User
+
+
+MIGRATIONS = {}
+
+
+class OAuthClient_v0(declarative_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))
+ redirect_uri = Column(Unicode)
+
+ type = Column(Enum(
+ u'confidential',
+ u'public',
+ name=u'oauth__client_type'))
+
+
+class OAuthUserClient_v0(declarative_base()):
+ __tablename__ = 'oauth__user_client'
+ id = Column(Integer, primary_key=True)
+
+ user_id = Column(Integer, ForeignKey(User.id))
+ client_id = Column(Integer, ForeignKey(OAuthClient_v0.id))
+
+ state = Column(Enum(
+ u'approved',
+ u'rejected',
+ name=u'oauth__relation_state'))
+
+
+class OAuthToken_v0(declarative_base()):
+ __tablename__ = 'oauth__tokens'
+
+ id = Column(Integer, primary_key=True)
+ created = Column(DateTime, nullable=False,
+ default=datetime.now)
+ expires = Column(DateTime, nullable=False,
+ default=lambda: datetime.now() + timedelta(days=30))
+ token = Column(Unicode, index=True)
+ refresh_token = Column(Unicode, index=True)
+
+ user_id = Column(Integer, ForeignKey(User.id), nullable=False,
+ index=True)
+
+ client_id = Column(Integer, ForeignKey(OAuthClient_v0.id), nullable=False)
+
+ 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_v0(declarative_base()):
+ __tablename__ = 'oauth__codes'
+
+ id = Column(Integer, primary_key=True)
+ created = Column(DateTime, nullable=False,
+ default=datetime.now)
+ expires = Column(DateTime, nullable=False,
+ default=lambda: datetime.now() + timedelta(minutes=5))
+ code = Column(Unicode, index=True)
+
+ user_id = Column(Integer, ForeignKey(User.id), nullable=False,
+ index=True)
+
+ client_id = Column(Integer, ForeignKey(OAuthClient_v0.id), nullable=False)
+
+
+class OAuthRefreshToken_v0(declarative_base()):
+ __tablename__ = 'oauth__refresh_tokens'
+
+ id = Column(Integer, primary_key=True)
+ created = Column(DateTime, nullable=False,
+ default=datetime.now)
+
+ token = Column(Unicode, index=True)
+
+ user_id = Column(Integer, ForeignKey(User.id), nullable=False)
+
+ # XXX: Is it OK to use OAuthClient_v0.id in this way?
+ client_id = Column(Integer, ForeignKey(OAuthClient_v0.id), nullable=False)
+
+
+@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_v0.__table__.create(db.bind)
+ OAuthUserClient_v0.__table__.create(db.bind)
+ OAuthToken_v0.__table__.create(db.bind)
+ OAuthCode_v0.__table__.create(db.bind)
+
+ db.commit()
+
+
+@RegisterMigration(2, MIGRATIONS)
+def remove_refresh_token_field(db):
+ metadata = MetaData(bind=db.bind)
+
+ token_table = Table('oauth__tokens', metadata, autoload=True,
+ autoload_with=db.bind)
+
+ refresh_token = token_table.columns['refresh_token']
+
+ refresh_token.drop()
+ db.commit()
+
+@RegisterMigration(3, MIGRATIONS)
+def create_refresh_token_table(db):
+ OAuthRefreshToken_v0.__table__.create(db.bind)
+
+ db.commit()
diff --git a/mediagoblin/plugins/oauth/models.py b/mediagoblin/plugins/oauth/models.py
new file mode 100644
index 00000000..439424d3
--- /dev/null
+++ b/mediagoblin/plugins/oauth/models.py
@@ -0,0 +1,192 @@
+# 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 datetime import datetime, timedelta
+
+
+from sqlalchemy import (
+ Column, Unicode, Integer, DateTime, ForeignKey, Enum)
+from sqlalchemy.orm import relationship, backref
+from mediagoblin.db.base import Base
+from mediagoblin.db.models import User
+from mediagoblin.plugins.oauth.tools import generate_identifier, \
+ generate_secret, generate_token, generate_code, generate_refresh_token
+
+# 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,
+ default=generate_identifier)
+ secret = Column(Unicode, index=True, default=generate_secret)
+
+ owner_id = Column(Integer, ForeignKey(User.id))
+ owner = relationship(
+ User,
+ backref=backref('registered_clients', cascade='all, delete-orphan'))
+
+ redirect_uri = Column(Unicode)
+
+ type = Column(Enum(
+ u'confidential',
+ u'public',
+ name=u'oauth__client_type'))
+
+ def update_secret(self):
+ self.secret = generate_secret()
+
+ 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=backref('oauth_client_relations',
+ cascade='all, delete-orphan'))
+
+ client_id = Column(Integer, ForeignKey(OAuthClient.id))
+ client = relationship(
+ OAuthClient,
+ backref=backref('oauth_user_relations', cascade='all, delete-orphan'))
+
+ 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'
+
+ id = Column(Integer, primary_key=True)
+ created = Column(DateTime, nullable=False,
+ default=datetime.now)
+ expires = Column(DateTime, nullable=False,
+ default=lambda: datetime.now() + timedelta(days=30))
+ token = Column(Unicode, index=True, default=generate_token)
+
+ user_id = Column(Integer, ForeignKey(User.id), nullable=False,
+ index=True)
+ user = relationship(
+ User,
+ backref=backref('oauth_tokens', cascade='all, delete-orphan'))
+
+ client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False)
+ client = relationship(
+ OAuthClient,
+ backref=backref('oauth_tokens', cascade='all, delete-orphan'))
+
+ def __repr__(self):
+ return '<{0} #{1} expires {2} [{3}, {4}]>'.format(
+ self.__class__.__name__,
+ self.id,
+ self.expires.isoformat(),
+ self.user,
+ self.client)
+
+class OAuthRefreshToken(Base):
+ __tablename__ = 'oauth__refresh_tokens'
+
+ id = Column(Integer, primary_key=True)
+ created = Column(DateTime, nullable=False,
+ default=datetime.now)
+
+ token = Column(Unicode, index=True,
+ default=generate_refresh_token)
+
+ user_id = Column(Integer, ForeignKey(User.id), nullable=False)
+
+ user = relationship(User, backref=backref('oauth_refresh_tokens',
+ cascade='all, delete-orphan'))
+
+ client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False)
+ client = relationship(OAuthClient,
+ backref=backref(
+ 'oauth_refresh_tokens',
+ cascade='all, delete-orphan'))
+
+ def __repr__(self):
+ return '<{0} #{1} [{3}, {4}]>'.format(
+ self.__class__.__name__,
+ self.id,
+ self.user,
+ self.client)
+
+
+class OAuthCode(Base):
+ __tablename__ = 'oauth__codes'
+
+ id = Column(Integer, primary_key=True)
+ created = Column(DateTime, nullable=False,
+ default=datetime.now)
+ expires = Column(DateTime, nullable=False,
+ default=lambda: datetime.now() + timedelta(minutes=5))
+ code = Column(Unicode, index=True, default=generate_code)
+
+ user_id = Column(Integer, ForeignKey(User.id), nullable=False,
+ index=True)
+ user = relationship(User, backref=backref('oauth_codes',
+ cascade='all, delete-orphan'))
+
+ client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False)
+ client = relationship(OAuthClient, backref=backref(
+ 'oauth_codes',
+ cascade='all, delete-orphan'))
+
+ 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,
+ OAuthRefreshToken,
+ 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..8a00c925
--- /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..27ff32b4
--- /dev/null
+++ b/mediagoblin/plugins/oauth/tools.py
@@ -0,0 +1,114 @@
+# -*- coding: utf-8 -*-
+# 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 uuid
+
+from random import getrandbits
+
+from datetime import datetime
+
+from functools import wraps
+
+from mediagoblin.plugins.api.tools import json_response
+
+
+def require_client_auth(controller):
+ '''
+ View decorator
+
+ - Requires the presence of ``?client_id``
+ '''
+ # Avoid circular import
+ from mediagoblin.plugins.oauth.models import OAuthClient
+
+ @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
+
+
+def create_token(client, user):
+ '''
+ Create an OAuthToken and an OAuthRefreshToken entry in the database
+
+ Returns the data structure expected by the OAuth clients.
+ '''
+ from mediagoblin.plugins.oauth.models import OAuthToken, OAuthRefreshToken
+
+ token = OAuthToken()
+ token.user = user
+ token.client = client
+ token.save()
+
+ refresh_token = OAuthRefreshToken()
+ refresh_token.user = user
+ refresh_token.client = client
+ refresh_token.save()
+
+ # expire time of token in full seconds
+ # timedelta.total_seconds is python >= 2.7 or we would use that
+ td = token.expires - datetime.now()
+ exp_in = 86400*td.days + td.seconds # just ignore µsec
+
+ return {'access_token': token.token, 'token_type': 'bearer',
+ 'refresh_token': refresh_token.token, 'expires_in': exp_in}
+
+
+def generate_identifier():
+ ''' Generates a ``uuid.uuid4()`` '''
+ return unicode(uuid.uuid4())
+
+
+def generate_token():
+ ''' Uses generate_identifier '''
+ return generate_identifier()
+
+
+def generate_refresh_token():
+ ''' Uses generate_identifier '''
+ return generate_identifier()
+
+
+def generate_code():
+ ''' Uses generate_identifier '''
+ return generate_identifier()
+
+
+def generate_secret():
+ '''
+ Generate a long string of pseudo-random characters
+ '''
+ # XXX: We might not want it to use bcrypt, since bcrypt takes its time to
+ # generate the result.
+ return unicode(getrandbits(192))
+
diff --git a/mediagoblin/plugins/oauth/views.py b/mediagoblin/plugins/oauth/views.py
new file mode 100644
index 00000000..d6fd314f
--- /dev/null
+++ b/mediagoblin/plugins/oauth/views.py
@@ -0,0 +1,254 @@
+# -*- coding: utf-8 -*-
+# 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
+
+from urllib import urlencode
+
+from werkzeug.exceptions import BadRequest
+
+from mediagoblin.tools.response import render_to_response, redirect
+from mediagoblin.decorators import require_active_login
+from mediagoblin.messages import add_message, SUCCESS
+from mediagoblin.tools.translate import pass_to_ugettext as _
+from mediagoblin.plugins.oauth.models import OAuthCode, OAuthClient, \
+ OAuthUserClient, OAuthRefreshToken
+from mediagoblin.plugins.oauth.forms import ClientRegistrationForm, \
+ AuthorizationForm
+from mediagoblin.plugins.oauth.tools import require_client_auth, \
+ create_token
+from mediagoblin.plugins.api.tools import json_response
+
+_log = logging.getLogger(__name__)
+
+
+@require_active_login
+def register_client(request):
+ '''
+ Register an OAuth client
+ '''
+ form = ClientRegistrationForm(request.form)
+
+ if request.method == 'POST' and form.validate():
+ client = OAuthClient()
+ client.name = unicode(form.name.data)
+ client.description = unicode(form.description.data)
+ client.type = unicode(form.type.data)
+ client.owner_id = request.user.id
+ client.redirect_uri = unicode(form.redirect_uri.data)
+
+ 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.')
+ raise BadRequest()
+
+ 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:
+ raise BadRequest()
+
+ relation.save()
+
+ return redirect(request, 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 should 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'No redirect_uri supplied!']},
+ _disable_cors=True)
+
+ code = OAuthCode()
+ code.user = request.user
+ code.client = client
+ code.save()
+
+ redirect_uri = ''.join([
+ redirect_uri,
+ '?',
+ urlencode({'code': code.code})])
+
+ _log.debug('Redirecting to {0}'.format(redirect_uri))
+
+ return redirect(request, location=redirect_uri)
+ else:
+ # Show prompt to allow client to access data
+ # - on accept: send the user agent back to the redirect_uri with the
+ # code parameter
+ # - on deny: send the user agent back to the redirect uri with error
+ # information
+ 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):
+ '''
+ Access token endpoint provides access tokens to any clients that have the
+ right grants/credentials
+ '''
+
+ client = None
+ user = None
+
+ if request.GET.get('code'):
+ # Validate the code arg, then get the client object from the db.
+ code = OAuthCode.query.filter(OAuthCode.code ==
+ request.GET.get('code')).first()
+
+ if not code:
+ return json_response({
+ 'error': 'invalid_request',
+ 'error_description':
+ 'Invalid code.'})
+
+ client = code.client
+ user = code.user
+
+ elif request.args.get('refresh_token'):
+ # Validate a refresh token, then get the client object from the db.
+ refresh_token = OAuthRefreshToken.query.filter(
+ OAuthRefreshToken.token ==
+ request.args.get('refresh_token')).first()
+
+ if not refresh_token:
+ return json_response({
+ 'error': 'invalid_request',
+ 'error_description':
+ 'Invalid refresh token.'})
+
+ client = refresh_token.client
+ user = refresh_token.user
+
+ if client:
+ client_identifier = request.GET.get('client_id')
+
+ if not client_identifier:
+ return json_response({
+ 'error': 'invalid_request',
+ 'error_description':
+ 'Missing client_id in request.'})
+
+ if not client_identifier == client.identifier:
+ return json_response({
+ 'error': 'invalid_client',
+ 'error_description':
+ 'Mismatching client credentials.'})
+
+ if client.type == u'confidential':
+ 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 == client.secret:
+ return json_response({
+ 'error': 'invalid_client',
+ 'error_description':
+ 'Mismatching client credentials.'})
+
+
+ access_token_data = create_token(client, user)
+
+ return json_response(access_token_data, _disable_cors=True)
+
+ return json_response({
+ 'error': 'invalid_request',
+ 'error_description':
+ 'Missing `code` or `refresh_token` parameter in request.'})