diff options
Diffstat (limited to 'mediagoblin/plugins/oauth')
-rw-r--r-- | mediagoblin/plugins/oauth/README.rst | 148 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/__init__.py | 109 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/forms.py | 69 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/migrations.py | 158 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/models.py | 192 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/templates/oauth/authorize.html | 31 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/templates/oauth/client/connections.html | 34 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/templates/oauth/client/list.html | 45 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/templates/oauth/client/register.html | 34 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/tools.py | 114 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/views.py | 254 |
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.'}) |