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:
Joar Wandborg 2012-09-21 13:02:35 +02:00
parent d4c066abf0
commit 88a9662be4
14 changed files with 602 additions and 44 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = {

View 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

View 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()

View File

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

View 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 %}

View File

@ -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 %}

View 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 %}

View File

@ -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 %}

View 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

View File

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