diff options
Diffstat (limited to 'mediagoblin/plugins/oauth')
-rw-r--r-- | mediagoblin/plugins/oauth/README.rst | 4 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/__init__.py | 2 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/forms.py | 9 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/migrations.py | 128 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/models.py | 87 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/templates/oauth/authorize.html | 2 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/tools.py | 73 | ||||
-rw-r--r-- | mediagoblin/plugins/oauth/views.py | 161 |
8 files changed, 354 insertions, 112 deletions
diff --git a/mediagoblin/plugins/oauth/README.rst b/mediagoblin/plugins/oauth/README.rst index 405a67e2..753b180f 100644 --- a/mediagoblin/plugins/oauth/README.rst +++ b/mediagoblin/plugins/oauth/README.rst @@ -7,6 +7,10 @@ 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. diff --git a/mediagoblin/plugins/oauth/__init__.py b/mediagoblin/plugins/oauth/__init__.py index 4714d95d..5762379d 100644 --- a/mediagoblin/plugins/oauth/__init__.py +++ b/mediagoblin/plugins/oauth/__init__.py @@ -34,7 +34,7 @@ def setup_plugin(): _log.debug('OAuth config: {0}'.format(config)) routes = [ - ('mediagoblin.plugins.oauth.authorize', + ('mediagoblin.plugins.oauth.authorize', '/oauth/authorize', 'mediagoblin.plugins.oauth.views:authorize'), ('mediagoblin.plugins.oauth.authorize_client', diff --git a/mediagoblin/plugins/oauth/forms.py b/mediagoblin/plugins/oauth/forms.py index 2a956dad..5edd992a 100644 --- a/mediagoblin/plugins/oauth/forms.py +++ b/mediagoblin/plugins/oauth/forms.py @@ -19,14 +19,13 @@ import wtforms from urlparse import urlparse from mediagoblin.tools.extlib.wtf_html5 import URLField -from mediagoblin.tools.translate import fake_ugettext_passthrough as _ +from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ class AuthorizationForm(wtforms.Form): - client_id = wtforms.HiddenField(_(u'Client ID'), - [wtforms.validators.Required()]) - next = wtforms.HiddenField(_(u'Next URL'), - [wtforms.validators.Required()]) + 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')) diff --git a/mediagoblin/plugins/oauth/migrations.py b/mediagoblin/plugins/oauth/migrations.py index f2af3907..d7b89da3 100644 --- a/mediagoblin/plugins/oauth/migrations.py +++ b/mediagoblin/plugins/oauth/migrations.py @@ -14,16 +14,109 @@ # 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 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.sql.util import RegisterMigration +from mediagoblin.db.migration_tools import RegisterMigration +from mediagoblin.db.models import User -from mediagoblin.plugins.oauth.models import OAuthClient, OAuthToken, \ - OAuthUserClient, OAuthCode 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) @@ -38,9 +131,28 @@ def remove_and_replace_token_and_code(db): code_table.drop() - OAuthClient.__table__.create(db.bind) - OAuthUserClient.__table__.create(db.bind) - OAuthToken.__table__.create(db.bind) - OAuthCode.__table__.create(db.bind) + 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 index 7e247c1a..439424d3 100644 --- a/mediagoblin/plugins/oauth/models.py +++ b/mediagoblin/plugins/oauth/models.py @@ -14,17 +14,17 @@ # 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, Enum) -from sqlalchemy.orm import relationship +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. @@ -41,11 +41,14 @@ class OAuthClient(Base): name = Column(Unicode) description = Column(Unicode) - identifier = Column(Unicode, unique=True, index=True) - secret = Column(Unicode, index=True) + 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='registered_clients') + owner = relationship( + User, + backref=backref('registered_clients', cascade='all, delete-orphan')) redirect_uri = Column(Unicode) @@ -54,14 +57,8 @@ class OAuthClient(Base): u'public', name=u'oauth__client_type')) - def generate_identifier(self): - self.identifier = unicode(uuid.uuid4()) - - def generate_secret(self): - self.secret = unicode( - bcrypt.hashpw( - unicode(uuid.uuid4()), - bcrypt.gensalt())) + def update_secret(self): + self.secret = generate_secret() def __repr__(self): return '<{0} {1}:{2} ({3})>'.format( @@ -76,10 +73,15 @@ class OAuthUserClient(Base): id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey(User.id)) - user = relationship(User, backref='oauth_clients') + user = relationship( + User, + backref=backref('oauth_client_relations', + cascade='all, delete-orphan')) client_id = Column(Integer, ForeignKey(OAuthClient.id)) - client = relationship(OAuthClient, backref='users') + client = relationship( + OAuthClient, + backref=backref('oauth_user_relations', cascade='all, delete-orphan')) state = Column(Enum( u'approved', @@ -103,15 +105,18 @@ class OAuthToken(Base): 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) + token = Column(Unicode, index=True, default=generate_token) user_id = Column(Integer, ForeignKey(User.id), nullable=False, index=True) - user = relationship(User) + user = relationship( + User, + backref=backref('oauth_tokens', cascade='all, delete-orphan')) client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False) - client = relationship(OAuthClient) + client = relationship( + OAuthClient, + backref=backref('oauth_tokens', cascade='all, delete-orphan')) def __repr__(self): return '<{0} #{1} expires {2} [{3}, {4}]>'.format( @@ -121,6 +126,34 @@ class OAuthToken(Base): 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' @@ -130,14 +163,17 @@ class OAuthCode(Base): default=datetime.now) expires = Column(DateTime, nullable=False, default=lambda: datetime.now() + timedelta(minutes=5)) - code = Column(Unicode, index=True) + code = Column(Unicode, index=True, default=generate_code) user_id = Column(Integer, ForeignKey(User.id), nullable=False, index=True) - user = relationship(User) + user = relationship(User, backref=backref('oauth_codes', + cascade='all, delete-orphan')) client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False) - client = relationship(OAuthClient) + client = relationship(OAuthClient, backref=backref( + 'oauth_codes', + cascade='all, delete-orphan')) def __repr__(self): return '<{0} #{1} expires {2} [{3}, {4}]>'.format( @@ -150,6 +186,7 @@ class OAuthCode(Base): MODELS = [ OAuthToken, + OAuthRefreshToken, OAuthCode, OAuthClient, OAuthUserClient] diff --git a/mediagoblin/plugins/oauth/templates/oauth/authorize.html b/mediagoblin/plugins/oauth/templates/oauth/authorize.html index 647fa41f..8a00c925 100644 --- a/mediagoblin/plugins/oauth/templates/oauth/authorize.html +++ b/mediagoblin/plugins/oauth/templates/oauth/authorize.html @@ -16,7 +16,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. -#} {% extends "mediagoblin/base.html" %} -{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} +{% import "mediagoblin/utils/wtforms.html" as wtforms_util %} {% block mediagoblin_content %} <form action="{{ request.urlgen('mediagoblin.plugins.oauth.authorize_client') }}" diff --git a/mediagoblin/plugins/oauth/tools.py b/mediagoblin/plugins/oauth/tools.py index d21c8a5b..27ff32b4 100644 --- a/mediagoblin/plugins/oauth/tools.py +++ b/mediagoblin/plugins/oauth/tools.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # GNU MediaGoblin -- federated, autonomous media hosting # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. # @@ -14,13 +15,26 @@ # 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.oauth.models import OAuthClient 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'): @@ -41,3 +55,60 @@ def require_client_auth(controller): 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 index cf605fd2..d6fd314f 100644 --- a/mediagoblin/plugins/oauth/views.py +++ b/mediagoblin/plugins/oauth/views.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # GNU MediaGoblin -- federated, autonomous media hosting # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. # @@ -15,22 +16,21 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import logging -import json -from webob import exc, Response from urllib import urlencode -from uuid import uuid4 -from datetime import datetime + +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, ERROR +from mediagoblin.messages import add_message, SUCCESS from mediagoblin.tools.translate import pass_to_ugettext as _ -from mediagoblin.plugins.oauth.models import OAuthCode, OAuthToken, \ - OAuthClient, OAuthUserClient +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 +from mediagoblin.plugins.oauth.tools import require_client_auth, \ + create_token from mediagoblin.plugins.api.tools import json_response _log = logging.getLogger(__name__) @@ -45,14 +45,11 @@ def register_client(request): if request.method == 'POST' and form.validate(): client = OAuthClient() - client.name = unicode(request.form['name']) - client.description = unicode(request.form['description']) - client.type = unicode(request.form['type']) + client.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(request.form['redirect_uri']) - - client.generate_identifier() - client.generate_secret() + client.redirect_uri = unicode(form.redirect_uri.data) client.save() @@ -92,9 +89,9 @@ def authorize_client(request): form.client_id.data).first() if not client: - _log.error('''No such client id as received from client authorization - form.''') - return exc.HTTPBadRequest() + _log.error('No such client id as received from client authorization \ +form.') + raise BadRequest() if form.validate(): relation = OAuthUserClient() @@ -105,11 +102,11 @@ def authorize_client(request): elif form.deny.data: relation.state = u'rejected' else: - return exc.HTTPBadRequest + raise BadRequest() relation.save() - return exc.HTTPFound(location=form.next.data) + return redirect(request, location=form.next.data) return render_to_response( request, @@ -136,7 +133,7 @@ def authorize(request, client): return json_response({ 'status': 400, 'errors': - [u'Public clients MUST have a redirect_uri pre-set']}, + [u'Public clients should have a redirect_uri pre-set.']}, _disable_cors=True) redirect_uri = client.redirect_uri @@ -146,11 +143,10 @@ def authorize(request, client): 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) + 'errors': [u'No redirect_uri supplied!']}, + _disable_cors=True) code = OAuthCode() - code.code = unicode(uuid4()) code.user = request.user code.client = client code.save() @@ -162,7 +158,7 @@ def authorize(request, client): _log.debug('Redirecting to {0}'.format(redirect_uri)) - return exc.HTTPFound(location=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 @@ -180,56 +176,79 @@ def authorize(request, 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 code: - if code.client.type == u'confidential': - client_identifier = request.GET.get('client_id') - - if not client_identifier: - return json_response({ - 'error': 'invalid_request', - 'error_description': - 'Missing client_id in request'}) - - client_secret = request.GET.get('client_secret') - - if not client_secret: - return json_response({ - 'error': 'invalid_request', - 'error_description': - 'Missing client_secret in request'}) - - if not client_secret == code.client.secret or \ - not client_identifier == code.client.identifier: - return json_response({ - 'error': 'invalid_client', - 'error_description': - 'The client_id or client_secret does not match the' - ' code'}) - - token = OAuthToken() - token.token = unicode(uuid4()) - token.user = code.user - token.client = code.client - token.save() - - access_token_data = { - 'access_token': token.token, - 'token_type': 'bearer', - 'expires_in': int( - round( - (token.expires - datetime.now()).total_seconds()))} - return json_response(access_token_data, _disable_cors=True) - else: + if not code: return json_response({ 'error': 'invalid_request', 'error_description': - 'Invalid code'}) - else: - return json_response({ - 'error': 'invalid_request', - 'error_descriptin': - 'Missing `code` parameter in request'}) + '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.'}) |