[WIP] (#73150)
gitea/authentic/pipeline/head Something is wrong with the build of this commit Details

This commit is contained in:
Paul Marillonnet 2023-01-19 11:26:23 +01:00
parent 25aec13ee8
commit 4df7835eaa
10 changed files with 253 additions and 5 deletions

View File

@ -31,3 +31,23 @@ class FcAuthenticatorForm(forms.ModelForm):
class Meta:
model = FcAuthenticator
exclude = ('name', 'slug', 'ou', 'button_description', 'button_label')
class LinkMethodChoiceForm(forms.Form):
VALIDATION_TYPE_CHOICES = (
('send-validation-link', _('Send an account validation link')),
('type-password', _('Let me input my password to validate my account')),
)
validation_type = forms.ChoiceField(
choices=VALIDATION_TYPE_CHOICES,
widget=forms.RadioSelect,
required=True,
)
class TypePasswordForm(forms.Form):
password = forms.CharField(
label=_('Password'),
widget=forms.PasswordInput,
help_text=_("Your existing account password"),
)

View File

@ -0,0 +1,29 @@
# Generated by Django 2.2.26 on 2023-01-24 09:32
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentic2_auth_fc', '0009_fcemailverificationtoken'),
]
operations = [
migrations.AddField(
model_name='fcaccount',
name='is_active',
field=models.BooleanField(default=False, verbose_name='Is FC account active'),
),
migrations.AddField(
model_name='fcemailverificationtoken',
name='fc_account',
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to='authentic2_auth_fc.FcAccount',
verbose_name='FC account',
),
),
]

View File

@ -151,6 +151,7 @@ class FcAccount(models.Model):
related_name='fc_accounts',
on_delete=models.CASCADE,
)
is_active = models.BooleanField(_('Is FC account active'), default=False)
sub = models.TextField(verbose_name=_('sub'), db_index=True)
order = models.PositiveIntegerField(verbose_name=_('order'), default=0)
token = models.TextField(verbose_name=_('access token'), default='{}')
@ -204,6 +205,12 @@ class FcEmailVerificationToken(models.Model):
related_name='fc_email_verification_tokens',
on_delete=models.CASCADE,
)
fc_account = models.ForeignKey(
to=FcAccount,
null=True,
verbose_name=_('FC account'),
on_delete=models.SET_NULL,
)
@classmethod
def cleanup(cls, now=None):
@ -211,7 +218,7 @@ class FcEmailVerificationToken(models.Model):
cls.objects.filter(expires__lte=now).delete()
@classmethod
def create(cls, user, expires=None, duration=None):
def create(cls, user, fc_account=None, expires=None, duration=None):
if not duration:
duration = cls.DURATION
expires = expires or (timezone.now() + datetime.timedelta(seconds=duration))

View File

@ -0,0 +1,10 @@
{% extends "emails/body_base.html" %}
{% load i18n %}
{% block content %}
<p>{% blocktrans %}Hi {{ user }},{% endblocktrans %}</p>
<p>{% trans "You can complete your account validation by clicking on the button below." %}</p>
{% include "emails/button-link.html" with url=verification_link label=_("Complete your account validation") %}
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "emails/body_base.txt" %}{% load i18n %}{% block content %}{% autoescape off %}{% blocktrans %}Hi {{ user }},{% endblocktrans %}
{% trans "You can complete your account validation by clicking on the link below:" %}
{{ verification_link }}
{% endautoescape %}{% endblock %}

View File

@ -0,0 +1,4 @@
{% extends "emails/subject.txt" %}
{% load i18n %}
{% block email-subject %}{% trans "Account pending linkage with FranceConnect" %}{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends "authentic2_auth_fc/base.html"%}
{% load static %}
{% load i18n gadjo %}
{% block content %}
<p>
{% blocktrans trimmed %}
Your email address known to the FranceConnect service matches an existing account.
For this reason, please prove your ownership of address {{ email }} by one of the
two following methods:
{% endblocktrans %}
</p>
<form method="post">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button class="submit-button">{% trans "Submit" %}</button>
<button class="cancel-button" name="cancel" formnovalidate>{% trans "Cancel" %}</button>
</div>
</form>
{% endblock %}

