From 518a46db72e47b69a15f4e34e24fadd84f5743f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 7 Feb 2016 18:09:30 +0100 Subject: [PATCH 1/2] Login via OAuth2 --- mygpo/moauth/__init__.py | 0 mygpo/moauth/admin.py | 7 ++ mygpo/moauth/apps.py | 5 + mygpo/moauth/backends.py | 58 +++++++++ mygpo/moauth/migrations/0001_initial.py | 27 ++++ mygpo/moauth/migrations/__init__.py | 0 mygpo/moauth/models.py | 13 ++ mygpo/moauth/tests.py | 3 + mygpo/moauth/urls.py | 16 +++ mygpo/moauth/views.py | 156 ++++++++++++++++++++++++ mygpo/settings.py | 11 ++ mygpo/urls.py | 1 + mygpo/web/templates/home.html | 4 +- 13 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 mygpo/moauth/__init__.py create mode 100644 mygpo/moauth/admin.py create mode 100644 mygpo/moauth/apps.py create mode 100644 mygpo/moauth/backends.py create mode 100644 mygpo/moauth/migrations/0001_initial.py create mode 100644 mygpo/moauth/migrations/__init__.py create mode 100644 mygpo/moauth/models.py create mode 100644 mygpo/moauth/tests.py create mode 100644 mygpo/moauth/urls.py create mode 100644 mygpo/moauth/views.py diff --git a/mygpo/moauth/__init__.py b/mygpo/moauth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mygpo/moauth/admin.py b/mygpo/moauth/admin.py new file mode 100644 index 000000000..14e0d2cbe --- /dev/null +++ b/mygpo/moauth/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +from . import models + +@admin.register(models.AuthRequest) +class AuthRequestAdmin(admin.ModelAdmin): + list_display = ('created', 'state') diff --git a/mygpo/moauth/apps.py b/mygpo/moauth/apps.py new file mode 100644 index 000000000..bdf15e1b6 --- /dev/null +++ b/mygpo/moauth/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + name = 'auth' diff --git a/mygpo/moauth/backends.py b/mygpo/moauth/backends.py new file mode 100644 index 000000000..38986c1ef --- /dev/null +++ b/mygpo/moauth/backends.py @@ -0,0 +1,58 @@ +import requests + +from django.db import IntegrityError +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend + +import logging +logger = logging.getLogger(__name__) + + +class OAuth2Backend(ModelBackend): + """ OAuth2 authentication backend + + Authenticates based on token info URL; Uses Users from the ModelBackend """ + + def authenticate(self, token_info_url=None): + logger.info('Authenticating user from "%s"', token_info_url) + if token_info_url is None: + return + + token = self._get_token_info(token_info_url) + username = token['user']['login'] + return self._get_user(username) + + def _get_token_info(self, token_info_url): + """ Retrieves token info and returns the username """ + + headers = { + 'Accept': 'application/json' + } + + r = requests.get(token_info_url, headers=headers) + token = r.json() + #{ + # 'token': '62b6a03b16a5453f810cf6d32ac975f8', + # 'app': { + # 'url': None, + # 'name': 'gpodder.net', + # 'client_id': 'Nb0QLDW2psFSXfGwmCvJ1ElhITu9P3Kg' + # }, + # 'created_at': '2016-02-07T12:42:14.140Z', + # 'user': { + # 'login': 'stefan' + # }, + # 'scopes': [ + # 'actions:add', + # 'podcastlists' + # ] + #} + return token + + def _get_user(self, username): + """ Get user based on username """ + User = get_user_model() + try: + return User.objects.create(username=username) + except IntegrityError as ie: + return User.objects.get(username__iexact=username) diff --git a/mygpo/moauth/migrations/0001_initial.py b/mygpo/moauth/migrations/0001_initial.py new file mode 100644 index 000000000..5a77e6b07 --- /dev/null +++ b/mygpo/moauth/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-02-07 12:13 +from __future__ import unicode_literals + +import datetime +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AuthRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('scopes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, max_length=64), size=None)), + ('state', models.CharField(max_length=32)), + ('created', models.DateTimeField(default=datetime.datetime.utcnow)), + ], + ), + ] diff --git a/mygpo/moauth/migrations/__init__.py b/mygpo/moauth/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mygpo/moauth/models.py b/mygpo/moauth/models.py new file mode 100644 index 000000000..f8ea96b95 --- /dev/null +++ b/mygpo/moauth/models.py @@ -0,0 +1,13 @@ +from datetime import datetime + +from django.db import models +from django.contrib.postgres.fields import ArrayField + + +class AuthRequest(models.Model): + + scopes = ArrayField(models.CharField(max_length=64, blank=True)) + + state = models.CharField(max_length=32) + + created = models.DateTimeField(default=datetime.utcnow) diff --git a/mygpo/moauth/tests.py b/mygpo/moauth/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/mygpo/moauth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/mygpo/moauth/urls.py b/mygpo/moauth/urls.py new file mode 100644 index 000000000..8fbe08c57 --- /dev/null +++ b/mygpo/moauth/urls.py @@ -0,0 +1,16 @@ +from django.conf.urls import url + +from . import views + + +urlpatterns = [ + + url(r'^oauth/login$', + views.InitiateOAuthLogin.as_view(), + name='login-oauth'), + + url(r'^oauth/callback$', + views.OAuthCallback.as_view(), + name='oauth-callback'), + +] diff --git a/mygpo/moauth/views.py b/mygpo/moauth/views.py new file mode 100644 index 000000000..843162f5d --- /dev/null +++ b/mygpo/moauth/views.py @@ -0,0 +1,156 @@ +import requests +from requests.auth import HTTPBasicAuth + +import urllib.parse + +from django.db import IntegrityError +from django.core.urlresolvers import reverse +from django.shortcuts import render +from django.views.generic.base import RedirectView +from django.views.generic.base import View +from django.http import HttpResponseRedirect +from django.contrib.sites.requests import RequestSite +from django.contrib.auth import login, get_user_model, authenticate +from django.conf import settings + +from mygpo.utils import random_token +from . import models + +import logging +logger = logging.getLogger(__name__) + + +AVAILABLE_SCOPES = [ + 'subscriptions', + 'suggestions', + 'account', + 'favorites', + 'podcastlists', + 'apps:get', + 'apps:sync', + 'actions:get', + 'actions:add', +] + +class InitiateOAuthLogin(RedirectView): + + def get_redirect_url(self): + + client_id = settings.MYGPO_AUTH_CLIENT_ID + redir_uri = self._get_callback_url() + state = random_token() + response_type = 'code' + + models.AuthRequest.objects.create( + scopes = AVAILABLE_SCOPES, + state = state, + ) + logger.info('Initiated new new auth request "%s"', state) + + scopes = AVAILABLE_SCOPES + qs = self._get_qs(client_id, redir_uri, scopes, state, response_type) + return _get_authorize_url('/authorize', qs) + + def _get_qs(self, client_id, redirect_uri, scopes, state, response_type): + return urllib.parse.urlencode([ + ('client_id', client_id), + ('redirect_uri', redirect_uri), + ('scope', ' '.join(scopes)), + ('state', state), + ('response_type', response_type), + ]) + + def _get_callback_url(self): + protocol = 'https' if self.request.is_secure() else 'http' + site = RequestSite(self.request) + domain = site.domain + view = reverse('oauth-callback') + return '{0}://{1}{2}'.format(protocol, domain, view) + + +class OAuthCallback(View): + """ OAuth 2 callback handler + + Gets and verifies token, logs in user """ + + def get(self, request): + + if 'error' in self.request.GET: + # handle error + # error=server_error&error_description=An+unknown+error+occured + return + + code = self.request.GET.get('code', None) + state = self.request.GET.get('state', None) + + try: + authreq = models.AuthRequest.objects.get(state=state) + except models.AuthRequest.DoesNotExist: + # handle + return + + access_token, token_info_url = self._get_access_token(code) + + user = authenticate(token_info_url=token_info_url) + login(self.request, user) + + return HttpResponseRedirect(reverse('home')) + + def _get_access_token(self, code): + payload = { + 'grant_type': 'authorization_code', + 'code': code, + 'client_id': settings.MYGPO_AUTH_CLIENT_ID, + } + auth = HTTPBasicAuth(settings.MYGPO_AUTH_CLIENT_ID, + settings.MYGPO_AUTH_CLIENT_SECRET) + + qs = self._get_qs(AVAILABLE_SCOPES) + token_url = _get_authorize_url('/token', qs) + r = requests.post(token_url, data=payload, auth=auth) + if r.status_code != 200: + return # handle error + + resp = r.json() + access_token = resp['access_token'] + expires_in = resp['expires_in'] + token_type = resp['token_type'] + scopes = resp['scope'].split(' ') + #{ + # 'expires_in': 3599.995724, + # 'access_token': 'a46de116972b46e88481e7a082db60ca', + # 'token_type': 'Bearer', + # 'scope': 'podcastlists subscriptions suggestions apps:get actions:get account actions:add apps:sync favorites' + #} + logger.info( + 'Received %s token "%s" for scopes "%s", expires in %f', + token_type, access_token, ' '.join(scopes), expires_in + ) + + token_info = r.links['https://gpodder.net/relation/token-info']['url'] + + # Reference Resolution + # https://tools.ietf.org/html/rfc3986#section-5 + token_info_url = urllib.parse.urljoin(settings.MYGPO_AUTH_URL, + token_info) + + return access_token, token_info_url + + login(self.request, user) + + + def _get_qs(self, scopes): + return urllib.parse.urlencode([ + ('scope', ' '.join(scopes)), + ]) + + +def _get_authorize_url(endpoint, qs): + r = urllib.parse.urlsplit(settings.MYGPO_AUTH_URL) + path = r.path + if path.endswith('/'): + path = path[:-1] + + path = path + endpoint + parts = (r.scheme, r.netloc, path, qs, r.fragment) + return urllib.parse.urlunsplit(parts) diff --git a/mygpo/settings.py b/mygpo/settings.py index d7e6624e8..2d313e2d0 100644 --- a/mygpo/settings.py +++ b/mygpo/settings.py @@ -135,6 +135,7 @@ def get_intOrNone(name, default): 'django_celery_results', 'django_celery_beat', 'mygpo.core', + 'mygpo.moauth', 'mygpo.podcasts', 'mygpo.chapters', 'mygpo.search', @@ -184,6 +185,7 @@ def get_intOrNone(name, default): ACCOUNT_ACTIVATION_DAYS = int(os.getenv('ACCOUNT_ACTIVATION_DAYS', 7)) AUTHENTICATION_BACKENDS = ( + 'mygpo.moauth.backends.OAuth2Backend', 'mygpo.users.backend.CaseInsensitiveModelBackend', 'mygpo.web.auth.EmailAuthenticationBackend', ) @@ -374,3 +376,12 @@ def get_intOrNone(name, default): SEARCH_CUTOFF = float(os.getenv('SEARCH_CUTOFF', 0.3)) + + +# OAuth + +MYGPO_AUTH_CLIENT_ID = os.getenv('MYGPO_AUTH_CLIENT_ID', None) +MYGPO_AUTH_CLIENT_SECRET = os.getenv('MYGPO_AUTH_CLIENT_SECRET', None) + +MYGPO_AUTH_URL = os.getenv('MYGPO_AUTH_URL', None) + diff --git a/mygpo/urls.py b/mygpo/urls.py index 9acee1225..54f446e22 100644 --- a/mygpo/urls.py +++ b/mygpo/urls.py @@ -35,6 +35,7 @@ url(r'^', include('mygpo.subscriptions.urls')), url(r'^', include('mygpo.users.urls')), url(r'^', include('mygpo.podcastlists.urls')), + url(r'^', include('mygpo.moauth.urls')), url(r'^suggestions/', include('mygpo.suggestions.urls')), url(r'^publisher/', include('mygpo.publisher.urls')), url(r'^administration/', include('mygpo.administration.urls')), diff --git a/mygpo/web/templates/home.html b/mygpo/web/templates/home.html index 515ef2845..c9b891439 100644 --- a/mygpo/web/templates/home.html +++ b/mygpo/web/templates/home.html @@ -42,7 +42,7 @@ @@ -99,7 +99,7 @@

