Commit 6ba8825b authored by CAMPION Sebastien's avatar CAMPION Sebastien
Browse files

Merge branch '279-cgu-validation' into 'django'

Resolve "CGU validation"

Closes #279

See merge request !146
parents 528af39a 254fab6f
Pipeline #54475 failed with stage
in 52 seconds
......@@ -13,6 +13,8 @@ from .models import (
......@@ -69,3 +71,5 @@, WebappVersionAdmin), JobAdmin)
......@@ -33,11 +33,10 @@ class UserForm(forms.ModelForm):
email = forms.CharField(label='Email', label_suffix='', required=False)
class Meta:
model = User
fields = ('first_name', 'last_name', 'email')
fields = ('first_name', 'last_name')
class SSHForm(forms.ModelForm):
from django.conf import settings
from django.contrib.auth.mixins import UserPassesTestMixin
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect
......@@ -8,26 +8,83 @@ from .models import Job
from .helpers import get_request_user
class IsProviderMixin(object):
"""Authorize a user to access specific views
# FIXME: should we validate API calls with this mixin too ? The answer is not
# obvious because it would not be too good to break the API when the ToS are
# updated (at least there should be a grace period)
# FIXME: right now we ensure that at least one address is validated. Should we
# force the validation of all addresses ?
class AllgoValidAccountMixin:
"""Common mixin for allgo accounts validation
An account is valid if:
- at least one of its email address is verified
- its owner agreed the latest ToS
In the case of the 'ProviderAccessMixin' the verified address must also
have its domain listed in ALLGO_ALLOWED_DEVELOPER_DOMAINS.
def dispatch(self, request, *args, **kwargs):
user = request.user
if not user.is_anonymous():
# registered users must have their email validated
if isinstance(self, ProviderAccessMixin):
# user visiting 'ProviderAccess' pages must have at least one
# of their provider addresses validated
email_addresses = [addr for addr in user.email_addresses
if addr.is_provider]
if not email_addresses:
# user does not have Provider access
# -> return 403
raise PermissionDenied
# user visiting 'UserAccess' pages must have any of their
# addresses validated
email_addresses = user.email_addresses
if not any(addr.verified for addr in email_addresses):
# user has no verified address
return redirect("main:user_need_validation")
# registered users must have agreed the latest ToS
if not user.has_agreed_tos:
return redirect("main:user_need_validation")
return super().dispatch(request, *args, **kwargs)
class UserAccessMixin(LoginRequiredMixin, AllgoValidAccountMixin):
"""Mixin to be included in views usable by registered users only"""
class ProviderAccessMixin(LoginRequiredMixin, AllgoValidAccountMixin):
"""Mixin to be included in views that require provider-level access
(i.e. user allowed to create new web applications)
class AllAccessMixin(AllgoValidAccountMixin):
"""Mixin to be included in views usable by any user (registered or not)
Note: the purpose of using this mixin (rather that no mixin at all) is that
it ensures that the user registration is complete (email address
validated). Thus the user is invited to complete the registration before
landing to the webapp_detail page rather that when he submits his first job
(which would be discarded)
email_addr = request.user.provider_address
if email_addr is None:
# user has no valid email address in the allowed domains
raise PermissionDenied
elif not email_addr.verified:
# user has a valid address but it is still unverified
return redirect("account_email_verification_sent")
# user has a valid and verified address
return super().dispatch(request, *args, **kwargs)
class JobAuthMixin(UserPassesTestMixin):
class JobAuthMixin(AllgoValidAccountMixin, UserPassesTestMixin):
"""Check authorization to access a given job"""
def test_func(self):
......@@ -612,8 +612,47 @@ class Job(TimeStampModel):
return os.path.join(settings.DATASTORE, str(
# def __str__(self):
# return self.webapp
class Tos(BaseModel):
""" The Terms of Service model
It can contains several version of a same ToS. The current view will always
look for the latest version in the model. Check the view if you have any
NOTE: ToS versions are ordered by (not Tos.version), because
comparing version numbers with MySQL is quite a messy job. The latest ToS
is the one with the highest id.
url = models.URLField(blank=True)
version = models.CharField(unique=True, max_length=32)
def __str__(self):
return str(self.version)
def get_latest(cls):
"""Get the most up-to-date Terms of Service"""
return Tos.objects.order_by("-id").first()
class Meta:
db_table = "dj_tos"
class UserAgreement(BaseModel):
""" User agreement model
Link a user to one or several ToS.
tos = models.ForeignKey('Tos', on_delete=models.CASCADE, related_name='tos')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_agreement')
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return "{0} - {1}".format(self.user, self.tos)
class Meta:
db_table = 'dj_user_agreement'
@receiver(post_save, sender=User)
......@@ -631,44 +670,54 @@ def save_user_profile(sender, instance, **kwargs):
def provider_address(user):
"""Get the first EmailAddress in the allowed developer domains
WARNING: the returned EmailAddress may not be verified
def is_provider(email_addr: EmailAddress) -> bool:
"""Return true if the email address is in the list of domains allowed to provide applications
This function finds the first allauth EmailAddress of the user that matches
a domain listed in settings.ALLOWED_DEVELOPER_DOMAINS
WARNING: the address may not be verified
If None is returned, then the user is not allowed to create a webapp.
_, domain ="@")
return domain in settings.ALLOWED_DEVELOPER_DOMAINS
except ValueError:
# malformatted email
return False
EmailAddress.add_to_class('is_provider', is_provider)
When the function returns an EmailAddress this does not automatically imply
that the user can create a webapp, because we still have to ensure that the
email is validated (however this is sufficient for displaying the "create
webapp"/"import webapp" buttons).
def email_addresses(user: User):
"""Get the email addresses associated to a user account
returns a db query of EmailAddress objects
for email_addr in EmailAddress.objects.filter(user=user):
_, domain ="@")
except ValueError:
# malformatted email
if domain in settings.ALLOWED_DEVELOPER_DOMAINS:
return email_addr
return None
def is_provider(user):
return EmailAddress.objects.filter(user=user)
auth.models.User.add_to_class('email_addresses', email_addresses)
def is_provider(user: User):
"""Return true if the user has at least one email address in the allowed
developer domains
Warning: this function returns True even if the email address is not
return user.provider_address is not None
return any(addr.is_provider for addr in user.email_addresses)
# Add the `is_provider` function as a `User` model method
auth.models.User.add_to_class('provider_address', provider_address)
# Add the `is_provider` method to the `User` model
auth.models.User.add_to_class('is_provider', is_provider)
def has_agreed_tos(user: User):
"""Return true if the user has agreed the latest ToS (if present)"""
tos = Tos.get_latest()
return tos is None or UserAgreement.objects.filter(
user=user, tos=tos).exists()
auth.models.User.add_to_class('has_agreed_tos', has_agreed_tos)
# NOTE: because there is a circular dependency between and
#, we have to do this import after 'Job' and 'Webapp' are defined
from .helpers import is_allowed_ip_admin
......@@ -74,6 +74,10 @@ urlpatterns = [
url(r'^profile/ssh/add$', views.UserSSHAdd.as_view(), name='user_ssh_add'),
url(r'^profile/ssh/delete$', views.UserSSHDelete.as_view(), name='user_ssh_delete'),
url(r'^profile/password$', views.UserPasswordUpdate.as_view(), name='user_password'),
url(r'^profile/need_validation$', views.UserNeedValidation.as_view(), name='user_need_validation'),
# Terms of service urls
url(r'^tos$', views.TosDetail.as_view(), name='tos_detail'),
# url(r'^runners/$', views.RunnerList.as_view(), name='runner_list'),
# url(r'^runners/_add$', views.RunnerCreate.as_view(), name='runner_create'),
......@@ -56,7 +56,6 @@ from django.views.generic import (
from django.views.generic.detail import SingleObjectMixin
from taggit.models import Tag
from allauth.account.models import EmailAddress
from .forms import (
......@@ -71,7 +70,7 @@ from .forms import (
# Local imports
import config
from .helpers import get_base_url, get_ssh_data, upload_data, notify_controller, lookup_job_file, get_request_user, query_webapps_for_user
from .mixins import IsProviderMixin, JobAuthMixin
from .mixins import UserAccessMixin, ProviderAccessMixin, AllAccessMixin, JobAuthMixin
from .models import (
......@@ -82,6 +81,8 @@ from .models import (
from .signals import job_post_save
from .templatetags.converters import status_icon
......@@ -105,7 +106,7 @@ def error_handler(status, reason, default, request, exception=None):
return default(request, exception)
class IndexDetail(TemplateView):
class IndexDetail(AllAccessMixin, TemplateView):
"""Home view
Generate the home as a standard `TemplateView` by calling a specific
......@@ -151,7 +152,7 @@ class LegacyWebappDetail(SingleObjectMixin, RedirectView):
# -----------------------------------------------------------------------------
class WebappList(ListView):
class WebappList(AllAccessMixin, ListView):
""" Display a paginated list of available webapps.
The webapps are filtered from the most recent to the oldest and no private
......@@ -178,7 +179,7 @@ class WebappList(ListView):
def get_queryset(self):
return query_webapps_for_user(self.request.user).order_by('-created_at')
class UserWebappList(ListView):
class UserWebappList(AllAccessMixin, ListView):
"""List of user's webapp
Returns all the webapps owned by a specific user. Only the user can its
......@@ -204,7 +205,7 @@ class UserWebappList(ListView):
return queryset
class WebappUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
class WebappUpdate(UserAccessMixin, SuccessMessageMixin, UpdateView):
"""Form to update the webapp data
......@@ -266,7 +267,7 @@ class WebappUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
return super().form_invalid(form)
class WebappCreate(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, CreateView):
class WebappCreate(ProviderAccessMixin, SuccessMessageMixin, CreateView):
"""Create a new webapp
......@@ -356,7 +357,7 @@ def get_rails_webapp_metadata(*, webapp_id=None, docker_name=None):
log.error("webapp import error: failed to get %s (%s)", url, e)
class WebappImport(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, FormView):
class WebappImport(ProviderAccessMixin, SuccessMessageMixin, FormView):
"""Import a new webapp
This only creates the Webapp entry (along with the tags and webapp
......@@ -423,8 +424,8 @@ class WebappImport(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, For
current_user = self.request.user
if not current_user.is_superuser:
# get the user EmailAddress that matches the owner of the imported app
email_addr = EmailAddress.objects.filter(
user=current_user, email=js["user"]).first()
email_addr = current_user.email_addresses.filter(
# ensure this app has the same owner
if email_addr is None:
......@@ -434,8 +435,10 @@ class WebappImport(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, For
# ensure the user email is verified
if not email_addr.verified:
return redirect("account_email_verification_sent")
"Webapp %r belongs to %r but this address is still not verified"
% (docker_name,
return redirect("main:user_need_validation")
# We can import the webapp !
......@@ -465,7 +468,7 @@ class WebappImport(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, For
self.object = webapp
return super().form_valid(form)
class WebappVersionImport(LoginRequiredMixin, DetailView):
class WebappVersionImport(UserAccessMixin, DetailView):
"""Import version
This view is enabled only for webapps created with imported=True
......@@ -560,7 +563,7 @@ class WebappVersionImport(LoginRequiredMixin, DetailView):
return HttpResponseRedirect(request.path_info)
class WebappJson(LoginRequiredMixin, DetailView):
class WebappJson(UserAccessMixin, DetailView):
"""json variant of the application details
(used by the /aio/apps/<DOCKER_NAME>/events endpoint)
......@@ -579,7 +582,7 @@ class WebappJson(LoginRequiredMixin, DetailView):
"sandbox_state": webapp.get_sandbox_state_display(),
class WebappSandboxPanel(LoginRequiredMixin, TemplateView):
class WebappSandboxPanel(UserAccessMixin, TemplateView):
"""Create a new sandbox for a given application
......@@ -725,7 +728,7 @@ class WebappSandboxPanel(LoginRequiredMixin, TemplateView):
# -----------------------------------------------------------------------------
class TagList(ListView):
class TagList(AllAccessMixin, ListView):
"""List all available tag along with their number of occurences
......@@ -772,7 +775,7 @@ class TagList(ListView):
return tags
class TagWebappList(ListView):
class TagWebappList(AllAccessMixin, ListView):
"""List all available webapps for a given tag
......@@ -797,7 +800,7 @@ class TagWebappList(ListView):
# -----------------------------------------------------------------------------
class UserUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
class UserUpdate(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
"""Update the user profile
......@@ -835,6 +838,10 @@ class UserUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
kwargs['ssh_fingerprint'] = fingerprint
if token:
kwargs['token'] = token
kwargs['agreements'] = UserAgreement.objects.filter(
return super(UserUpdate, self).get_context_data(**kwargs)
......@@ -856,7 +863,7 @@ class UserToken(LoginRequiredMixin, RedirectView):
return reverse('main:user_detail')
class UserSSHAdd(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
class UserSSHAdd(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
"""Save a SSH key to the database.
......@@ -895,7 +902,7 @@ class UserSSHDelete(LoginRequiredMixin, RedirectView):
return reverse('main:user_detail')
class UserPasswordUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
class UserPasswordUpdate(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
"""Update the user's password.
We reuse the Django password form system in order to keep something robust
......@@ -936,10 +943,73 @@ class UserPasswordUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
return super(UserPasswordUpdate, self) \
.dispatch(request, *args, **kwargs)
class UserNeedValidation(LoginRequiredMixin, DetailView):
"""This page is displayed when the user has not completed the registration process
- email address needs verification
template_name = "user_need_validation.html"
def get_object(self):
return None
def get_context_data(self, **kwargs):
# last ToS agreement by the user
agreement = UserAgreement.objects.filter(user=self.request.user
# next ToS to be signed
next_tos = Tos.get_latest()
if agreement is not None and agreement.tos == next_tos:
# latest agreement already signed
next_tos = None
return super().get_context_data(
agreement=agreement, next_tos=next_tos, **kwargs)
def get(self, request):
if all(addr.verified for addr in request.user.email_addresses
) and request.user.has_agreed_tos:
# redirect to the main page if validation is complete
messages.success(request, "Your account is fully validated")
return redirect("main:home")
return super().get(request)
def post(self, request):
# re-send a confirmation e-mail if 'confirm_address_id' is provided
email_addr = request.user.email_addresses.get(
except (KeyError, ValueError, ObjectDoesNotExist):
if not email_addr.verified:
"Confirmation e-mail sent to %s" %
# store user agreement
if "yes_i_agree" in request.POST:
tos = Tos.objects.get(id=int(request.POST["agree_tos_id"]))
except (KeyError, ValueError, ObjectDoesNotExist):
if UserAgreement.objects.get_or_create(
tos=tos, user=request.user)[1]:
"Terms of Service version %s agreed" % tos.version)
return redirect(request.path_info)
# -----------------------------------------------------------------------------
class JobList(LoginRequiredMixin, ListView):
class JobList(UserAccessMixin, ListView):
"""Display the list of jobs for a given identified user
......@@ -1029,7 +1099,7 @@ class JobDetail(JobAuthMixin, DetailView):
return super().render_to_response(context, **kwargs)
class JobCreate(SuccessMessageMixin, CreateView):
class JobCreate(AllAccessMixin, SuccessMessageMixin, CreateView):
""" Display the data related a specific web and create a job instance
into the database
......@@ -1281,7 +1351,7 @@ class JobFileDownloadAll(JobAuthMixin, View):
# -----------------------------------------------------------------------------
class RunnerList(LoginRequiredMixin, ListView):
class RunnerList(UserAccessMixin, ListView):
"""List all runners of a given user
......@@ -1319,7 +1389,7 @@ class RunnerList(LoginRequiredMixin, ListView):
return super().get_context_data(**kwargs)
class RunnerCreate(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, CreateView):
class RunnerCreate(ProviderAccessMixin, SuccessMessageMixin, CreateView):
"""Create a runner and save it into the database
......@@ -1366,7 +1436,7 @@ class RunnerCreate(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, Cre
return reverse_lazy('main:runner_update', args=(,))
class RunnerUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
class RunnerUpdate(UserAccessMixin, SuccessMessageMixin, UpdateView):
"""Update a runner and save it into the database
......@@ -1395,7 +1465,7 @@ class RunnerUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
"allgo/runner", "-", self.object.token, get_base_url(self.request)]
return super().get_context_data(**kwargs)
class RunnerDelete(LoginRequiredMixin, DeleteView):
class RunnerDelete(UserAccessMixin, DeleteView):
"""Delete a runner
......@@ -1439,3 +1509,13 @@ def auth(request):
return HttpResponse(status=200)
return HttpResponse(status=403)
class TosDetail(View):
def get(self, request):
tos = Tos.get_latest()
if tos is None:
raise Http404
return redirect(tos.url)
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Manage e-mail addresses" %}{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item active"><a href="{% url 'main:user_detail' %}">My profile</a></li>
<li class="breadcrumb-item active" aria-current="page">e-mail</li>
{% endblock %}
{% block content %}
<div class="container">
<div class="allgo-page">
<div class="border-bottom pb-3 mb-3">
<h2>{% trans "E-mail Addresses" %}</h2>
{% if user.emailaddress_set.all %}
<p>{% trans 'The following e-mail addresses are associated with your account:' %}</p>
<form action="{% url 'account_email' %}" class="email_list" method="post">
{% csrf_token %}
<table class="table col-md-3" style="width:1px; white-space: nowrap;">
{% for emailaddress in user.emailaddress_set.all %}
<label for="email_radio_{{forloop.counter}}" class="{% if emailaddress.primary %}primary_email{%endif%}">
<input id="email_radio_{{forloop.counter}}" type="radio" name="email" {% if emailaddress.primary or user.emailaddress_set.count == 1 %}checked="checked"{%endif %} value="{{}}"/>
{{ }}