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.
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):
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()
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
--- /dev/null
+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)
-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'),
(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'))
+
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
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
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)
# 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:
'userform': userform,
'profileform': profileform,
'contribform': contribform,
+ 'can_change_email': can_change_email,
}, NavContext(request, "account"))
@login_required
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():
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
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',
}, 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
extra_context={
'sitename': site.name,
'next': '/account/auth/%s/%s' % (siteid, urldata),
+ 'oauth_providers': [(k,v) for k,v in sorted(settings.OAUTH.items())],
},
)
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 *
psycopg2==2.5
pycrypto==2.6
django_markwhat==1.4
+requests-oauthlib==0.4.0
{%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
</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>
<input type="hidden" name="next" value="{{next}}" />
</div>
<div class="submit-row">
- <label> </label><input type="submit" value="Log in" />
+ <label> </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>
--- /dev/null
+{%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%}
</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 %}