Commit 85ff84b4 authored by Christophe de Vienne's avatar Christophe de Vienne
Browse files

Complete oauth2 support

closes #3103928.
parent e082557d17e7
syntax: glob
*.pyc
*.swp
test/data
Summary
-------
Social Network (Fb, tw, g+ etc) Authentication
External service (Fb, tw, g+ etc) Authentication
"""cubicweb-oauth application package
Social Network (Facebook, Twitter, Google+, etc) Authentication
External service (Facebook, Twitter, Google+, etc) Authentication
"""
......@@ -10,10 +10,13 @@ version = '.'.join(str(num) for num in numversion)
license = 'LGPL'
author = 'LOGILAB S.A. (Paris, FRANCE)'
author_email = 'contact@logilab.fr'
description = 'Social Network (Facebook, Twitter, Google+, etc) Authentication'
description = 'External service (Facebook, Twitter, Google+, etc) Authentication'
web = 'http://www.cubicweb.org/project/%s' % distname
__depends__ = {'cubicweb': '>= 3.17.5'}
__depends__ = {
'cubicweb': '>= 3.17.5',
'rauth': '>= 0.6.1',
}
__recommends__ = {}
classifiers = [
......
"""
Special authentifiers.
:license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses
"""
__docformat__ = "restructuredtext en"
from cubicweb import AuthenticationError
from cubicweb.server.sources import native
class Token(object): pass
EXT_TOKEN = Token()
class DirectAuthentifier(native.BaseAuthentifier):
"""return CWUser eid for the given login.
Before doing so, it makes sure the authentication request comes from
xxx by checking the special '__externalauth_directauth' kwarg.
"""
auth_rql = (
'Any U WHERE U is CWUser, '
'U login %(login)s'
)
def authenticate(self, session, login, **kwargs):
"""Return the CWUser eid for the given login.
Make sure the request comes from inside externalauth by
checking the special '__externalauth_directauth' kwarg.
"""
session.debug('authentication by %s', self.__class__.__name__)
directauth = kwargs.get('__externalauth_directauth', None)
try:
if directauth == EXT_TOKEN:
rset = session.execute(self.auth_rql, {'login': login})
if rset:
session.debug('Successfully identified %s', login)
return rset[0][0]
except Exception, exc:
session.debug('authentication failure (%s)', exc)
raise AuthenticationError('user is not registered')
cubicweb-oauth (0.1.0-1) unstable; urgency=low
* initial release
--
Source: cubicweb-oauth
Section: web
Priority: optional
Maintainer: LOGILAB S.A. (Paris, FRANCE) <contact@logilab.fr>
Build-Depends: debhelper (>= 7), python (>= 2.6), python-support
Standards-Version: 3.9.3
XS-Python-Version: >= 2.6
Package: cubicweb-oauth
Architecture: all
Depends: cubicweb-common (>= 3.17.6), ${python:Depends}
Description:
CubicWeb is a semantic web application framework.
.
.
This package will install all the components you need to run the
cubicweb-oauth application (cube :)..
Upstream Author:
LOGILAB S.A. (Paris, FRANCE) <contact@logilab.fr>
Copyright:
Copyright (c) 2013 LOGILAB S.A. (Paris, FRANCE).
http://www.logilab.fr -- mailto:contact@logilab.fr
#!/usr/bin/make -f
# Sample debian/rules that uses debhelper.
# GNU copyright 1997 to 1999 by Joey Hess.
# Uncomment this to turn on verbose mode.
#export DH_VERBOSE=1
build: build-arch build-indep
build-arch:
# Nothing to do
build-indep: build-stamp
build-stamp:
dh_testdir
NO_SETUPTOOLS=1 python setup.py -q build
touch build-stamp
clean:
dh_testdir
dh_testroot
rm -f build-stamp configure-stamp
rm -rf build
find . -name "*.pyc" | xargs rm -f
dh_clean
install: build
dh_testdir
dh_testroot
dh_clean -k
dh_installdirs -i
NO_SETUPTOOLS=1 python setup.py -q install --no-compile --prefix=debian/cubicweb-oauth/usr/
# remove generated .egg-info file
rm -rf debian/cubicweb-oauth/usr/lib/python*
# Build architecture-independent files here.
binary-indep: build install
dh_testdir
dh_testroot
dh_install -i
dh_installchangelogs -i
dh_installexamples -i
dh_installdocs -i
dh_installman -i
dh_pysupport -i /usr/share/cubicweb
dh_link -i
dh_compress -i -X.py -X.ini -X.xml -Xtest
dh_fixperms -i
dh_installdeb -i
dh_gencontrol -i
dh_md5sums -i
dh_builddeb -i
# Build architecture-dependent files here.
binary-arch:
binary: binary-indep
.PHONY: build clean binary-arch binary-indep binary
......@@ -10,7 +10,59 @@
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""cubicweb-oauth entity's classes"""
from rauth import OAuth2Service
from cubicweb.predicates import is_instance
from cubicweb.appobject import AppObject
from cubicweb.view import EntityAdapter
class Provider(AppObject):
__registry__ = 'provider'
__abstract__ = True
class FacebookProvider(Provider):
__regid__ = 'facebook'
authorize_url = 'https://graph.facebook.com/oauth/authorize'
access_token_url = 'https://graph.facebook.com/oauth/access_token'
base_url = 'https://graph.facebook.com/'
scope = 'email'
def retrieve_info(self, request):
me = request.get(
'me?fields=name,first_name,last_name,email,username'
).json()
return {
'uid': me['id'],
'email': me['email'],
'firstname': me['first_name'],
'lastname': me['last_name'],
'username': me['username']
}
class ServiceAdapter(EntityAdapter):
__regid__ = 'externalauth.service'
__select__ = is_instance('ExternalAuthService')
@property
def provider(self):
spid = self.entity.provider[0].spid
return self._cw.vreg['provider'][spid][0](self._cw)
@property
def oauth2_service(self):
provider = self.provider
return OAuth2Service(
name=self.entity.application_name,
client_id=self.entity.application_id,
client_secret=self.entity.application_secret,
authorize_url=provider.authorize_url,
access_token_url=provider.access_token_url,
base_url=provider.base_url,
)
# -*- coding: utf-8 -*-
#
# copyright 2013 Unlish (Montpellier, FRANCE), all rights reserved.
# contact http://www.unlish.com -- mailto:contact@unlish.com
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
......@@ -10,7 +13,22 @@
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""cubicweb-oauth specific hooks and operations."""
from cubicweb.server import hook
from cubes.oauth.authplugin import DirectAuthentifier
class ServerStartupHook(hook.Hook):
"""register authentifier at startup."""
__regid__ = 'oauth-authentifier-register'
events = ('server_startup',)
"""cubicweb-oauth specific hooks and operations"""
def __call__(self):
self.debug('registering externalauth authentifier')
self.repo.system_source.add_authentifier(DirectAuthentifier())
......@@ -22,3 +22,4 @@ You could setup site properties or a workflow here for example.
# Example of site property change
#set_property('ui.site-title', "<sitename>")
create_entity('ExternalAuthProvider', spid=u'facebook', name=u'Facebook')
# -*- coding: utf-8 -*-
#
# copyright 2013 Unlish (Montpellier, FRANCE), all rights reserved.
# contact http://www.unlish.com -- mailto:contact@unlish.com
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
......@@ -10,7 +13,132 @@
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""cubicweb-oauth schema."""
from yams.buildobjs import (
String, EntityType, SubjectRelation, Datetime, Boolean
)
from cubicweb.schema import ERQLExpression
# TODO set the maxsize of the String attributes.
sp_attrs_perms = {
'read': ('managers', 'users', 'guests'),
'update': ('managers',),
}
class ExternalAuthProvider(EntityType):
__permissions__ = {
'read': ('managers', 'users', 'guests'),
'add': ('managers',),
'update': ('managers',),
'delete': ('managers',)
}
spid = String(
__permissions__=sp_attrs_perms,
maxsize=16, required=True, unique=True,
description="Provider unique id ('facebook', 'twitter')")
name = String(
__permissions__=sp_attrs_perms,
maxsize=16, required=True, unique=True,
description="Provider user-friendly name ('Facebook', 'Twitter')")
# XXX make it workfloable
class ExternalAuthService(EntityType):
__permissions__ = {
'read': ('managers',),
'add': ('managers',),
'update': ('managers',),
'delete': ('managers',),
}
__unique_together__ = [
('provider', 'application_id'),
]
provider = SubjectRelation(
'ExternalAuthProvider',
__permissions__={
'read': ('managers',),
'add': ('managers',),
'delete': ('managers',)
},
cardinality='1*',
inlined=True
)
application_name = String(
maxsize=64, description="Application name",
__permissions__=sp_attrs_perms)
application_id = String(
maxsize=64, description="OAuth2 client_id.",
__permissions__=sp_attrs_perms)
application_secret = String(
maxsize=64, description="OAuth2 client_secret",
__permissions__=sp_attrs_perms)
class ExternalIdentity(EntityType):
__permissions__ = {
'read': ('managers', ERQLExpression('X identity_of U')),
'add': ('managers',),
'update': ('managers',),
'delete': ('managers', ERQLExpression('X identity_of U'))
}
__unique_together__ = [('provider', 'uid')]
provider = SubjectRelation(
'ExternalAuthProvider', cardinality='1*', inlined=True,
__permissions__={
'read': ('managers', 'users'),
'add': ('managers',),
'delete': ('managers',)
}
)
identity_of = SubjectRelation(
'CWUser', cardinality='?*', inlined=True,
__permissions__={
'read': ('managers', 'users',),
'add': ('managers',),
'delete': ('managers',)
}
)
uid = String(
required=True,
__permissions__={
'read': ('managers', 'users',),
'update': ('managers',),
}
)
class OAuth2Session(EntityType):
__permissions__ = {
'read': ('managers', ERQLExpression('X external_identity I, I identity_of U')),
'add': ('managers',),
'update': ('managers',),
'delete': ('managers', ERQLExpression('X external_identity I, I identity_of U'))
}
"""cubicweb-oauth schema"""
external_identity = SubjectRelation(
'ExternalIdentity', cardinality='1*', inlined=True,
__permissions__={
'read': ('managers', 'users',),
'add': ('managers',),
'delete': ('managers',)
}
)
service = SubjectRelation(
'ExternalAuthService', cardinality='1*', inlined=True,
__permissions__={
'read': ('managers', 'users',),
'add': ('managers',),
'delete': ('managers',)
}
)
access_token = String(required=True, unique=True)
active = Boolean(required=True, default=True)
expiry = Datetime()
......@@ -10,7 +10,419 @@
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""cubicweb-oauth views/forms/actions/components for web ui"""
import urllib
from logilab.common.decorators import clear_cache
from cubicweb.utils import make_uid
from cubicweb.predicates import anonymous_user, configuration_values, match_form_params
from cubicweb.view import View
from cubicweb.web.controller import Controller
from cubicweb.web.views import authentication, urlrewrite, forms
from cubicweb.web.views import basecomponents, basetemplates
from cubicweb.web import Redirect, formfields, formwidgets
from cubicweb.server import Service
from cubes.oauth.authplugin import EXT_TOKEN
_ = unicode
NEGOSTATE = {}
LOGINNOWSTATE = {}
def login_now(self, login):
req = self._cw
key = make_uid()
LOGINNOWSTATE[key] = login
clear_cache(req, 'get_authorization')
# prepare login info for .set_session call
req.form['__externalauthlogin'] = login
req.form['__externalauthkey'] = key
# close anonymous connection
if req.cnx:
req.cnx.close()
req.cnx = None
try:
self.appli.session_handler.set_session(req)
except Redirect:
pass
assert req.user.login == login, req.user.login
class ExternalAuthReqRewriter(urlrewrite.SimpleReqRewriter):
rules = [
('/externalauth-confirm', {'vid':'externalauth-confirm'})
]
class ExternalAuthConfirmError(Controller):
__regid__ = 'externalauth-confirm'
# XXX check if these error_xxx are parts of oauth1/2
__select__ = match_form_params(
'error_code', 'error_message', '__externalauth_negociationid')
def publish(self, rset=None):
form = self._cw.form
raise Redirect(self._cw.build_url(
__message='error while authenticating: %s %s'
% (form.get('error_code'), form.get('error_message', ''))))
class ExternalAuthConfirm(Controller):
__regid__ = 'externalauth-confirm'
__select__ = match_form_params('code', '__externalauth_negociationid')
def publish(self, rset=None):
form = self._cw.form
code = form.get('code')
negociationid = form.get('__externalauth_negociationid')
if not (code and negociationid):
return
nego = NEGOSTATE.pop(negociationid)
with self.appli.repo.internal_session() as session:
try:
service = session.entity_from_eid(nego['service'])
adapter = service.cw_adapt_to('externalauth.service')
oauth2_session = adapter.oauth2_service.get_auth_session(data={
'code': code,
'redirect_uri': nego['redirect_uri']
})
infos = adapter.provider.retrieve_info(oauth2_session)
extid_rset = session.execute(
'ExternalIdentity SI WHERE SI provider P, '
'P eid %(peid)s, SI uid %(uid)s',
{'peid': service.provider[0].eid, 'uid': infos['uid']}
)
assert extid_rset.rowcount < 2
if extid_rset:
external_identity = extid_rset.get_entity(0, 0)
else:
external_identity = session.create_entity(
'ExternalIdentity', provider=service.provider[0],
uid=infos['uid']
)
# deactivate any previous access_token
session.execute(
'SET X active FALSE WHERE '
'X is OAuth2Session, '
'S external_identity SI, SI eid %(sieid)s, '
'S service SE, SE eid %(seid)s',
{'sieid': external_identity.eid, 'seid': service.eid}
)
oauth2_session = session.create_entity(
'OAuth2Session', service=service,
external_identity=external_identity,
access_token=oauth2_session.access_token,
active=True
)
session.commit(free_cnxset=False)
except Exception, e:
raise Redirect(self._cw.build_url(__message=str(e)))
if external_identity.identity_of:
user = external_identity.identity_of[0]
self.debug('identified %s on %s as %s' % (
user.login, service.provider[0].name, infos['uid']))
login_now(self, user.login)
raise Redirect(self._cw.build_url())
else:
self.debug(
"Unknown identity %s on %s: will create a local account",
infos['uid'], service.provider[0].name)
token = make_uid()
infos.update({
'external_identity': external_identity,
'oauth2_session': oauth2_session
})
self._cw.session.data[token] = infos
raise Redirect(self._cw.build_url(
vid='externalauth-createuser',
__token=token
))
class ExternalAuthCreateUserForm(forms.FieldsForm):
__regid__ = action = 'externalauth-createuser'
form_buttons = [
formwidgets.SubmitButton(label=_('create user')),
formwidgets.ResetButton(label=_('cancel')),
]
class RegisterUserService(Service):
__regid__ = 'oauth_register_user' # in CW 3.18, use the standard one
default_groups = ('users',)
def call(self, login, password, email=None, groups=None, **kwargs):
session = self._cw
# for consistency, keep same error as unique check hook (although not required)
errmsg = session._('the value "%s" is already used, use another one')
if (session.execute('CWUser X WHERE X login %(login)s', {'login': login},
build_descr=False)
or session.execute('CWUser X WHERE X use_email C, C address %(login)s',
{'login': login}, build_descr=False)):
qname = role_name('login', 'subject')
raise ValidationError(None, {qname: errmsg % login})
# we have to create the user
if isinstance(password, unicode):
# password should *always* be utf8 encoded
password = password.encode('UTF8')
kwargs['login'] = login
kwargs['upassword'] = password
user = session.create_entity('CWUser', **kwargs)
if groups is None:
groups = self.default_groups
if groups:
session.execute('SET X in_group G WHERE X eid %%(x)s, G name IN (%s)' %
','.join(repr(u) for u in groups),
{'x': user.eid})
if email or '@' in login:
d = {'login': login, 'email': email or login}
if session.execute('EmailAddress X WHERE X address %(email)s', d,
build_descr=False):
qname = role_name('address', 'subject')
raise ValidationError(None, {qname: errmsg % d['email']})
session.execute('INSERT EmailAddress X: X address %(email)s, '
'U primary_email X, U use_email X '
'WHERE U login %(login)s', d, build_descr=False)
return True
class ExternalAuthCreateUserController(Controller):
__regid__ = 'externalauth-createuser'
def publish(self, rset=None):
token = self._cw.form.get('__token')