Skip to content

Commit

Permalink
Initiate email change
Browse files Browse the repository at this point in the history
  • Loading branch information
rafalp committed Jun 12, 2024
1 parent 573fee9 commit 00df4fd
Show file tree
Hide file tree
Showing 13 changed files with 356 additions and 63 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ To work on admin's JavaScript or CSS, `cd` to `misago-admin` and install depende

### E-mails

Misago uses [Mailpit](https://github.com/axllent/mailpit) to capture emails sent from development instance.
Misago uses [Mailpit](https://github.com/axllent/mailpit) to capture emails sent from the development instance.

To browse those emails, visit the <http://127.0.0.1:8025> in your browser for the web interface.

Expand Down
72 changes: 58 additions & 14 deletions misago/account/emailchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,93 @@
import hashlib
from base64 import urlsafe_b64decode, urlsafe_b64encode
from datetime import datetime, timedelta
from enum import StrEnum
from typing import TYPE_CHECKING

from django.conf import settings
from django.utils import timezone
from django.utils.translation import pgettext

if TYPE_CHECKING:
from ..users.models import User

TIMESTAMP_FORMAT = "%Y%m%d%H%S"


class EmailChangeTokenErrorCode(StrEnum):
PAYLOAD_MISSING = "PAYLOAD-MISSING"
SIGNATURE_INVALID = "SIGNATURE-INVALID"
SIGNATURE_MISSING = "SIGNATURE-MISSING"
TOKEN_EXPIRED = "TOKEN-EXPIRED"
TOKEN_INVALID = "TOKEN-INVALID"


class EmailChangeTokenError(ValueError):
pass
code: EmailChangeTokenErrorCode

def __init__(self, code: EmailChangeTokenErrorCode):
self.code = code

def __str__(self):
if self.code == EmailChangeTokenErrorCode.PAYLOAD_MISSING:
return pgettext(
"email change token error",
"Mail change confirmation link is missing a payload.",
)
if self.code == EmailChangeTokenErrorCode.TOKEN_EXPIRED:
return pgettext(
"email change token error",
"Mail change confirmation link has expired.",
)
if self.code == EmailChangeTokenErrorCode.TOKEN_INVALID:
return pgettext(
"email change token error",
"Mail change confirmation link is invalid.",
)
if self.code == EmailChangeTokenErrorCode.SIGNATURE_INVALID:
return pgettext(
"email change token error",
"Mail change confirmation link has invalid signature.",
)
if self.code == EmailChangeTokenErrorCode.SIGNATURE_MISSING:
return pgettext(
"email change token error",
"Mail change confirmation link is missing a signature.",
)

return self.code.value


def create_email_change_token(user: "User", new_email: str) -> str:
timestamp = timezone.now().strftime(TIMESTAMP_FORMAT)
message = urlsafe_b64encode(f"{timestamp}:{new_email}".encode("utf-8"))
signature = get_email_change_signature(user, message)
payload = urlsafe_b64encode(f"{timestamp}:{new_email}".encode("utf-8"))
signature = get_email_change_signature(user, payload)

return f"{signature}-{message.decode('utf-8')}"
return f"{signature}-{payload.decode('utf-8')}"


def read_email_change_token(user: "User", token: str) -> str:
if "-" not in token:
raise EmailChangeTokenError("missing signature or message")
raise EmailChangeTokenError(EmailChangeTokenErrorCode.TOKEN_INVALID)

signature, message = token.split("-", 1)
if not signature or not message:
raise EmailChangeTokenError("missing signature or message")
signature, payload = token.split("-", 1)
if not signature:
raise EmailChangeTokenError(EmailChangeTokenErrorCode.SIGNATURE_MISSING)
if not payload:
raise EmailChangeTokenError(EmailChangeTokenErrorCode.PAYLOAD_MISSING)

if signature != get_email_change_signature(user, message.encode("utf-8")):
raise EmailChangeTokenError("invalid signature for message")
if signature != get_email_change_signature(user, payload.encode("utf-8")):
raise EmailChangeTokenError(EmailChangeTokenErrorCode.SIGNATURE_INVALID)

timestamp, email = urlsafe_b64decode(message).decode("utf-8").split(":", 1)
timestamp, email = urlsafe_b64decode(payload).decode("utf-8").split(":", 1)

created = datetime.strptime(timestamp, TIMESTAMP_FORMAT).replace(
tzinfo=timezone.utc
)
expires = created + timedelta(hours=settings.MISAGO_EMAIL_CHANGE_TOKEN_EXPIRES)

if timezone.now() > expires:
raise EmailChangeTokenError("token expired")
raise EmailChangeTokenError(EmailChangeTokenErrorCode.TOKEN_EXPIRED)

return email

Expand All @@ -55,5 +99,5 @@ def get_email_change_secret(user: "User") -> bytes:
).encode("utf-8")


def get_email_change_signature(user: "User", message: bytes) -> str:
return hmac.new(get_email_change_secret(user), message, hashlib.sha256).hexdigest()
def get_email_change_signature(user: "User", payload: bytes) -> str:
return hmac.new(get_email_change_secret(user), payload, hashlib.sha256).hexdigest()
3 changes: 2 additions & 1 deletion misago/account/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from ..permissions.accounts import allow_delete_own_account
from ..profile.profilefields import profile_fields
from ..users.utils import hash_email
from ..users.validators import validate_email, validate_username
from .namechanges import get_available_username_changes

Expand Down Expand Up @@ -339,7 +340,7 @@ def clean_current_password(self):

def clean_new_email(self):
data = self.cleaned_data["new_email"]
if data == self.instance.email:
if hash_email(data) == self.instance.email_hash:
raise forms.ValidationError(
pgettext(
"account email form",
Expand Down
124 changes: 124 additions & 0 deletions misago/account/tests/test_account_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from django.urls import reverse

from ...conf.test import override_dynamic_settings
from ...test import assert_contains


@override_dynamic_settings(enable_oauth2_client=True)
def test_account_email_returns_error_if_oauth_client_is_enabled(db, client):
response = client.get(reverse("misago:account-email"))
assert response.status_code == 404


def test_account_email_returns_error_for_guests(db, client):
response = client.get(reverse("misago:account-email"))
assert_contains(response, "You need to be signed in", status_code=403)


def test_account_email_renders_form(user_client):
response = user_client.get(reverse("misago:account-email"))
assert_contains(response, "Change email address")


def test_account_email_form_sends_email_confirmation_link_on_change(
user_client, user_password, mailoutbox
):
response = user_client.post(
reverse("misago:account-email"),
{
"current_password": user_password,
"new_email": "[email protected]",
"confirm_email": "[email protected]",
},
)
assert response.status_code == 302

assert len(mailoutbox) == 1


def test_account_email_form_redirects_to_message_page_on_change(
user_client, user_password
):
response = user_client.post(
reverse("misago:account-email"),
{
"current_password": user_password,
"new_email": "[email protected]",
"confirm_email": "[email protected]",
},
)
assert response.status_code == 302

response = user_client.get(response.headers["location"])
assert_contains(response, "Confirm email address change")


def test_account_email_form_validates_current_password(user_client):
response = user_client.post(
reverse("misago:account-email"),
{
"current_password": "invalid",
"new_email": "[email protected]",
"confirm_email": "[email protected]",
},
)
assert response.status_code == 200
assert_contains(response, "Change email address")
assert_contains(response, "Password is incorrect.")


def test_account_email_form_validates_new_email(user_client, user_password):
response = user_client.post(
reverse("misago:account-email"),
{
"current_password": user_password,
"new_email": "invalid",
"confirm_email": "invalid",
},
)
assert response.status_code == 200
assert_contains(response, "Change email address")
assert_contains(response, "Enter a valid email address.")


def test_account_email_form_validates_email_is_new(user, user_client, user_password):
response = user_client.post(
reverse("misago:account-email"),
{
"current_password": user_password,
"new_email": user.email,
"confirm_email": user.email,
},
)
assert response.status_code == 200
assert_contains(response, "This email address is the same as the current one.")


def test_account_email_form_validates_new_emails_match(user_client, user_password):
response = user_client.post(
reverse("misago:account-email"),
{
"current_password": user_password,
"new_email": "[email protected]",
"confirm_email": "[email protected]",
},
)
assert response.status_code == 200
assert_contains(response, "Change email address")
assert_contains(response, "New email addresses don&#x27;t match.")


def test_account_email_form_displays_message_page_on_change_in_htmx(
user_client, user_password
):
response = user_client.post(
reverse("misago:account-email"),
{
"current_password": user_password,
"new_email": "[email protected]",
"confirm_email": "[email protected]",
},
headers={"hx-request": "true"},
)
assert response.status_code == 200
assert_contains(response, "Confirm email address change")
2 changes: 1 addition & 1 deletion misago/account/tests/test_account_password.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.urls import reverse

from ...conf.test import override_dynamic_settings
from ...test import assert_contains, assert_has_success_message, assert_not_contains
from ...test import assert_contains, assert_has_success_message


@override_dynamic_settings(enable_oauth2_client=True)
Expand Down
45 changes: 28 additions & 17 deletions misago/account/tests/test_email_change_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from ..emailchange import (
EmailChangeTokenError,
EmailChangeTokenErrorCode,
create_email_change_token,
read_email_change_token,
)
Expand All @@ -24,7 +25,16 @@ def test_email_change_token_is_invalidated_by_user_email_change(user):
with pytest.raises(EmailChangeTokenError) as exc_info:
read_email_change_token(user, token)

assert exc_info.value.args[0] == "invalid signature for message"
assert str(exc_info.value) == "Mail change confirmation link has invalid signature."
assert exc_info.value.code == EmailChangeTokenErrorCode.SIGNATURE_INVALID


def test_read_email_change_token_raises_error_if_token_is_invalid(user):
with pytest.raises(EmailChangeTokenError) as exc_info:
read_email_change_token(user, "invalid")

assert str(exc_info.value) == "Mail change confirmation link is invalid."
assert exc_info.value.code == EmailChangeTokenErrorCode.TOKEN_INVALID


def test_email_change_token_is_invalidated_by_user_password_change(user):
Expand All @@ -36,7 +46,8 @@ def test_email_change_token_is_invalidated_by_user_password_change(user):
with pytest.raises(EmailChangeTokenError) as exc_info:
read_email_change_token(user, token)

assert exc_info.value.args[0] == "invalid signature for message"
assert str(exc_info.value) == "Mail change confirmation link has invalid signature."
assert exc_info.value.code == EmailChangeTokenErrorCode.SIGNATURE_INVALID


def test_email_change_token_is_invalidated_by_secret_key_change(user):
Expand All @@ -46,35 +57,34 @@ def test_email_change_token_is_invalidated_by_secret_key_change(user):
with pytest.raises(EmailChangeTokenError) as exc_info:
read_email_change_token(user, token)

assert exc_info.value.args[0] == "invalid signature for message"


def test_read_email_change_token_raises_error_if_token_is_invalid(user):
with pytest.raises(EmailChangeTokenError) as exc_info:
read_email_change_token(user, "invalid")

assert exc_info.value.args[0] == "missing signature or message"
assert str(exc_info.value) == "Mail change confirmation link has invalid signature."
assert exc_info.value.code == EmailChangeTokenErrorCode.SIGNATURE_INVALID


def test_read_email_change_token_raises_error_if_token_misses_signature(user):
def test_read_email_change_token_raises_error_if_token_is_missing_signature(user):
with pytest.raises(EmailChangeTokenError) as exc_info:
read_email_change_token(user, "-invalid")

assert exc_info.value.args[0] == "missing signature or message"
assert (
str(exc_info.value) == "Mail change confirmation link is missing a signature."
)
assert exc_info.value.code == EmailChangeTokenErrorCode.SIGNATURE_MISSING


def test_read_email_change_token_raises_error_if_token_misses_message(user):
def test_read_email_change_token_raises_error_if_token_is_missing_payload(user):
with pytest.raises(EmailChangeTokenError) as exc_info:
read_email_change_token(user, "invalid-")

assert exc_info.value.args[0] == "missing signature or message"
assert str(exc_info.value) == "Mail change confirmation link is missing a payload."
assert exc_info.value.code == EmailChangeTokenErrorCode.PAYLOAD_MISSING


def test_read_email_change_token_raises_error_if_token_signature_is_invalid(user):
with pytest.raises(EmailChangeTokenError) as exc_info:
read_email_change_token(user, "invalid-message")
read_email_change_token(user, "invalid-payload")

assert exc_info.value.args[0] == "invalid signature for message"
assert str(exc_info.value) == "Mail change confirmation link has invalid signature."
assert exc_info.value.code == EmailChangeTokenErrorCode.SIGNATURE_INVALID


@override_settings(MISAGO_EMAIL_CHANGE_TOKEN_EXPIRES=0)
Expand All @@ -84,4 +94,5 @@ def test_read_email_change_token_raises_error_if_token_is_expired(user):
with pytest.raises(EmailChangeTokenError) as exc_info:
read_email_change_token(user, token)

assert exc_info.value.args[0] == "token expired"
assert str(exc_info.value) == "Mail change confirmation link has expired."
assert exc_info.value.code == EmailChangeTokenErrorCode.TOKEN_EXPIRED
10 changes: 10 additions & 0 deletions misago/account/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@
settings.AccountEmailView.as_view(),
name="account-email",
),
path(
"email/confirm/",
settings.AccountEmailConfirm.as_view(),
name="account-email-confirm-sent",
),
path(
"email/confirm/<int:user_id>/<token>/",
settings.account_email_confirm_change,
name="account-email-confirm-change",
),
path(
"download-data/",
settings.AccountDownloadDataView.as_view(),
Expand Down
Loading

0 comments on commit 00df4fd

Please sign in to comment.