Skip to content
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

[ENG-7298] Password Reset API #11001

Draft
wants to merge 2 commits into
base: feature/b-and-i-25-01
Choose a base branch
from
Draft
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
9 changes: 9 additions & 0 deletions api/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions api/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<user_id>\w+)/$', views.UserDetail.as_view(), name=views.UserDetail.view_name),
re_path(r'^(?P<user_id>\w+)/addons/$', views.UserAddonList.as_view(), name=views.UserAddonList.view_name),
Expand Down
118 changes: 117 additions & 1 deletion api/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
UserDetailSerializer,
UserIdentitiesSerializer,
UserInstitutionsRelationshipSerializer,
UserResetPasswordSerializer,
UserSerializer,
UserEmail,
UserEmailsSerializer,
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 = (
Expand Down
3 changes: 2 additions & 1 deletion api_tests/base/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,6 +60,7 @@ def setUp(self):
RawMetricsView,
RegistriesModerationMetricsView,
MetricsOpenapiView,
ResetPassword,
]

def test_root_returns_200(self):
Expand Down
1 change: 1 addition & 0 deletions website/settings/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading