From 1b2f1e4fd16c5d057fa6781d4804ac25b95d6943 Mon Sep 17 00:00:00 2001 From: Pavel Dedik Date: Tue, 24 Mar 2026 14:21:33 +0100 Subject: [PATCH 1/2] Add OTP brute-force protection and single-use OTP --- auth_token/authorization_request/backends.py | 26 ++++++++++-- auth_token/config.py | 1 + auth_token/contrib/is_core_auth/views.py | 14 +++++++ auth_token/exceptions.py | 4 ++ auth_token/locale/cs/LC_MESSAGES/django.mo | Bin 4119 -> 4307 bytes auth_token/locale/cs/LC_MESSAGES/django.po | 4 ++ auth_token/migrations/0017_migration.py | 16 +++++++ auth_token/models.py | 4 ++ example/dj/apps/app/tests/is_core.py | 30 +++++++++++++ example/dj/apps/app/tests/utils.py | 42 +++++++++++++++++++ 10 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 auth_token/migrations/0017_migration.py diff --git a/auth_token/authorization_request/backends.py b/auth_token/authorization_request/backends.py index 5347cff..ba76709 100644 --- a/auth_token/authorization_request/backends.py +++ b/auth_token/authorization_request/backends.py @@ -1,5 +1,9 @@ from auth_token.config import settings -from auth_token.utils import create_otp, deactivate_otp, get_valid_otp, otp_key_generator_factory +from auth_token.exceptions import MaxOTPAttemptsExceeded +from auth_token.utils import ( + cancel_authorization_request, create_otp, deactivate_otp, + get_valid_otp, otp_key_generator_factory, +) from auth_token.models import MobileDevice @@ -74,15 +78,29 @@ def initialize(self, authorization_request): otp = self._create_otp(authorization_request) authorization_request.secret_key = otp.secret_key - def authenticate(self, authorization_request, otp_secret_key=None, **kwargs): + def authenticate(self, authorization_request, otp_secret_key=None, deactivate_otp_on_success=True, **kwargs): if otp_secret_key is None: return False if (settings.AUTHORIZATION_REQUEST_OTP_DEBUG_CODE and settings.AUTHORIZATION_REQUEST_OTP_DEBUG_CODE == otp_secret_key): - otp_secret_key = None + return True + + otp = get_valid_otp( + authorization_request.slug, + key=otp_secret_key, + related_objects=[authorization_request], + deactivate=deactivate_otp_on_success, + ) + + if otp is None and settings.MAX_OTP_ATTEMPTS is not None: + authorization_request.change_and_save( + failed_attempts=authorization_request.failed_attempts + 1 + ) + if authorization_request.failed_attempts >= settings.MAX_OTP_ATTEMPTS: + cancel_authorization_request(authorization_request) + raise MaxOTPAttemptsExceeded - otp = get_valid_otp(authorization_request.slug, key=otp_secret_key, related_objects=[authorization_request]) return otp is not None def _deactivate(self, authorization_request): diff --git a/auth_token/config.py b/auth_token/config.py index 7e79abd..58914b5 100644 --- a/auth_token/config.py +++ b/auth_token/config.py @@ -49,6 +49,7 @@ 'OTP_DEFAULT_KEY_GENERATOR': 'auth_token.utils.generate_otp_key', # Default key generator for OTP 'OTP_EXPIRATION_RETENTION_PERIOD': 0, # Expired tokens are deleted immediately + 'MAX_OTP_ATTEMPTS': None, # Maximum number of failed OTP attempts before cancelling authorization request 'DEFAULT_AUTHORIZATION_REQUEST_AGE': 60 * 60, # Default authorization request expiration time (default: 1 hour), 'AUTHORIZATION_REQUEST_PRESERVE_AGE': 60 * 60 * 24 * 7, # Keep expired authorization requests in database # (default: 7 days) diff --git a/auth_token/contrib/is_core_auth/views.py b/auth_token/contrib/is_core_auth/views.py index 9cafabd..d5db848 100644 --- a/auth_token/contrib/is_core_auth/views.py +++ b/auth_token/contrib/is_core_auth/views.py @@ -1,10 +1,12 @@ from urllib.parse import quote_plus from django import forms +from django.contrib import messages from django.contrib.auth import login as auth_login from django.core.exceptions import ValidationError from django.forms.utils import ErrorList from django.http import HttpResponseRedirect +from django.utils.translation import gettext as _ from django.views.generic.base import RedirectView from django.urls import reverse @@ -17,6 +19,7 @@ from auth_token.contrib.common.views import LogoutView as _LogoutView from auth_token.contrib.common.views import LoginCodeVerificationView as _LoginCodeVerificationView from auth_token.contrib.is_core_auth.forms import LoginCodeVerificationForm +from auth_token.exceptions import MaxOTPAttemptsExceeded from auth_token.utils import create_authorization_request, grant_authorization_request, login, takeover from is_core.generic_views.base import DefaultCoreViewMixin @@ -94,6 +97,17 @@ class LoginCodeVerificationView(_LoginCodeVerificationView): template_name = 'is_core/login.html' form_class = LoginCodeVerificationForm + def post(self, request, *args, **kwargs): + try: + return super().post(request, *args, **kwargs) + except MaxOTPAttemptsExceeded: + messages.error(request, _('Maximum number of verification attempts reached. Please log in again.')) + return HttpResponseRedirect('{url}?{redirect_field_name}={value}'.format( + url=reverse('IS:login'), + redirect_field_name=self.redirect_field_name, + value=self.get_redirect_url(), + )) + def form_valid(self, form): self.log_successful_request() grant_authorization_request(form.get_authorization_request()) diff --git a/auth_token/exceptions.py b/auth_token/exceptions.py index 1b9fc31..e304271 100644 --- a/auth_token/exceptions.py +++ b/auth_token/exceptions.py @@ -1,2 +1,6 @@ class KeyGeneratorError(Exception): pass + + +class MaxOTPAttemptsExceeded(Exception): + pass diff --git a/auth_token/locale/cs/LC_MESSAGES/django.mo b/auth_token/locale/cs/LC_MESSAGES/django.mo index 1b85c7bdfdeef874b060ede6345754fca59e6e4d..8f49b955547d32fd704900c71c74a37c1802790d 100644 GIT binary patch delta 1503 zcmXxkX-Jeo9LMpo+zzd*)XL7W%j_`iu#=$3ASe>dpy*ob(S2;!$J|}Xt|bV16%<`a z7ZyYmQe+VY7NH9zK}19Z9Z0?GeJ|=o-{0=^XJb~PxgPphvzq{j-VcGlkVHxdFw0IFm z7?UzjsEnlH1@6VyxCG0F8#5Yr;S`MEN<57v*o(RN2^ZiOq(d`;zhWGR6R^y=4kvP4 zkA)b+BA#zfQQ=;588wg_sE+SCyOA-ON0^V#oN3g3?=cTQyW^kk_!p|b-#89)IFN-n z7Uy6d&o|XnwA$;{fHkNG)uL8lH;%z()WAAW9i7DScn%BjHfo?fsOz31Suii%aT?YC z0BYbLF|~($f2A^;hBZ7@BioPj@h~dK*Kiu%#Cq&S?U6z@L=!flmh>G~;SaP}%;+_M zYE&xLqEcLky6(|v)?X)n(l81CU?mnYUFEz6HIpzZ_l;PDH&Ho$jJkgSwI>Fhe^3J| zW?6K7Icmo1kZhPusD8HPuSr$I}z6E)(4sFi3&CTotN9(V@T!FAMw@3`~5$j`jt zPbv9~>hG8HH!3wl=u{IOiRy1=ii$?K(zz9NVFPLgzVigqhdGaA%XFazejj!HL)ZQk z_25^o{VkGVvy7t9Mz(1A*bs6|9XR3y@bi=cKQ zVGwPCA`;uoA7HbtMG!&|Wf5)MxEZ9EP2b<06ZGJj&wZY`^SqpMy&pQ~lZ7wMkw-=w zBz6+zDzj01SHlObE->rBdThiV+=V%;#yskGXK?`U;4l_Z_y58)*4COOu@CET6zk0j zHo?Vt1}@-1EMW}G*oxIrvwm#H?KqARypFwi3pL?0+=2_Z6-(|%+(v&56By;@&De@j z*0(Gd?8N#}6Q6KTA$zdX*nng1Br=y>!3}uB`**#6A2r`BZo((-A~w-qLjCSN*08>P z@_`l9LaV3@tYaJ_%%+VcP!qObGiGoj9!G6-1a;pTBL_i>QrX!*i7RHWs=V zSms;p>^EkxfnBJ!Ic&oL9K&(Ej0>n#CMfU$Ok)!BsD-CcCAfzw?L6wfbsz8MbS?CU zV&p%|#RLPY^#jxio}+613RSD`sM`KSO&ljJdKOad5Nbnt)csdcC!Il(Xb(~I&bg0K z8F?Bf|Jw0O29$x<$l+`WwcuOS1m92#uln~@64AEBpSS^U54@s1AF zL+JSnoBnsTvyMvFc3>}|G_pqc9H> Date: Mon, 30 Mar 2026 09:56:18 +0200 Subject: [PATCH 2/2] chore: Bump version to 0.7.0 --- auth_token/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth_token/version.py b/auth_token/version.py index 2ddde44..9dc1567 100644 --- a/auth_token/version.py +++ b/auth_token/version.py @@ -1,4 +1,4 @@ -VERSION = (0, 6, 5) +VERSION = (0, 7, 0) def get_version():