[WIP] (#73150)
gitea/authentic/pipeline/head Something is wrong with the build of this commit
Details
gitea/authentic/pipeline/head Something is wrong with the build of this commit
Details
This commit is contained in:
parent
25aec13ee8
commit
4df7835eaa
|
@ -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"),
|
||||
)
|
||||
|
|
|
@ -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',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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))
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -0,0 +1,4 @@
|
|||
{% extends "emails/subject.txt" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block email-subject %}{% trans "Account pending linkage with FranceConnect" %}{% endblock %}
|
|
@ -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 %}
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Reference in New Issue