Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions api/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
65 changes: 65 additions & 0 deletions api/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
ExternalLoginSerialiser,
ConfirmEmailTokenSerializer,
SanctionTokenSerializer,
UserResendConfirmationSerializer,
)
from django.contrib.auth.models import AnonymousUser
from django.http import JsonResponse
Expand Down Expand Up @@ -935,6 +936,70 @@ 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.

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.
"""
permission_classes = (
drf_permissions.AllowAny,
)
serializer_class = UserResendConfirmationSerializer
view_category = 'users'
view_name = 'request-resend-confirmation'
throttle_classes = (NonCookieAuthThrottle, BurstRateThrottle, RootAnonThrottle, SendEmailThrottle)

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.')

status_message = language.RESEND_CONFIRMATION_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_RESEND_CONFIRMATION_ERROR_MESSAGE,
'kind': 'error',
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
else:
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,
data={
'message': status_message,
'kind': 'success',
},
)

class UserSettings(JSONAPIBaseView, generics.RetrieveUpdateAPIView, UserMixin):
permission_classes = (
Expand Down
116 changes: 116 additions & 0 deletions api_tests/users/views/test_user_resend_confirmation.py
Original file line number Diff line number Diff line change
@@ -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.'
63 changes: 0 additions & 63 deletions framework/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion notifications.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
74 changes: 0 additions & 74 deletions tests/test_resend_confirmation.py

This file was deleted.

11 changes: 11 additions & 0 deletions website/language.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,17 @@
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.'

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.',
Expand Down
Loading
Loading