Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
129 changes: 67 additions & 62 deletions apps/api/plane/authentication/views/app/password_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,79 +98,84 @@ def post(self, request):

class ResetPasswordEndpoint(View):
def post(self, request, uidb64, token):
"""Set a new password for the user encoded in uidb64.

Always redirects: to sign-in on success, back to the reset-password page with an error code when the
link or the submitted password is rejected.
"""
try:
# Decode the id from the uidb64
try:
id = smart_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(id=id)
except (ValueError, User.DoesNotExist):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"],
error_message="INVALID_PASSWORD_TOKEN",
)
params = exc.get_error_dict()
url = urljoin(
base_host(request=request, is_app=True),
"accounts/reset-password?" + urlencode(params),
)
return HttpResponseRedirect(url)

# check if the token is valid for the user
if not PasswordResetTokenGenerator().check_token(user, token):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"],
error_message="INVALID_PASSWORD_TOKEN",
)
params = exc.get_error_dict()
url = urljoin(
base_host(request=request, is_app=True),
"accounts/reset-password?" + urlencode(params),
)
return HttpResponseRedirect(url)

password = request.POST.get("password", False)

if not password:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
error_message="INVALID_PASSWORD",
)
url = urljoin(
base_host(request=request, is_app=True),
"accounts/reset-password?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)

# Check the password complexity
results = zxcvbn(password)
if results["score"] < 3:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
error_message="PASSWORD_TOO_WEAK",
)
url = urljoin(
base_host(request=request, is_app=True),
"accounts/reset-password?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)

# set_password also hashes the password that the user will get
user.set_password(password)
user.is_password_autoset = False
user.save()
id = smart_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(id=id)
except DjangoUnicodeDecodeError:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

low -- Behavior change on the app endpoint: an undecodable-utf8 uidb64 (e.g. /auth/reset-password/not/<token>/) now returns error_code=5130 EXPIRED_PASSWORD_TOKEN where preview returns 5125 INVALID_PASSWORD_TOKEN, because this previously-dead handler is now reachable ahead of the tuple clause.

Concrete effect: a user who mangles a reset URL (or whose mail client truncates it) sees "Expired password token. Please try again." for a link that was never valid -- mildly misleading, and it may send them to re-request a link they already have. Both codes render as the same banner in apps/web/helpers/authentication.helper.tsx:292-298, so there is no functional breakage.

The PR description already flags this and offers to drop the handler instead. If you'd rather preserve 5125, delete the except DjangoUnicodeDecodeError clause from both files (the tuple clause catches it via ValueError) and update the two test_undecodable_uidb64_redirects cases, which currently pin the new code.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done — dropped the handler from both files in 67d08b9, 5125 preserved on the app endpoint.

Agreed on the reasoning: an undecodable uidb64 was never a valid link that later expired, so INVALID_PASSWORD_TOKEN is the accurate answer, and the remaining except (ValueError, ValidationError, User.DoesNotExist) covers it for free (DjangoUnicodeDecodeErrorUnicodeDecodeErrorUnicodeErrorValueError, and force_str is its only raise site). The import is gone from both files with smart_bytes/smart_str still in use, so it stays F401-clean.

One consequence worth stating outright, since it inverts which endpoint moves: on preview the space endpoint did answer 5130 here — its except wrapped the whole method body, so unlike the app one it was reachable. Deleting from both therefore restores the app endpoint to its exact preview behaviour and changes the space endpoint from 5130 to 5125. That is the direction I think is right, and it makes the two endpoints agree, which is the point of the PR — but it is a user-visible copy change on the space side ("Expired password token. Please try again." → "Invalid password token."), so flagging it rather than burying it. The two test_undecodable_uidb64_redirects cases now pin 5125 and the PR description is updated to match.

5130 is no longer emitted anywhere in apps/api. I left the enum entries in packages/constants/src/auth/index.ts:151 and both authentication.helper.tsx copies alone — they are Record<Enum, …> definitions, so a dead key breaks nothing, and removing them would strand any link already in flight from an older API.

Two test anchors added alongside, since both rejected-uidb64 fixtures now produce the same response and the old 5130 expectation was the only thing distinguishing them:

  • test_uidb64_fixtures_reach_their_branches pins that "a" raises inside urlsafe_base64_decode (binascii, never reaches smart_str) while "not" decodes to b"\x9e\x8b" and raises DjangoUnicodeDecodeError. Without it, a change that stopped "not" from raising at all would leave the utf-8 branch untested with every test green — confirmed by mutation: pointing the fixture at "aGk" fails it.
  • test_error_code_wire_values pins 5125/5020/5021 as literals, since the tests otherwise read the expected code from the same dict the view writes; renumbering would stay green here and break the hardcoded TS constants.

20 passed. Reverting only the two view files gives 5 failed, 15 passed.

exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_PASSWORD_TOKEN"],
error_message="EXPIRED_PASSWORD_TOKEN",
)
url = urljoin(
base_host(request=request, is_app=True),
"accounts/reset-password?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
except (ValueError, ValidationError, User.DoesNotExist):
# Malformed base64, a non-UUID id or an id that matches no user
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"],
error_message="INVALID_PASSWORD_TOKEN",
)
params = exc.get_error_dict()
url = urljoin(
base_host(request=request, is_app=True),
"accounts/reset-password?" + urlencode(params),
)
return HttpResponseRedirect(url)

