Add OAuth models, plugin DB migrations, api_auth
authorJoar Wandborg <git@wandborg.com>
Wed, 12 Sep 2012 20:41:04 +0000 (22:41 +0200)
committerJoar Wandborg <git@wandborg.com>
Thu, 13 Sep 2012 18:38:59 +0000 (20:38 +0200)
mediagoblin/db/sql/open.py
mediagoblin/gmg_commands/dbupdate.py
mediagoblin/plugins/oauth/__init__.py [new file with mode: 0644]
mediagoblin/plugins/oauth/models.py [new file with mode: 0644]
mediagoblin/plugins/oauth/views.py [new file with mode: 0644]
mediagoblin/tools/pluginapi.py

index ce5f06042f8d8f33c95763edf41ebd394bca448f..95691b2c43b1f42db8d738f07bea13a2ebb12496 100644 (file)
@@ -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):
index 1c48ff579597389b12f4e9eb08d8a43b79894899..12329b547b155b384c8b14233538dc4f56fa35b1 100644 (file)
@@ -14,6 +14,8 @@
 # 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 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 (file)
index 0000000..af39ae9
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+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 (file)
index 0000000..295987c
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+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 (file)
index 0000000..7627b97
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+import logging
+import json
+
+from webob import exc, Response
+from urllib import urlencode
+from uuid import uuid4
+from datetime import datetime
+from 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))
index bf3775d59398c8502722756ee5b310c9a54e7195..f0c8bbc8f1ebcc679d27eb6ef54f8d63e5929b01 100644 (file)
@@ -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