Skip to content

Commit 7e94e44

Browse files
committed
upgrade user confirmation to v2
1 parent 9f366cd commit 7e94e44

File tree

4 files changed

+225
-4
lines changed

4 files changed

+225
-4
lines changed

api/users/serializers.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ class Meta:
442442
type_ = 'user_reset_password'
443443

444444

445-
class ExternalLoginConfirmEmailSerializer(BaseAPISerializer):
445+
class ConfirmEmailSerializer(BaseAPISerializer):
446446
uid = ser.CharField(write_only=True, required=True)
447447
destination = ser.CharField(write_only=True, required=True)
448448
token = ser.CharField(write_only=True, required=True)

api/users/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
re_path(r'^(?P<user_id>\w+)/addons/(?P<provider>\w+)/accounts/$', views.UserAddonAccountList.as_view(), name=views.UserAddonAccountList.view_name),
1515
re_path(r'^(?P<user_id>\w+)/addons/(?P<provider>\w+)/accounts/(?P<account_id>\w+)/$', views.UserAddonAccountDetail.as_view(), name=views.UserAddonAccountDetail.view_name),
1616
re_path(r'^(?P<user_id>\w+)/claim/$', views.ClaimUser.as_view(), name=views.ClaimUser.view_name),
17+
re_path(r'^(?P<user_id>\w+)/confirm/$', views.ConfirmEmailView.as_view(), name=views.ConfirmEmailView.view_name),
1718
re_path(r'^(?P<user_id>\w+)/draft_registrations/$', views.UserDraftRegistrations.as_view(), name=views.UserDraftRegistrations.view_name),
1819
re_path(r'^(?P<user_id>\w+)/institutions/$', views.UserInstitutions.as_view(), name=views.UserInstitutions.view_name),
1920
re_path(r'^(?P<user_id>\w+)/nodes/$', views.UserNodes.as_view(), name=views.UserNodes.view_name),

api/users/views.py

+88-3
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
UserChangePasswordSerializer,
6969
UserMessageSerializer,
7070
ExternalLoginSerialiser,
71-
ExternalLoginConfirmEmailSerializer,
71+
ConfirmEmailSerializer,
7272
)
7373
from django.contrib.auth.models import AnonymousUser
7474
from django.http import JsonResponse
@@ -80,6 +80,7 @@
8080
from framework.auth.oauth_scopes import CoreScopes, normalize_scopes
8181
from framework.auth.exceptions import ChangePasswordError
8282
from framework.celery_tasks.handlers import enqueue_task
83+
from framework.flask import redirect
8384
from framework.utils import throttle_period_expired
8485
from framework.sessions.utils import remove_sessions_for_user
8586
from framework.exceptions import PermissionsError, HTTPError
@@ -105,7 +106,7 @@
105106
from website import mails, settings, language
106107
from website.project.views.contributor import send_claim_email, send_claim_registered_email
107108
from website.util.metrics import CampaignClaimedTags, CampaignSourceTags
108-
from framework.auth import exceptions
109+
from framework.auth import exceptions, cas
109110

110111

111112
class UserMixin:
@@ -1060,6 +1061,90 @@ def post(self, request, *args, **kwargs):
10601061
return Response(status=status.HTTP_204_NO_CONTENT)
10611062

10621063

