# -*- coding: utf-8 -*-
#
# This file is part of Invenio.
# Copyright (C) 2014, 2015, 2016 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.
"""Handlers for customizing oauthclient endpoints."""
from __future__ import absolute_import, print_function
from functools import partial, wraps
import six
from flask import current_app, flash, redirect, render_template, request, \
session, url_for
from flask_babelex import gettext as _
from flask_login import current_user
from invenio_db import db
from werkzeug.utils import import_string
from .errors import AlreadyLinkedError, OAuthClientError, OAuthError, \
OAuthRejectedRequestError, OAuthResponseError
from .models import RemoteAccount, RemoteToken
from .proxies import current_oauthclient
from .signals import account_info_received, account_setup_committed, \
account_setup_received
from .utils import disable_csrf, fill_form, oauth_authenticate, \
oauth_get_user, oauth_register, registrationform_cls
#
# Token handling
#
[docs]def get_session_next_url(remote_app):
"""Return redirect url stored in session.
:param remote_app: The remote application.
:returns: The redirect URL.
"""
return session.get(
'%s_%s' % (token_session_key(remote_app), 'next_url')
)
[docs]def set_session_next_url(remote_app, url):
"""Store redirect url in session for security reasons.
:param remote_app: The remote application.
:param url: the redirect URL.
"""
session['%s_%s' % (token_session_key(remote_app), 'next_url')] = \
url
[docs]def token_session_key(remote_app):
"""Generate a session key used to store the token for a remote app.
:param remote_app: The remote application.
:returns: The session key.
"""
return '%s_%s' % (current_app.config['OAUTHCLIENT_SESSION_KEY_PREFIX'],
remote_app)
[docs]def response_token_setter(remote, resp):
"""Extract token from response and set it for the user.
:param remote: The remote application.
:param resp: The response.
:raises invenio_oauthclient.errors.OAuthClientError: If authorization with
remote service failed.
:raises invenio_oauthclient.errors.OAuthResponseError: In case of bad
authorized request.
:returns: The token.
"""
if resp is None:
raise OAuthRejectedRequestError('User rejected request.', remote, resp)
else:
if 'access_token' in resp:
return oauth2_token_setter(remote, resp)
elif 'oauth_token' in resp and 'oauth_token_secret' in resp:
return oauth1_token_setter(remote, resp)
elif 'error' in resp:
# Only OAuth2 specifies how to send error messages
raise OAuthClientError(
'Authorization with remote service failed.', remote, resp,
)
raise OAuthResponseError('Bad OAuth authorized request', remote, resp)
[docs]def oauth1_token_setter(remote, resp, token_type='', extra_data=None):
"""Set an OAuth1 token.
:param remote: The remote application.
:param resp: The response.
:param token_type: The token type. (Default: ``''``)
:param extra_data: Extra information. (Default: ``None``)
:returns: A :class:`invenio_oauthclient.models.RemoteToken` instance.
"""
return token_setter(
remote,
resp['oauth_token'],
secret=resp['oauth_token_secret'],
extra_data=extra_data,
token_type=token_type,
)
[docs]def oauth2_token_setter(remote, resp, token_type='', extra_data=None):
"""Set an OAuth2 token.
The refresh_token can be used to obtain a new access_token after
the old one is expired. It is saved in the database for long term use.
A refresh_token will be present only if `access_type=offline` is included
in the authorization code request.
:param remote: The remote application.
:param resp: The response.
:param token_type: The token type. (Default: ``''``)
:param extra_data: Extra information. (Default: ``None``)
:returns: A :class:`invenio_oauthclient.models.RemoteToken` instance.
"""
return token_setter(
remote,
resp['access_token'],
secret='',
token_type=token_type,
extra_data=extra_data,
)
[docs]def token_setter(remote, token, secret='', token_type='', extra_data=None,
user=None):
"""Set token for user.
:param remote: The remote application.
:param token: The token to set.
:param token_type: The token type. (Default: ``''``)
:param extra_data: Extra information. (Default: ``None``)
:param user: The user owner of the remote token. If it's not defined,
the current user is used automatically. (Default: ``None``)
:returns: A :class:`invenio_oauthclient.models.RemoteToken` instance or
``None``.
"""
session[token_session_key(remote.name)] = (token, secret)
user = user or current_user
# Save token if user is not anonymous (user exists but can be not active at
# this moment)
if not user.is_anonymous:
uid = user.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
[docs]def token_getter(remote, token=''):
"""Retrieve OAuth access token.
Used by flask-oauthlib to get the access token when making requests.
:param remote: The remote application.
:param token: Type of token to get. Data passed from ``oauth.request()`` to
identify which token to retrieve. (Default: ``''``)
:returns: The token.
"""
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)
[docs]def token_delete(remote, token=''):
"""Remove OAuth access tokens from session.
:param remote: The remote application.
:param token: Type of token to get. Data passed from ``oauth.request()`` to
identify which token to retrieve. (Default: ``''``)
:returns: The token.
"""
session_key = token_session_key(remote.name)
return session.pop(session_key, None)
#
# Error handling decorators
#
[docs]def oauth_error_handler(f):
"""Decorator to handle exceptions."""
@wraps(f)
def inner(*args, **kwargs):
# OAuthErrors should not happen, so they are not caught here. Hence
# they will result in a 500 Internal Server Error which is what we
# are interested in.
try:
return f(*args, **kwargs)
except OAuthClientError as e:
current_app.logger.warning(e.message, exc_info=True)
return oauth2_handle_error(
e.remote, e.response, e.code, e.uri, e.description
)
except OAuthRejectedRequestError:
flash(_('You rejected the authentication request.'),
category='info')
return redirect('/')
except AlreadyLinkedError:
flash(_('External service is already linked to another account.'),
category='danger')
return redirect(url_for('invenio_oauthclient_settings.index'))
return inner
#
# Handlers
#
@oauth_error_handler
[docs]def authorized_default_handler(resp, remote, *args, **kwargs):
"""Store access token in session.
Default authorized handler.
:param remote: The remote application.
:param resp: The response.
:returns: Redirect response.
"""
response_token_setter(remote, resp)
db.session.commit()
return redirect(url_for('invenio_oauthclient_settings.index'))
@oauth_error_handler
[docs]def authorized_signup_handler(resp, remote, *args, **kwargs):
"""Handle sign-in/up functionality.
:param remote: The remote application.
:param resp: The response.
:returns: Redirect response.
"""
# Remove any previously stored auto register session key
session.pop(token_session_key(remote.name) + '_autoregister', None)
# Store token in session
# ----------------------
# Set token in session - token object only returned if
# current_user.is_autenticated().
token = response_token_setter(remote, resp)
handlers = current_oauthclient.signup_handlers[remote.name]
# Sign-in/up user
# ---------------
if not current_user.is_authenticated:
account_info = handlers['info'](resp)
account_info_received.send(
remote, token=token, response=resp, account_info=account_info
)
user = oauth_get_user(
remote.consumer_key,
account_info=account_info,
access_token=token_getter(remote)[0],
)
if user is None:
# Auto sign-up if user not found
form_cls = registrationform_cls()
form = fill_form(
disable_csrf(form_cls()),
account_info['user']
)
user = oauth_register(form)
# if registration fails ...
if user is None:
# requires extra information
session[
token_session_key(remote.name) + '_autoregister'] = True
session[token_session_key(remote.name) +
'_account_info'] = account_info
session[token_session_key(remote.name) +
'_response'] = resp
db.session.commit()
return redirect(url_for(
'.signup',
remote_app=remote.name,
))
# Authenticate user
if not oauth_authenticate(remote.consumer_key, user,
require_existing_link=False,
remember=current_app.config[
'OAUTHCLIENT_REMOTE_APPS']
[remote.name].get('remember', False)):
return current_app.login_manager.unauthorized()
# Link account
# ------------
# Need to store token in database instead of only the session when
# called first time.
token = response_token_setter(remote, resp)
# Setup account
# -------------
if not token.remote_account.extra_data:
account_setup = handlers['setup'](token, resp)
account_setup_received.send(
remote, token=token, response=resp, account_setup=account_setup
)
db.session.commit()
account_setup_committed.send(remote, token=token)
else:
db.session.commit()
# Redirect to next
next_url = get_session_next_url(remote.name)
if next_url:
return redirect(next_url)
return redirect(url_for('invenio_oauthclient_settings.index'))
[docs]def disconnect_handler(remote, *args, **kwargs):
"""Handle unlinking of remote account.
This default handler will just delete the remote account link. You may
wish to extend this module to perform clean-up in the remote service
before removing the link (e.g. removing install webhooks).
:param remote: The remote application.
:returns: Redirect response.
"""
if not current_user.is_authenticated:
return current_app.login_manager.unauthorized()
with db.session.begin_nested():
account = RemoteAccount.get(
user_id=current_user.get_id(),
client_id=remote.consumer_key
)
if account:
account.delete()
db.session.commit()
return redirect(url_for('invenio_oauthclient_settings.index'))
[docs]def signup_handler(remote, *args, **kwargs):
"""Handle extra signup information.
:param remote: The remote application.
:returns: Redirect response or the template rendered.
"""
# User already authenticated so move on
if current_user.is_authenticated:
return redirect('/')
# Retrieve token from session
oauth_token = token_getter(remote)
if not oauth_token:
return redirect('/')
session_prefix = token_session_key(remote.name)
# Test to see if this is coming from on authorized request
if not session.get(session_prefix + '_autoregister', False):
return redirect(url_for('.login', remote_app=remote.name))
form = registrationform_cls()(request.form)
if form.validate_on_submit():
account_info = session.get(session_prefix + '_account_info')
response = session.get(session_prefix + '_response')
# Register user
user = oauth_register(form)
if user is None:
raise OAuthError('Could not create user.', remote)
# Remove session key
session.pop(session_prefix + '_autoregister', None)
# Link account and set session data
token = token_setter(remote, oauth_token[0], secret=oauth_token[1],
user=user)
handlers = current_oauthclient.signup_handlers[remote.name]
if token is None:
raise OAuthError('Could not create token for user.', remote)
if not token.remote_account.extra_data:
account_setup = handlers['setup'](token, response)
account_setup_received.send(
remote, token=token, response=response,
account_setup=account_setup
)
# Registration has been finished
db.session.commit()
account_setup_committed.send(remote, token=token)
else:
# Registration has been finished
db.session.commit()
# Authenticate the user
if not oauth_authenticate(remote.consumer_key, user,
require_existing_link=False,
remember=current_app.config[
'OAUTHCLIENT_REMOTE_APPS']
[remote.name].get('remember', False)):
# Redirect the user after registration (which doesn't include the
# activation), waiting for user to confirm his email.
return redirect(url_for('security.login'))
# Remove account info from session
session.pop(session_prefix + '_account_info', None)
session.pop(session_prefix + '_response', None)
# Redirect to next
next_url = get_session_next_url(remote.name)
if next_url:
return redirect(next_url)
else:
return redirect('/')
# Pre-fill form
account_info = session.get(session_prefix + '_account_info')
if not form.is_submitted():
form = fill_form(form, account_info['user'])
return render_template(
current_app.config['OAUTHCLIENT_SIGNUP_TEMPLATE'],
form=form,
remote=remote,
app_title=current_app.config['OAUTHCLIENT_REMOTE_APPS'][
remote.name].get('title', ''),
app_description=current_app.config['OAUTHCLIENT_REMOTE_APPS'][
remote.name].get('description', ''),
app_icon=current_app.config['OAUTHCLIENT_REMOTE_APPS'][
remote.name].get('icon', None),
)
[docs]def oauth_logout_handler(sender_app, user=None):
"""Remove all access tokens from session on logout."""
oauth = current_app.extensions['oauthlib.client']
for remote in oauth.remote_apps.values():
token_delete(remote)
db.session.commit()
#
# Helpers
#
[docs]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.string_types):
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
[docs]def make_token_getter(remote):
"""Make a token getter for a remote application."""
return partial(token_getter, remote)
[docs]def oauth2_handle_error(remote, resp, error_code, error_uri,
error_description):
"""Handle errors during exchange of one-time code for an access tokens."""
flash(_('Authorization with remote service failed.'))
return redirect('/')