Implement support for Oauth2 based login integrations
authorMagnus Hagander <magnus@hagander.net>
Mon, 28 Aug 2017 14:28:03 +0000 (16:28 +0200)
committerMagnus Hagander <magnus@hagander.net>
Mon, 28 Aug 2017 14:31:57 +0000 (16:31 +0200)
This creates Google, Github, Microsoft and Facebook login integrations.
Other providers can also be added if needed. Accounts still need to be
created in the community auth system, and will be automatically created
on first login, when the user also gets to pick a username. Once an
account exists, it will be matched on email address from the external
systems.

No methods are enabled by default, as they all require encryption keys
and identities configured in local_settings.py.

Review by Stephen Frost, Jonathan Katz and Daniel Gustafsson.

13 files changed:
media/img/misc/btn_login_facebook.png [new file with mode: 0644]
media/img/misc/btn_login_github.png [new file with mode: 0644]
media/img/misc/btn_login_google.png [new file with mode: 0644]
media/img/misc/btn_login_microsoft.png [new file with mode: 0644]
pgweb/account/forms.py
pgweb/account/oauthclient.py [new file with mode: 0644]
pgweb/account/urls.py
pgweb/account/views.py
pgweb/settings.py
requirements.txt
templates/account/login.html
templates/account/signup_oauth.html [new file with mode: 0644]
templates/account/userprofileform.html

diff --git a/media/img/misc/btn_login_facebook.png b/media/img/misc/btn_login_facebook.png
new file mode 100644 (file)
index 0000000..df97a70
Binary files /dev/null and b/media/img/misc/btn_login_facebook.png differ
diff --git a/media/img/misc/btn_login_github.png b/media/img/misc/btn_login_github.png
new file mode 100644 (file)
index 0000000..e0b1105
Binary files /dev/null and b/media/img/misc/btn_login_github.png differ
diff --git a/media/img/misc/btn_login_google.png b/media/img/misc/btn_login_google.png
new file mode 100644 (file)
index 0000000..68618ff
Binary files /dev/null and b/media/img/misc/btn_login_google.png differ
diff --git a/media/img/misc/btn_login_microsoft.png b/media/img/misc/btn_login_microsoft.png
new file mode 100644 (file)
index 0000000..e2e12cc
Binary files /dev/null and b/media/img/misc/btn_login_microsoft.png differ
index 75297b107824701716524d7093627ba42786dd1f..a8ab5848a6f8ee7de0cbfa41fad51305127391df 100644 (file)
@@ -12,6 +12,17 @@ from recaptcha import ReCaptchaField
 import logging
 log = logging.getLogger(__name__)
 
+def _clean_username(username):
+       username = username.lower()
+
+       if not re.match('^[a-z0-9\.-]+$', username):
+               raise forms.ValidationError("Invalid character in user name. Only a-z, 0-9, . and - allowed for compatibility with third party software.")
+       try:
+               User.objects.get(username=username)
+       except User.DoesNotExist:
+               return username
+       raise forms.ValidationError("This username is already in use")
+
 # Override some error handling only in the default authentication form
 class PgwebAuthenticationForm(AuthenticationForm):
        def clean(self):
@@ -53,15 +64,7 @@ class SignupForm(forms.Form):
                return email2
 
        def clean_username(self):
-               username = self.cleaned_data['username'].lower()
-
-               if not re.match('^[a-z0-9\.-]+$', username):
-                       raise forms.ValidationError("Invalid character in user name. Only a-z, 0-9, . and - allowed for compatibility with third party software.")
-               try:
-                       User.objects.get(username=username)
-               except User.DoesNotExist:
-                       return username
-               raise forms.ValidationError("This username is already in use")
+               return _clean_username(self.cleaned_data['username'])
 
        def clean_email(self):
                email = self.cleaned_data['email'].lower()
@@ -72,6 +75,25 @@ class SignupForm(forms.Form):
                        return email
                raise forms.ValidationError("A user with this email address is already registered")
 
