diff --git a/media/img/misc/btn_login_facebook.png b/media/img/misc/btn_login_facebook.png new file mode 100644 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 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 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 index 0000000..e2e12cc Binary files /dev/null and b/media/img/misc/btn_login_microsoft.png differ diff --git a/pgweb/account/forms.py b/pgweb/account/forms.py index 75297b1..a8ab584 100644 --- a/pgweb/account/forms.py +++ b/pgweb/account/forms.py @@ -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 index 0000000..93ab764 --- /dev/null +++ b/pgweb/account/oauthclient.py @@ -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) diff --git a/pgweb/account/urls.py b/pgweb/account/urls.py index ab476e4..1b28bf9 100644 --- a/pgweb/account/urls.py +++ b/pgweb/account/urls.py @@ -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')) + diff --git a/pgweb/account/views.py b/pgweb/account/views.py index 8acb292..416fd56 100644 --- a/pgweb/account/views.py +++ b/pgweb/account/views.py @@ -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 @@ -32,12 +33,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 +91,11 @@ def profile(request): # models on a single form. (profile, created) = UserProfile.objects.get_or_create(pk=request.user.pk) + # Don't allow users whos 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 +129,7 @@ def profile(request): 'userform': userform, 'profileform': profileform, 'contribform': contribform, + 'can_change_email': can_change_email, }, NavContext(request, "account")) @login_required @@ -126,6 +138,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 +177,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,12 +216,18 @@ 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', @@ -287,6 +315,65 @@ 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: + form = SignupOauthForm(initial={ + 'username': request.session['oauth_email'].replace('@', '.'), + 'email': request.session['oauth_email'], + 'first_name': request.session['oauth_firstname'], + 'last_name': request.session['oauth_lastname'], + }) + + 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 +418,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())], }, ) diff --git a/pgweb/settings.py b/pgweb/settings.py index a443c7a..7187073 100644 --- a/pgweb/settings.py +++ b/pgweb/settings.py @@ -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 * diff --git a/requirements.txt b/requirements.txt index 1ef882c..20e72e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ django-markdown==0.2.1 psycopg2==2.5 pycrypto==2.6 django_markwhat==1.4 +requests-oauthlib==0.4.0 diff --git a/templates/account/login.html b/templates/account/login.html index 3cac7c8..eb48816 100644 --- a/templates/account/login.html +++ b/templates/account/login.html @@ -1,6 +1,6 @@ {%extends "base/page.html"%} {%block contents%} -

Log in

+

Sign in

{%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.

If you do not already have an account, -you may sign up for one now. If you have one but have lost your +you can either create +a decicated account, or use one of the third party sign-in systems below. +

+ +

Community account sign-in

+

+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 password reset form.

@@ -34,10 +41,18 @@ password, you can use the password reset form.
- +
+{%if oauth_providers%} +

Third party sign in

+{%for p,d in oauth_providers%} +

Sign in with {{p|capfirst}}

+{%endfor%} +{%endif%} + + diff --git a/templates/account/signup_oauth.html b/templates/account/signup_oauth.html new file mode 100644 index 0000000..dc71c88 --- /dev/null +++ b/templates/account/signup_oauth.html @@ -0,0 +1,23 @@ +{%extends "base/form.html"%} +{%block pre_form_header%} +

+ We find no account associated with your email address {{email}}. +

+ +

+ If your account is under a different name, please + cancel this sign-in and sign in with the + appropriate account instead. +

+ +

+ If you with so sign up for a new account, please select a username + and verify the other details: +

+{% endblock %} + +{%block post_form%} +

+ +

+{%endblock%} diff --git a/templates/account/userprofileform.html b/templates/account/userprofileform.html index 51dd566..d1e77f3 100644 --- a/templates/account/userprofileform.html +++ b/templates/account/userprofileform.html @@ -16,7 +16,12 @@ Email - {{user.email}} (change) + {{user.email}} {%if can_change_email%}(change){%else%} +

+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%} {%for field in userform%} {%if field.errors %}