diff --git a/api/users/serializers.py b/api/users/serializers.py index 2707a4141c0..16fa593bd37 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -433,6 +433,15 @@ class Meta: type_ = 'user_passwords' +class UserResetPasswordSerializer(BaseAPISerializer): + uid = ser.CharField(write_only=True, required=True) + token = ser.CharField(write_only=True, required=True) + password = ser.CharField(write_only=True, required=True) + + class Meta: + type_ = 'user_reset_password' + + class UserSettingsSerializer(JSONAPISerializer): id = IDField(source='_id', read_only=True) type = TypeField() diff --git a/api/users/urls.py b/api/users/urls.py index ef53094a121..8a5b4155af5 100644 --- a/api/users/urls.py +++ b/api/users/urls.py @@ -4,6 +4,7 @@ app_name = 'osf' urlpatterns = [ + re_path(r'^reset_password/$', views.ResetPassword.as_view(), name=views.ResetPassword.view_name), re_path(r'^$', views.UserList.as_view(), name=views.UserList.view_name), re_path(r'^(?P\w+)/$', views.UserDetail.as_view(), name=views.UserDetail.view_name), re_path(r'^(?P\w+)/addons/$', views.UserAddonList.as_view(), name=views.UserAddonList.view_name), diff --git a/api/users/views.py b/api/users/views.py index e28be64c18f..ade464e1dc7 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -47,6 +47,7 @@ UserDetailSerializer, UserIdentitiesSerializer, UserInstitutionsRelationshipSerializer, + UserResetPasswordSerializer, UserSerializer, UserEmail, UserEmailsSerializer, @@ -61,7 +62,7 @@ from django.contrib.auth.models import AnonymousUser from django.http import JsonResponse from django.utils import timezone -from framework.auth.core import get_user +from framework.auth.core import generate_verification_key, get_user from framework.auth.views import send_confirm_email_async from framework.auth.oauth_scopes import CoreScopes, normalize_scopes from framework.auth.exceptions import ChangePasswordError @@ -85,9 +86,13 @@ OSFGroup, OSFUser, Email, + Tag, ) from website import mails, settings from website.project.views.contributor import send_claim_email, send_claim_registered_email +from website.util.metrics import CampaignClaimedTags, CampaignSourceTags +from framework.auth import exceptions + class UserMixin: """Mixin with convenience methods for retrieving the current user based on the @@ -724,6 +729,117 @@ def create(self, request, *args, **kwargs): remove_sessions_for_user(user) return Response(status=status.HTTP_204_NO_CONTENT) +class ResetPassword(JSONAPIBaseView, generics.ListCreateAPIView): + """ + View for handling reset password requests. + + GET: + - Takes an email as a query parameter. + - If the email is associated with an OSF account, sends an email with instructions to reset the password. + - If the email is not provided or invalid, returns a validation error. + - If the user has recently requested a password reset, returns a throttling error. + + POST: + - Takes uid, token, and new password in the request data. + - Verifies the token and resets the password if valid. + - If the token is invalid or expired, returns an error. + - If the request data is incomplete, returns a validation error. + """ + permission_classes = ( + drf_permissions.AllowAny, + ) + serializer_class = UserResetPasswordSerializer + view_category = 'users' + view_name = 'request-reset-password' + + 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 = ( + f'If there is an OSF account associated with {email}, an email with instructions on how to ' + f'reset the OSF password has been sent to {email}. If you do not receive an email and believe ' + 'you should have, please contact OSF Support. ' + ) + kind = 'success' + # check if the user exists + user_obj = get_user(email=email) + + if user_obj: + # rate limit forgot_password_post + if not throttle_period_expired(user_obj.email_last_sent, settings.SEND_EMAIL_THROTTLE): + status_message = 'You have recently requested to change your password. Please wait a few minutes ' \ + 'before trying again.' + kind = 'error' + return Response({'message': status_message, 'kind': kind}, status=status.HTTP_429_TOO_MANY_REQUESTS) + elif user_obj.is_active: + # new random verification key (v2) + user_obj.verification_key_v2 = generate_verification_key(verification_type='password') + user_obj.email_last_sent = timezone.now() + user_obj.save() + reset_link = f'{settings.RESET_PASSWORD_URL}{user_obj._id}/{user_obj.verification_key_v2['token']}/' + mails.send_mail( + to_addr=email, + mail=mails.FORGOT_PASSWORD, + reset_link=reset_link, + can_change_preferences=False, + ) + return Response(status=status.HTTP_200_OK, data={'message': status_message, 'kind': kind}) + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + uid = request.data.get('uid', None) + token = request.data.get('token', None) + password = request.data.get('password', None) + if not (uid and token and password): + error_data = { + 'message_short': 'Invalid Request.', + 'message_long': 'The request must include uid, token, and password.', + } + return JsonResponse( + error_data, + status=status.HTTP_400_BAD_REQUEST, + content_type='application/vnd.api+json; application/json', + ) + + user_obj = OSFUser.load(uid) + if not (user_obj and user_obj.verify_password_token(token=token)): + error_data = { + 'message_short': 'Invalid Request.', + 'message_long': 'The requested URL is invalid, has expired, or was already used', + } + return JsonResponse( + error_data, + status=status.HTTP_400_BAD_REQUEST, + content_type='application/vnd.api+json; application/json', + ) + + else: + # clear verification key (v2) + user_obj.verification_key_v2 = {} + # new verification key (v1) for CAS + user_obj.verification_key = generate_verification_key(verification_type=None) + try: + user_obj.set_password(password) + osf4m_source_tag, created = Tag.all_tags.get_or_create(name=CampaignSourceTags.Osf4m.value, system=True) + osf4m_claimed_tag, created = Tag.all_tags.get_or_create(name=CampaignClaimedTags.Osf4m.value, system=True) + if user_obj.all_tags.filter(id=osf4m_source_tag.id, system=True).exists(): + user_obj.add_system_tag(osf4m_claimed_tag) + user_obj.save() + except exceptions.ChangePasswordError as error: + return JsonResponse( + error.messages, + status=status.HTTP_400_BAD_REQUEST, + content_type='application/vnd.api+json; application/json', + ) + + return Response( + status=status.HTTP_200_OK, + content_type='application/vnd.api+json; application/json', + ) + class UserSettings(JSONAPIBaseView, generics.RetrieveUpdateAPIView, UserMixin): permission_classes = ( diff --git a/api_tests/base/test_views.py b/api_tests/base/test_views.py index 212ebed351a..c743c582bdd 100644 --- a/api_tests/base/test_views.py +++ b/api_tests/base/test_views.py @@ -17,7 +17,7 @@ CountedAuthUsageView, MetricsOpenapiView, ) -from api.users.views import ClaimUser +from api.users.views import ClaimUser, ResetPassword from api.wb.views import MoveFileMetadataView, CopyFileMetadataView from rest_framework.permissions import IsAuthenticatedOrReadOnly, IsAuthenticated from api.base.permissions import TokenHasScope @@ -60,6 +60,7 @@ def setUp(self): RawMetricsView, RegistriesModerationMetricsView, MetricsOpenapiView, + ResetPassword, ] def test_root_returns_200(self): diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 0d3f1e85c02..f806f119a92 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -87,6 +87,7 @@ def parent_dir(path): DOMAIN = PROTOCOL + 'localhost:5000/' INTERNAL_DOMAIN = DOMAIN API_DOMAIN = PROTOCOL + 'localhost:8000/' +RESET_PASSWORD_URL = PROTOCOL + 'localhost:5000/resetpassword/' # TODO set angular reset password url PREPRINT_PROVIDER_DOMAINS = { 'enabled': False,