diff --git a/invenio/modules/oauthclient/__init__.py b/invenio/modules/oauthclient/__init__.py new file mode 100644 index 000000000..0eab0f888 --- /dev/null +++ b/invenio/modules/oauthclient/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio 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 +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. diff --git a/invenio/modules/oauthclient/client.py b/invenio/modules/oauthclient/client.py new file mode 100644 index 000000000..6576330f1 --- /dev/null +++ b/invenio/modules/oauthclient/client.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio 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 +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +from flask_oauthlib.client import OAuth +from flask.ext.registry import DictRegistry, RegistryProxy + +oauth = OAuth() +""" +Flask-OAuthlib extension +""" + +handlers = RegistryProxy('oauthclientext.handlers', DictRegistry) +""" +Registry of handlers for authorized handler callbacks +""" + +disconnect_handlers = RegistryProxy( + 'oauthclientext.disconnecthandlers', DictRegistry +) +""" +Registry of handlers for authorized handler callbacks +""" diff --git a/invenio/modules/oauthclient/config.py b/invenio/modules/oauthclient/config.py new file mode 100644 index 000000000..d4872448a --- /dev/null +++ b/invenio/modules/oauthclient/config.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio 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 +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +OAUTHCLIENT_REMOTE_APPS = {} +""" +Configuration of remote applications +""" + +OAUTHCLIENT_SESSION_KEY_PREFIX = "oauth_token" +""" +The session key prefix used when storing the access token for a remote app +in session. +""" diff --git a/invenio/modules/oauthclient/handlers.py b/invenio/modules/oauthclient/handlers.py new file mode 100644 index 000000000..1e275cd9e --- /dev/null +++ b/invenio/modules/oauthclient/handlers.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio 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 +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +import six +from functools import wraps, partial +from werkzeug.utils import import_string +from flask import session, redirect, flash, url_for, current_app +from flask.ext.login import current_user + +from invenio.base.globals import cfg + +from .models import RemoteToken, RemoteAccount + + +def token_session_key(remote_app): + """ Generate a session key used to store the token for a remote app """ + return '%s_%s' % (cfg['OAUTHCLIENT_SESSION_KEY_PREFIX'], remote_app) + + +def oauth1_token_setter(remote, resp, token_type='', extra_data=None): + return token_setter( + remote, + resp['oauth_token'], + secret=resp['oauth_token_secret'], + extra_data=extra_data, + token_type=token_type, + ) + + +def oauth2_token_setter(remote, resp, token_type='', extra_data=None): + return token_setter( + remote, + resp['access_token'], + secret='', + token_type=token_type, + extra_data=extra_data, + ) + + +def token_setter(remote, token, secret='', token_type='', extra_data=None): + """ + Set token for user + """ + session[token_session_key(remote.name)] = (token, secret) + + # Save token if used is authenticated + if current_user.is_authenticated(): + uid = current_user.get_id() + cid = remote.consumer_key + + # Check for already existing token + t = RemoteToken.get(uid, cid, token_type=token_type) + + if t: + t.update_token(token, secret) + else: + t = RemoteToken.create( + uid, cid, token, secret, + token_type=token_type, extra_data=extra_data + ) + return t + return None + + +def token_getter(remote, token=''): + """ + Retrieve OAuth access token - used by flask-oauthlib to get the access + token when making requests. + + :param token: Type of token to get. Data passed from ``oauth.request()`` to + identify which token to retrieve. + """ + session_key = token_session_key(remote.name) + + if session_key not in session and current_user.is_authenticated(): + # Fetch key from token store if user is authenticated, and the key + # isn't already cached in the session. + remote_token = RemoteToken.get( + current_user.get_id(), + remote.consumer_key, + token_type=token, + ) + + if remote_token is None: + return None + + # Store token and secret in session + session[session_key] = remote_token.token() + + return session.get(session_key, None) + + +def default_handler(resp, remote, *args, **kwargs): + """ + Default authorized handler + + :param resp: + :param remote: + """ + if resp is not None: + if 'access_token' in resp: + oauth2_token_setter(remote, resp) + else: + oauth1_token_setter(remote, resp) + else: + flash("You rejected the authentication request.") + return redirect('/') + + +def disconnect_handler(remote, *args, **kwargs): + if not current_user.is_authenticated(): + current_app.login_manager.unauthorized() + + account = RemoteAccount.get( + user_id=current_user.get_id(), + client_id=remote.consumer_key + ) + if account: + account.delete() + + return redirect(url_for('oauthclient_settings.index')) + + +def make_handler(f, remote, with_response=True): + """ + Make a handler for authorized and disconnect callbacks + + :param f: Callable or an import path to a callable + """ + if isinstance(f, six.text_type): + f = import_string(f) + + @wraps(f) + def inner(*args, **kwargs): + if with_response: + return f(args[0], remote, *args[1:], **kwargs) + else: + return f(remote, *args, **kwargs) + return inner + + +def make_token_getter(remote): + """ + Make a token getter for a remote application + """ + return partial(token_getter, remote) diff --git a/invenio/modules/oauthclient/models.py b/invenio/modules/oauthclient/models.py new file mode 100644 index 000000000..fd068bdce --- /dev/null +++ b/invenio/modules/oauthclient/models.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2014 CERN. +# +# Invenio is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# Invenio 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 +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Invenio; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + + +from sqlalchemy.ext.mutable import MutableDict +from invenio.modules.accounts.models import User +from invenio.ext.sqlalchemy import db + + +class RemoteAccount(db.Model): + """ + Storage for remote linked accounts + """ + + __tablename__ = 'remoteACCOUNT' + + __table_args__ = ( + db.UniqueConstraint('user_id', 'client_id'), + db.Model.__table_args__ + ) + + # + # Fields + # + id = db.Column( + db.Integer(15, unsinged=True), + primary_key=True, + autoincrement=True + ) + """ Primary key """ + + user_id = db.Column( + db.Integer(15, unsigned=True), + db.ForeignKey(User.id), + nullable=False + ) + """ Local user linked with a remote app via the access token""" + + client_id = db.Column(db.String(255), nullable=False) + """ Client ID of remote application (defined in OAUTHCLIENT_REMOTE_APPS)""" + + extra_data = db.Column(MutableDict.as_mutable(db.JSON), nullable=False) + """ Extra data associated with this linked account """ + + # + # Relationships propoerties + # + user = db.relationship('User') + """ SQLAlchemy relationship to user """ + + tokens = db.relationship( + "RemoteToken", + backref="remote_account", + ) + """ SQLAlchemy relationship to RemoteToken objects """ + + @classmethod + def get(cls, user_id, client_id): + """ + Method to get RemoteAccount object for user + """ + return cls.query.filter_by( + user_id=user_id, + client_id=client_id, + ).first() + + @classmethod + def create(cls, user_id, client_id, extra_data): + account = cls( + user_id=user_id, + client_id=client_id, + extra_data=extra_data or dict() + ) + db.session.add(account) + db.session.commit() + return account + + def delete(self): + """ + Delete remote account toegether with all stored tokens + """ + RemoteToken.query.filter_by(id_remote_account=self.id).delete() + db.session.delete(self) + db.session.commit() + + +class RemoteToken(db.Model): + """ + Storage for the access tokens for linked accounts + """ + __tablename__ = 'remoteTOKEN' + + # + # Fields + # + id_remote_account = db.Column( + db.Integer(15, unsigned=True), + db.ForeignKey(RemoteAccount.id), + nullable=False, + primary_key=True + ) + """ Foreign key to account """ + + token_type = db.Column( + db.String(40), default='', nullable=False, primary_key=True + ) + """ Type of token """ + + access_token = db.Column(db.Text(), nullable=False) + """ Access token to remote application """ + + secret = db.Column(db.Text(), default='', nullable=False) + """ Used only by OAuth 1 """ + + def token(self): + """ + Return token as expected by Flask-OAuthlib + """ + return (self.access_token, self.secret) + + def update_token(self, token, secret): + if self.access_token != token or self.secret != secret: + self.access_token = token + self.secret = secret + db.session.commit() + + @classmethod + def get(cls, user_id, client_id, token_type='', access_token=None): + """ + Method to get RemoteToken for user + """ + args = [ + RemoteAccount.user_id == user_id, + RemoteAccount.client_id == client_id, + RemoteToken.token_type == token_type, + ] + + if access_token: + args.append(RemoteToken.access_token == access_token) + + return cls.query.options(db.joinedload('remote_account')).filter( + *args + ).first() + + @classmethod + def get_by_token(cls, client_id, access_token, token_type=''): + """ + Method to get RemoteAccount object for token + """ + return cls.query.options(db.joinedload('remote_account')).filter( + RemoteAccount.client_id == client_id, + RemoteToken.token_type == token_type, + RemoteAccount.access_token == access_token, + ).first() + + @classmethod + def create(cls, user_id, client_id, token, secret, + token_type='', extra_data=None): + """ + Create a new access token - if RemoteAccount doesn't exist create it + as well. + """ + account = RemoteAccount.get(user_id, client_id) + + if account is None: + account = RemoteAccount( + user_id=user_id, + client_id=client_id, + extra_data=extra_data or dict(), + ) + db.session.add(account) + + token = cls( + token_type=token_type, + remote_account=account, + access_token=token, + secret=secret, + ) + db.session.add(token) + db.session.commit() + return token diff --git a/invenio/modules/oauthclient/templates/oauthclient/settings/index.html b/invenio/modules/oauthclient/templates/oauthclient/settings/index.html new file mode 100644 index 000000000..4cdf45da6 --- /dev/null +++ b/invenio/modules/oauthclient/templates/oauthclient/settings/index.html @@ -0,0 +1,20 @@ +{# +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio 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 +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. +#} + +{% extends "oauthclient/settings/index_base.html" %} \ No newline at end of file diff --git a/invenio/modules/oauthclient/templates/oauthclient/settings/index_base.html b/invenio/modules/oauthclient/templates/oauthclient/settings/index_base.html new file mode 100644 index 000000000..471dd0893 --- /dev/null +++ b/invenio/modules/oauthclient/templates/oauthclient/settings/index_base.html @@ -0,0 +1,48 @@ +{# +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio 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 +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. +#} + +{%- import "accounts/settings/helpers.html" as helpers with context %} +{%- from "_formhelpers.html" import render_field with context %} +{%- extends "accounts/settings/index.html" %} + +{% block settings_body %} +{{helpers.panel_start( + 'Linked accounts', + with_body=False, + icon="fa fa-link fa-fw" +)}} +<div class="panel-body"> + <p>Tired of entering password for {{config.CFG_SITE_NAME}} every time you sign in? Setup single sign-on with one or more of the services below:</p> +</div> +<ul class="list-group"> + {%- for s in services %} + <li class="list-group-item"> + <div class="pull-right"> + {%- if s.account -%} + <a href="{{url_for('oauthclient.disconnect', remote_app=s.appid)}}" class="btn btn-default btn-xs"><i class="fa fa-times-circle"></i> Disconnect</a> + {%- else -%} + <a href="{{url_for('oauthclient.login', remote_app=s.appid)}}" class="btn btn-default btn-xs"><i class="fa fa-link"></i> Connect</a> + {%- endif -%} + </div> + {% if s.icon %}<i class="{{s.icon}}"></i> {% endif %}{{s.title}}{% if s.account %} <i class="fa fa-check" style="color: #5cb85c;"></i>{% endif %}<br/><small class="text-muted">{{s.description}}</small> + </li> + {%- endfor %} +</ul> +{{helpers.panel_end(with_body=False)}} +{% endblock %} \ No newline at end of file diff --git a/invenio/modules/oauthclient/testsuite/__init__.py b/invenio/modules/oauthclient/testsuite/__init__.py new file mode 100644 index 000000000..0eab0f888 --- /dev/null +++ b/invenio/modules/oauthclient/testsuite/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio 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 +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. diff --git a/invenio/modules/oauthclient/testsuite/test_models.py b/invenio/modules/oauthclient/testsuite/test_models.py new file mode 100644 index 000000000..6100842a3 --- /dev/null +++ b/invenio/modules/oauthclient/testsuite/test_models.py @@ -0,0 +1,60 @@ +from __future__ import absolute_import + +from invenio.testsuite import InvenioTestCase +from invenio.ext.sqlalchemy import db + + +class BaseTestCase(InvenioTestCase): + def setUp(self): + from ..models import RemoteAccount, RemoteToken + RemoteToken.query.delete() + RemoteAccount.query.delete() + db.session.commit() + db.session.expunge_all() + + def tearDown(self): + db.session.expunge_all() + + +class RemoteAccountTestCase(BaseTestCase): + def test_get_create(self): + from ..models import RemoteAccount + + created_acc = RemoteAccount.create(1, "dev", dict(somekey="somevalue")) + assert created_acc + + retrieved_acc = RemoteAccount.get(1, "dev") + assert created_acc.id == retrieved_acc.id + assert retrieved_acc.extra_data == dict(somekey="somevalue") + + db.session.delete(retrieved_acc) + assert RemoteAccount.get(1, "dev") is None + + +class RemoteTokenTestCase(BaseTestCase): + def test_get_create(self): + from ..models import RemoteAccount, RemoteToken + + t = RemoteToken.create(2, "dev", "mytoken", "mysecret") + assert t + assert t.token() == ('mytoken', 'mysecret') + + acc = RemoteAccount.get(2, "dev") + assert acc + assert t.remote_account.id == acc.id + assert t.token_type == '' + + t2 = RemoteToken.create( + 2, "dev", "mytoken2", "mysecret2", + token_type='t2' + ) + assert t2.remote_account.id == acc.id + assert t2.token_type == 't2' + + t3 = RemoteToken.get(2, "dev") + t4 = RemoteToken.get(2, "dev", token_type="t2") + assert t4.token() != t3.token() + + assert RemoteToken.query.count() == 2 + acc.delete() + assert RemoteToken.query.count() == 0 diff --git a/invenio/modules/oauthclient/testsuite/test_views.py b/invenio/modules/oauthclient/testsuite/test_views.py new file mode 100644 index 000000000..2a3ac01b0 --- /dev/null +++ b/invenio/modules/oauthclient/testsuite/test_views.py @@ -0,0 +1,180 @@ +from __future__ import absolute_import + +from mock import MagicMock, patch +from six.moves.urllib_parse import quote_plus +from flask import url_for, session +from invenio.testsuite import InvenioTestCase, make_test_suite, run_test_suite +from invenio.ext.sqlalchemy import db + + +class RemoteAccountTestCase(InvenioTestCase): + def setUp(self): + params = lambda x: dict( + request_token_params={'scope': ''}, + base_url='https://foo.bar/', + request_token_url=None, + access_token_url="https://foo.bar/oauth/access_token", + authorize_url="https://foo.bar/oauth/authorize", + consumer_key=x, + consumer_secret='testsecret', + ) + + self.app.config['OAUTHCLIENT_REMOTE_APPS'] = dict( + test=dict( + authorized_handler=self.handler, + params=params('testid') + ), + test_invalid=dict( + authorized_handler=self.handler_invalid, + params=params('test_invalidid') + ), + full=dict( + params=params("fullid") + ), + ) + self.handled_resp = None + self.handled_remote = None + self.handled_args = None + self.handled_kwargs = None + + from invenio.modules.oauthclient.models import RemoteToken, \ + RemoteAccount + RemoteToken.query.delete() + RemoteAccount.query.delete() + db.session.commit() + + def tearDown(self): + self.handled_resp = None + self.handled_remote = None + self.handled_args = None + self.handled_kwargs = None + + from invenio.modules.oauthclient.models import RemoteToken, \ + RemoteAccount + RemoteToken.query.delete() + RemoteAccount.query.delete() + db.session.commit() + + def handler(self, resp, remote, *args, **kwargs): + self.handled_resp = resp + self.handled_remote = remote + self.handled_args = args + self.handled_kwargs = kwargs + return "TEST" + + def handler_invalid(self): + self.handled_resp = 1 + self.handled_remote = 1 + self.handled_args = 1 + self.handled_kwargs = 1 + + def mock_response(self, app='test', data=None): + """ Mock the oauth response to use the remote """ + from invenio.modules.oauthclient.client import oauth + + # Mock oauth remote application + oauth.remote_apps[app].handle_oauth2_response = MagicMock( + return_value=data or { + "access_token": "test_access_token", + "scope": "", + "token_type": "bearer" + } + ) + + def test_login(self): + # Test redirect + resp = self.client.get(url_for("oauthclient.login", remote_app='test')) + self.assertStatus(resp, 302) + self.assertEqual( + resp.location, + "https://foo.bar/oauth/authorize?response_type=code&" + "client_id=testid&redirect_uri=%s" % quote_plus(url_for( + "oauthclient.authorized", remote_app='test', _external=True + )) + ) + + # Invalid remote + resp = self.client.get( + url_for("oauthclient.login", remote_app='invalid') + ) + self.assertStatus(resp, 404) + + def test_authorized(self): + # Fake an authorized request + with self.app.test_client() as c: + # Ensure remote apps have been loaded (due to before first + # request) + c.get(url_for("oauthclient.login", remote_app='test')) + self.mock_response(app='test') + self.mock_response(app='test_invalid') + + resp = c.get( + url_for( + "oauthclient.authorized", + remote_app='test', + code='test', + ) + ) + assert resp.data == "TEST" + assert self.handled_remote.name == 'test' + assert not self.handled_args + assert not self.handled_kwargs + assert self.handled_resp['access_token'] == 'test_access_token' + + resp = self.assertRaises( + TypeError, + c.get, + url_for( + "oauthclient.authorized", + remote_app='test_invalid', + code='test', + ) + ) + + @patch('invenio.ext.session.interface.SessionInterface.save_session') + def test_token_getter_setter(self, save_session): + from invenio.modules.oauthclient.models import RemoteToken + from invenio.modules.oauthclient.handlers import token_getter + from invenio.modules.oauthclient.client import oauth + + user = MagicMock() + user.get_id = MagicMock(return_value=1) + user.is_authenticated = MagicMock(return_value=True) + with patch('flask.ext.login._get_user', return_value=user): + with self.app.test_client() as c: + c.get(url_for("oauthclient.login", remote_app='full')) + self.mock_response(app='full') + + c.get(url_for( + "oauthclient.authorized", remote_app='full', code='test', + )) + + assert session['oauth_token_full'] == ('test_access_token', '') + + t = RemoteToken.get(1, "fullid") + assert t.remote_account.client_id == 'fullid' + assert t.access_token == 'test_access_token' + assert RemoteToken.query.count() == 1 + + self.mock_response(app='full', data={ + "access_token": "new_access_token", + "scope": "", + "token_type": "bearer" + }) + + c.get(url_for( + "oauthclient.authorized", remote_app='full', code='test', + )) + + t = RemoteToken.get(1, "fullid") + assert t.access_token == 'new_access_token' + assert RemoteToken.query.count() == 1 + + val = token_getter(oauth.remote_apps['full']) + assert val == ('new_access_token', '') + + +TEST_SUITE = make_test_suite(RemoteAccountTestCase) + +if __name__ == "__main__": + run_test_suite(TEST_SUITE) diff --git a/invenio/modules/oauthclient/upgrades/__init__.py b/invenio/modules/oauthclient/upgrades/__init__.py new file mode 100644 index 000000000..0eab0f888 --- /dev/null +++ b/invenio/modules/oauthclient/upgrades/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio 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 +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. diff --git a/invenio/modules/oauthclient/upgrades/oauthclient_2014_03_02_initial.py b/invenio/modules/oauthclient/upgrades/oauthclient_2014_03_02_initial.py new file mode 100644 index 000000000..1c4291957 --- /dev/null +++ b/invenio/modules/oauthclient/upgrades/oauthclient_2014_03_02_initial.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio 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 +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +from sqlalchemy import * +from invenio.ext.sqlalchemy import db +from invenio.modules.upgrader.api import op + +depends_on = [] + + +def info(): + return "Initial creation of tables" + + +def do_upgrade(): + """ Implement your upgrades here """ + op.create_table( + 'remoteACCOUNT', + db.Column('id', db.Integer(display_width=15), nullable=False), + db.Column('user_id', db.Integer(display_width=15), nullable=False), + db.Column('client_id', db.String(length=255), nullable=False), + db.Column('extra_data', db.JSON, nullable=True), + db.ForeignKeyConstraint(['user_id'], ['user.id'], ), + db.PrimaryKeyConstraint('id'), + db.UniqueConstraint('user_id', 'client_id'), + mysql_charset='utf8', + mysql_engine='MyISAM' + ) + op.create_table( + 'remoteTOKEN', + db.Column('id_remote_account', db.Integer(display_width=15), + nullable=False), + db.Column('token_type', db.String(length=40), nullable=False), + db.Column('access_token', db.Text(), nullable=False), + db.Column('secret', db.Text(), nullable=False), + db.ForeignKeyConstraint(['id_remote_account'], ['remoteACCOUNT.id'], ), + db.PrimaryKeyConstraint('id_remote_account', 'token_type'), + mysql_charset='utf8', + mysql_engine='MyISAM' + ) + + +def estimate(): + return 1 diff --git a/invenio/modules/oauthclient/utils.py b/invenio/modules/oauthclient/utils.py new file mode 100644 index 000000000..0ff312a86 --- /dev/null +++ b/invenio/modules/oauthclient/utils.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio 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 +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + + +from flask.ext.login import current_user, logout_user, login_user +from invenio.ext.login import authenticate, UserInfo +from invenio.ext.sqlalchemy import db +from invenio.ext.script import generate_secret_key + + +from .models import RemoteToken, RemoteAccount + + +def oauth_authenticate(client_id, email=None, access_token=None, + require_existing_link=True, auto_register=False): + """ + Authenticate and authenticated oauth request + """ + if email is None and access_token is None: + return False + + # Authenticate via the access token + if access_token: + token = RemoteToken.get_by_token(client_id, access_token) + + if token: + u = UserInfo(token.remote_account.user_id) + if login_user(u): + return True + + if email: + if authenticate(email): + if not require_existing_link: + return True + + # Pre-existing link required so check + account = RemoteAccount.get(current_user.get_id(), client_id) + if account: + return True + + # Account doesn't exists, and thus the user haven't linked + # the accounts + logout_user() + return None + elif auto_register: + from invenio.modules.accounts.models import User + if not User.query.filter_by(email=email).first(): + # Email doesn't exists so we can proceed to register user. + u = User( + nickname="", + email=email, + password=generate_secret_key(), + note='1', # Activated + ) + + try: + db.session.add(u) + db.session.commit() + login_user(UserInfo(u.id)) + return True + except Exception: + pass + return False diff --git a/invenio/modules/oauthclient/views/__init__.py b/invenio/modules/oauthclient/views/__init__.py new file mode 100644 index 000000000..24fea4ac8 --- /dev/null +++ b/invenio/modules/oauthclient/views/__init__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio 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 +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +from __future__ import absolute_import +from .client import blueprint as client_blueprint +from .settings import blueprint as settings_blueprint + +blueprints = [ + client_blueprint, + settings_blueprint +] diff --git a/invenio/modules/oauthclient/views/client.py b/invenio/modules/oauthclient/views/client.py new file mode 100644 index 000000000..16ebafe50 --- /dev/null +++ b/invenio/modules/oauthclient/views/client.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio 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 +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +""" +OAuth client blueprint +""" + +from __future__ import absolute_import + +from flask import Blueprint, abort, current_app, url_for, request + +from invenio.base.globals import cfg +from invenio.ext.sslify import ssl_required + +from ..client import oauth, handlers, disconnect_handlers +from ..handlers import default_handler, make_token_getter, make_handler, \ + disconnect_handler + + +blueprint = Blueprint( + 'oauthclient', + __name__, + url_prefix="/oauth", + static_folder="../static", + template_folder="../templates", +) + + +@blueprint.before_app_first_request +def setup_app(): + """ + Setup OAuth clients + """ + oauth.init_app(current_app) + + # Add remote applications + for remote_app, conf in cfg['OAUTHCLIENT_REMOTE_APPS'].items(): + # Prevent double creation problems + if remote_app not in oauth.remote_apps: + remote = oauth.remote_app( + remote_app, + **conf['params'] + ) + + remote = oauth.remote_apps[remote_app] + + # Set token getter for remote + remote.tokengetter(make_token_getter(remote)) + + # Register authorized handler + handlers.register( + remote_app, + remote.authorized_handler(make_handler( + conf.get('authorized_handler', default_handler), + remote, + )) + ) + + # Register disconnect handler + disconnect_handlers.register( + remote_app, make_handler( + conf.get('disconnect_handler', disconnect_handler), + remote, + with_response=False, + ) + ) + + +@blueprint.route('/login/<remote_app>/') +@ssl_required +def login(remote_app): + """ + Send user to remote application for authentication + """ + if remote_app not in oauth.remote_apps: + return abort(404) + + callback_url = url_for( + '.authorized', + remote_app=remote_app, + next=request.args.get('next') or request.referrer or None, + _external=True, + ) + + return oauth.remote_apps[remote_app].authorize(callback=callback_url) + + +@blueprint.route('/authorized/<remote_app>/') +@ssl_required +def authorized(remote_app=None): + """ + Authorized handler callback + """ + if remote_app not in handlers: + return abort(404) + + return handlers[remote_app]() + + +@blueprint.route('/disconnect/<remote_app>/') +@ssl_required +def disconnect(remote_app): + """ + Disconnect user from remote application. Removes application as well as + associated information. + """ + if remote_app not in disconnect_handlers: + return abort(404) + + return disconnect_handlers[remote_app]() diff --git a/invenio/modules/oauthclient/views/settings.py b/invenio/modules/oauthclient/views/settings.py new file mode 100644 index 000000000..bd8c6fcda --- /dev/null +++ b/invenio/modules/oauthclient/views/settings.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio 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 +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +""" +OAuth Client Settings Blueprint +""" + +from __future__ import absolute_import + +import six +from operator import itemgetter +from flask import Blueprint, render_template, request +#from flask import redirect, url_for +from flask.ext.login import login_required, current_user +from flask.ext.breadcrumbs import register_breadcrumb +from flask.ext.menu import register_menu +from invenio.base.i18n import _ +from invenio.base.globals import cfg +#from invenio.ext.sqlalchemy import db +from invenio.ext.sslify import ssl_required + +from ..models import RemoteAccount +from ..client import oauth + + +blueprint = Blueprint( + 'oauthclient_settings', + __name__, + url_prefix="/account/settings/linkedaccounts", + static_folder="../static", + template_folder="../templates", +) + + +@blueprint.route("/", methods=['GET', 'POST']) +@ssl_required +@login_required +@register_menu( + blueprint, 'settings.oauthclient', + _('%(icon)s Linked accounts', icon='<i class="fa fa-link fa-fw"></i>'), + order=3, + active_when=lambda: request.endpoint.startswith("oauthclient_settings.") +) +@register_breadcrumb( + blueprint, 'breadcrumbs.settings.oauthclient', _('Linked accounts') +) +def index(): + services = [] + service_map = {} + i = 0 + + for appid, conf in six.iteritems(cfg['OAUTHCLIENT_REMOTE_APPS']): + if not conf.get('hide', False): + services.append(dict( + appid=appid, + title=conf['title'], + icon=conf.get('icon', None), + description=conf.get('description', None), + account=None + )) + service_map[oauth.remote_apps[appid].consumer_key] = i + i += 1 + + # Fetch already linked accounts + accounts = RemoteAccount.query.filter_by( + user_id=current_user.get_id() + ).all() + + for a in accounts: + if a.client_id in service_map: + services[service_map[a.client_id]]['account'] = a + + # Sort according to title + services.sort(key=itemgetter('title')) + + return render_template( + "oauthclient/settings/index.html", + services=services + )