From: Joar Wandborg Date: Wed, 12 Sep 2012 20:41:04 +0000 (+0200) Subject: Add OAuth models, plugin DB migrations, api_auth X-Git-Url: https://vcs.fsf.org/?a=commitdiff_plain;h=f46e2a4db9e70aba473bec537300103c9102ef1a;p=mediagoblin.git Add OAuth models, plugin DB migrations, api_auth --- diff --git a/mediagoblin/db/sql/open.py b/mediagoblin/db/sql/open.py index ce5f0604..95691b2c 100644 --- a/mediagoblin/db/sql/open.py +++ b/mediagoblin/db/sql/open.py @@ -19,6 +19,7 @@ from sqlalchemy import create_engine import logging from mediagoblin.db.sql.base import Base, Session +from mediagoblin import mg_globals _log = logging.getLogger(__name__) @@ -51,10 +52,18 @@ class DatabaseMaster(object): def load_models(app_config): import mediagoblin.db.sql.models - if True: - for media_type in app_config['media_types']: - _log.debug("Loading %s.models", media_type) - __import__(media_type + ".models") + for media_type in app_config['media_types']: + _log.debug("Loading %s.models", media_type) + __import__(media_type + ".models") + + for plugin in mg_globals.global_config['plugins'].keys(): + _log.debug("Loading %s.models", plugin) + try: + __import__(plugin + ".models") + except ImportError as exc: + _log.debug("Could not load {0}.models: {1}".format( + plugin, + exc)) def setup_connection_and_db_from_config(app_config): diff --git a/mediagoblin/gmg_commands/dbupdate.py b/mediagoblin/gmg_commands/dbupdate.py index 1c48ff57..12329b54 100644 --- a/mediagoblin/gmg_commands/dbupdate.py +++ b/mediagoblin/gmg_commands/dbupdate.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import logging + from sqlalchemy.orm import sessionmaker from mediagoblin.db.sql.open import setup_connection_and_db_from_config @@ -21,6 +23,9 @@ from mediagoblin.db.sql.util import MigrationManager from mediagoblin.init import setup_global_and_app_config from mediagoblin.tools.common import import_component +_log = logging.getLogger(__name__) +logging.basicConfig() +_log.setLevel(logging.DEBUG) def dbupdate_parse_setup(subparser): pass @@ -37,7 +42,7 @@ class DatabaseData(object): self.name, self.models, self.migrations, session) -def gather_database_data(media_types): +def gather_database_data(media_types, plugins): """ Gather all database data relevant to the extensions we have installed so we can do migrations and table initialization. @@ -61,10 +66,41 @@ def gather_database_data(media_types): managed_dbdata.append( DatabaseData(media_type, models, migrations)) + for plugin in plugins: + try: + models = import_component('{0}.models:MODELS'.format(plugin)) + except ImportError as exc: + _log.debug('No models found for {0}: {1}'.format( + plugin, + exc)) + + models = [] + except AttributeError as exc: + _log.warning('Could not find MODELS in {0}.models, have you \ +forgotten to add it? ({1})'.format(plugin, exc)) + + try: + migrations = import_component('{0}.migrations:MIGRATIONS'.format( + plugin)) + except ImportError as exc: + _log.debug('No migrations found for {0}: {1}'.format( + plugin, + exc)) + + migrations = {} + except AttributeError as exc: + _log.debug('Cloud not find MIGRATIONS in {0}.migrations, have you \ +forgotten to add it? ({1})'.format(plugin, exc)) + + if models: + managed_dbdata.append( + DatabaseData(plugin, models, migrations)) + + return managed_dbdata -def run_dbupdate(app_config): +def run_dbupdate(app_config, global_config): """ Initialize or migrate the database as specified by the config file. @@ -73,7 +109,9 @@ def run_dbupdate(app_config): """ # Gather information from all media managers / projects - dbdatas = gather_database_data(app_config['media_types']) + dbdatas = gather_database_data( + app_config['media_types'], + global_config['plugins'].keys()) # Set up the database connection, db = setup_connection_and_db_from_config(app_config) @@ -89,4 +127,4 @@ def run_dbupdate(app_config): def dbupdate(args): global_config, app_config = setup_global_and_app_config(args.conf_file) - run_dbupdate(app_config) + run_dbupdate(app_config, global_config) diff --git a/mediagoblin/plugins/oauth/__init__.py b/mediagoblin/plugins/oauth/__init__.py new file mode 100644 index 00000000..af39ae93 --- /dev/null +++ b/mediagoblin/plugins/oauth/__init__.py @@ -0,0 +1,90 @@ +# 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 . + +import os +import logging + +from routes.route import Route +from webob import exc + +from mediagoblin.tools import pluginapi +from mediagoblin.tools.response import render_to_response +from mediagoblin.plugins.oauth.models import OAuthToken + +_log = logging.getLogger(__name__) + +PLUGIN_DIR = os.path.dirname(__file__) + + +def setup_plugin(): + config = pluginapi.get_config('mediagoblin.plugins.oauth') + + _log.info('Setting up OAuth...') + _log.debug('OAuth config: {0}'.format(config)) + + routes = [ + Route('mediagoblin.plugins.oauth.authorize', '/oauth/authorize', + controller='mediagoblin.plugins.oauth.views:authorize'), + Route('mediagoblin.plugins.oauth.test', '/api/test', + controller='mediagoblin.plugins.oauth.views:api_test'), + Route('mediagoblin.plugins.oauth.access_token', '/oauth/access_token', + controller='mediagoblin.plugins.oauth.views:access_token')] + + pluginapi.register_routes(routes) + pluginapi.register_template_path(os.path.join(PLUGIN_DIR, 'templates')) + + +class OAuthAuth(object): + ''' + An object with two significant methods, 'trigger' and 'run'. + + Using a similar object to this, plugins can register specific + authentication logic, for example the GET param 'access_token' for OAuth. + + - trigger: Analyze the 'request' argument, return True if you think you + can handle the request, otherwise return False + - run: The authentication logic, set the request.user object to the user + you intend to authenticate and return True, otherwise return False. + + If run() returns False, an HTTP 403 Forbidden error will be shown. + + You may also display custom errors, just raise them within the run() + method. + ''' + def __init__(self): + pass + + def trigger(self, request): + return True + + def __call__(self, request, *args, **kw): + access_token = request.GET.get('access_token') + if access_token: + token = OAuthToken.query.filter(OAuthToken.token == access_token)\ + .first() + + if not token: + return False + + request.user = token.user + + return True + + +hooks = { + 'setup': setup_plugin, + 'auth': OAuthAuth() + } diff --git a/mediagoblin/plugins/oauth/models.py b/mediagoblin/plugins/oauth/models.py new file mode 100644 index 00000000..295987c8 --- /dev/null +++ b/mediagoblin/plugins/oauth/models.py @@ -0,0 +1,58 @@ +# 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 . + +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) +from sqlalchemy.orm import relationship + + +class OAuthToken(Base): + __tablename__ = 'oauth__tokens' + + id = Column(Integer, primary_key=True) + created = Column(DateTime, nullable=False, + default=datetime.now) + expires = Column(DateTime, nullable=False, + default=lambda: datetime.now() + timedelta(days=30)) + token = Column(Unicode, index=True) + refresh_token = Column(Unicode, index=True) + + user_id = Column(Integer, ForeignKey(User.id), nullable=False, + index=True) + user = relationship(User) + + +class OAuthCode(Base): + __tablename__ = 'oauth__codes' + + id = Column(Integer, primary_key=True) + created = Column(DateTime, nullable=False, + default=datetime.now) + expires = Column(DateTime, nullable=False, + default=lambda: datetime.now() + timedelta(minutes=5)) + code = Column(Unicode, index=True) + + user_id = Column(Integer, ForeignKey(User.id), nullable=False, + index=True) + user = relationship(User) + + +MODELS = [OAuthToken, OAuthCode] diff --git a/mediagoblin/plugins/oauth/views.py b/mediagoblin/plugins/oauth/views.py new file mode 100644 index 00000000..7627b97a --- /dev/null +++ b/mediagoblin/plugins/oauth/views.py @@ -0,0 +1,105 @@ +# 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 . + +import logging +import json + +from webob import exc, Response +from urllib import urlencode +from uuid import uuid4 +from datetime import datetime +from functools import wraps + +from mediagoblin.tools import pluginapi +from mediagoblin.tools.response import render_to_response +from mediagoblin.decorators import require_active_login +from mediagoblin.messages import add_message, SUCCESS, ERROR +from mediagoblin.tools.translate import pass_to_ugettext as _ +from mediagoblin.plugins.oauth.models import OAuthCode, OAuthToken + +_log = logging.getLogger(__name__) + + +@require_active_login +def authorize(request): + # TODO: Check if allowed + + # Client is allowed by the user + if True or already_authorized: + # Generate a code + # Save the code, the client will later use it to obtain an access token + # Redirect the user agent to the redirect_uri with the code + + if not 'redirect_uri' in request.GET: + add_message(request, ERROR, _('No redirect_uri found')) + + code = OAuthCode() + code.code = unicode(uuid4()) + code.user = request.user + code.save() + + redirect_uri = ''.join([ + request.GET.get('redirect_uri'), + '?', + urlencode({'code': code.code})]) + + _log.debug('Redirecting to {0}'.format(redirect_uri)) + + return exc.HTTPFound(location=redirect_uri) + else: + # Show prompt to allow client to access data + # - on accept: send the user agent back to the redirect_uri with the + # code parameter + # - on deny: send the user agent back to the redirect uri with error + # information + pass + return render_to_response(request, 'oauth/base.html', {}) + + +def access_token(request): + if request.GET.get('code'): + code = OAuthCode.query.filter(OAuthCode.code == request.GET.get('code'))\ + .first() + + if code: + token = OAuthToken() + token.token = unicode(uuid4()) + token.user = code.user + token.save() + + access_token_data = { + 'access_token': token.token, + 'token_type': 'what_do_i_use_this_for', # TODO + 'expires_in': + (token.expires - datetime.now()).total_seconds(), + 'refresh_token': 'This should probably be safe'} + return Response(json.dumps(access_token_data)) + + error_data = { + 'error': 'Incorrect code'} + return Response(json.dumps(error_data)) + + +@pluginapi.api_auth +def api_test(request): + if not request.user: + return exc.HTTPForbidden() + + user_data = { + 'username': request.user.username, + 'email': request.user.email} + + return Response(json.dumps(user_data)) diff --git a/mediagoblin/tools/pluginapi.py b/mediagoblin/tools/pluginapi.py index bf3775d5..f0c8bbc8 100644 --- a/mediagoblin/tools/pluginapi.py +++ b/mediagoblin/tools/pluginapi.py @@ -29,7 +29,7 @@ How do plugins work? ==================== Plugins are structured like any Python project. You create a Python package. -In that package, you define a high-level ``__init__.py`` module that has a +In that package, you define a high-level ``__init__.py`` module that has a ``hooks`` dict that maps hooks to callables that implement those hooks. Additionally, you want a LICENSE file that specifies the license and a @@ -58,6 +58,8 @@ Lifecycle import logging +from functools import wraps + from mediagoblin import mg_globals @@ -205,3 +207,34 @@ def get_config(key): global_config = mg_globals.global_config plugin_section = global_config.get('plugins', {}) return plugin_section.get(key, {}) + + +def api_auth(controller): + @wraps(controller) + def wrapper(request, *args, **kw): + auth_candidates = [] + + for auth in PluginManager().get_hook_callables('auth'): + _log.debug('Plugin auth: {0}'.format(auth)) + if auth.trigger(request): + auth_candidates.append(auth) + + # If we can't find any authentication methods, we should not let them + # pass. + if not auth_candidates: + from webob import exc + return exc.HTTPForbidden() + + # For now, just select the first one in the list + auth = auth_candidates[0] + + _log.debug('Using {0} to authorize request {1}'.format( + auth, request.url)) + + if not auth(request, *args, **kw): + from webob import exc + return exc.HTTPForbidden() + + return controller(request, *args, **kw) + + return wrapper