Skip to content

[ENG-7965] Add v2 email token confirmation endpoints #11139

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
29 changes: 27 additions & 2 deletions api/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,13 +442,38 @@ class Meta:
type_ = 'user_reset_password'


class ExternalLoginConfirmEmailSerializer(BaseAPISerializer):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactor because it's now shared

class ConfirmEmailTokenSerializer(BaseAPISerializer):
uid = ser.CharField(write_only=True, required=True)
destination = ser.CharField(write_only=True, required=True)
token = ser.CharField(write_only=True, required=True)
redirect_url = ser.CharField(read_only=True, required=False)

class Meta:
type_ = 'external_login_confirm_email'
type_ = 'email_token_serializer'


class SanctionTokenSerializer(ConfirmEmailTokenSerializer):
action = ser.ChoiceField(
write_only=True,
required=True,
choices=[
'approve',
'reject',
],
)
sanction_type = ser.ChoiceField(
write_only=True,
required=False,
choices=[
'registration',
'embargo',
'embargo_termination_approval',
'retraction',
],
)

class Meta:
type_ = 'sanction_token_serializer'


class ExternalLoginSerialiser(BaseAPISerializer):
Expand Down
2 changes: 2 additions & 0 deletions api/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
re_path(r'^(?P<user_id>\w+)/addons/(?P<provider>\w+)/accounts/$', views.UserAddonAccountList.as_view(), name=views.UserAddonAccountList.view_name),
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),
re_path(r'^(?P<user_id>\w+)/claim/$', views.ClaimUser.as_view(), name=views.ClaimUser.view_name),
re_path(r'^(?P<user_id>\w+)/confirm/$', views.ConfirmEmailView.as_view(), name=views.ConfirmEmailView.view_name),
re_path(r'^(?P<user_id>\w+)/sanction_response/$', views.SanctionResponseView.as_view(), name=views.SanctionResponseView.view_name),
re_path(r'^(?P<user_id>\w+)/draft_registrations/$', views.UserDraftRegistrations.as_view(), name=views.UserDraftRegistrations.view_name),
re_path(r'^(?P<user_id>\w+)/institutions/$', views.UserInstitutions.as_view(), name=views.UserInstitutions.view_name),
re_path(r'^(?P<user_id>\w+)/nodes/$', views.UserNodes.as_view(), name=views.UserNodes.view_name),
Expand Down
138 changes: 136 additions & 2 deletions api/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@
UserChangePasswordSerializer,
UserMessageSerializer,
ExternalLoginSerialiser,
ExternalLoginConfirmEmailSerializer,
ConfirmEmailTokenSerializer,
SanctionTokenSerializer,
)
from django.contrib.auth.models import AnonymousUser
from django.http import JsonResponse
Expand Down Expand Up @@ -102,6 +103,8 @@
Email,
Tag,
)
from osf.utils.tokens import TokenHandler
from osf.utils.tokens.handlers import sanction_handler
from website import mails, settings, language
from website.project.views.contributor import send_claim_email, send_claim_registered_email
from website.util.metrics import CampaignClaimedTags, CampaignSourceTags
Expand Down Expand Up @@ -1060,6 +1063,137 @@ def post(self, request, *args, **kwargs):
return Response(status=status.HTTP_204_NO_CONTENT)


class ConfirmEmailView(generics.CreateAPIView):
"""
Confirm an e-mail address created during *first-time* OAuth login.

**URL:** POST /v2/users/<user_id>/confirm/

**Body (JSON):**
{
"uid": "<osf_user_id>",
"token": "<email_verification_token>",
"destination": "<campaign-code or relative URL>"
}

On success returns a response with a 201 status code with a JSONAPI payload that includes the `redirect_url`
attritbute.
"""
permission_classes = (
base_permissions.TokenHasScope,
)
required_read_scopes = [CoreScopes.USERS_CONFIRM]
required_write_scopes = [CoreScopes.USERS_CONFIRM]

view_category = 'users'
view_name = 'confirm-user'