View File

@ -32,4 +32,14 @@ fcpatterns = [
urlpatterns = [
re_path(r'^fc/', include(fcpatterns)),
path('accounts/fc/unlink/', views.unlink, name='fc-unlink'),
path(
'accounts/fc/link-method-choice/',
login_required(views.LinkMethodChoiceView.as_view()),
name='fc-link-method-choice',
),
re_path(
r'^accounts/fc/type-password/(?P<token>[A-Za-z0-9_ -]+)/$',
login_required(views.TypePasswordView.as_view()),
name='fc-validation-type-password',
),
]

View File

@ -405,6 +405,10 @@ class LoginOrLinkView(View):
# set session expiration policy to EXPIRE_AT_BROWSER_CLOSE
request.session.set_expiry(0)
# redirect to validation page when FC identity matches an existing account
if user and not created:
return utils_misc.redirect(request, 'fc-link-method-choice', params={'next': self.next_url})
# redirect to account edit page if any required attribute is not filled
# only on user registration
missing = created and self.missing_required_attributes(user)
@ -474,9 +478,10 @@ class LoginOrLinkView(View):
# in a transaction or not, we must use one to prevent later SQL
# queries to fail.
with transaction.atomic():
models.FcAccount.objects.create(
fc_account = models.FcAccount.objects.create(
user=user,
sub=sub,
is_active=False,
order=0,
token=json.dumps(token),
user_info=json.dumps(user_info),
@ -498,7 +503,7 @@ class LoginOrLinkView(View):
logger.info('auth_fc: new account "%s" created with FranceConnect sub "%s"', user, sub)
hooks.call_hooks('event', name='fc-create', user=user, sub=sub, request=request)
token = models.FcEmailVerificationToken.create(user)
token = models.FcEmailVerificationToken.create(user, fc_account=fc_account)
verification_link = request.build_absolute_uri(
reverse('fc-verification', kwargs={'token': token.value})
)
@ -585,6 +590,126 @@ class LoginOrLinkView(View):
login_or_link = LoginOrLinkView.as_view()
class LinkMethodChoiceView(FormView):
next_url = '/'
display_message_on_redirect = False
template_name = 'authentic2_auth_fc/link_method_choice.html'
def redirect(self):
return utils_misc.redirect(self.request, self.next_url)
def get_form_class(self):
from .forms import LinkMethodChoiceForm
return LinkMethodChoiceForm
def dispatch(self, request, *args, next_url=None, **kwargs):
if next_url:
self.next_url = next_url
# check user is candidate for explicit linking
if request.user.email_verified and 'fc' in request.user.email_verified_sources:
messages.info(request, _('Your account is already valid.'))
return self.redirect()
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
token = models.FcEmailVerificationToken.create(self.request.user)
if form.cleaned_data.get('validation_type') == 'send-validation-link':
verification_link = self.request.build_absolute_uri(
reverse('fc-verification', kwargs={'token': token.value})
)
utils_misc.send_templated_mail(
self.request.user,
template_names=['authentic2_auth_fc/fc_link_validation_pending'],
context={
'user': self.request.user,
'verification_link': verification_link,
},
request=self.request,
)
messages.warning(
self.request,
_(
'Please complete your account validation by visiting the validation link sent '
'to you by email. Some features may be restricted pending account validation.'
),
)
token.sent = True
token.save()
return self.redirect()
if form.cleaned_data.get('validation_type') == 'type-password':
return utils_misc.redirect(
self.request, reverse('fc-validation-type-password', kwargs={'token': token.value})
)
return super().form_valid(form)
def post(self, request, *args, **kwargs):
if 'cancel' in request.POST:
return utils_misc.redirect(request, 'account_management')
return super().post(request, *args, **kwargs)
class TypePasswordView(FormView):
next_url = '/'
token = None
def redirect(self):
return utils_misc.redirect(self.request, self.next_url)
def get_form_class(self):
from .forms import TypePasswordForm
return TypePasswordForm
def dispatch(self, request, *args, next_url=None, **kwargs):
error = None
if not request.user.is_active:
error = _(
'Your FranceConnect identity matches a de-activated account. Please use a '
'different email address or contact the site administrator.'
)
else:
if next_url:
self.next_url = next_url
token_value = kwargs.get('token')
try:
self.token = models.FcEmailVerificationToken.objects.get(value=token_value)
except models.FcEmailVerificationToken.DoesNotExist:
error = _('Invalid account verification request.')
else:
if self.token.user != request.user:
error = _('Invalid account verification request.')
elif self.token.expires < now():
error = _('Your account verification request has expired.')
if error:
messages.error(request, error)
return utils_misc.redirect(request, 'account_management')
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
password = form.cleaned_data.get('password')
if self.request.user.check_password(password):
fc_account = (
self.token.fc_account
or models.FcAccount.objects.filter(user=self.request.user).order_by('order').first()
)
if fc_account:
fc_account.is_active = True
fc_account.save(update_fields=['is_active'])
self.request.user.set_email_verified(True, source='fc')
messages.info(self.request, _('Your account has been validated.'))
else:
# todo IP ratelimit & token usage count
messages.error(
self.request,
_('The password you typed is invalid. Your account validation is still pending.'),
)
return super().form_valid(form)
class UnlinkView(FormView):
template_name = 'authentic2_auth_fc/unlink.html'
@ -702,6 +827,12 @@ class FcEmailVerificationView(View):
)
return utils_misc.redirect(request, 'account_management')
fc_account = (
token.fc_account or models.FcAccount.objects.filter(user=token.user).order_by('order').first()
)
if fc_account:
fc_account.is_active = True
fc_account.save(update_fields=['is_active'])
token.user.set_email_verified(True, source='fc')
messages.info(
request,

View File

@ -349,11 +349,16 @@ def test_unlink_after_login_with_password(app, franceconnect, simple_user):
assert response.request.path == '/accounts/'
def test_unlink_after_login_with_fc(app, franceconnect, simple_user):
def test_unlink_after_login_with_fc(app, franceconnect, simple_user, mailoutbox):
models.FcAccount.objects.create(user=simple_user, sub=franceconnect.sub, user_info='{}')
response = franceconnect.login_with_fc(app, path='/accounts/')
response = response.maybe_follow()
response.form.set('validation_type', 'send-validation-link')
response = response.form.submit().follow()
assert 'https://testserver/fc/verify/' in mailoutbox[0].body
# xxx broken next_url redirect
response = app.get('/accounts/')
response = response.click('Delete link')
response.form.set('new_password1', 'ikKL1234')
response.form.set('new_password2', 'ikKL1234')
@ -469,7 +474,7 @@ def test_login_with_missing_required_attributes(settings, app, franceconnect):
assert 'The following fields are mandatory for account creation: Title' in cookie
def test_can_change_password(settings, app, franceconnect):
def test_can_change_password(settings, app, franceconnect, mailoutbox):
user = User.objects.create(email='john.doe@example.com')
models.FcAccount.objects.create(user=user, sub=franceconnect.sub)
@ -494,6 +499,12 @@ def test_can_change_password(settings, app, franceconnect):
response = response.maybe_follow()
assert len(response.pyquery('[href*="password/change"]')) == 0
# xxx broken next_url redirect
response.form.set('validation_type', 'send-validation-link')
response = response.form.submit().follow()
assert 'https://testserver/fc/verify/' in mailoutbox[0].body
response = app.get('/accounts/')
# Unlink
response = response.click('Delete link')
response.form.set('new_password1', 'ikKL1234')