From 2089d9fbeef9ede1f8a1a2c06dd31263aa1496d0 Mon Sep 17 00:00:00 2001 From: Bohdan Odintsov Date: Fri, 11 Sep 2026 00:59:53 +0300 Subject: [PATCH 1/4] Add API for resend confirmation URL --- api/users/urls.py | 1 + api/users/views.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/api/users/urls.py b/api/users/urls.py index 97ac4bb05b9..edc6c99058c 100644 --- a/api/users/urls.py +++ b/api/users/urls.py @@ -5,6 +5,7 @@ urlpatterns = [ re_path(r'^reset_password/$', views.ResetPassword.as_view(), name=views.ResetPassword.view_name), + re_path(r'^resend_confirmation/$', views.ResendConfirmation.as_view(), name=views.ResendConfirmation.view_name), re_path(r'^external_login_comfirm_email/$', views.ExternalLoginConfirmEmailView.as_view(), name=views.ExternalLoginConfirmEmailView.view_name), re_path(r'^external_login/$', views.ExternalLogin.as_view(), name=views.ExternalLogin.view_name), re_path(r'^$', views.UserList.as_view(), name=views.UserList.view_name), diff --git a/api/users/views.py b/api/users/views.py index 9dc9f9475b3..e3822b63ccf 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -935,6 +935,65 @@ def post(self, request, *args, **kwargs): content_type='application/vnd.api+json; application/json', ) +class ResendConfirmation(JSONAPIBaseView, generics.ListCreateAPIView): + """ + View for handling resend confirmation URL requests. + + GET: + - Takes an email as a query parameter. + - If the email is not provided or invalid, returns a validation error. + - If the user has recently requested a resend URL, returns a throttling error. + """ + permission_classes = ( + drf_permissions.AllowAny, + ) + view_category = 'users' + view_name = 'request-resend-confirmation' + throttle_classes = (NonCookieAuthThrottle, BurstRateThrottle, RootAnonThrottle, SendEmailThrottle) + + def get(self, request, *args, **kwargs): + email = request.query_params.get('email', None) + if not email: + raise ValidationError('Request must include email in query params.') + + status_message = language.RESET_PASSWORD_SUCCESS_STATUS_MESSAGE.format(email=email) + # check if the user exists + user_obj = get_user(email=email) + + if user_obj: + # rate limit resend_confirmation_post + if not throttle_period_expired(user_obj.email_last_sent, settings.SEND_EMAIL_THROTTLE): + return Response( + { + 'message': language.THROTTLE_PASSWORD_CHANGE_ERROR_MESSAGE, + 'kind': 'error', + }, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + else: + notification_type = NotificationTypeEnum.USER_INITIAL_CONFIRM_EMAIL + confirmation_url = user_obj.get_confirmation_url( + email, + external=True, + force=True, + renew=False, + ) + notification_type.instance.emit( + destination_address=email, + event_context={ + 'user_fullname': user_obj.fullname, + 'confirmation_url': f'{confirmation_url}', + }, + save=False + ) + + return Response( + status=status.HTTP_200_OK, + data={ + 'message': status_message, + 'kind': 'success', + }, + ) class UserSettings(JSONAPIBaseView, generics.RetrieveUpdateAPIView, UserMixin): permission_classes = ( From e8be76fa6a9c17b3188a64870bb5701d0237be7d Mon Sep 17 00:00:00 2001 From: Bohdan Odintsov Date: Fri, 11 Sep 2026 12:19:13 +0300 Subject: [PATCH 2/4] add language --- api/users/views.py | 4 ++-- website/language.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/api/users/views.py b/api/users/views.py index e3822b63ccf..a7a4410ebca 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -956,7 +956,7 @@ def get(self, request, *args, **kwargs): if not email: raise ValidationError('Request must include email in query params.') - status_message = language.RESET_PASSWORD_SUCCESS_STATUS_MESSAGE.format(email=email) + status_message = language.RESEND_CONFIRMATION_SUCCESS_STATUS_MESSAGE.format(email=email) # check if the user exists user_obj = get_user(email=email) @@ -965,7 +965,7 @@ def get(self, request, *args, **kwargs): if not throttle_period_expired(user_obj.email_last_sent, settings.SEND_EMAIL_THROTTLE): return Response( { - 'message': language.THROTTLE_PASSWORD_CHANGE_ERROR_MESSAGE, + 'message': language.THROTTLE_RESEND_CONFIRMATION_ERROR_MESSAGE, 'kind': 'error', }, status=status.HTTP_429_TOO_MANY_REQUESTS, diff --git a/website/language.py b/website/language.py index 024783d13ec..1bf2054fc8b 100644 --- a/website/language.py +++ b/website/language.py @@ -222,6 +222,14 @@ THROTTLE_PASSWORD_CHANGE_ERROR_MESSAGE = \ 'You have recently requested to change your password. Please wait a few minutes before trying again.' +RESEND_CONFIRMATION_SUCCESS_STATUS_MESSAGE = ( + 'If there is an OSF account associated with {email}, an confirmation link has been sent to {email}.' + 'If you do not receive an email and believe you should have, please contact OSF Support. ' +) + +THROTTLE_RESEND_CONFIRMATION_ERROR_MESSAGE = \ + 'You have recently requested to resend your confirmation link. Please wait a few minutes before trying again.' + SANCTION_STATUS_MESSAGES = { 'registration': { 'approve': 'Your registration approval has been accepted.', From 6805098218dddbc83900f5fee98cffb73c3186f7 Mon Sep 17 00:00:00 2001 From: Bohdan Odintsov Date: Fri, 11 Sep 2026 12:38:40 +0300 Subject: [PATCH 3/4] flake8 --- api/users/views.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/api/users/views.py b/api/users/views.py index a7a4410ebca..a5104293c3a 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -984,16 +984,16 @@ def get(self, request, *args, **kwargs): 'user_fullname': user_obj.fullname, 'confirmation_url': f'{confirmation_url}', }, - save=False + save=False, ) return Response( - status=status.HTTP_200_OK, - data={ - 'message': status_message, - 'kind': 'success', - }, - ) + status=status.HTTP_200_OK, + data={ + 'message': status_message, + 'kind': 'success', + }, + ) class UserSettings(JSONAPIBaseView, generics.RetrieveUpdateAPIView, UserMixin): permission_classes = ( From 66483b613a563ae7777082fc027536cb60836490 Mon Sep 17 00:00:00 2001 From: Bohdan Odintsov Date: Mon, 21 Sep 2026 15:44:09 +0300 Subject: [PATCH 4/4] added tests, converted to use send_confirm_email_async --- api/users/serializers.py | 5 + api/users/views.py | 40 +++--- .../views/test_user_resend_confirmation.py | 116 ++++++++++++++++++ framework/auth/views.py | 63 ---------- notifications.yaml | 2 +- tests/test_resend_confirmation.py | 74 ----------- website/language.py | 3 + website/routes.py | 17 --- 8 files changed, 148 insertions(+), 172 deletions(-) create mode 100644 api_tests/users/views/test_user_resend_confirmation.py delete mode 100644 tests/test_resend_confirmation.py diff --git a/api/users/serializers.py b/api/users/serializers.py index 05f7c27bc77..e55ed86691b 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -460,6 +460,11 @@ class UserResetPasswordSerializer(BaseAPISerializer): class Meta: type_ = 'user_reset_password' +class UserResendConfirmationSerializer(BaseAPISerializer): + email = ser.CharField(write_only=True, required=True) + + class Meta: + type_ = 'user_resend_confirmation' class ConfirmEmailTokenSerializer(BaseAPISerializer): uid = ser.CharField(write_only=True, required=True) diff --git a/api/users/views.py b/api/users/views.py index a5104293c3a..806bd59de13 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -69,6 +69,7 @@ ExternalLoginSerialiser, ConfirmEmailTokenSerializer, SanctionTokenSerializer, + UserResendConfirmationSerializer, ) from django.contrib.auth.models import AnonymousUser from django.http import JsonResponse @@ -939,7 +940,7 @@ class ResendConfirmation(JSONAPIBaseView, generics.ListCreateAPIView): """ View for handling resend confirmation URL requests. - GET: + POST: - Takes an email as a query parameter. - If the email is not provided or invalid, returns a validation error. - If the user has recently requested a resend URL, returns a throttling error. @@ -947,12 +948,15 @@ class ResendConfirmation(JSONAPIBaseView, generics.ListCreateAPIView): permission_classes = ( drf_permissions.AllowAny, ) + serializer_class = UserResendConfirmationSerializer view_category = 'users' view_name = 'request-resend-confirmation' throttle_classes = (NonCookieAuthThrottle, BurstRateThrottle, RootAnonThrottle, SendEmailThrottle) - def get(self, request, *args, **kwargs): - email = request.query_params.get('email', None) + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + email = request.data.get('email', None) if not email: raise ValidationError('Request must include email in query params.') @@ -971,21 +975,23 @@ def get(self, request, *args, **kwargs): status=status.HTTP_429_TOO_MANY_REQUESTS, ) else: - notification_type = NotificationTypeEnum.USER_INITIAL_CONFIRM_EMAIL - confirmation_url = user_obj.get_confirmation_url( - email, - external=True, - force=True, - renew=False, - ) - notification_type.instance.emit( - destination_address=email, - event_context={ - 'user_fullname': user_obj.fullname, - 'confirmation_url': f'{confirmation_url}', - }, - save=False, + if not user_obj.email_verifications: + # already confirmed + status_message = language.RESEND_CONFIRMATION_ALREADY_CONFIRMED_ERROR_MESSAGE.format(email=email) + return Response( + { + 'message': status_message, + 'kind': 'error', + }, + status=status.HTTP_400_BAD_REQUEST, + ) + send_confirm_email_async( + user=user_obj, + email=user_obj.username, + renew=True ) + user_obj.email_last_sent = timezone.now() + user_obj.save() return Response( status=status.HTTP_200_OK, diff --git a/api_tests/users/views/test_user_resend_confirmation.py b/api_tests/users/views/test_user_resend_confirmation.py new file mode 100644 index 00000000000..0c2580383b7 --- /dev/null +++ b/api_tests/users/views/test_user_resend_confirmation.py @@ -0,0 +1,116 @@ +import pytest + +from api.base.settings import BYPASS_THROTTLE_TOKEN +from api.base.settings.defaults import API_BASE +from osf.models import NotificationTypeEnum +from osf_tests.factories import ( + UserFactory, + UnconfirmedUserFactory, +) +from tests.utils import capture_notifications +from website import language + + +def resend_payload(email): + return { + 'data': { + 'type': 'user_resend_confirmation', + 'attributes': { + 'email': email, + } + } + } + + +class TestResendConfirmation: + + @pytest.fixture() + def unconfirmed_user(self): + return UnconfirmedUserFactory() + + @pytest.fixture() + def confirmed_user(self): + return UserFactory() + + @pytest.fixture() + def url(self): + return f'/{API_BASE}users/resend_confirmation/' + + @pytest.fixture() + def headers(self): + # skip DRF throttle + return {'X-THROTTLE-TOKEN': BYPASS_THROTTLE_TOKEN} + + def test_post(self, app, url, headers, unconfirmed_user): + email = unconfirmed_user.username + old_tokens = set(unconfirmed_user.email_verifications) + assert unconfirmed_user.email_last_sent is None + + with capture_notifications() as notifications: + res = app.post_json_api(url, resend_payload(email), headers=headers) + assert res.status_code == 200 + assert res.json['kind'] == 'success' + assert res.json['message'] == language.RESEND_CONFIRMATION_SUCCESS_STATUS_MESSAGE.format(email=email) + + assert len(notifications['emits']) == 1 + emit = notifications['emits'][0] + assert emit['type'] == NotificationTypeEnum.USER_INITIAL_CONFIRM_EMAIL + assert emit['kwargs']['destination_address'] == email + + # the link in the email carries a freshly generated token, and it was saved + unconfirmed_user.reload() + new_tokens = set(unconfirmed_user.email_verifications) - old_tokens + assert len(new_tokens) == 1 + confirmation_url = emit['kwargs']['event_context']['confirmation_url'] + assert f'confirm/{unconfirmed_user._id}/{new_tokens.pop()}/' in confirmation_url + assert unconfirmed_user.email_last_sent is not None + + def test_post_email_case_insensitive(self, app, url, headers, unconfirmed_user): + with capture_notifications() as notifications: + res = app.post_json_api(url, resend_payload(unconfirmed_user.username.upper()), headers=headers) + assert res.status_code == 200 + assert res.json['kind'] == 'success' + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationTypeEnum.USER_INITIAL_CONFIRM_EMAIL + + def test_post_already_confirmed(self, app, url, headers, confirmed_user): + email = confirmed_user.username + + with capture_notifications(expect_none=True): + res = app.post_json_api(url, resend_payload(email), expect_errors=True, headers=headers) + assert res.status_code == 400 + assert res.json['kind'] == 'error' + assert res.json['message'] == language.RESEND_CONFIRMATION_ALREADY_CONFIRMED_ERROR_MESSAGE.format(email=email) + confirmed_user.reload() + assert confirmed_user.email_last_sent is None + + def test_post_unknown_email(self, app, url, headers): + # same response as for an existing account + email = 'random@random.com' + + with capture_notifications(expect_none=True): + res = app.post_json_api(url, resend_payload(email), headers=headers) + assert res.status_code == 200 + assert res.json['kind'] == 'success' + assert res.json['message'] == language.RESEND_CONFIRMATION_SUCCESS_STATUS_MESSAGE.format(email=email) + + def test_post_missing_email(self, app, url, headers): + payload = { + 'data': { + 'type': 'user_resend_confirmation', + 'attributes': { + } + } + } + with capture_notifications(expect_none=True): + res = app.post_json_api(url, payload, expect_errors=True, headers=headers) + assert res.status_code == 400 + assert res.json['errors'][0]['source']['pointer'] == '/data/attributes/email' + assert res.json['errors'][0]['detail'] == 'This field is required.' + + def test_post_blank_email(self, app, url, headers): + with capture_notifications(expect_none=True): + res = app.post_json_api(url, resend_payload(''), expect_errors=True, headers=headers) + assert res.status_code == 400 + assert res.json['errors'][0]['source']['pointer'] == '/data/attributes/email' + assert res.json['errors'][0]['detail'] == 'This field may not be blank.' diff --git a/framework/auth/views.py b/framework/auth/views.py index 870eec7c204..bc0ec8de347 100644 --- a/framework/auth/views.py +++ b/framework/auth/views.py @@ -950,69 +950,6 @@ def register_user(**kwargs): return {'message': 'You may now log in.'} -@collect_auth -def resend_confirmation_get(auth): - """ - View for user to land on resend confirmation page. - HTTP Method: GET - """ - - # If user is already logged in, log user out - if auth.logged_in: - return auth_logout(redirect_url=request.url) - - form = ResendConfirmationForm(request.form) - return { - 'form': form, - } - - -@collect_auth -def resend_confirmation_post(auth): - """ - View for user to submit resend confirmation form. - HTTP Method: POST - """ - try: - # If user is already logged in, log user out - if auth.logged_in: - return auth_logout(redirect_url=request.url) - - form = ResendConfirmationForm(request.form) - - if form.validate(): - clean_email = form.email.data - user = get_user(email=clean_email) - status_message = ( - f'If there is an OSF account associated with this unconfirmed email address {clean_email}, ' - 'a confirmation email has been resent to it. If you do not receive an email and believe ' - 'you should have, please contact OSF Support.' - ) - kind = 'success' - if user: - if throttle_period_expired(user.email_last_sent, settings.SEND_EMAIL_THROTTLE): - try: - send_confirm_email(user, clean_email, renew=True) - except KeyError: - # already confirmed, redirect to my-projects - status_message = f'This email {clean_email} has already been confirmed.' - kind = 'warning' - user.email_last_sent = timezone.now() - user.save() - else: - status_message = ('You have recently requested to resend your confirmation email. ' - 'Please wait a few minutes before trying again.') - kind = 'error' - status.push_status_message(status_message, kind=kind, trust=False) - else: - forms.push_errors_to_status(form.errors) - except Exception as err: - sentry.log_exception(f'Async email confirmation failed because of the error: {err}') - - # Don't go anywhere - return {'form': form} - - def external_login_email_get(): """ Landing view for first-time oauth-login user to enter their email address. diff --git a/notifications.yaml b/notifications.yaml index aad381cdcb9..c14324df931 100644 --- a/notifications.yaml +++ b/notifications.yaml @@ -128,7 +128,7 @@ notification_types: __docs__: 'Sign up confirmation emails for OSF, native campaigns and branded campaigns' object_content_type_model_name: osfuser template: 'website/templates/initial_confirm.html.mako' - tests: ['tests/test_resend_confirmation.py', 'tests/test_auth.py'] + tests: ['api_tests/users/views/test_user_resend_confirmation.py', 'tests/test_auth.py'] - name: user_request_deactivation subject: '[via OSF] Deactivation Request' diff --git a/tests/test_resend_confirmation.py b/tests/test_resend_confirmation.py deleted file mode 100644 index 9b1fcdf74ce..00000000000 --- a/tests/test_resend_confirmation.py +++ /dev/null @@ -1,74 +0,0 @@ -from osf.models import NotificationTypeEnum -from tests.base import OsfTestCase -from osf_tests.factories import ( - UserFactory, - UnconfirmedUserFactory, -) -from tests.utils import capture_notifications -from website.util import web_url_for -from tests.test_webtests import assert_in_html - -class TestResendConfirmation(OsfTestCase): - - def setUp(self): - super().setUp() - self.unconfirmed_user = UnconfirmedUserFactory() - self.confirmed_user = UserFactory() - self.get_url = web_url_for('resend_confirmation_get') - self.post_url = web_url_for('resend_confirmation_post') - - # test that resend confirmation page is load correctly - def test_resend_confirmation_get(self): - res = self.app.get(self.get_url) - assert res.status_code == 200 - assert 'Resend Confirmation' in res.text - assert res.get_form('resendForm') - - # test that unconfirmed user can receive resend confirmation email - def test_can_receive_resend_confirmation_email(self): - # load resend confirmation page and submit email - res = self.app.get(self.get_url) - form = res.get_form('resendForm') - form['email'] = self.unconfirmed_user.unconfirmed_emails[0] - with capture_notifications() as notifications: - res = form.submit(self.app) - # check email, request and response - assert len(notifications['emits']) == 1 - assert notifications['emits'][0]['type'] == NotificationTypeEnum.USER_INITIAL_CONFIRM_EMAIL - assert res.status_code == 200 - assert res.request.path == self.post_url - - - # test that confirmed user cannot receive resend confirmation email - def test_cannot_receive_resend_confirmation_email_1(self): - # load resend confirmation page and submit email - res = self.app.get(self.get_url) - form = res.get_form('resendForm') - form['email'] = self.confirmed_user.emails.first().address - res = form.submit(self.app) - assert res.status_code == 200 - assert res.request.path == self.post_url - - # test that non-existing user cannot receive resend confirmation email - def test_cannot_receive_resend_confirmation_email_2(self): - # load resend confirmation page and submit email - res = self.app.get(self.get_url) - form = res.get_form('resendForm') - form['email'] = 'random@random.com' - res = form.submit(self.app) - # check email, request and response - assert res.status_code == 200 - assert res.request.path == self.post_url - - # test that user cannot submit resend confirmation request too quickly - def test_cannot_resend_confirmation_twice_quickly(self): - # load resend confirmation page and submit email - res = self.app.get(self.get_url) - form = res.get_form('resendForm') - form['email'] = self.unconfirmed_user.email - with capture_notifications(): - form.submit(self.app) - res = form.submit(self.app) - - # check request and response - assert res.status_code == 200 diff --git a/website/language.py b/website/language.py index 1bf2054fc8b..9cf360c5989 100644 --- a/website/language.py +++ b/website/language.py @@ -230,6 +230,9 @@ THROTTLE_RESEND_CONFIRMATION_ERROR_MESSAGE = \ 'You have recently requested to resend your confirmation link. Please wait a few minutes before trying again.' +RESEND_CONFIRMATION_ALREADY_CONFIRMED_ERROR_MESSAGE = \ + 'The email address {email} has already been confirmed. Please log in to your account.' + SANCTION_STATUS_MESSAGES = { 'registration': { 'approve': 'Your registration approval has been accepted.', diff --git a/website/routes.py b/website/routes.py index 3a66ac18149..81df7855f7b 100644 --- a/website/routes.py +++ b/website/routes.py @@ -563,23 +563,6 @@ def make_url_map(app): OsfWebRenderer('public/resetpassword.mako', render_mako_string, trust=False) ), - # resend confirmation get - Rule( - '/resend/', - 'get', - auth_views.resend_confirmation_get, - OsfWebRenderer('resend.mako', render_mako_string, trust=False) - ), - - # resend confirmation post - Rule( - '/resend/', - 'post', - auth_views.resend_confirmation_post, - OsfWebRenderer('resend.mako', render_mako_string, trust=False) - - ), - # oauth user email get Rule( '/external-login/email',