+class SignupOauthForm(forms.Form):
+       username = forms.CharField(max_length=30)
+       first_name = forms.CharField(max_length=30)
+       last_name = forms.CharField(max_length=30)
+       email = forms.EmailField()
+       captcha = ReCaptchaField()
+
+       def __init__(self, *args, **kwargs):
+               super(SignupOauthForm, self).__init__(*args, **kwargs)
+               self.fields['first_name'].widget.attrs['readonly'] = True
+               self.fields['first_name'].widget.attrs['disabled'] = True
+               self.fields['last_name'].widget.attrs['readonly'] = True
+               self.fields['last_name'].widget.attrs['disabled'] = True
+               self.fields['email'].widget.attrs['readonly'] = True
+               self.fields['email'].widget.attrs['disabled'] = True
+
+       def clean_username(self):
+               return _clean_username(self.cleaned_data['username'])
+
 class UserProfileForm(forms.ModelForm):
        class Meta:
                model = UserProfile
diff --git a/pgweb/account/oauthclient.py b/pgweb/account/oauthclient.py
new file mode 100644 (file)
index 0000000..93ab764
--- /dev/null
@@ -0,0 +1,168 @@
+from django.conf import settings
+from django.contrib.auth import login as django_login
+from django.http import HttpResponseRedirect
+from django.contrib.auth.models import User
+
+import sys
+
+from pgweb.util.misc import get_client_ip
+
+import logging
+log = logging.getLogger(__name__)
+
+#
+# Generic OAuth login for multiple providers
+#
+def _login_oauth(request, provider, authurl, tokenurl, scope, authdatafunc):
+       from requests_oauthlib import OAuth2Session
+
+       client_id = settings.OAUTH[provider]['clientid']
+       client_secret = settings.OAUTH[provider]['secret']
+       redir = '{0}/account/login/{1}/'.format(settings.SITE_ROOT, provider)
+
+       oa = OAuth2Session(client_id, scope=scope, redirect_uri=redir)
+       if request.GET.has_key('code'):
+               log.info("Completing {0} oauth2 step from {1}".format(provider, get_client_ip(request)))
+
+               # Receiving a login request from the provider, so validate data
+               # and log the user in.
+               if request.GET['state'] != request.session.pop('oauth_state'):
+                       log.warning("Invalid state received in {0} oauth2 step from {1}".format(provider, get_client_ip(request)))
+                       raise Exception("Invalid OAuth state received")
+
+               token = oa.fetch_token(tokenurl,
+                                                          client_secret=client_secret,
+                                                          code=request.GET['code'])
+               (email, firstname, lastname) = authdatafunc(oa)
+
+               try:
+                       user = User.objects.get(email=email)
+               except User.DoesNotExist:
+                       log.info("Oauth signin of {0} using {1} from {2}. User not found, offering signup.".format(email, provider, get_client_ip(request)))
+
+                       # Offer the user a chance to sign up. The full flow is
+                       # handled elsewhere, so store the details we got from
+                       # the oauth login in the session, and pass the user on.
+                       request.session['oauth_email'] = email
+                       request.session['oauth_firstname'] = firstname
+                       request.session['oauth_lastname'] = lastname
+                       return HttpResponseRedirect('/account/signup/oauth/')
+
+               log.info("Oauth signin of {0} using {1} from {2}.".format(email, provider, get_client_ip(request)))
+
+               user.backend = settings.AUTHENTICATION_BACKENDS[0]
+               django_login(request, user)
+               n = request.session.pop('login_next')
+               if n:
+                       return HttpResponseRedirect(n)
+               else:
+                       return HttpResponseRedirect('/account/')
+       else:
+               log.info("Initiating {0} oauth2 step from {1}".format(provider, get_client_ip(request)))
+               # First step is redirect to provider
+               authorization_url, state = oa.authorization_url(
+                       authurl,
+                       prompt='consent',
+               )
+               request.session['login_next'] = request.GET.get('next', '')
+               request.session['oauth_state'] = state
+               request.session.modified = True
+               return HttpResponseRedirect(authorization_url)
+
+
+#
+# Google login
+#  Registration: https://console.developers.google.com/apis/
+#
+def oauth_login_google(request):
+       def _google_auth_data(oa):
+               r = oa.get('https://www.googleapis.com/oauth2/v1/userinfo').json()
+               if not r['verified_email']:
+                       raise Exception("Verified email required")
+               return (r['email'],
+                               r['given_name'],
+                               r['family_name'])
+
+       return _login_oauth(
+               request,
+               'google',
+               'https://accounts.google.com/o/oauth2/v2/auth',
+               'https://accounts.google.com/o/oauth2/token',
+               ['https://www.googleapis.com/auth/userinfo.email',
+                        'https://www.googleapis.com/auth/userinfo.profile'],
+               _google_auth_data)
+
+#
+# Github login
+#  Registration: https://github.com/settings/developers
+#
+def oauth_login_github(request):
+       def _github_auth_data(oa):
+               # Github just returns full name, so we're just going to have to
+               # split that.
+               r = oa.get('https://api.github.com/user').json()
+               n = r['name'].split(None, 1)
+               # Email is at a separate endpoint
+               r = oa.get('https://api.github.com/user/emails').json()
+               for e in r:
+                       if e['verified'] and e['primary']:
+                               return (
+                                       e['email'],
+                                       n[0],
+                                       n[1],
+                               )
+               raise Exception("Could not find email")
+
+       return _login_oauth(
+               request,
+               'github',
+               'https://github.com/login/oauth/authorize',
+               'https://github.com/login/oauth/access_token',
+               ['user:email', ],
+               _github_auth_data)
+
+#
+# Facebook login
+#  Registration: https://developers.facebook.com/apps
+#
+def oauth_login_facebook(request):
+       def _facebook_auth_data(oa):
+               r = oa.get('https://graph.facebook.com/me?fields=email,first_name,last_name').json()
+               return (r['email'],
+                               r['first_name'],
+                               r['last_name'])
+
+       return _login_oauth(
+               request,
+               'facebook',
+               'https://www.facebook.com/dialog/oauth',
+               'https://graph.facebook.com/oauth/access_token',
+               ['public_profile', 'email', ],
+               _facebook_auth_data)
+
+
+#
+# Microsoft login
+#  Registration: https://apps.dev.microsoft.com/
+#
+def oauth_login_microsoft(request):
+       def _microsoft_auth_data(oa):
+               r = oa.get("https://apis.live.net/v5.0/me").json()
+               return (r['emails']['account'],
+                               r['first_name'],
+                               r['last_name'])
+
+       return _login_oauth(
+               request,
+               'microsoft',
+               'https://login.live.com/oauth20_authorize.srf',
+               'https://login.live.com/oauth20_token.srf',
+               ['wl.basic', 'wl.emails' ],
+               _microsoft_auth_data)
+
+
+def login_oauth(request, provider):
+       fn = 'oauth_login_{0}'.format(provider)
+       m = sys.modules[__name__]
+       if hasattr(m, fn):
+               return getattr(m, fn)(request)
index ab476e42f871aab446632d24f3923e4bc42b0a00..1b28bf9c8ab1cfabf1b92fe9483dd04118e53650 100644 (file)
@@ -1,5 +1,5 @@
-from django.conf.urls import patterns
-
+from django.conf.urls import patterns, url
+from django.conf import settings
 
 urlpatterns = patterns('',
        (r'^$', 'pgweb.account.views.home'),
@@ -45,4 +45,9 @@ urlpatterns = patterns('',
        (r'^reset/complete/$', 'pgweb.account.views.reset_complete'),
        (r'^signup/$', 'pgweb.account.views.signup'),
        (r'^signup/complete/$', 'pgweb.account.views.signup_complete'),
+       (r'^signup/oauth/$', 'pgweb.account.views.signup_oauth'),
 )
+
+for provider in settings.OAUTH.keys():
+       urlpatterns.append(url(r'^login/({0})/$'.format(provider), 'pgweb.account.oauthclient.login_oauth'))
+
index 8acb292da63d1ed9f15484d437de9d9c85c77582..d9b9152e6b1e9fa059673c4536e2c3c7b451e417 100644 (file)
@@ -1,4 +1,5 @@
 from django.contrib.auth.models import User
+from django.contrib.auth import login as django_login
 import django.contrib.auth.views as authviews
 from django.http import HttpResponseRedirect, Http404, HttpResponse
 from django.shortcuts import render_to_response, get_object_or_404
@@ -18,6 +19,7 @@ from Crypto import Random
 import time
 import json
 from datetime import datetime, timedelta
+import itertools
 
 from pgweb.util.contexts import NavContext
 from pgweb.util.misc import send_template_mail, generate_random_token, get_client_ip
@@ -32,12 +34,17 @@ from pgweb.profserv.models import ProfessionalService
 
 from models import CommunityAuthSite, EmailChangeToken
 from forms import PgwebAuthenticationForm
-from forms import SignupForm, UserForm, UserProfileForm, ContributorForm
+from forms import SignupForm, SignupOauthForm
+from forms import UserForm, UserProfileForm, ContributorForm
 from forms import ChangeEmailForm
 
 import logging
 log = logging.getLogger(__name__)
 
+# The value we store in user.password for oauth logins. This is
+# a value that must not match any hashers.
+OAUTH_PASSWORD_STORE='oauth_signin_account_no_password'
+
 @login_required
 def home(request):
        myarticles = NewsArticle.objects.filter(org__managers=request.user, approved=False)
@@ -85,6 +92,11 @@ def profile(request):
        # models on a single form.
        (profile, created) = UserProfile.objects.get_or_create(pk=request.user.pk)
 
+       # Don't allow users whose accounts were created via oauth to change
+       # their email, since that would kill the connection between the
+       # accounts.
+       can_change_email = (request.user.password != OAUTH_PASSWORD_STORE)
+
        # We may have a contributor record - and we only show that part of the
        # form if we have it for this user.
        try:
@@ -118,6 +130,7 @@ def profile(request):
                        'userform': userform,
                        'profileform': profileform,
                        'contribform': contribform,
+                       'can_change_email': can_change_email,
                        }, NavContext(request, "account"))
 
 @login_required