serializer_class = ConfirmEmailTokenSerializer

def post(self, request, *args, **kwargs):
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)

uid = serializer.validated_data['uid']
token = serializer.validated_data['token']

user = OSFUser.load(uid)
if not user:
raise ValidationError('User not found.')

verification = user.email_verifications.get(token)
if not verification:
raise ValidationError('Invalid or expired token.')

provider = next(iter(verification['external_identity']))
provider_id = next(iter(verification['external_identity'][provider]))

if provider not in user.external_identity:
raise ValidationError('External-ID provider mismatch.')

external_status = user.external_identity[provider][provider_id]
ensure_external_identity_uniqueness(provider, provider_id, user)

email = verification['email']
if not user.is_registered:
user.register(email)

user.emails.get_or_create(address=email.lower())
user.external_identity[provider][provider_id] = 'VERIFIED'
user.date_last_logged_in = timezone.now()

del user.email_verifications[token]
user.verification_key = generate_verification_key()
user.save()

service_url = self.request.build_absolute_uri()
if external_status == 'CREATE':
service_url += '&' + urlencode({'new': 'true'})
elif external_status == 'LINK':
mails.send_mail(
user=user,
to_addr=user.username,
mail=mails.EXTERNAL_LOGIN_LINK_SUCCESS,
external_id_provider=provider,
can_change_preferences=False,
)

enqueue_task(update_affiliation_for_orcid_sso_users.s(user._id, provider_id))
serializer.validated_data['redirect_url'] = service_url
return Response(
data=serializer.data,
status=status.HTTP_201_CREATED,
)


class SanctionResponseView(generics.CreateAPIView, UserMixin):
"""
**URL:** POST /v2/users/<user_id>/sanction_response/

**Body (JSON):**
{
"uid": "<osf_user_id>",
"token": "<email_verification_token>",
"destination": "<campaign-code or relative URL>"
}

On success the endpoint returns (HTTP 200)
"""
permission_classes = (
base_permissions.TokenHasScope,
)
required_read_scopes = [CoreScopes.NULL]
required_write_scopes = [CoreScopes.SANCTION_RESPONSE]

view_category = 'users'
view_name = 'sanction-response'

serializer_class = SanctionTokenSerializer

def perform_create(self, serializer):
uid = serializer.validated_data['uid']
token = serializer.validated_data['token']
action = serializer.validated_data['action']
if not action:
raise ValidationError('`approve` or `reject` action not found.')
sanction_type = serializer.validated_data.get('sanction_type')
if not sanction_type:
raise ValidationError('sanction_type not found.')

if self.get_user() != OSFUser.load(uid):
raise ValidationError('User not found.')

token_handler = TokenHandler.from_string(token)

sanction_handler(
sanction_type,
action,
payload=token_handler.payload,
encoded_token=token_handler.encoded_token,
user=self.get_user(),
)


class UserEmailsList(JSONAPIBaseView, generics.ListAPIView, generics.CreateAPIView, UserMixin, ListFilterMixin):
permission_classes = (
drf_permissions.IsAuthenticatedOrReadOnly,
Expand Down Expand Up @@ -1212,7 +1346,7 @@ class ExternalLoginConfirmEmailView(generics.CreateAPIView):
permission_classes = (
drf_permissions.AllowAny,
)
serializer_class = ExternalLoginConfirmEmailSerializer
serializer_class = ConfirmEmailTokenSerializer
view_category = 'users'
view_name = 'external-login-confirm-email'
throttle_classes = (NonCookieAuthThrottle, BurstRateThrottle, RootAnonThrottle)
Expand Down
11 changes: 10 additions & 1 deletion api_tests/base/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@
CountedAuthUsageView,
MetricsOpenapiView,
)
from api.users.views import ClaimUser, ResetPassword, ExternalLoginConfirmEmailView, ExternalLogin
from api.users.views import (
ClaimUser,
ResetPassword,
ExternalLoginConfirmEmailView,
ExternalLogin,
ConfirmEmailView,
SanctionResponseView
)
from api.registrations.views import RegistrationCallbackView
from api.wb.views import MoveFileMetadataView, CopyFileMetadataView
from rest_framework.permissions import IsAuthenticatedOrReadOnly, IsAuthenticated
Expand Down Expand Up @@ -63,8 +70,10 @@ def setUp(self):
MetricsOpenapiView,
ResetPassword,
ExternalLoginConfirmEmailView,
ConfirmEmailView,
ExternalLogin,
RegistrationCallbackView,
SanctionResponseView
]