# check if the token is valid for the user
if not PasswordResetTokenGenerator().check_token(user, token):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"],
error_message="INVALID_PASSWORD_TOKEN",
)
params = exc.get_error_dict()
url = urljoin(
base_host(request=request, is_app=True),
"sign-in?" + urlencode({"success": True}),
"accounts/reset-password?" + urlencode(params),
)
return HttpResponseRedirect(url)
except DjangoUnicodeDecodeError:

password = request.POST.get("password", False)

if not password:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_PASSWORD_TOKEN"],
error_message="EXPIRED_PASSWORD_TOKEN",
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
error_message="INVALID_PASSWORD",
)
url = urljoin(
base_host(request=request, is_app=True),
"accounts/reset-password?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)

# Check the password complexity
results = zxcvbn(password)
if results["score"] < 3:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
error_message="PASSWORD_TOO_WEAK",
)
url = urljoin(
base_host(request=request, is_app=True),
"accounts/reset-password?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)

# set_password also hashes the password that the user will get
user.set_password(password)
user.is_password_autoset = False
user.save()

url = urljoin(
base_host(request=request, is_app=True),
"sign-in?" + urlencode({"success": True}),
)
return HttpResponseRedirect(url)
87 changes: 50 additions & 37 deletions apps/api/plane/authentication/views/space/password_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,51 +110,64 @@ def post(self, request):

class ResetPasswordSpaceEndpoint(View):
def post(self, request, uidb64, token):
"""Set a new password for the user encoded in uidb64.

Always redirects: to the space host on success, back to the reset-password page with an error code when
the link or the submitted password is rejected.
"""
try:
# Decode the id from the uidb64
id = smart_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(id=id)

# check if the token is valid for the user
if not PasswordResetTokenGenerator().check_token(user, token):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"],
error_message="INVALID_PASSWORD_TOKEN",
)
params = exc.get_error_dict()
url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(params)}"
return HttpResponseRedirect(url)

password = request.POST.get("password", False)

if not password:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
error_message="INVALID_PASSWORD",
)
url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501
return HttpResponseRedirect(url)

# Check the password complexity
results = zxcvbn(password)
if results["score"] < 3:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
error_message="PASSWORD_TOO_WEAK",
)
url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501
return HttpResponseRedirect(url)

# set_password also hashes the password that the user will get
user.set_password(password)
user.is_password_autoset = False
user.save()

return HttpResponseRedirect(base_host(request=request, is_space=True))
except DjangoUnicodeDecodeError:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_PASSWORD_TOKEN"],
error_message="EXPIRED_PASSWORD_TOKEN",
)
url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501
return HttpResponseRedirect(url)
except (ValueError, ValidationError, User.DoesNotExist):
# Malformed base64, a non-UUID id or an id that matches no user
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"],
error_message="INVALID_PASSWORD_TOKEN",
)
url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501
return HttpResponseRedirect(url)

# check if the token is valid for the user
if not PasswordResetTokenGenerator().check_token(user, token):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"],
error_message="INVALID_PASSWORD_TOKEN",
)
params = exc.get_error_dict()
url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(params)}"
return HttpResponseRedirect(url)

password = request.POST.get("password", False)

if not password:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
error_message="INVALID_PASSWORD",
)
url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501
return HttpResponseRedirect(url)

# Check the password complexity
results = zxcvbn(password)
if results["score"] < 3:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
error_message="PASSWORD_TOO_WEAK",
)
url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501
return HttpResponseRedirect(url)

# set_password also hashes the password that the user will get
user.set_password(password)
user.is_password_autoset = False
user.save()

return HttpResponseRedirect(base_host(request=request, is_space=True))
Loading