@@ -126,6 +139,11 @@ def change_email(request):
        tokens = EmailChangeToken.objects.filter(user=request.user)
        token = len(tokens) and tokens[0] or None
 
+       if request.user.password == OAUTH_PASSWORD_STORE:
+               # Link shouldn't exist in this case, so just throw an unfriendly
+               # error message.
+               return HttpServerError("This account cannot change email address as it's connected to a third party login site.")
+
        if request.method == 'POST':
                form = ChangeEmailForm(request.user, data=request.POST)
                if form.is_valid():
@@ -160,6 +178,11 @@ def confirm_change_email(request, tokenhash):
        tokens = EmailChangeToken.objects.filter(user=request.user, token=tokenhash)
        token = len(tokens) and tokens[0] or None
 
+       if request.user.password == OAUTH_PASSWORD_STORE:
+               # Link shouldn't exist in this case, so just throw an unfriendly
+               # error message.
+               return HttpServerError("This account cannot change email address as it's connected to a third party login site.")
+
        if token:
                # Valid token find, so change the email address
                request.user.email = token.email
@@ -194,18 +217,31 @@ def orglist(request):
 
 def login(request):
        return authviews.login(request, template_name='account/login.html',
-                                                  authentication_form=PgwebAuthenticationForm)
+                                                  authentication_form=PgwebAuthenticationForm,
+                                                  extra_context={
+                                                          'oauth_providers': [(k,v) for k,v in sorted(settings.OAUTH.items())],
+                                                  })
 
 def logout(request):
        return authviews.logout_then_login(request, login_url='/')
 
 def changepwd(request):
+       if request.user.password == OAUTH_PASSWORD_STORE:
+               return HttpServerError("This account cannot change password as it's connected to a third party login site.")
+
        log.info("Initiating password change from {0}".format(get_client_ip(request)))
        return authviews.password_change(request,
                                                                         template_name='account/password_change.html',
                                                                         post_change_redirect='/account/changepwd/done/')
 
 def resetpwd(request):
+       if request.method == "POST":
+               try:
+                       u = User.objects.get(email__iexact=request.POST['email'])
+                       if u.password == OAUTH_PASSWORD_STORE:
+                               return HttpServerError("This account cannot change password as it's connected to a third party login site.")
+               except User.DoesNotExist:
+                       pass
        log.info("Initiating password set from {0}".format(get_client_ip(request)))
        return authviews.password_reset(request, template_name='account/password_reset.html',
                                                                        email_template_name='account/password_reset_email.txt',
@@ -287,6 +323,74 @@ def signup_complete(request):
        }, NavContext(request, 'account'))
 
 
+@transaction.atomic
+def signup_oauth(request):
+       if not request.session.has_key('oauth_email') \
+          or not request.session.has_key('oauth_firstname') \
+          or not request.session.has_key('oauth_lastname'):
+               return HttpServerError('Invalid redirect received')
+
+       if request.method == 'POST':
+               # Second stage, so create the account. But verify that the
+               # nonce matches.
+               data = request.POST.copy()
+               data['email'] = request.session['oauth_email']
+               data['first_name'] = request.session['oauth_firstname']
+               data['last_name'] = request.session['oauth_lastname']
+               form = SignupOauthForm(data=data)
+               if form.is_valid():
+                       log.info("Creating user for {0} from {1} from oauth signin of email {2}".format(form.cleaned_data['username'], get_client_ip(request), request.session['oauth_email']))
+
+                       user = User.objects.create_user(form.cleaned_data['username'].lower(),
+                                                                                       request.session['oauth_email'],
+                                                                                       last_login=datetime.now())
+                       user.first_name = request.session['oauth_firstname']
+                       user.last_name = request.session['oauth_lastname']
+                       user.password = OAUTH_PASSWORD_STORE
+                       user.save()
+
+                       # Clean up our session
+                       del request.session['oauth_email']
+                       del request.session['oauth_firstname']
+                       del request.session['oauth_lastname']
+                       request.session.modified = True
+
+                       # We can immediately log the user in because their email
+                       # is confirmed.
+                       user.backend = settings.AUTHENTICATION_BACKENDS[0]
+                       django_login(request, user)
+
+                       # Redirect to the account page
+                       return HttpResponseRedirect('/account/')
+       elif request.GET.has_key('do_abort'):
+               del request.session['oauth_email']
+               del request.session['oauth_firstname']
+               del request.session['oauth_lastname']
+               request.session.modified = True
+               return HttpResponseRedirect('/')
+       else:
+               # Generate possible new username
+               suggested_username = request.session['oauth_email'].replace('@', '.')[:30]
+               for u in itertools.chain([
+                               "{0}{1}".format(request.session['oauth_firstname'].lower(), request.session['oauth_lastname'][0].lower()),
+                               "{0}{1}".format(request.session['oauth_firstname'][0].lower(), request.session['oauth_lastname'].lower()),
+               ], ("{0}{1}{2}".format(request.session['oauth_firstname'].lower(), request.session['oauth_lastname'][0].lower(), n) for n in xrange(100))):
+                       if not User.objects.filter(username=u[:30]).exists():
+                               suggested_username = u[:30]
+                               break
+               form = SignupOauthForm(initial={
+                       'username': suggested_username,
+                       'email': request.session['oauth_email'],
+                       'first_name': request.session['oauth_firstname'][:30],
+                       'last_name': request.session['oauth_lastname'][:30],
+               })
+
+       return render_to_response('account/signup_oauth.html', {
+               'form': form,
+               'operation': 'New account',
+               'savebutton': 'Sign up for new account',
+               'recaptcha': True,
+               }, NavContext(request, 'account'))
 
 ####
 ## Community authentication endpoint