def test_root_returns_200(self):
Expand Down
Empty file.
150 changes: 150 additions & 0 deletions api_tests/users/views/sanction_response/test_user_sanction_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import pytest

from api.base.settings.defaults import API_BASE
from framework.auth import Auth
from osf_tests.factories import AuthUserFactory, RetractionFactory
from osf.utils.tokens import encode


@pytest.mark.django_db
class TestSanctionResponse:

@pytest.fixture()
def user(self):
return AuthUserFactory()

@pytest.fixture
def sanction(self):
sanction = RetractionFactory()
registration = sanction.registrations.first()
sanction.add_authorizer(sanction.initiated_by, registration, save=True)
return sanction

@pytest.fixture
def registration(self, sanction):
return sanction.registrations.first()

@pytest.fixture
def url(self, sanction):
return f'/{API_BASE}users/{sanction.initiated_by._id}/sanction_response/'

@pytest.fixture
def approval_token(self, sanction):
return sanction.approval_state[sanction.initiated_by._id]['approval_token']

@pytest.fixture()
def sanction_url(self, user):
return f'/{API_BASE}users/{user._id}/sanction_response/'

@pytest.fixture()
def token(self, user):
return encode({'uid': user._id, 'email': user.username})

def test_get_not_allowed(self, app, sanction_url):
res = app.get(sanction_url, expect_errors=True)
assert res.status_code == 405

def test_post_missing_fields(self, app, sanction_url, user):
res = app.post_json_api(
sanction_url,
{'data': {'attributes': {}}},
auth=user.auth,
expect_errors=True
)
print(res.json)
assert res.json['errors'] == [
{
'source': {
'pointer': '/data/attributes/uid'
},
'detail': 'This field is required.'
},
{
'source': {
'pointer': '/data/attributes/destination'
},
'detail': 'This field is required.'
},
{
'source': {
'pointer': '/data/attributes/token'
},
'detail': 'This field is required.'
},
{
'source': {
'pointer': '/data/attributes/action'
},
'detail': 'This field is required.'
}
]
assert res.status_code == 400

def test_post_user_not_found(self, app, token):
fake_uid = 'abc12'
url = f'/{API_BASE}users/{fake_uid}/sanction_response/'
res = app.post_json_api(
url,
{
'data': {
'attributes': {
'uid': fake_uid,
'token': token,
'action': 'approve',
'sanction_type': 'retraction',
'destination': 'foo'
}
}
},
expect_errors=True
)
assert res.json['errors'] == [{'detail': 'Not found.'}]

def test_missing_action(self, app, url, sanction, registration, approval_token):
user = sanction.initiated_by
res = app.post_json_api(
url,
{
'data': {
'attributes': {
'uid': user._id,
'token': approval_token,
'destination': 'notebook',
'sanction_type': 'retraction',
}
}
},
auth=Auth(user),
expect_errors=True
)
assert res.status_code == 400
assert res.json['errors'] == [
{
'source': {
'pointer': '/data/attributes/action'
},
'detail': 'This field is required.'
}
]

sanction.refresh_from_db()
registration.refresh_from_db()

def test_post_missing_sanction_type(self, app, sanction_url, user, token):
res = app.post_json_api(
sanction_url,
{
'data': {
'attributes': {
'uid': user._id,
'token': token,
'action': 'reject',
'destination': 'foo'
}
}
},
auth=user.auth,
expect_errors=True
)
assert res.status_code == 400
assert res.json['errors'] == [{'detail': 'sanction_type not found.'}]
Loading