Skip to content

Commit

Permalink
Imp/password reset improvements (#942)
Browse files Browse the repository at this point in the history
* password-reset

* imp/pw-reset-email-invalidation

* mutations

* pw token gen is sync

* dependency updates
  • Loading branch information
ieaves authored May 20, 2024
1 parent a733b79 commit 1a08e36
Show file tree
Hide file tree
Showing 11 changed files with 642 additions and 541 deletions.
12 changes: 7 additions & 5 deletions grai-server/app/auth/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@
from api.common import IsAuthenticated, get_user
from api.pagination import DataWrapper
from api.types import BasicResult
from users.models import User
from users.models import User, Audit, AuditEvents
from users.types import Device, Profile

from .validation import send_validation_email, verification_generator
from .password_reset import password_reset_generator


def validate_password(password: str, user: User):
Expand Down Expand Up @@ -150,7 +151,6 @@ async def updatePassword(self, info: Info, old_password: str, password: str) ->

user.set_password(password)
await sync_to_async(user.save)()

return user

@strawberry.mutation
Expand All @@ -159,6 +159,8 @@ async def requestPasswordReset(self, email: str) -> BasicResult:

try:
user = await UserModel.objects.filter(username=email).aget()
audit = Audit(user_id=user.id, event=AuditEvents.PASSWORD_RESET.name)
await sync_to_async(audit.save)()

subject = "Grai Password Reset"
email_template_name = "auth/password_reset_email.txt"
Expand All @@ -168,7 +170,7 @@ async def requestPasswordReset(self, email: str) -> BasicResult:
"base_url": settings.FRONTEND_URL,
"uid": user.pk,
"user": user,
"token": default_token_generator.make_token(user),
"token": await sync_to_async(password_reset_generator.make_token)(user),
}
email_message = render_to_string(email_template_name, c)
html_message = render_to_string(html_template_name, c)
Expand All @@ -194,7 +196,7 @@ async def resetPassword(self, token: str, uid: str, password: str) -> Profile:
try:
user = await UserModel.objects.aget(pk=uid)

if not default_token_generator.check_token(user, token):
if not await sync_to_async(password_reset_generator.check_token)(user, token):
raise Exception("Token invalid")

validate_password(password, user)
Expand Down Expand Up @@ -224,7 +226,7 @@ async def completeSignup(self, token: str, uid: str, first_name: str, last_name:
user.set_password(password)
await sync_to_async(user.save)()

send_validation_email(user)
await sync_to_async(send_validation_email)(user)

return user

Expand Down
19 changes: 19 additions & 0 deletions grai-server/app/auth/password_reset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from users.models import User


class GraiPasswordResetGenerator(PasswordResetTokenGenerator):
def _make_hash_value(self, user: User, timestamp):
email_field = user.get_email_field_name()
email = getattr(user, email_field, "") or ""

last_reset = user.last_pw_reset()
if last_reset is None:
raise Exception("Cannot generate password reset without request attempt")

reset_timestamp = "" if last_reset is None else last_reset.created_at.replace(microsecond=0, tzinfo=None)

return f"{user.pk}::{user.password}::{timestamp}::{reset_timestamp}::{email}"


password_reset_generator = GraiPasswordResetGenerator()
10 changes: 8 additions & 2 deletions grai-server/app/auth/tests/test_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

from api.schema import schema
from auth.validation import verification_generator
from auth.password_reset import password_reset_generator
from users.models import Audit, AuditEvents


@pytest.mark.django_db
Expand Down Expand Up @@ -535,8 +537,10 @@ async def test_request_password_reset_no_user():
@pytest.mark.django_db
async def test_reset_password(test_context):
context, organisation, workspace, user, membership = test_context
audit = Audit(user_id=user.id, event=AuditEvents.PASSWORD_RESET.name)
await sync_to_async(audit.save)()

token = default_token_generator.make_token(user)
token = await sync_to_async(password_reset_generator.make_token)(user)

mutation = """
mutation ResetPassword($token: String!, $uid: String!, $password: String!) {
Expand All @@ -560,8 +564,10 @@ async def test_reset_password(test_context):
@pytest.mark.django_db
async def test_reset_password_short(test_context):
context, organisation, workspace, user, membership = test_context
audit = Audit(user_id=user.id, event=AuditEvents.PASSWORD_RESET.name)
await sync_to_async(audit.save)()

token = default_token_generator.make_token(user)
token = await sync_to_async(password_reset_generator.make_token)(user)

mutation = """
mutation ResetPassword($token: String!, $uid: String!, $password: String!) {
Expand Down
41 changes: 41 additions & 0 deletions grai-server/app/auth/tests/test_password_reset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from auth.password_reset import password_reset_generator
from users.models import User, AuditEvents, Audit
from datetime import datetime
import pytest
from uuid import uuid4

TIMESTAMP = datetime.now().replace(microsecond=0, tzinfo=None)


@pytest.mark.django_db
@pytest.fixture
def user():
user = User(username=f"[email protected]")
user.save()
return user


@pytest.mark.django_db
@pytest.fixture
def audit(user):
audit = Audit(user_id=user.id, event=AuditEvents.PASSWORD_RESET.name)
audit.save()
return audit


@pytest.mark.django_db
@pytest.mark.xfail
def test_pw_reset_hash_no_pw_reset():
password_reset_generator._make_hash_value(User(username=f"[email protected]"), TIMESTAMP)


@pytest.mark.django_db
def test_pw_reset_hash(user, audit):
hash_str = password_reset_generator._make_hash_value(user, TIMESTAMP)
pk, pw, ts, r_ts, email = hash_str.split("::")

assert pk == str(user.pk)
assert pw == user.password
assert ts == str(TIMESTAMP)
assert r_ts == str(audit.created_at.replace(microsecond=0, tzinfo=None))
assert email == user.username
Loading

0 comments on commit 1a08e36

Please sign in to comment.