@@ -331,6 +435,7 @@ def communityauth(request, siteid):
                                                           extra_context={
                                                                   'sitename': site.name,
                                                                   'next': '/account/auth/%s/%s' % (siteid, urldata),
+                                                                  'oauth_providers': [(k,v) for k,v in sorted(settings.OAUTH.items())],
                                                           },
                                                   )
 
index a443c7ae6893df6c967a01de9ffa90d2430d64e7..7187073e276d4a9d3b300b89b0a37bb528c6ff07 100644 (file)
@@ -172,6 +172,7 @@ VARNISH_PURGERS=()                                     # Extra servers that can
 LIST_ACTIVATORS=()                                                                    # Servers that can activate lists
 ARCHIVES_SEARCH_SERVER="archives.postgresql.org"       # Where to post REST request for archives search
 FRONTEND_SMTP_RELAY="magus.postgresql.org"             # Where to relay user generated email
+OAUTH={}                                               # OAuth providers and keys
 
 # Load local settings overrides
 from settings_local import *
index 1ef882c480fa765ac69e97449079a9570c372c83..20e72e768cb883f38bb0ffb3e6d7f357261a2b9f 100644 (file)
@@ -3,4 +3,5 @@ django-markdown==0.2.1
 psycopg2==2.5
 pycrypto==2.6
 django_markwhat==1.4
