Merge branch 'master' of gitorious.org:mediagoblin/mediagoblin

This commit is contained in:
Christopher Allan Webber 2013-04-13 20:21:14 -05:00
commit 0f6ab7da86
7 changed files with 297 additions and 101 deletions

View File

@ -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',

View File

@ -102,6 +102,21 @@ class OAuthCode_v0(declarative_base()):
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)
@ -122,3 +137,22 @@ def remove_and_replace_token_and_code(db):
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()

View File

@ -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.base import Base
from mediagoblin.db.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]

View File

@ -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))

View File

@ -16,21 +16,21 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
import json
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__)
@ -51,9 +51,6 @@ def register_client(request):
client.owner_id = request.user.id
client.redirect_uri = unicode(form.redirect_uri.data)
client.generate_identifier()
client.generate_secret()
client.save()
add_message(request, SUCCESS, _('The client {0} has been registered!')\
@ -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 BadRequest()
_log.error('No such client id as received from client authorization \
form.')
raise BadRequest()
if form.validate():
relation = OAuthUserClient()
@ -105,7 +102,7 @@ def authorize_client(request):
elif form.deny.data:
relation.state = u'rejected'
else:
return BadRequest
raise BadRequest()
relation.save()
@ -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()
@ -180,59 +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()
# 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
access_token_data = {
'access_token': token.token,
'token_type': 'bearer',
'expires_in': exp_in}
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.'})

View File

@ -71,7 +71,7 @@ class TestOAuth(object):
assert response.status_int == 200
# Should display an error
assert ctx['form'].redirect_uri.errors
assert len(ctx['form'].redirect_uri.errors)
# Should not pass through
assert not client
@ -79,12 +79,16 @@ class TestOAuth(object):
def test_2_successful_public_client_registration(self, test_app):
''' Successfully register a public client '''
self._setup(test_app)
uri = 'http://foo.example'
self.register_client(test_app, u'OMGOMG', 'public', 'OMG!',
'http://foo.example')
uri)
client = self.db.OAuthClient.query.filter(
self.db.OAuthClient.name == u'OMGOMG').first()
# redirect_uri should be set
assert client.redirect_uri == uri
# Client should have been registered
assert client
@ -116,7 +120,7 @@ class TestOAuth(object):
redirect_uri = 'https://foo.example'
response = test_app.get('/oauth/authorize', {
'client_id': client.identifier,
'scope': 'admin',
'scope': 'all',
'redirect_uri': redirect_uri})
# User-agent should NOT be redirected
@ -142,6 +146,7 @@ class TestOAuth(object):
return authorization_response, client_identifier
def get_code_from_redirect_uri(self, uri):
''' Get the value of ?code= from an URI '''
return parse_qs(urlparse(uri).query)['code'][0]
def test_token_endpoint_successful_confidential_request(self, test_app):
@ -170,6 +175,11 @@ code={1}&client_secret={2}'.format(client_id, code, client.secret))
assert type(token_data['expires_in']) == int
assert token_data['expires_in'] > 0
# There should be a refresh token provided in the token data
assert len(token_data['refresh_token'])
return client_id, token_data
def test_token_endpont_missing_id_confidential_request(self, test_app):
''' Unsuccessful request against token endpoint, missing client_id '''
self._setup(test_app)
@ -192,4 +202,30 @@ code={0}&client_secret={1}'.format(code, client.secret))
assert 'error' in token_data
assert not 'access_token' in token_data
assert token_data['error'] == 'invalid_request'
assert token_data['error_description'] == 'Missing client_id in request'
assert len(token_data['error_description'])
def test_refresh_token(self, test_app):
''' Try to get a new access token using the refresh token '''
# Get an access token and a refresh token
client_id, token_data =\
self.test_token_endpoint_successful_confidential_request(test_app)
client = self.db.OAuthClient.query.filter(
self.db.OAuthClient.identifier == client_id).first()
token_res = test_app.get('/oauth/access_token',
{'refresh_token': token_data['refresh_token'],
'client_id': client_id,
'client_secret': client.secret
})
assert token_res.status_int == 200
new_token_data = json.loads(token_res.body)
assert not 'error' in new_token_data
assert 'access_token' in new_token_data
assert 'token_type' in new_token_data
assert 'expires_in' in new_token_data
assert type(new_token_data['expires_in']) == int
assert new_token_data['expires_in'] > 0

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
import os
import pkg_resources
import shutil
@ -28,7 +29,6 @@ from mediagoblin import mg_globals
from mediagoblin.db.models import User, MediaEntry, Collection
from mediagoblin.tools import testing
from mediagoblin.init.config import read_mediagoblin_config
from mediagoblin.db.open import setup_connection_and_db_from_config
from mediagoblin.db.base import Session
from mediagoblin.meddleware import BaseMeddleware
from mediagoblin.auth.lib import bcrypt_gen_password_hash
@ -50,7 +50,9 @@ USER_DEV_DIRECTORIES_TO_SETUP = ['media/public', 'media/queue']
BAD_CELERY_MESSAGE = """\
Sorry, you *absolutely* must run tests with the
mediagoblin.init.celery.from_tests module. Like so:
$ CELERY_CONFIG_MODULE=mediagoblin.init.celery.from_tests ./bin/py.test"""
$ CELERY_CONFIG_MODULE=mediagoblin.init.celery.from_tests {0}
""".format(sys.argv[0])
class BadCeleryEnviron(Exception): pass
@ -230,7 +232,7 @@ def fixture_media_entry(title=u"Some title", slug=None,
entry.slug = slug
entry.uploader = uploader or fixture_add_user().id
entry.media_type = u'image'
if gen_slug:
entry.generate_slug()
if save: