From edb6ee61cfdf7c57eb523a7f6827749b2be174c3 Mon Sep 17 00:00:00 2001 From: Enrico Zini Date: Mon, 16 May 2016 17:24:27 +0200 Subject: [PATCH] Added prototype dm claim account interface --- backend/models.py | 4 + dm/__init__.py | 0 dm/admin.py | 3 + dm/migrations/__init__.py | 0 dm/models.py | 1 + dm/templates/dm/claim.html | 44 ++++++++++ dm/templates/dm/claim_confirm.html | 25 ++++++ dm/tests.py | 3 + dm/urls.py | 15 ++++ dm/views.py | 136 +++++++++++++++++++++++++++++ nm2/settings.py | 1 + nmlayout/templates/nm-base.html | 2 +- urls.py | 23 ++--- 13 files changed, 239 insertions(+), 18 deletions(-) create mode 100644 dm/__init__.py create mode 100644 dm/admin.py create mode 100644 dm/migrations/__init__.py create mode 100644 dm/models.py create mode 100644 dm/templates/dm/claim.html create mode 100644 dm/templates/dm/claim_confirm.html create mode 100644 dm/tests.py create mode 100644 dm/urls.py create mode 100644 dm/views.py diff --git a/backend/models.py b/backend/models.py index f213c1e4..6d7d6532 100644 --- a/backend/models.py +++ b/backend/models.py @@ -790,6 +790,10 @@ class Fingerprint(models.Model): fpr = FingerprintField(verbose_name="OpenPGP key fingerprint", max_length=40, unique=True) is_active = models.BooleanField(default=False, help_text="whether this key is curently in use") + def get_key(self): + from keyring.models import Key + return Key.objects.get_or_download(self.fpr) + def save(self, *args, **kw): """ Save, and add an entry to the Person audit log. diff --git a/dm/__init__.py b/dm/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dm/admin.py b/dm/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/dm/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/dm/migrations/__init__.py b/dm/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dm/models.py b/dm/models.py new file mode 100644 index 00000000..137941ff --- /dev/null +++ b/dm/models.py @@ -0,0 +1 @@ +from django.db import models diff --git a/dm/templates/dm/claim.html b/dm/templates/dm/claim.html new file mode 100644 index 00000000..f32b020a --- /dev/null +++ b/dm/templates/dm/claim.html @@ -0,0 +1,44 @@ +{% extends "public/base.html" %} +{% load nm %} + +{% block content %} + +

Associate SSO username to existing person

+ +

For many Debian Maintainer entries, the site does not currently know the +corresponding username that is used by the Debian Single Sign-On, +and therefore cannot give you permission on your own data. This page helps to +fix this situation.

+ + +

Since you are logged in, I already know that your Single Sign-On username is +{{username}}. The missing bit of information is who are you in the site, +and I would like to find out using your GPG key. Please enter the fingerprint +in this form:

+ +
{% csrf_token %} +{{form.as_p}} + +
+ +{% if person %} + +

+Ok, so you claim to be {{person}}, is +that right? If that is not correct, please change the fingerprint in the form +above and click "Update". +

+ +

+If it is correct, I need a way to trust you. Please decrypt the GPG snippet +that you find after this paragraph, and you will find a URL. Visit that URL and +I will be able to trust that you are indeed {{person}}. +

+ +
+{{challenge}}
+
+ +{% endif %} + +{% endblock %} diff --git a/dm/templates/dm/claim_confirm.html b/dm/templates/dm/claim_confirm.html new file mode 100644 index 00000000..75451a71 --- /dev/null +++ b/dm/templates/dm/claim_confirm.html @@ -0,0 +1,25 @@ +{% extends "public/base.html" %} +{% load nm %} + +{% block content %} + +

Confirm association of SSO username to existing person

+ +{% if errors %} +

There have been error with your confirmation email: +

+

+{% endif %} + +{% if mapped %} +

Your Single Sing-On account name {{username}} is now associated to +{{person}}. If you visit that page, +you should now be recognised as the owner of the page.

+{% endif %} + +{% endblock %} + diff --git a/dm/tests.py b/dm/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/dm/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/dm/urls.py b/dm/urls.py new file mode 100644 index 00000000..253bc780 --- /dev/null +++ b/dm/urls.py @@ -0,0 +1,15 @@ +# coding: utf-8 +from __future__ import print_function +from __future__ import absolute_import +from __future__ import division +from __future__ import unicode_literals +from django.conf.urls import url +from django.views.generic import RedirectView +from . import views + +urlpatterns = [ + url(r'^$', RedirectView.as_view(url="/", permanent=True), name="dm_index"), + url(r'^claim$', views.Claim.as_view(), name="dm_claim"), + url(r'^claim/confirm/(?P[^/]+)$', views.ClaimConfirm.as_view(), name="dm_claim_confirm"), +] + diff --git a/dm/views.py b/dm/views.py new file mode 100644 index 00000000..895c51a8 --- /dev/null +++ b/dm/views.py @@ -0,0 +1,136 @@ +# coding: utf8 +from __future__ import print_function +from __future__ import absolute_import +from __future__ import division +from __future__ import unicode_literals +from django.shortcuts import render +from django.views.generic import TemplateView, View +from django.views.generic.edit import FormView +from django.core import signing +from django.core.urlresolvers import reverse +from django import forms +from backend.mixins import VisitorMixin +import backend.models as bmodels + +def is_valid_username(username): + if username.endswith("@users.alioth.debian.org"): return True + if username.endswith("@debian.org"): return True + return False + + +class ClaimForm(forms.Form): + fpr = forms.CharField(label="Fingerprint", min_length=40, widget=forms.TextInput(attrs={"size": 60})) + + def clean_fpr(self): + data = bmodels.FingerprintField.clean_fingerprint(self.cleaned_data['fpr']) + try: + fpr = bmodels.Fingerprint.objects.get(fpr=self.cleaned_data["fpr"]) + except bmodels.Fingerprint.DoesNotExist: + raise forms.ValidationError("The GPG fingerprint is not known to this system. " + "If you are a Debian Maintainer, and you entered the fingerprint that is in the DM keyring, " + "please contact Front Desk to get this fixed.") + + if not fpr.is_active: + raise forms.ValidationError("The GPG fingerprint corresponds to a key that is not currently the active key of the user.") + + if is_valid_username(fpr.user.username): + raise forms.ValidationError("The GPG fingerprint corresponds to a person that has a valid Single Sign-On username.") + + return data + + +class Claim(VisitorMixin, FormView): + """ + Validate and send an encrypted HMAC url to associate an alioth account with + a DM key + """ + template_name = "dm/claim.html" + form_class = ClaimForm + + def pre_dispatch(self): + super(Claim, self).pre_dispatch() + if self.visitor is not None: raise PermissionDenied + if self.request.sso_username is None: raise PermissionDenied + if not is_valid_username(self.request.sso_username): raise PermissionDenied + self.username = self.request.sso_username + + def get_context_data(self, fpr=None, **kw): + ctx = super(Claim, self).get_context_data(**kw) + ctx["username"] = self.username + if fpr: + ctx["fpr"] = fpr + ctx["person"] = fpr.user + + key = fpr.get_key() + if not key.key_is_fresh(): key.update_key() + plaintext = self.request.build_absolute_uri(reverse("dm_claim_confirm", kwargs={ + "token": signing.dumps({ + "u": self.username, + "f": fpr.fpr, + }) + })) + plaintext += "\n" + ctx["challenge"] = key.encrypt(plaintext.encode("utf8")) + return ctx + + def form_valid(self, form): + fpr = bmodels.Fingerprint.objects.get(fpr=form.cleaned_data["fpr"]) + return self.render_to_response(self.get_context_data(form=form, fpr=fpr)) + + +class ClaimConfirm(VisitorMixin, TemplateView): + """ + Validate the claim confirmation links + """ + template_name = "dm/claim_confirm.html" + + def validate_token(self, token): + parsed = signing.loads(token) + self.errors = [] + + if self.visitor is not None: + self.errors.append("Your SSO username is already associated with a person in the system") + return False + + # Validate fingerprint + try: + self.fpr = bmodels.Fingerprint.objects.get(fpr=parsed["f"]) + except bmodels.Fingerprint.DoesNotExist: + self.fpr = None + self.errors.append("The GPG fingerprint is not known to this system") + return False + + if not self.fpr.is_active: + self.errors.append("The GPG fingerprint corresponds to a key that is not currently the active key of the user.") + + if is_valid_username(self.fpr.user.username): + self.errors.append("The GPG fingerprint corresponds to a person that has a valid Single Sign-On username.") + + # Validate username + self.username = parsed["u"] + if not is_valid_username(self.username): + self.errors.append("The username does not look like a valid SSO username") + + try: + existing_person = bmodels.Person.objects.get(username=self.username) + except bmodels.Person.DoesNotExist: + existing_person = None + if existing_person is not None: + self.errors.append("The SSO username is already associated with a different person in the system") + + return not self.errors + + def get_context_data(self, **kw): + ctx = super(ClaimConfirm, self).get_context_data(**kw) + + if self.validate_token(self.kwargs["token"]): + # Do the mapping + self.fpr.person.username = self.username + self.fpr.person.save() + ctx["mapped"] = True + ctx["person"] = self.fpr.person + ctx["fpr"] = self.fpr + ctx["username"] = self.username + ctx["errors"] = self.errors + + return ctx diff --git a/nm2/settings.py b/nm2/settings.py index ea804888..c845b097 100644 --- a/nm2/settings.py +++ b/nm2/settings.py @@ -146,6 +146,7 @@ INSTALLED_APPS = [ 'apikeys', 'public', 'restricted', + 'dm', 'maintenance', 'projectb', 'minechangelogs', diff --git a/nmlayout/templates/nm-base.html b/nmlayout/templates/nm-base.html index 9caf4095..1ad5d451 100644 --- a/nmlayout/templates/nm-base.html +++ b/nmlayout/templates/nm-base.html @@ -33,7 +33,7 @@ {% block relatedpages %} {% if user.is_anonymous %} {% if request.sso_username %} - {{request.sso_username}} not known to this site yet + {{request.sso_username}} not known to this site yet {% else %} login {% endif %} diff --git a/urls.py b/urls.py index 8c4d35b0..5485affa 100644 --- a/urls.py +++ b/urls.py @@ -1,20 +1,8 @@ -# NM website -# -# Copyright (C) 2012--2013 Enrico Zini -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - +# coding: utf-8 +from __future__ import print_function +from __future__ import absolute_import +from __future__ import division +from __future__ import unicode_literals from django.conf.urls import patterns, include, url from django.views.generic import TemplateView from django_dacs import views as django_dacs_views @@ -33,6 +21,7 @@ urlpatterns = [ # DACS login url(r'^public/', include("public.urls")), url(r'^am/', include("restricted.urls")), + url(r'^dm/', include("dm.urls")), url(r'^api/', include("api.urls")), url(r'^apikeys/', include("apikeys.urls")), url(r'^keyring/', include("keyring.urls")), -- GitLab