Discover and track your podcasts

- {% trans "Login" %} + {% trans "Login" %} {% trans "Register" %}
From 64e0e7633900a6a33460b4529914522f4e828a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 6 Aug 2017 21:48:12 +0200 Subject: [PATCH 2/2] Link to mygpo-auth registration page The registration workflow is still incomplete --- mygpo/settings.py | 2 + mygpo/users/checks.py | 13 ++ mygpo/users/urls.py | 16 --- mygpo/users/views/registration.py | 217 +----------------------------- mygpo/users/views/user.py | 12 +- 5 files changed, 26 insertions(+), 234 deletions(-) diff --git a/mygpo/settings.py b/mygpo/settings.py index 2d313e2d0..bca3782ac 100644 --- a/mygpo/settings.py +++ b/mygpo/settings.py @@ -385,3 +385,5 @@ def get_intOrNone(name, default): MYGPO_AUTH_URL = os.getenv('MYGPO_AUTH_URL', None) +MYGPO_AUTH_REGISTER_URL = os.getenv('MYGPO_AUTH_REGISTER_URL', None) + diff --git a/mygpo/users/checks.py b/mygpo/users/checks.py index 61d730723..db462f33a 100644 --- a/mygpo/users/checks.py +++ b/mygpo/users/checks.py @@ -1,6 +1,7 @@ from django.core.checks import register, Warning from django.db import connection from django.db.utils import OperationalError +from django.conf import settings SQL = """ @@ -38,3 +39,15 @@ def check_case_insensitive_users(app_configs=None, **kwargs): raise return errors + + +@register() +def check_registration_url(app_configs=None, **kwargs): + errors = [] + + if not settings.MYGPO_AUTH_REGISTER_URL: + txt = 'The setting MYGPO_AUTH_REGISTER_URL is not set.' + wid = 'users.W002' + errors.append(Warning(txt, id=wid)) + + return errors diff --git a/mygpo/users/urls.py b/mygpo/users/urls.py index fef682b5a..2ca246320 100644 --- a/mygpo/users/urls.py +++ b/mygpo/users/urls.py @@ -11,22 +11,6 @@ registration.RegistrationView.as_view(), name='register'), - url(r'^registration_complete/$', - registration.TemplateView.as_view( - template_name='registration/registration_complete.html'), - name='registration-complete'), - - url(r'^activate/(?P\w+)$', - registration.ActivationView.as_view()), - - url(r'^registration/resend$', - registration.ResendActivationView.as_view(), - name='resend-activation'), - - url(r'^registration/resent$', - registration.ResentActivationView.as_view(), - name='resent-activation'), - url(r'^account/$', settings.account, name='account'), diff --git a/mygpo/users/views/registration.py b/mygpo/users/views/registration.py index ac5218857..dbc77cdd2 100644 --- a/mygpo/users/views/registration.py +++ b/mygpo/users/views/registration.py @@ -1,220 +1,13 @@ import re -from django import forms -from django.core.validators import RegexValidator -from django.core.exceptions import ValidationError -from django.db import IntegrityError, transaction from django.http import HttpResponseRedirect -from django.views.generic.edit import FormView -from django.utils.translation import ugettext as _ -from django.template.loader import render_to_string -from django.urls import reverse, reverse_lazy -from django.views.generic import TemplateView from django.views import View -from django.contrib import messages -from django.contrib.auth import get_user_model -from django.contrib.sites.requests import RequestSite +from django.conf import settings -from mygpo.utils import random_token -from mygpo.users.models import UserProxy - -USERNAME_MAXLEN = get_user_model()._meta.get_field('username').max_length - - -class DuplicateUsername(ValidationError): - """ The username is already in use """ - - def __init__(self, username): - self.username = username - super().__init__('The username {0} is already in use.' - .format(username)) - - -class DuplicateEmail(ValidationError): - """ The email address is already in use """ - - def __init__(self, email): - self.email = email - super().__init__('The email address {0} is already in use.' - .format(email)) - - -class UsernameValidator(RegexValidator): - """ Validates that a username uses only allowed characters """ - regex = r'^\w[\w.+-]*$' - message = 'Invalid Username' - code = 'invalid-username' - flags = re.ASCII - - -class RegistrationForm(forms.Form): - """ Form that is used to register a new user """ - username = forms.CharField(max_length=USERNAME_MAXLEN, - validators=[UsernameValidator()], - ) - email = forms.EmailField() - password1 = forms.CharField(widget=forms.PasswordInput()) - password2 = forms.CharField(widget=forms.PasswordInput()) - - def clean(self): - cleaned_data = super(RegistrationForm, self).clean() - password1 = cleaned_data.get('password1') - password2 = cleaned_data.get('password2') - - if not password1 or password1 != password2: - raise forms.ValidationError('Passwords do not match') - - -class RegistrationView(FormView): +class RegistrationView(View): """ View to register a new user """ - template_name = 'registration/registration_form.html' - form_class = RegistrationForm - success_url = reverse_lazy('registration-complete') - - def form_valid(self, form): - """ called whene the form was POSTed and its contents were valid """ - - try: - user = self.create_user(form) - - except ValidationError as e: - messages.error(self.request, '; '.join(e.messages)) - return HttpResponseRedirect(reverse('register')) - - except IntegrityError: - messages.error(self.request, - _('Username or email address already in use')) - return HttpResponseRedirect(reverse('register')) - - send_activation_email(user, self.request) - return super(RegistrationView, self).form_valid(form) - - @transaction.atomic - def create_user(self, form): - User = get_user_model() - user = User() - username = form.cleaned_data['username'] - - self._check_username(username) - user.username = username - - email_addr = form.cleaned_data['email'] - user.email = email_addr - - user.set_password(form.cleaned_data['password1']) - user.is_active = False - user.full_clean() - - try: - user.save() - - except IntegrityError as e: - if 'django_auth_unique_email' in str(e): - # this was not caught by the form validation, but now validates - # the DB's unique constraint - raise DuplicateEmail(email_addr) from e - else: - raise - - user.profile.activation_key = random_token() - user.profile.save() - - return user - - def _check_username(self, username): - """ Check if the username is already in use - - Until there is a case-insensitive constraint on usernames, it is - necessary to check for existing usernames manually. This is not a - perfect solution, but the chance that two people sign up with the same - username at the same time is low enough. """ - UserModel = get_user_model() - users = UserModel.objects.filter(username__iexact=username) - if users.exists(): - raise DuplicateUsername(username) - - -class ActivationView(TemplateView): - """ Activates an already registered user """ - - template_name = 'registration/activation_failed.html' - - def get(self, request, activation_key): - User = get_user_model() - - try: - user = UserProxy.objects.get( - profile__activation_key=activation_key, - is_active=False, - ) - except UserProxy.DoesNotExist: - messages.error(request, _('The activation link is either not ' - 'valid or has already expired.')) - return super(ActivationView, self).get(request, activation_key) - - user.activate() - messages.success(request, _('Your user has been activated. ' - 'You can log in now.')) - return HttpResponseRedirect(reverse('login')) - - -class ResendActivationForm(forms.Form): - """ Form for resending the activation email """ - - username = forms.CharField(max_length=USERNAME_MAXLEN, required=False) - email = forms.EmailField(required=False) - - def clean(self): - cleaned_data = super(ResendActivationForm, self).clean() - username = cleaned_data.get('username') - email = cleaned_data.get('email') - - if not username and not email: - raise forms.ValidationError(_('Either username or email address ' - 'are required.')) - - -class ResendActivationView(FormView): - """ View to resend the activation email """ - template_name = 'registration/resend_activation.html' - form_class = ResendActivationForm - success_url = reverse_lazy('resent-activation') - - def form_valid(self, form): - """ called whene the form was POSTed and its contents were valid """ - - try: - user = UserProxy.objects.all().by_username_or_email( - form.cleaned_data['username'], - form.cleaned_data['email'], - ) - - except UserProxy.DoesNotExist: - messages.error(self.request, _('User does not exist.')) - return HttpResponseRedirect(reverse('resend-activation')) - - if user.profile.activation_key is None: - messages.success(self.request, _('Your account already has been ' - 'activated. Go ahead and log in.')) - - send_activation_email(user, self.request) - return super(ResendActivationView, self).form_valid(form) - - -class ResentActivationView(TemplateView): - template_name = 'registration/resent_activation.html' - - -def send_activation_email(user, request): - """ Sends the activation email for the given user """ - - subj = render_to_string('registration/activation_email_subject.txt') - # remove trailing newline added by render_to_string - subj = subj.strip() - msg = render_to_string('registration/activation_email.txt', { - 'site': RequestSite(request), - 'activation_key': user.profile.activation_key, - }) - user.email_user(subj, msg) + def get(self, request): + url = settings.MYGPO_AUTH_REGISTER_URL + return HttpResponseRedirect(url) diff --git a/mygpo/users/views/user.py b/mygpo/users/views/user.py index 631fe3a19..9df98021c 100644 --- a/mygpo/users/views/user.py +++ b/mygpo/users/views/user.py @@ -26,7 +26,6 @@ from mygpo.constants import DEFAULT_LOGIN_REDIRECT from mygpo.web.auth import get_google_oauth_flow from mygpo.users.models import UserProxy -from mygpo.users.views.registration import send_activation_email from mygpo.utils import random_token import logging @@ -83,11 +82,11 @@ def post(self, request): messages.error(request, _('Wrong username or password.')) return HttpResponseRedirect(login_page) - if not user.is_active: - send_activation_email(user, request) + # send_activation_email(user, request) messages.error(request, _('Please activate your account first. ' - 'We have just re-sent your activation email')) + 'We have just re-sent your activation ' + 'email')) return HttpResponseRedirect(login_page) # set up the user's session @@ -130,9 +129,10 @@ def restore_password(request): return render(request, 'password_reset_failed.html') if not user.is_active: - send_activation_email(user, request) + # send_activation_email(user, request) messages.error(request, _('Please activate your account first. ' - 'We have just re-sent your activation email')) + 'We have just re-sent your activation ' + 'email')) return HttpResponseRedirect(reverse('login')) site = RequestSite(request)