+requests-oauthlib==0.4.0
 
index 3cac7c8cd8b1d516516472f93189eebeaad3b82c..c16f00c0f397660bfd37dd6508e255f49f555a90 100644 (file)
@@ -1,6 +1,6 @@
 {%extends "base/page.html"%}
 {%block contents%}
-<h1>Log in</h1>
+<h1>Sign in</h1>
 <p>
 {%if sitename%}
 The website you are trying to log in to ({{sitename}}) is using the
@@ -15,7 +15,14 @@ Please log in to your community account to reach this area.
 </p>
 <p>
 If you do not already have an account,
-you may <a href="/account/signup/">sign up</a> for one now. If you have one but have lost your
+you can either <a href="/account/signup/">create</a>
+a dedicated account, or use one of the third party sign-in systems below.
+</p>
+
+<h2>Community account sign-in</h2>
+<p>
+If you have a postgresql.org community account with a password, please
+use the form below to sign in. If you have one but have lost your
 password, you can use the <a href="/account/reset/">password reset</a> form.
 </p>
 
@@ -34,10 +41,18 @@ password, you can use the <a href="/account/reset/">password reset</a> form.
     <input type="hidden" name="next" value="{{next}}" />
   </div>
   <div class="submit-row">
-    <label>&nbsp;</label><input type="submit" value="Log in" />
+    <label>&nbsp;</label><input type="submit" value="Sign in with community account password" />
   </div>
 </form>
 
+{%if oauth_providers%}
+<h2>Third party sign in</h2>
+{%for p,d in oauth_providers%}
+<p><a href="/account/login/{{p}}/?next={{next}}"><img src="/media/img/misc/btn_login_{{p}}.png" alt="Sign in with {{p|capfirst}}"></a></p>
+{%endfor%}
+{%endif%}
+
+
 <script type="text/javascript">
 document.getElementById('id_username').focus()
 </script>
diff --git a/templates/account/signup_oauth.html b/templates/account/signup_oauth.html
new file mode 100644 (file)
index 0000000..c8b2136
--- /dev/null
@@ -0,0 +1,23 @@
+{%extends "base/form.html"%}
+{%block pre_form_header%}
+<p>
+  We find no account associated with your email address {{email}}.
+</p>
+
+<p>
+  If your account is under a different name, please
+  <a href="?do_abort=1">cancel</a> this sign-in and sign in with the
+  appropriate account instead.
+</p>
+
+<p>
+  If you wish to sign up for a new account, please select a username
+  and verify the other details:
+</p>
+{% endblock %}
+
+{%block post_form%}
+<p>
+  <a href="?do_abort=1"><button>Cancel signup and log in with another account</button></a>
+</p>
+{%endblock%}
index 51dd56683545594db8a69d21388961d10497ba5c..d1e77f3d3fcb0a5a90db8f08db294b6f576f5511 100644 (file)
  </tr>
  <tr>
   <th>Email</th>
-  <td>{{user.email}} (<i><a href="change_email/">change</a></i>)</td>
+  <td>{{user.email}} {%if can_change_email%}(<i><a href="change_email/">change</a></i>){%else%}
+<br/><br/>
+The email address of this account cannot be changed, because the account does
+not have a local password, most likely because it's connected to a third
+party system (such as Google or Facebook).
+{%endif%}</td>
  </tr>
 {%for field in userform%}
  {%if field.errors %}