1064+
class ConfirmEmailView(JSONAPIBaseView, generics.CreateAPIView):
1065+
"""
1066+
Confirm an e-mail address created during *first-time* OAuth login.
1067+
1068+
**URL:** POST /v2/users/external_login/confirm_email/
1069+
1070+
**Body (JSON):**
1071+
{
1072+
"uid": "<osf_user_id>",
1073+
"token": "<email_verification_token>",
1074+
"destination": "<campaign-code or relative URL>"
1075+
}
1076+
1077+
On success the endpoint redirects (HTTP 302) to CAS with a
1078+
one-time verification key, exactly like the original Flask view.
1079+
"""
1080+
permission_classes = (
1081+
drf_permissions.IsAuthenticatedOrReadOnly,
1082+
base_permissions.TokenHasScope,
1083+
)
1084+
serializer_class = ConfirmEmailSerializer
1085+
throttle_classes = (NonCookieAuthThrottle, BurstRateThrottle, RootAnonThrottle)
1086+
view_category = 'users'
1087+
view_name = 'confirm-user'
1088+
1089+
def post(self, request, *args, **kwargs):
1090+
data = self.get_serializer(data=request.data)
1091+
data.is_valid(raise_exception=True)
1092+
1093+
uid = data.validated_data['uid']
1094+
token = data.validated_data['token']
1095+
1096+
user = OSFUser.load(uid)
1097+
if not user:
1098+
raise ValidationError('User not found.')
1099+
1100+
verification = user.email_verifications.get(token)
1101+
if not verification:
1102+
raise ValidationError('Invalid or expired token.')
1103+
1104+
provider = next(iter(verification['external_identity']))
1105+
provider_id = next(iter(verification['external_identity'][provider]))
1106+
1107+
if provider not in user.external_identity:
1108+
raise ValidationError('External-ID provider mismatch.')
1109+
1110+
external_status = user.external_identity[provider][provider_id]
1111+
ensure_external_identity_uniqueness(provider, provider_id, user)
1112+
1113+
email = verification['email']
1114+
if not user.is_registered:
1115+
user.register(email)
1116+
1117+
user.emails.get_or_create(address=email.lower())
1118+
user.external_identity[provider][provider_id] = 'VERIFIED'
1119+
user.date_last_logged_in = timezone.now()
1120+
1121+
del user.email_verifications[token]
1122+
user.verification_key = generate_verification_key()
1123+
user.save()
1124+
1125+
service_url = request.build_absolute_uri()
1126+
if external_status == 'CREATE':
1127+
service_url += '&' + urlencode({'new': 'true'})
1128+
elif external_status == 'LINK':
1129+
mails.send_mail(
1130+
user=user,
1131+
to_addr=user.username,
1132+
mail=mails.EXTERNAL_LOGIN_LINK_SUCCESS,
1133+
external_id_provider=provider,
1134+
can_change_preferences=False,
1135+
)
1136+
1137+
enqueue_task(update_affiliation_for_orcid_sso_users.s(user._id, provider_id))
1138+
1139+
return redirect(
1140+
cas.get_login_url(
1141+
service_url,
1142+
username=user.username,
1143+
verification_key=user.verification_key,
1144+
),
1145+
)
1146+
1147+
10631148
class UserEmailsList(JSONAPIBaseView, generics.ListAPIView, generics.CreateAPIView, UserMixin, ListFilterMixin):
10641149
permission_classes = (
10651150
drf_permissions.IsAuthenticatedOrReadOnly,
@@ -1212,7 +1297,7 @@ class ExternalLoginConfirmEmailView(generics.CreateAPIView):
12121297
permission_classes = (
12131298
drf_permissions.AllowAny,
12141299
)
1215-
serializer_class = ExternalLoginConfirmEmailSerializer
1300+
serializer_class = ConfirmEmailSerializer
12161301
view_category = 'users'
12171302
view_name = 'external-login-confirm-email'
12181303
throttle_classes = (NonCookieAuthThrottle, BurstRateThrottle, RootAnonThrottle)
+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import pytest
2+
from unittest import mock
3+
4+
from api.base.settings.defaults import API_BASE
5+
from osf_tests.factories import UserFactory
6+
7+
8+
@pytest.mark.django_db
9+
class TestConfirmEmail:
10+
11+
@pytest.fixture()
12+
def user_with_email_verification(self):
13+
user = UserFactory()
14+
15+
external_identity = {
16+
'ORCID': {
17+
'0000-0000-0000-0000': 'CREATE',
18+
}
19+
}
20+
token = user.add_unconfirmed_email(email, external_identity=external_identity)
21+
user.external_identity.update(external_identity)
22+
user.save()
23+
return user, token, email
24+
25+
@pytest.fixture()
26+
def confirm_url(self, user_with_email_verification):
27+
user, _, _ = user_with_email_verification
28+
return f'/{API_BASE}users/{user._id}/confirm/'
29+
30+
def test_get_not_allowed(self, app, confirm_url):
31+
res = app.get(confirm_url, expect_errors=True)
32+
assert res.status_code == 405
33+
34+
def test_post_missing_fields(self, app, confirm_url):
35+
res = app.post_json_api(confirm_url, {'data': {'attributes': {}}}, expect_errors=True)
36+
assert res.status_code == 400
37+
assert any('uid' in err['detail'].lower() for err in res.json['errors'])
38+
39+
def test_post_user_not_found(self, app):
40+
fake_user_id = 'abcd1'
41+
url = f'/{API_BASE}users/{fake_user_id}/confirm/'
42+
payload = {
43+
'data': {
44+
'attributes': {
45+
'uid': fake_user_id,
46+
'token': 'doesnotmatter',
47+
}
48+
}
49+
}
50+
res = app.post_json_api(url, payload, expect_errors=True)
51+
assert res.status_code == 400
52+
assert 'user not found' in res.json['errors'][0]['detail'].lower()
53+
54+
def test_post_invalid_token(self, app, confirm_url, user_with_email_verification):
55+
user, _, _ = user_with_email_verification
56+
payload = {
57+
'data': {
58+
'attributes': {
59+
'uid': user._id,
60+
'token': 'badtoken'
61+
}
62+
}
63+
}
64+
res = app.post_json_api(confirm_url, payload, expect_errors=True)
65+
assert res.status_code == 400
66+
assert 'expired' in res.json['errors'][0]['detail'].lower()
67+
68+
def test_post_provider_mismatch(self, app, confirm_url, user_with_email_verification):
69+
user, token, _ = user_with_email_verification
70+
user.external_identity = {} # clear the valid provider
71+
user.save()
72+
73+
payload = {
74+
'data': {
75+
'attributes': {
76+
'uid': user._id,
77+
'token': token
78+
}
79+
}
80+
}
81+
res = app.post_json_api(confirm_url, payload, expect_errors=True)
82+
assert res.status_code == 400
83+
assert 'provider mismatch' in res.json['errors'][0]['detail'].lower()
84+
85+
@mock.patch('website.mails.send_mail')
86+
def test_post_success_create(self, mock_send_mail, app, confirm_url, user_with_email_verification):
87+
user, token, email = user_with_email_verification
88+
assert not user.is_registered
89+
90+
res = app.post_json_api(
91+
confirm_url,
92+
{
93+
'data': {
94+
'attributes': {
95+
'uid': user._id,
96+
'token': token
97+
}
98+
}
99+
},
100+
expect_errors=True
101+
)
102+
assert res.status_code == 302
103+
assert '&new=true' in res.headers['Location']
104+
assert not mock_send_mail.called
105+
106+
user.reload()
107+
assert user.is_registered
108+
assert token not in user.email_verifications
109+
assert user.external_identity['ORCID']['0000-0000-0000-0000'] == 'VERIFIED'
110+
assert user.emails.filter(address=email.lower()).exists()
111+
112+
@mock.patch('framework.auth.tasks.update_affiliation_for_orcid_sso_users.delay')
113+
@mock.patch('website.mails.send_mail')
114+
def test_post_success_link(self, mock_send_mail, mock_affil, app, confirm_url, user_with_email_verification):
115+
user, token, email = user_with_email_verification
116+
user.external_identity['ORCID']['0000-0000-0000-0000'] = 'LINK'
117+
user.save()
118+
119+
payload = {
120+
'data': {
121+
'attributes': {
122+
'uid': user._id,
123+
'token': token
124+
}
125+
}
126+
}
127+
128+
res = app.post_json_api(confirm_url, payload, expect_errors=True)
129+
assert res.status_code == 302
130+
assert '&new=true' not in res.headers['Location']
131+
assert mock_send_mail.called
132+
assert mock_affil.called
133+
134+
user.reload()
135+
assert user.external_identity['ORCID']['0000-0000-0000-0000'] == 'VERIFIED'

0 commit comments

Comments
 (0)