diff --git a/requirements.txt b/requirements.txt index c1df66c43d..9dfd334a16 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ pytz==2015.7 django-formtools qrcode requests +requests_oauthlib==0.8.0 diff --git a/seahub/auth/views.py b/seahub/auth/views.py index e8c2e55ec9..0f7294064a 100644 --- a/seahub/auth/views.py +++ b/seahub/auth/views.py @@ -245,6 +245,7 @@ def login(request, template_name='registration/login.html', enable_shib_login = getattr(settings, 'ENABLE_SHIB_LOGIN', False) enable_krb5_login = getattr(settings, 'ENABLE_KRB5_LOGIN', False) enable_adfs_login = getattr(settings, 'ENABLE_ADFS_LOGIN', False) + enable_oauth = getattr(settings, 'ENABLE_OAUTH', False) login_bg_image_path = LOGIN_BG_IMAGE_PATH # get path that background image of login page @@ -262,6 +263,7 @@ def login(request, template_name='registration/login.html', 'enable_shib_login': enable_shib_login, 'enable_krb5_login': enable_krb5_login, 'enable_adfs_login': enable_adfs_login, + 'enable_oauth': enable_oauth, 'login_bg_image_path': login_bg_image_path, }, context_instance=RequestContext(request)) diff --git a/seahub/oauth/__init__.py b/seahub/oauth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/seahub/oauth/admin.py b/seahub/oauth/admin.py new file mode 100644 index 0000000000..8c38f3f3da --- /dev/null +++ b/seahub/oauth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/seahub/oauth/backends.py b/seahub/oauth/backends.py new file mode 100644 index 0000000000..313523838f --- /dev/null +++ b/seahub/oauth/backends.py @@ -0,0 +1,55 @@ +from django.conf import settings +from django.contrib.auth.backends import RemoteUserBackend + +from seahub.base.accounts import User +from registration.models import notify_admins_on_activate_request + +class OauthRemoteUserBackend(RemoteUserBackend): + """ + This backend is to be used in conjunction with the ``RemoteUserMiddleware`` + found in the middleware module of this package, and is used when the server + is handling authentication outside of Django. + + By default, the ``authenticate`` method creates ``User`` objects for + usernames that don't already exist in the database. Subclasses can disable + this behavior by setting the ``create_unknown_user`` attribute to + ``False``. + """ + + # Create a User object if not already in the database? + create_unknown_user = getattr(settings, 'OAUTH_CREATE_UNKNOWN_USER', True) + # Create active user by default. + activate_after_creation = getattr(settings, 'OAUTH_ACTIVATE_USER_AFTER_CREATION', True) + + def get_user(self, username): + try: + user = User.objects.get(email=username) + except User.DoesNotExist: + user = None + return user + + def authenticate(self, remote_user): + """ + The username passed as ``remote_user`` is considered trusted. This + method simply returns the ``User`` object with the given username, + creating a new ``User`` object if ``create_unknown_user`` is ``True``. + + Returns None if ``create_unknown_user`` is ``False`` and a ``User`` + object with the given username is not found in the database. + """ + if not remote_user: + return + + username = self.clean_username(remote_user) + try: + user = User.objects.get(email=username) + except User.DoesNotExist: + if self.create_unknown_user: + user = User.objects.create_user( + email=username, is_active=self.activate_after_creation) + if user and self.activate_after_creation is False: + notify_admins_on_activate_request(user.email) + else: + user = None + + return user diff --git a/seahub/oauth/migrations/__init__.py b/seahub/oauth/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/seahub/oauth/models.py b/seahub/oauth/models.py new file mode 100644 index 0000000000..71a8362390 --- /dev/null +++ b/seahub/oauth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/seahub/oauth/tests.py b/seahub/oauth/tests.py new file mode 100644 index 0000000000..7ce503c2dd --- /dev/null +++ b/seahub/oauth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/seahub/oauth/urls.py b/seahub/oauth/urls.py new file mode 100644 index 0000000000..4177b00660 --- /dev/null +++ b/seahub/oauth/urls.py @@ -0,0 +1,9 @@ +# Copyright (c) 2012-2016 Seafile Ltd. + +from django.conf.urls import patterns, url +from seahub.oauth.views import oauth_login, oauth_callback + +urlpatterns = patterns('', + url(r'login/$', oauth_login, name='oauth_login'), + url(r'callback/$', oauth_callback, name='oauth_callback'), +) diff --git a/seahub/oauth/views.py b/seahub/oauth/views.py new file mode 100644 index 0000000000..2acc48df53 --- /dev/null +++ b/seahub/oauth/views.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- + +import os +import logging +from requests_oauthlib import OAuth2Session +from django.http import HttpResponseRedirect +from django.template import RequestContext +from django.shortcuts import render_to_response +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ + +from seahub import auth +from seahub.profile.models import Profile +from seahub.utils import is_valid_email +import seahub.settings as settings + +logger = logging.getLogger(__name__) + +ENABLE_OAUTH = getattr(settings, 'ENABLE_OAUTH', False) +if ENABLE_OAUTH: + + if getattr(settings, 'OAUTH_ENABLE_INSECURE_TRANSPORT', False): + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' + + # Used for oauth workflow. + CLIENT_ID = getattr(settings, 'OAUTH_CLIENT_ID', '') + CLIENT_SECRET = getattr(settings, 'OAUTH_CLIENT_SECRET', '') + AUTHORIZATION_URL = getattr(settings, 'OAUTH_AUTHORIZATION_URL', '') + REDIRECT_URL = getattr(settings, 'OAUTH_REDIRECT_URL', '') + TOKEN_URL = getattr(settings, 'OAUTH_TOKEN_URL', '') + USER_INFO_URL = getattr(settings, 'OAUTH_USER_INFO_URL', '') + SCOPE = getattr(settings, 'OAUTH_SCOPE', '') + + # Used for init an user for Seahub. + PROVIDER_DOMAIN = getattr(settings, 'OAUTH_PROVIDER_DOMAIN', '') + ATTRIBUTE_MAP = { + 'id': (True, "email"), + } + ATTRIBUTE_MAP.update(getattr(settings, 'OAUTH_ATTRIBUTE_MAP', {})) + + session = OAuth2Session(client_id=CLIENT_ID, + scope=SCOPE, redirect_uri=REDIRECT_URL) + +def oauth_check(func): + """ Decorator for check if OAuth valid. + """ + def _decorated(request): + + error = False + if not ENABLE_OAUTH: + logger.error('OAuth not enabled.') + error = True + else: + if not CLIENT_ID or not CLIENT_SECRET or not AUTHORIZATION_URL \ + or not REDIRECT_URL or not TOKEN_URL or not USER_INFO_URL \ + or not SCOPE or not PROVIDER_DOMAIN: + logger.error('OAuth relevant settings invalid.') + logger.error('CLIENT_ID: %s' % CLIENT_ID) + logger.error('CLIENT_SECRET: %s' % CLIENT_SECRET) + logger.error('AUTHORIZATION_URL: %s' % AUTHORIZATION_URL) + logger.error('REDIRECT_URL: %s' % REDIRECT_URL) + logger.error('TOKEN_URL: %s' % TOKEN_URL) + logger.error('USER_INFO_URL: %s' % USER_INFO_URL) + logger.error('SCOPE: %s' % SCOPE) + logger.error('PROVIDER_DOMAIN: %s' % PROVIDER_DOMAIN) + error = True + + if error: + return render_to_response('error.html', { + 'error_msg': _('Error, please contact administrator.'), + }, context_instance=RequestContext(request)) + + return func(request) + + return _decorated + +# https://requests-oauthlib.readthedocs.io/en/latest/examples/github.html +# https://requests-oauthlib.readthedocs.io/en/latest/examples/google.html +@oauth_check +def oauth_login(request): + """Step 1: User Authorization. + Redirect the user/resource owner to the OAuth provider (i.e. Github) + using an URL with a few key OAuth parameters. + """ + try: + authorization_url, state = session.authorization_url( + AUTHORIZATION_URL) + except Exception as e: + logger.error(e) + return render_to_response('error.html', { + 'error_msg': _('Error, please contact administrator.'), + }, context_instance=RequestContext(request)) + + return HttpResponseRedirect(authorization_url) + +# Step 2: User authorization, this happens on the provider. + +@oauth_check +def oauth_callback(request): + """ Step 3: Retrieving an access token. + The user has been redirected back from the provider to your registered + callback URL. With this redirection comes an authorization code included + in the redirect URL. We will use that to obtain an access token. + """ + try: + session.fetch_token(TOKEN_URL, client_secret=CLIENT_SECRET, + authorization_response=request.get_full_path()) + user_info_resp = session.get(USER_INFO_URL) + except Exception as e: + logger.error(e) + return render_to_response('error.html', { + 'error_msg': _('Error, please contact administrator.'), + }, context_instance=RequestContext(request)) + + def format_user_info(user_info_resp): + + error = False + user_info = {} + user_info_json = user_info_resp.json() + + for item, attr in ATTRIBUTE_MAP.items(): + required, user_attr = attr + value = str(user_info_json.get(item, '')) + + # ccnet email + if user_attr == 'email': + user_info[user_attr] = value if is_valid_email(value) else \ + '%s@%s' % (value, PROVIDER_DOMAIN) + else: + user_info[user_attr] = value + + if required and not value: + error = True + + return user_info, error + + user_info, error = format_user_info(user_info_resp) + if error: + logger.error('Required user info not found.') + logger.error(user_info) + return render_to_response('error.html', { + 'error_msg': _('Error, please contact administrator.'), + }, context_instance=RequestContext(request)) + + # seahub authenticate user + email = user_info['email'] + user = auth.authenticate(remote_user=email) + + if not user or not user.is_active: + logger.error('User %s not found or inactive.' % email) + # a page for authenticate user failed + return render_to_response('error.html', { + 'error_msg': _(u'User %s not found.') % email + }, context_instance=RequestContext(request)) + + # User is valid. Set request.user and persist user in the session + # by logging the user in. + request.user = user + auth.login(request, user) + user.set_unusable_password() + user.save() + + # update user's profile + name = user_info['name'] if user_info.has_key('name') else '' + contact_email = user_info['contact_email'] if \ + user_info.has_key('contact_email') else '' + + profile = Profile.objects.get_profile_by_user(email) + if not profile: + profile = Profile(user=email) + + if name: + profile.nickname = name.strip() + + if contact_email: + profile.contact_email = contact_email.strip() + + profile.save() + + # redirect user to home page + return HttpResponseRedirect(reverse('libraries')) diff --git a/seahub/settings.py b/seahub/settings.py index 0de35c0496..31abdbb971 100644 --- a/seahub/settings.py +++ b/seahub/settings.py @@ -240,7 +240,12 @@ CONSTANCE_DATABASE_CACHE_BACKEND = 'default' AUTHENTICATION_BACKENDS = ( 'seahub.base.accounts.AuthBackend', + 'seahub.oauth.backends.OauthRemoteUserBackend', + ) + +ENABLE_OAUTH = False + LOGIN_REDIRECT_URL = '/profile/' LOGIN_URL = SITE_ROOT + 'accounts/login' LOGOUT_REDIRECT_URL = None diff --git a/seahub/templates/registration/login.html b/seahub/templates/registration/login.html index f646216161..f32c862885 100644 --- a/seahub/templates/registration/login.html +++ b/seahub/templates/registration/login.html @@ -66,6 +66,10 @@ html, body, #wrapper { height:100%; } + {% if enable_oauth %} + {% trans "Single Sign-On" %} + {% endif %} + {% if enable_adfs_login %} ADFS {% endif %} diff --git a/seahub/urls.py b/seahub/urls.py index e2ef5a84ce..6d4221888b 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -110,6 +110,8 @@ urlpatterns = patterns( (r'^sso/$', sso), url(r'^shib-login/', shib_login, name="shib_login"), + (r'^oauth/', include('seahub.oauth.urls')), + url(r'^$', libraries, name='libraries'), #url(r'^home/$', direct_to_template, { 'template': 'home.html' } ), url(r'^robots\.txt$', TemplateView.as_view(template_name='robots.txt', content_type='text/plain')),