Added client registration caps to OAuth plugin
THE MIGRATIONS SUPPLIED WITH THIS COMMIT WILL DROP AND RE-CREATE YOUR oauth__tokens AND oauth__codes TABLES. ALL YOUR OAUTH CODES AND TOKENS WILL BE LOST. - Fixed pylint issues in db/sql/migrations. - Added __repr__ to the User model. - Added _disable_cors option to json_response. - Added crude error handling to the api.tools.api_auth decorator - Updated the OAuth README. - Added client registration, client overview, connection overview, client authorization views and templates. - Added error handling to the OAuthAuth Auth object. - Added AuthorizationForm, ClientRegistrationForm in oauth/forms. - Added migrations for OAuth, added client registration migration. - Added OAuthClient, OAuthUserClient models. - Added oauth/tools with require_client_auth decorator method.
This commit is contained in:
parent
d4c066abf0
commit
88a9662be4
@ -16,15 +16,12 @@
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from sqlalchemy import (MetaData, Table, Column, Boolean, SmallInteger,
|
from sqlalchemy import (MetaData, Table, Column, Boolean, SmallInteger,
|
||||||
Integer, Unicode, UnicodeText, DateTime, ForeignKey)
|
Integer, Unicode, UnicodeText, DateTime, ForeignKey)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from mediagoblin.db.sql.util import RegisterMigration
|
from mediagoblin.db.sql.util import RegisterMigration
|
||||||
from mediagoblin.db.sql.models import MediaEntry, Collection, User
|
from mediagoblin.db.sql.models import MediaEntry, Collection, User
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS = {}
|
MIGRATIONS = {}
|
||||||
|
|
||||||
|
|
||||||
@ -66,6 +63,7 @@ def add_transcoding_progress(db_conn):
|
|||||||
col.create(media_entry)
|
col.create(media_entry)
|
||||||
db_conn.commit()
|
db_conn.commit()
|
||||||
|
|
||||||
|
|
||||||
@RegisterMigration(4, MIGRATIONS)
|
@RegisterMigration(4, MIGRATIONS)
|
||||||
def add_collection_tables(db_conn):
|
def add_collection_tables(db_conn):
|
||||||
metadata = MetaData(bind=db_conn.bind)
|
metadata = MetaData(bind=db_conn.bind)
|
||||||
@ -92,6 +90,7 @@ def add_collection_tables(db_conn):
|
|||||||
|
|
||||||
db_conn.commit()
|
db_conn.commit()
|
||||||
|
|
||||||
|
|
||||||
@RegisterMigration(5, MIGRATIONS)
|
@RegisterMigration(5, MIGRATIONS)
|
||||||
def add_mediaentry_collected(db_conn):
|
def add_mediaentry_collected(db_conn):
|
||||||
metadata = MetaData(bind=db_conn.bind)
|
metadata = MetaData(bind=db_conn.bind)
|
||||||
@ -102,4 +101,3 @@ def add_mediaentry_collected(db_conn):
|
|||||||
col = Column('collected', Integer, default=0)
|
col = Column('collected', Integer, default=0)
|
||||||
col.create(media_entry)
|
col.create(media_entry)
|
||||||
db_conn.commit()
|
db_conn.commit()
|
||||||
|
|
||||||
|
@ -85,6 +85,14 @@ class User(Base, UserMixin):
|
|||||||
|
|
||||||
_id = SimpleFieldAlias("id")
|
_id = SimpleFieldAlias("id")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<{0} #{1} {2} {3} "{4}">'.format(
|
||||||
|
self.__class__.__name__,
|
||||||
|
self.id,
|
||||||
|
'verified' if self.email_verified else 'non-verified',
|
||||||
|
'admin' if self.is_admin else 'user',
|
||||||
|
self.username)
|
||||||
|
|
||||||
|
|
||||||
class MediaEntry(Base, MediaEntryMixin):
|
class MediaEntry(Base, MediaEntryMixin):
|
||||||
"""
|
"""
|
||||||
@ -362,12 +370,12 @@ class Collection(Base, CollectionMixin):
|
|||||||
slug = Column(Unicode)
|
slug = Column(Unicode)
|
||||||
created = Column(DateTime, nullable=False, default=datetime.datetime.now,
|
created = Column(DateTime, nullable=False, default=datetime.datetime.now,
|
||||||
index=True)
|
index=True)
|
||||||
description = Column(UnicodeText)
|
description = Column(UnicodeText)
|
||||||
creator = Column(Integer, ForeignKey(User.id), nullable=False)
|
creator = Column(Integer, ForeignKey(User.id), nullable=False)
|
||||||
items = Column(Integer, default=0)
|
items = Column(Integer, default=0)
|
||||||
|
|
||||||
get_creator = relationship(User)
|
get_creator = relationship(User)
|
||||||
|
|
||||||
def get_collection_items(self, ascending=False):
|
def get_collection_items(self, ascending=False):
|
||||||
order_col = CollectionItem.position
|
order_col = CollectionItem.position
|
||||||
if not ascending:
|
if not ascending:
|
||||||
|
@ -52,7 +52,7 @@ class Auth(object):
|
|||||||
raise NotImplemented()
|
raise NotImplemented()
|
||||||
|
|
||||||
|
|
||||||
def json_response(serializable, *args, **kw):
|
def json_response(serializable, _disable_cors=False, *args, **kw):
|
||||||
'''
|
'''
|
||||||
Serializes a json objects and returns a webob.Response object with the
|
Serializes a json objects and returns a webob.Response object with the
|
||||||
serialized value as the response body and Content-Type: application/json.
|
serialized value as the response body and Content-Type: application/json.
|
||||||
@ -64,11 +64,14 @@ def json_response(serializable, *args, **kw):
|
|||||||
'''
|
'''
|
||||||
response = Response(json.dumps(serializable), *args, **kw)
|
response = Response(json.dumps(serializable), *args, **kw)
|
||||||
response.headers['Content-Type'] = 'application/json'
|
response.headers['Content-Type'] = 'application/json'
|
||||||
cors_headers = {
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
if not _disable_cors:
|
||||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
cors_headers = {
|
||||||
'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With'}
|
'Access-Control-Allow-Origin': '*',
|
||||||
response.headers.update(cors_headers)
|
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With'}
|
||||||
|
response.headers.update(cors_headers)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@ -149,6 +152,11 @@ def api_auth(controller):
|
|||||||
auth, request.url))
|
auth, request.url))
|
||||||
|
|
||||||
if not auth(request, *args, **kw):
|
if not auth(request, *args, **kw):
|
||||||
|
if getattr(auth, 'errors', []):
|
||||||
|
return json_response({
|
||||||
|
'status': 403,
|
||||||
|
'errors': auth.errors})
|
||||||
|
|
||||||
return exc.HTTPForbidden()
|
return exc.HTTPForbidden()
|
||||||
|
|
||||||
return controller(request, *args, **kw)
|
return controller(request, *args, **kw)
|
||||||
|
@ -122,20 +122,21 @@ Capabilities
|
|||||||
- `Authorization endpoint`_ - Located at ``/oauth/authorize``
|
- `Authorization endpoint`_ - Located at ``/oauth/authorize``
|
||||||
- `Token endpoint`_ - Located at ``/oauth/access_token``
|
- `Token endpoint`_ - Located at ``/oauth/access_token``
|
||||||
- `Authorization Code Grant`_
|
- `Authorization Code Grant`_
|
||||||
|
- `Client Registration`_
|
||||||
|
|
||||||
.. _`Authorization endpoint`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-3.1
|
.. _`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
|
.. _`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
|
.. _`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
|
Incapabilities
|
||||||
==============
|
==============
|
||||||
|
|
||||||
- `Client Registration`_ - `planned feature
|
- Only `bearer tokens`_ are issued.
|
||||||
<http://issues.mediagoblin.org/ticket/497>`_
|
|
||||||
- `Access Token Scope`_
|
- `Access Token Scope`_
|
||||||
- `Implicit Grant`_
|
- `Implicit Grant`_
|
||||||
- ...
|
- ...
|
||||||
|
|
||||||
.. _`Client Registration`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-2
|
.. _`bearer tokens`: http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-08
|
||||||
.. _`Access Token Scope`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-3.3
|
.. _`Access Token Scope`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-3.3
|
||||||
.. _`Implicit Grant`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-4.2
|
.. _`Implicit Grant`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-4.2
|
||||||
|
@ -18,11 +18,10 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from routes.route import Route
|
from routes.route import Route
|
||||||
from webob import exc
|
|
||||||
|
|
||||||
from mediagoblin.tools import pluginapi
|
from mediagoblin.tools import pluginapi
|
||||||
from mediagoblin.tools.response import render_to_response
|
from mediagoblin.plugins.oauth.models import OAuthToken, OAuthClient, \
|
||||||
from mediagoblin.plugins.oauth.models import OAuthToken
|
OAuthUserClient
|
||||||
from mediagoblin.plugins.api.tools import Auth
|
from mediagoblin.plugins.api.tools import Auth
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
@ -39,8 +38,19 @@ def setup_plugin():
|
|||||||
routes = [
|
routes = [
|
||||||
Route('mediagoblin.plugins.oauth.authorize', '/oauth/authorize',
|
Route('mediagoblin.plugins.oauth.authorize', '/oauth/authorize',
|
||||||
controller='mediagoblin.plugins.oauth.views:authorize'),
|
controller='mediagoblin.plugins.oauth.views:authorize'),
|
||||||
|
Route('mediagoblin.plugins.oauth.authorize_client', '/oauth/client/authorize',
|
||||||
|
controller='mediagoblin.plugins.oauth.views:authorize_client'),
|
||||||
Route('mediagoblin.plugins.oauth.access_token', '/oauth/access_token',
|
Route('mediagoblin.plugins.oauth.access_token', '/oauth/access_token',
|
||||||
controller='mediagoblin.plugins.oauth.views:access_token')]
|
controller='mediagoblin.plugins.oauth.views:access_token'),
|
||||||
|
Route('mediagoblin.plugins.oauth.access_token',
|
||||||
|
'/oauth/client/connections',
|
||||||
|
controller='mediagoblin.plugins.oauth.views:list_connections'),
|
||||||
|
Route('mediagoblin.plugins.oauth.register_client',
|
||||||
|
'/oauth/client/register',
|
||||||
|
controller='mediagoblin.plugins.oauth.views:register_client'),
|
||||||
|
Route('mediagoblin.plugins.oauth.list_clients',
|
||||||
|
'/oauth/client/list',
|
||||||
|
controller='mediagoblin.plugins.oauth.views:list_clients')]
|
||||||
|
|
||||||
pluginapi.register_routes(routes)
|
pluginapi.register_routes(routes)
|
||||||
pluginapi.register_template_path(os.path.join(PLUGIN_DIR, 'templates'))
|
pluginapi.register_template_path(os.path.join(PLUGIN_DIR, 'templates'))
|
||||||
@ -54,17 +64,42 @@ class OAuthAuth(Auth):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def __call__(self, request, *args, **kw):
|
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')
|
access_token = request.GET.get('access_token')
|
||||||
|
|
||||||
|
_log.debug('Authorizing request {0}'.format(request.url))
|
||||||
|
|
||||||
if access_token:
|
if access_token:
|
||||||
token = OAuthToken.query.filter(OAuthToken.token == access_token)\
|
token = OAuthToken.query.filter(OAuthToken.token == access_token)\
|
||||||
.first()
|
.first()
|
||||||
|
|
||||||
if not token:
|
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
|
return False
|
||||||
|
|
||||||
request.user = token.user
|
request.user = token.user
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
self.errors.append(u'No access_token specified')
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
hooks = {
|
hooks = {
|
||||||
|
70
mediagoblin/plugins/oauth/forms.py
Normal file
70
mediagoblin/plugins/oauth/forms.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# GNU MediaGoblin -- federated, autonomous media hosting
|
||||||
|
# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import wtforms
|
||||||
|
|
||||||
|
from urlparse import urlparse
|
||||||
|
|
||||||
|
from mediagoblin.tools.extlib.wtf_html5 import URLField
|
||||||
|
from mediagoblin.tools.translate import fake_ugettext_passthrough as _
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizationForm(wtforms.Form):
|
||||||
|
client_id = wtforms.HiddenField(_(u'Client ID'),
|
||||||
|
[wtforms.validators.Required()])
|
||||||
|
next = wtforms.HiddenField(_(u'Next URL'),
|
||||||
|
[wtforms.validators.Required()])
|
||||||
|
allow = wtforms.SubmitField(_(u'Allow'))
|
||||||
|
deny = wtforms.SubmitField(_(u'Deny'))
|
||||||
|
|
||||||
|
|
||||||
|
class ClientRegistrationForm(wtforms.Form):
|
||||||
|
name = wtforms.TextField(_('Name'), [wtforms.validators.Required()],
|
||||||
|
description=_('The name of the OAuth client'))
|
||||||
|
description = wtforms.TextAreaField(_('Description'),
|
||||||
|
[wtforms.validators.Length(min=0, max=500)],
|
||||||
|
description=_('''This will be visisble to users allowing your
|
||||||
|
appplication to authenticate as them.'''))
|
||||||
|
type = wtforms.SelectField(_('Type'),
|
||||||
|
[wtforms.validators.Required()],
|
||||||
|
choices=[
|
||||||
|
('confidential', 'Confidential'),
|
||||||
|
('public', 'Public')],
|
||||||
|
description=_('''<strong>Confidential</strong> - The client can
|
||||||
|
make requests to the GNU MediaGoblin instance that can not be
|
||||||
|
intercepted by the user agent (e.g. server-side client).<br />
|
||||||
|
<strong>Public</strong> - The client can't make confidential
|
||||||
|
requests to the GNU MediaGoblin instance (e.g. client-side
|
||||||
|
JavaScript client).'''))
|
||||||
|
|
||||||
|
redirect_uri = URLField(_('Redirect URI'),
|
||||||
|
[wtforms.validators.Optional(), wtforms.validators.URL()],
|
||||||
|
description=_('''The redirect URI for the applications, this field
|
||||||
|
is <strong>required</strong> for public clients.'''))
|
||||||
|
|
||||||
|
def __init__(self, *args, **kw):
|
||||||
|
wtforms.Form.__init__(self, *args, **kw)
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
if not wtforms.Form.validate(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.type.data == 'public' and not self.redirect_uri.data:
|
||||||
|
self.redirect_uri.errors.append(
|
||||||
|
_('This field is required for public clients'))
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
46
mediagoblin/plugins/oauth/migrations.py
Normal file
46
mediagoblin/plugins/oauth/migrations.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# GNU MediaGoblin -- federated, autonomous media hosting
|
||||||
|
# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from sqlalchemy import MetaData, Table
|
||||||
|
|
||||||
|
from mediagoblin.db.sql.util import RegisterMigration
|
||||||
|
|
||||||
|
from mediagoblin.plugins.oauth.models import OAuthClient, OAuthToken, \
|
||||||
|
OAuthUserClient, OAuthCode
|
||||||
|
|
||||||
|
MIGRATIONS = {}
|
||||||
|
|
||||||
|
|
||||||
|
@RegisterMigration(1, MIGRATIONS)
|
||||||
|
def remove_and_replace_token_and_code(db):
|
||||||
|
metadata = MetaData(bind=db.bind)
|
||||||
|
|
||||||
|
token_table = Table('oauth__tokens', metadata, autoload=True,
|
||||||
|
autoload_with=db.bind)
|
||||||
|
|
||||||
|
token_table.drop()
|
||||||
|
|
||||||
|
code_table = Table('oauth__codes', metadata, autoload=True,
|
||||||
|
autoload_with=db.bind)
|
||||||
|
|
||||||
|
code_table.drop()
|
||||||
|
|
||||||
|
OAuthClient.__table__.create(db.bind)
|
||||||
|
OAuthUserClient.__table__.create(db.bind)
|
||||||
|
OAuthToken.__table__.create(db.bind)
|
||||||
|
OAuthCode.__table__.create(db.bind)
|
||||||
|
|
||||||
|
db.commit()
|
@ -14,15 +14,84 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from mediagoblin.db.sql.base import Base
|
from mediagoblin.db.sql.base import Base
|
||||||
from mediagoblin.db.sql.models import User
|
from mediagoblin.db.sql.models import User
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column, Unicode, Integer, DateTime, ForeignKey)
|
Column, Unicode, Integer, DateTime, ForeignKey, Enum)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
# Don't remove this, I *think* it applies sqlalchemy-migrate functionality onto
|
||||||
|
# the models.
|
||||||
|
from migrate import changeset
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthClient(Base):
|
||||||
|
__tablename__ = 'oauth__client'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
created = Column(DateTime, nullable=False,
|
||||||
|
default=datetime.now)
|
||||||
|
|
||||||
|
name = Column(Unicode)
|
||||||
|
description = Column(Unicode)
|
||||||
|
|
||||||
|
identifier = Column(Unicode, unique=True, index=True)
|
||||||
|
secret = Column(Unicode, index=True)
|
||||||
|
|
||||||
|
owner_id = Column(Integer, ForeignKey(User.id))
|
||||||
|
owner = relationship(User, backref='registered_clients')
|
||||||
|
|
||||||
|
redirect_uri = Column(Unicode)
|
||||||
|
|
||||||
|
type = Column(Enum(
|
||||||
|
u'confidential',
|
||||||
|
u'public'))
|
||||||
|
|
||||||
|
def generate_identifier(self):
|
||||||
|
self.identifier = unicode(uuid.uuid4())
|
||||||
|
|
||||||
|
def generate_secret(self):
|
||||||
|
self.secret = unicode(
|
||||||
|
bcrypt.hashpw(
|
||||||
|
unicode(uuid.uuid4()),
|
||||||
|
bcrypt.gensalt()))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<{0} {1}:{2} ({3})>'.format(
|
||||||
|
self.__class__.__name__,
|
||||||
|
self.id,
|
||||||
|
self.name.encode('ascii', 'replace'),
|
||||||
|
self.owner.username.encode('ascii', 'replace'))
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthUserClient(Base):
|
||||||
|
__tablename__ = 'oauth__user_client'
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
||||||
|
user_id = Column(Integer, ForeignKey(User.id))
|
||||||
|
user = relationship(User, backref='oauth_clients')
|
||||||
|
|
||||||
|
client_id = Column(Integer, ForeignKey(OAuthClient.id))
|
||||||
|
client = relationship(OAuthClient, backref='users')
|
||||||
|
|
||||||
|
state = Column(Enum(
|
||||||
|
u'approved',
|
||||||
|
u'rejected'))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<{0} #{1} {2} [{3}, {4}]>'.format(
|
||||||
|
self.__class__.__name__,
|
||||||
|
self.id,
|
||||||
|
self.state.encode('ascii', 'replace'),
|
||||||
|
self.user,
|
||||||
|
self.client)
|
||||||
|
|
||||||
|
|
||||||
class OAuthToken(Base):
|
class OAuthToken(Base):
|
||||||
__tablename__ = 'oauth__tokens'
|
__tablename__ = 'oauth__tokens'
|
||||||
@ -39,6 +108,17 @@ class OAuthToken(Base):
|
|||||||
index=True)
|
index=True)
|
||||||
user = relationship(User)
|
user = relationship(User)
|
||||||
|
|
||||||
|
client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False)
|
||||||
|
client = relationship(OAuthClient)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<{0} #{1} expires {2} [{3}, {4}]>'.format(
|
||||||
|
self.__class__.__name__,
|
||||||
|
self.id,
|
||||||
|
self.expires.isoformat(),
|
||||||
|
self.user,
|
||||||
|
self.client)
|
||||||
|
|
||||||
|
|
||||||
class OAuthCode(Base):
|
class OAuthCode(Base):
|
||||||
__tablename__ = 'oauth__codes'
|
__tablename__ = 'oauth__codes'
|
||||||
@ -54,5 +134,20 @@ class OAuthCode(Base):
|
|||||||
index=True)
|
index=True)
|
||||||
user = relationship(User)
|
user = relationship(User)
|
||||||
|
|
||||||
|
client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False)
|
||||||
|
client = relationship(OAuthClient)
|
||||||
|
|
||||||
MODELS = [OAuthToken, OAuthCode]
|
def __repr__(self):
|
||||||
|
return '<{0} #{1} expires {2} [{3}, {4}]>'.format(
|
||||||
|
self.__class__.__name__,
|
||||||
|
self.id,
|
||||||
|
self.expires.isoformat(),
|
||||||
|
self.user,
|
||||||
|
self.client)
|
||||||
|
|
||||||
|
|
||||||
|
MODELS = [
|
||||||
|
OAuthToken,
|
||||||
|
OAuthCode,
|
||||||
|
OAuthClient,
|
||||||
|
OAuthUserClient]
|
||||||
|
31
mediagoblin/plugins/oauth/templates/oauth/authorize.html
Normal file
31
mediagoblin/plugins/oauth/templates/oauth/authorize.html
Normal file
@ -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 %}
|
@ -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 %}
|
45
mediagoblin/plugins/oauth/templates/oauth/client/list.html
Normal file
45
mediagoblin/plugins/oauth/templates/oauth/client/list.html
Normal file
@ -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 %}
|
@ -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 %}
|
43
mediagoblin/plugins/oauth/tools.py
Normal file
43
mediagoblin/plugins/oauth/tools.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# GNU MediaGoblin -- federated, autonomous media hosting
|
||||||
|
# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from mediagoblin.plugins.oauth.models import OAuthClient
|
||||||
|
from mediagoblin.plugins.api.tools import json_response
|
||||||
|
|
||||||
|
|
||||||
|
def require_client_auth(controller):
|
||||||
|
@wraps(controller)
|
||||||
|
def wrapper(request, *args, **kw):
|
||||||
|
if not request.GET.get('client_id'):
|
||||||
|
return json_response({
|
||||||
|
'status': 400,
|
||||||
|
'errors': [u'No client identifier in URL']},
|
||||||
|
_disable_cors=True)
|
||||||
|
|
||||||
|
client = OAuthClient.query.filter(
|
||||||
|
OAuthClient.identifier == request.GET.get('client_id')).first()
|
||||||
|
|
||||||
|
if not client:
|
||||||
|
return json_response({
|
||||||
|
'status': 400,
|
||||||
|
'errors': [u'No such client identifier']},
|
||||||
|
_disable_cors=True)
|
||||||
|
|
||||||
|
return controller(request, client)
|
||||||
|
|
||||||
|
return wrapper
|
@ -21,38 +21,142 @@ from webob import exc, Response
|
|||||||
from urllib import urlencode
|
from urllib import urlencode
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from mediagoblin.tools import pluginapi
|
from mediagoblin.tools.response import render_to_response, redirect
|
||||||
from mediagoblin.tools.response import render_to_response
|
|
||||||
from mediagoblin.decorators import require_active_login
|
from mediagoblin.decorators import require_active_login
|
||||||
from mediagoblin.messages import add_message, SUCCESS, ERROR
|
from mediagoblin.messages import add_message, SUCCESS, ERROR
|
||||||
from mediagoblin.tools.translate import pass_to_ugettext as _
|
from mediagoblin.tools.translate import pass_to_ugettext as _
|
||||||
from mediagoblin.plugins.oauth.models import OAuthCode, OAuthToken
|
from mediagoblin.plugins.oauth.models import OAuthCode, OAuthToken, \
|
||||||
|
OAuthClient, OAuthUserClient
|
||||||
|
from mediagoblin.plugins.oauth.forms import ClientRegistrationForm, \
|
||||||
|
AuthorizationForm
|
||||||
|
from mediagoblin.plugins.oauth.tools import require_client_auth
|
||||||
|
from mediagoblin.plugins.api.tools import json_response
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@require_active_login
|
@require_active_login
|
||||||
def authorize(request):
|
def register_client(request):
|
||||||
# TODO: Check if allowed
|
'''
|
||||||
|
Register an OAuth client
|
||||||
|
'''
|
||||||
|
form = ClientRegistrationForm(request.POST)
|
||||||
|
|
||||||
# Client is allowed by the user
|
if request.method == 'POST' and form.validate():
|
||||||
if True or already_authorized:
|
client = OAuthClient()
|
||||||
# Generate a code
|
client.name = unicode(request.POST['name'])
|
||||||
# Save the code, the client will later use it to obtain an access token
|
client.description = unicode(request.POST['description'])
|
||||||
# Redirect the user agent to the redirect_uri with the code
|
client.type = unicode(request.POST['type'])
|
||||||
|
client.owner_id = request.user.id
|
||||||
|
client.redirect_uri = unicode(request.POST['redirect_uri'])
|
||||||
|
|
||||||
if not 'redirect_uri' in request.GET:
|
client.generate_identifier()
|
||||||
add_message(request, ERROR, _('No redirect_uri found'))
|
client.generate_secret()
|
||||||
|
|
||||||
|
client.save()
|
||||||
|
|
||||||
|
add_message(request, SUCCESS, _('The client {0} has been registered!')\
|
||||||
|
.format(
|
||||||
|
client.name))
|
||||||
|
|
||||||
|
return redirect(request, 'mediagoblin.plugins.oauth.list_clients')
|
||||||
|
|
||||||
|
return render_to_response(
|
||||||
|
request,
|
||||||
|
'oauth/client/register.html',
|
||||||
|
{'form': form})
|
||||||
|
|
||||||
|
|
||||||
|
@require_active_login
|
||||||
|
def list_clients(request):
|
||||||
|
clients = request.db.OAuthClient.query.filter(
|
||||||
|
OAuthClient.owner_id == request.user.id).all()
|
||||||
|
return render_to_response(request, 'oauth/client/list.html',
|
||||||
|
{'clients': clients})
|
||||||
|
|
||||||
|
|
||||||
|
@require_active_login
|
||||||
|
def list_connections(request):
|
||||||
|
connections = OAuthUserClient.query.filter(
|
||||||
|
OAuthUserClient.user == request.user).all()
|
||||||
|
return render_to_response(request, 'oauth/client/connections.html',
|
||||||
|
{'connections': connections})
|
||||||
|
|
||||||
|
|
||||||
|
@require_active_login
|
||||||
|
def authorize_client(request):
|
||||||
|
form = AuthorizationForm(request.POST)
|
||||||
|
|
||||||
|
client = OAuthClient.query.filter(OAuthClient.id ==
|
||||||
|
form.client_id.data).first()
|
||||||
|
|
||||||
|
if not client:
|
||||||
|
_log.error('''No such client id as received from client authorization
|
||||||
|
form.''')
|
||||||
|
return exc.HTTPBadRequest()
|
||||||
|
|
||||||
|
if form.validate():
|
||||||
|
relation = OAuthUserClient()
|
||||||
|
relation.user_id = request.user.id
|
||||||
|
relation.client_id = form.client_id.data
|
||||||
|
if form.allow.data:
|
||||||
|
relation.state = u'approved'
|
||||||
|
elif form.deny.data:
|
||||||
|
relation.state = u'rejected'
|
||||||
|
else:
|
||||||
|
return exc.HTTPBadRequest
|
||||||
|
|
||||||
|
relation.save()
|
||||||
|
|
||||||
|
return exc.HTTPFound(location=form.next.data)
|
||||||
|
|
||||||
|
return render_to_response(
|
||||||
|
request,
|
||||||
|
'oauth/authorize.html',
|
||||||
|
{'form': form,
|
||||||
|
'client': client})
|
||||||
|
|
||||||
|
|
||||||
|
@require_client_auth
|
||||||
|
@require_active_login
|
||||||
|
def authorize(request, client):
|
||||||
|
# TODO: Get rid of the JSON responses in this view, it's called by the
|
||||||
|
# user-agent, not the client.
|
||||||
|
user_client_relation = OAuthUserClient.query.filter(
|
||||||
|
(OAuthUserClient.user == request.user)
|
||||||
|
& (OAuthUserClient.client == client))
|
||||||
|
|
||||||
|
if user_client_relation.filter(OAuthUserClient.state ==
|
||||||
|
u'approved').count():
|
||||||
|
redirect_uri = None
|
||||||
|
|
||||||
|
if client.type == u'public':
|
||||||
|
if not client.redirect_uri:
|
||||||
|
return json_response({
|
||||||
|
'status': 400,
|
||||||
|
'errors':
|
||||||
|
[u'Public clients MUST have a redirect_uri pre-set']},
|
||||||
|
_disable_cors=True)
|
||||||
|
|
||||||
|
redirect_uri = client.redirect_uri
|
||||||
|
|
||||||
|
if client.type == u'confidential':
|
||||||
|
redirect_uri = request.GET.get('redirect_uri', client.redirect_uri)
|
||||||
|
if not redirect_uri:
|
||||||
|
return json_response({
|
||||||
|
'status': 400,
|
||||||
|
'errors': [u'Can not find a redirect_uri for client: {0}'\
|
||||||
|
.format(client.name)]}, _disable_cors=True)
|
||||||
|
|
||||||
code = OAuthCode()
|
code = OAuthCode()
|
||||||
code.code = unicode(uuid4())
|
code.code = unicode(uuid4())
|
||||||
code.user = request.user
|
code.user = request.user
|
||||||
|
code.client = client
|
||||||
code.save()
|
code.save()
|
||||||
|
|
||||||
redirect_uri = ''.join([
|
redirect_uri = ''.join([
|
||||||
request.GET.get('redirect_uri'),
|
redirect_uri,
|
||||||
'?',
|
'?',
|
||||||
urlencode({'code': code.code})])
|
urlencode({'code': code.code})])
|
||||||
|
|
||||||
@ -65,28 +169,34 @@ def authorize(request):
|
|||||||
# code parameter
|
# code parameter
|
||||||
# - on deny: send the user agent back to the redirect uri with error
|
# - on deny: send the user agent back to the redirect uri with error
|
||||||
# information
|
# information
|
||||||
pass
|
form = AuthorizationForm(request.POST)
|
||||||
return render_to_response(request, 'oauth/base.html', {})
|
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):
|
def access_token(request):
|
||||||
if request.GET.get('code'):
|
if request.GET.get('code'):
|
||||||
code = OAuthCode.query.filter(OAuthCode.code == request.GET.get('code'))\
|
code = OAuthCode.query.filter(OAuthCode.code ==
|
||||||
.first()
|
request.GET.get('code')).first()
|
||||||
|
|
||||||
if code:
|
if code:
|
||||||
token = OAuthToken()
|
token = OAuthToken()
|
||||||
token.token = unicode(uuid4())
|
token.token = unicode(uuid4())
|
||||||
token.user = code.user
|
token.user = code.user
|
||||||
|
token.client = code.client
|
||||||
token.save()
|
token.save()
|
||||||
|
|
||||||
access_token_data = {
|
access_token_data = {
|
||||||
'access_token': token.token,
|
'access_token': token.token,
|
||||||
'token_type': 'what_do_i_use_this_for', # TODO
|
'token_type': 'bearer',
|
||||||
'expires_in':
|
'expires_in':
|
||||||
(token.expires - datetime.now()).total_seconds(),
|
(token.expires - datetime.now()).total_seconds()}
|
||||||
'refresh_token': 'This should probably be safe'}
|
return json_response(access_token_data, _disable_cors=True)
|
||||||
return Response(json.dumps(access_token_data))
|
|
||||||
|
|
||||||
error_data = {
|
error_data = {
|
||||||
'error': 'Incorrect code'}
|
'error': 'Incorrect code'}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user