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

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 (
Webapp,
WebappParameter,
WebappVersion,
Tos,
UserAgreement,
)
......@@ -69,3 +71,5 @@ admin.site.register(WebappVersion, WebappVersionAdmin)
admin.site.register(Job, JobAdmin)
admin.site.register(AllgoUser)
admin.site.register(JobQueue)
admin.site.register(Tos)
admin.site.register(UserAgreement)
......@@ -33,11 +33,10 @@ class UserForm(forms.ModelForm):
label_suffix='',
required=False,
)
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
else:
# 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"""
pass
class ProviderAccessMixin(LoginRequiredMixin, AllgoValidAccountMixin):
"""Mixin to be included in views that require provider-level access
(i.e. user allowed to create new web applications)
"""
pass
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)
"""
pass
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
email_addr.send_confirmation(request)
return redirect("account_email_verification_sent")
else:
# 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(self.id))
# 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
doubt.
NOTE: ToS versions are ordered by Tos.id (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)
@classmethod
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):
instance.allgouser.save()
@property
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.
try:
_, domain = email_addr.email.split("@")
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).
@property
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):
try:
_, domain = email_addr.email.split("@")
except ValueError:
# malformatted email
continue
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)
@property
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
verified
"""
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)
@property
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 models.py and
# helpers.py, 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'),
......
This diff is collapsed.
{% 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;">
<tbody>
{% for emailaddress in user.emailaddress_set.all %}
<tr>
<td>
<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="{{emailaddress.email}}"/>
{{ emailaddress.email }}
</label>
</td>
<td>
{% if emailaddress.primary %}<span class="primary">{% trans "Primary" %}</span>{% endif %}
</td>
<td>
{% if emailaddress.verified %}
<span class="text-success">{% trans "Verified" %}</span>
{% else %}
<span class="text-danger">{% trans "Unverified" %}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="buttonHolder">
<button class="btn btn-primary secondaryAction" type="submit" name="action_primary" >{% trans 'Make Primary' %}</button>
<button class="btn btn-primary secondaryAction" type="submit" name="action_send" >{% trans 'Re-send Verification' %}</button>
<button class="btn btn-primary primaryAction" type="submit" name="action_remove" >{% trans 'Remove' %}</button>
</div>
</form>
{% else %}
<p><strong>{% trans 'Warning:'%}</strong> {% trans "You currently do not have any e-mail address set up. You should really add an e-mail address so you can receive notifications, reset your password, etc." %}</p>
{% endif %}
</div>
<div class="border-bottom pb-3 mb-3">
<h4>{% trans "Add E-mail Address" %}</h4>
<form method="post" action="{% url 'account_email' %}" class="add_email">
{% csrf_token %}
{{ form.as_p }}
<button class="btn btn-primary" name="action_add" type="submit">{% trans "Add E-mail" %}</button>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_body %}
<script type="text/javascript">
(function() {
var message = "{% trans 'Do you really want to remove the selected e-mail address?' %}";
var actions = document.getElementsByName('action_remove');
if (actions.length) {
actions[0].addEventListener("click", function(e) {
if (! confirm(message)) {
e.preventDefault();
}
});
}
})();
</script>
{% endblock %}
......@@ -22,8 +22,8 @@
<ul>
<li><a href="https://wiki.inria.fr/sed_ren/">/SED/</a></li>
<li><a href="{% url 'main:tos_detail' %}">Terms</a></li>
{% comment Will be reintegrated with the RGPD %}
<li><a href="#">Terms</a></li>
<li><a href="#">Privacy</a></li>
{% endcomment %}
</ul>
......
{% extends "base.html" %}
{% load static htmlattrs socialaccount %}
{% block title %}Account needs validation{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item active" aria-current="page">My profile</li>
{% endblock %}
{% block content %}
<div class="container">
<div class="allgo-page">
<div class="pb-3 mb-3">
<h2>Your account needs validation</h2>
</div>
<div>
<h5>Email address</h5>
<p>Your e-mail address has to be verified. If it is still marked as
unverified, then you need to follow the link provided in the confirmation
e-mail that was sent during registration.</p>
<table class="table" style="width: 1px; white-space: nowrap;">
<tbody>
{% for addr in user.email_addresses %}
<tr>
<td>{{addr.email}}</td>
{% if addr.verified %}
<td><i class="text-success">Verified</i></td>
<td/>
{% else %}
<td>
<i class="text-danger">Unverified</i>
</td>
<td>
<form class="form-inline mt-0" method="post">
{% csrf_token %}
<input type="hidden" name="confirm_address_id" value="{{addr.id}}"/>
<input class="btn btn-primary" type="submit" value="Re-send verification e-mail"/>
</form>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if agreement or next_tos %}
<div class="mt-3">
<h5>Terms of Service</h5>
<p>You have to agree to the latest version of the Terms of Service.</p>
<table class="table" style="width: 1px; white-space: nowrap;">
<tbody>
{% if agreement %}
<tr>
<td>
<a href="{{agreement.tos.url}}">Terms of Service version {{ agreement.tos.version }}</a>
{% if next_tos %}
<i>(previous)</i>
{% endif %}
</td>
<td class="text-success"><i>Agreed on {{agreement.created_at}}</i></td>
<td/>
</tr>
{% endif %}
{% if next_tos %}
<tr>
<td>
<a href="{{next_tos.url}}">Terms of Service version {{ next_tos.version }}</a>
<i>(current)</i>
<br>
</td>
<td class="text-danger"><i>Not agreed</i></td>
</tr>
<tr>
<td colspan="2">
<form class="form" method="post">
{% csrf_token %}
<input type="hidden" name="agree_tos_id" value="{{next_tos.id}}"/>
<input type="checkbox" name="yes_i_agree"/> Yes, I have read and I agree to the Terms of Service version {{next_tos.version}}
<input class="btn btn-primary ml-3" type="submit" value="Submit"/>
</form>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block messages %}
{{ block.super }}
{% include 'partials/_form_messages.html' %}
{% endblock %}
{% block javascript %}
{{ block.super }}
<script defer src="{% static 'js/tooltip.js' %}"></script>
<script defer src="{% static 'js/copy.js' %}"></script>
{% endblock %}
......@@ -27,19 +27,26 @@
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
{{ form.email.label_tag }}
{{ form.email | attr:"placeholder:Enter your email" | add_class:"form-control" }}
</div>
</div>
<button type="submit" class="btn btn-primary float-right">
<i class="far fa-user"></i> Update profile
</button>
</form>
</div>
<div class="clearfix border-bottom pb-3 mb-3">
<h5>E-mail</h5>
<ul>
{% for addr in user.email_addresses %}
<li class="{% if addr.primary %}font-weight-bold{% endif %}">{{addr.email}}{% if addr.primary %}{% endif %} </li>
{% endfor %}
</ul>
<div class="float-right">
<a class="btn btn-primary" href="{% url 'account_email' %}" role="button">
<i class="fas fa-envelope"></i> Manage addresses
</a>
</div>
</div>
<div class="clearfix border-bottom pb-3 mb-3">
{% if user.has_usable_password %}
<h5>Password</h5>
......@@ -104,7 +111,7 @@
</div>
<div class="clearfix mb-3 pb-3">
<div class="clearfix border-bottom mb-3 pb-3">
<h5>Token</h5>
<p>
......@@ -127,10 +134,22 @@
</div>
</div>
</div>
</div>
</div>
{% if agreements %}
<div class="clearfix mb-3 pb-3">
<h5>Terms of Service</h5>
<p>You agreed to the following Terms of Service:
<ul>
{% for agreement in agreements %}
<li><a href="{{agreement.tos.url}}">Version {{agreement.tos.version}}</a> <i>agreed on {{agreement.created_at}}</i></li>
{% endfor %}
</ul>
</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
......
......@@ -134,7 +134,6 @@ MIGRATION_MODULES = {
# AUTHENTIFICATION
# ------------------------------------------------------------------------------
AUTHENTIFICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend',
]
LOGIN_REDIRECT_URL = 'main:home'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment