login/pwd authenticator: allow setting sms code duration (#88786)
gitea/authentic/pipeline/head This commit looks good
Details
gitea/authentic/pipeline/head This commit looks good
Details
This commit is contained in:
parent
a6df1a2750
commit
f3e57d5089
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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)',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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']
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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/')
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue