login/pwd authenticator: allow setting sms code duration (#88786)
gitea/authentic/pipeline/head This commit looks good Details

This commit is contained in:
Paul Marillonnet 2024-03-28 09:51:19 +01:00
parent a6df1a2750
commit f3e57d5089
9 changed files with 111 additions and 6 deletions

View File

@ -90,6 +90,7 @@ class LoginPasswordAuthenticatorAdvancedForm(forms.ModelForm):
'accept_email_authentication',
'accept_phone_authentication',
'phone_identifier_field',
'sms_code_duration',
):
del self.fields[field]
@ -111,6 +112,7 @@ class LoginPasswordAuthenticatorAdvancedForm(forms.ModelForm):
'accept_email_authentication',
'accept_phone_authentication',
'phone_identifier_field',
'sms_code_duration',
)

View File

@ -0,0 +1,33 @@
# Generated by Django 3.2.18 on 2024-04-03 08:36
import django.core.validators
from django.db import migrations, models
import authentic2.apps.authenticators.models
class Migration(migrations.Migration):
dependencies = [
('authenticators', '0019_fix_addroleaction_condition'),
]
operations = [
migrations.AddField(
model_name='loginpasswordauthenticator',
name='sms_code_duration',
field=models.PositiveSmallIntegerField(
blank=True,
help_text=authentic2.apps.authenticators.models.sms_code_duration_help_text,
null=True,
validators=[
django.core.validators.MinValueValidator(
60, 'Ensure that this value is higher than 60, or leave blank for default value.'
),
django.core.validators.MaxValueValidator(
3600, 'Ensure that this value is lower than 3600, or leave blank for default value.'
),
],
verbose_name='SMS codes lifetime (in seconds)',
),
),
]

View File

@ -21,6 +21,7 @@ import uuid
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Max
from django.shortcuts import render, reverse
@ -233,6 +234,12 @@ class BaseAuthenticator(models.Model):
return authenticator, created
def sms_code_duration_help_text():
return _(
f'Time (in seconds, between 60 and 3600) after which SMS codes expire. Default is {settings.SMS_CODE_DURATION}.'
)
class AuthenticatorRelatedObjectBase(models.Model):
authenticator = models.ForeignKey(BaseAuthenticator, on_delete=models.CASCADE)
@ -426,6 +433,20 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
max_length=32,
help_text=_('Maximum rate of SMSs sent to the same phone number.'),
)
sms_code_duration = models.PositiveSmallIntegerField(
_('SMS codes lifetime (in seconds)'),
help_text=sms_code_duration_help_text,
validators=[
MinValueValidator(
60, _('Ensure that this value is higher than 60, or leave blank for default value.')
),
MaxValueValidator(
3600, _('Ensure that this value is lower than 3600, or leave blank for default value.')
),
],
null=True,
blank=True,
)
type = 'password'
how = ['password', 'password-on-https']

View File

@ -43,6 +43,7 @@ from authentic2.a2_rbac.models import Role
from authentic2.a2_rbac.utils import get_default_ou_pk
from authentic2.custom_user.backends import DjangoRBACBackend
from authentic2.utils.crypto import base64url_decode, base64url_encode
from authentic2.utils.misc import get_password_authenticator
from authentic2.validators import HexaColourValidator, PhoneNumberValidator
# install our natural_key implementation
@ -818,7 +819,6 @@ class APIClient(models.Model):
class SMSCode(models.Model):
CODE_DURATION = 120
KIND_REGISTRATION = 'registration'
KIND_PASSWORD_LOST = 'password-reset'
KIND_PHONE_CHANGE = 'phone-change'
@ -860,7 +860,7 @@ class SMSCode(models.Model):
if not kind:
kind = cls.KIND_REGISTRATION
if not duration:
duration = cls.CODE_DURATION
duration = get_password_authenticator().sms_code_duration or settings.SMS_CODE_DURATION
expires = expires or (timezone.now() + datetime.timedelta(seconds=duration))
return cls.objects.create(kind=kind, user=user, phone=phone, expires=expires, fake=fake)

View File

@ -387,6 +387,7 @@ SMS_URL = ''
# allowed character set in SMS codes, without visually ambiguous characters (no '0' or 'O', and no '1', 'I' or 'L').
SMS_CODE_ALLOWED_CHARACTERS = '23456789ABCDEFGHJKMNPQRSTUVWXYZ'
SMS_CODE_LENGTH = 8
SMS_CODE_DURATION = 180
# Get select2 from local copy.
SELECT2_JS = '/static/xstatic/select2.min.js'

View File

@ -1597,6 +1597,7 @@ class InputSMSCodeView(cbv.ValidateCSRFMixin, FormView):
def dispatch(self, request, *args, **kwargs):
token = kwargs['token']
self.authenticator = utils_misc.get_password_authenticator()
try:
self.code = models.SMSCode.objects.get(url_token=token)
except models.SMSCode.DoesNotExist:
@ -1605,7 +1606,7 @@ class InputSMSCodeView(cbv.ValidateCSRFMixin, FormView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['duration'] = models.SMSCode.CODE_DURATION // 60
ctx['duration'] = (self.authenticator.sms_code_duration or settings.SMS_CODE_DURATION) // 60
return ctx
def post(self, request, *args, **kwargs):

View File

@ -147,9 +147,17 @@ def test_authenticators_password(app, superuser_or_admin, settings):
)
resp = app.get('/manage/authenticators/%s/edit/' % authenticator.pk)
assert 'Time (in seconds, between 60 and 3600) after which SMS codes expire. Default is 180' in resp.text
settings.SMS_CODE_DURATION = 240
resp = app.get('/manage/authenticators/%s/edit/' % authenticator.pk)
assert resp.form['sms_code_duration'].value == ''
resp.form['accept_email_authentication'] = False
resp.form['accept_phone_authentication'] = True
resp.form['sms_code_duration'] = '1200'
assert 'Time (in seconds, between 60 and 3600) after which SMS codes expire. Default is 240' in resp.text
assert resp.form['phone_identifier_field'].options == [
(str(phone1.id), False, 'Another phone'),
(str(phone2.id), False, 'Yet another phone'),
@ -161,6 +169,38 @@ def test_authenticators_password(app, superuser_or_admin, settings):
assert authenticator.accept_email_authentication is False
assert authenticator.accept_phone_authentication is True
assert authenticator.phone_identifier_field == phone2
assert authenticator.sms_code_duration == 1200
resp = app.get('/manage/authenticators/%s/edit/' % authenticator.pk)
resp.form['sms_code_duration'] = '4200' # too high
resp = resp.form.submit()
assert resp.pyquery('.error')[0].text_content().strip() == (
'Ensure that this value is lower than 3600, or leave blank for default value.'
)
authenticator.refresh_from_db()
assert authenticator.sms_code_duration == 1200
resp.form['sms_code_duration'] = '42' # too low
resp = resp.form.submit()
assert resp.pyquery('.error')[0].text_content().strip() == (
'Ensure that this value is higher than 60, or leave blank for default value.'
)
authenticator.refresh_from_db()
assert authenticator.sms_code_duration == 1200
resp.form['sms_code_duration'] = '2442' # new valid value
resp = resp.form.submit()
assert resp.location == f'/manage/authenticators/{authenticator.pk}/detail/'
resp = resp.follow()
assert not resp.pyquery('.error')
authenticator.refresh_from_db()
assert authenticator.sms_code_duration == 2442
resp = app.get('/manage/authenticators/%s/edit/' % authenticator.pk)
resp.form.set('sms_code_duration', '')
resp = resp.form.submit()
authenticator.refresh_from_db()
assert authenticator.sms_code_duration is None
@pytest.mark.freeze_time('2022-04-19 14:00')
@ -203,6 +243,7 @@ def test_authenticators_password_export(app, superuser):
'related_objects': [],
'accept_email_authentication': True,
'accept_phone_authentication': False,
'sms_code_duration': None,
}
resp = app.get('/manage/authenticators/')

View File

@ -24,7 +24,7 @@ from django.urls import reverse
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.models import Attribute, SMSCode, Token
from authentic2.utils.misc import send_password_reset_mail
from authentic2.utils.misc import get_password_authenticator, send_password_reset_mail
from . import utils
@ -88,6 +88,9 @@ def test_send_password_reset_by_sms_code(app, nomail_user, settings, phone_activ
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
rsps = responses.post('https://foo.whatever.none/', status=200)
authn = get_password_authenticator()
authn.sms_code_duration = 300
authn.save()
code_length = settings.SMS_CODE_LENGTH
assert not SMSCode.objects.count()
@ -102,7 +105,7 @@ def test_send_password_reset_by_sms_code(app, nomail_user, settings, phone_activ
code = SMSCode.objects.get()
assert rsps.call_count == 1
assert body['message'][-code_length:] == code.value
assert ('Your code is valid for the next %s minute' % (SMSCode.CODE_DURATION // 60)) in resp.text
assert 'Your code is valid for the next 5 minutes' in resp.text
assert 'The code you received by SMS.' in resp.text
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()

View File

@ -1333,6 +1333,9 @@ def test_phone_registration(app, db, settings, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
rsps = responses.post('https://foo.whatever.none/', status=200)
code_length = settings.SMS_CODE_LENGTH
authn = utils_misc.get_password_authenticator()
authn.sms_code_duration = 420
authn.save()
assert not SMSCode.objects.count()
assert not Token.objects.count()
@ -1343,7 +1346,7 @@ def test_phone_registration(app, db, settings, phone_activated_authn):
assert body['message'].startswith('Your code is')
code = SMSCode.objects.get()
assert body['message'][-code_length:] == code.value
assert ('Your code is valid for the next %s minute' % (SMSCode.CODE_DURATION // 60)) in resp.text
assert 'Your code is valid for the next 7 minutes' in resp.text
assert 'The code you received by SMS.' in resp.text
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()