Skip to content

Commit

Permalink
Add reset password functionality with email verification and token va…
Browse files Browse the repository at this point in the history
…lidation
  • Loading branch information
Ostap-Zherebetskyi committed Feb 26, 2025
1 parent 5313a38 commit b2fe442
Show file tree
Hide file tree
Showing 4 changed files with 128 additions and 1 deletion.
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
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

0 comments on commit b2fe442

Please sign in to comment.