", views.email_undo, name="email-undo"),
]
diff --git a/portal/profile/views.py b/portal/profile/views.py
index c31a197..a7d15f3 100644
--- a/portal/profile/views.py
+++ b/portal/profile/views.py
@@ -1,4 +1,4 @@
-"""The password page (FR-5..FR-7). No business logic here: see services.py (AD-17)."""
+"""The password, profile and e-mail pages (FR-5..FR-11). No business logic here (AD-17)."""
from __future__ import annotations
@@ -6,6 +6,7 @@ import logging
from django.shortcuts import redirect, render
from django.utils.translation import gettext as _
+from django.views.decorators.cache import never_cache
from django.views.decorators.debug import sensitive_post_parameters
from django.views.decorators.http import require_http_methods
@@ -16,7 +17,8 @@ from portal.adapters.directory import DirectoryUnavailable
from portal.adapters.registry import get_directory
from . import services
-from .forms import DETAIL_FIELDS, DetailsForm, PasswordChangeForm
+from .forms import DETAIL_FIELDS, DetailsForm, EmailChangeForm, PasswordChangeForm
+from .models import PendingEmailChange
logger = logging.getLogger(__name__)
@@ -159,3 +161,110 @@ def profile(request):
remember(request, result.account) # this session shows the new name at once
request.session[NOTICE_FLAG] = "saved" if result.changed else "unchanged"
return redirect("profile")
+
+
+# -- e-mail change (FR-10, FR-11) -------------------------------------------------------------
+
+EMAIL_SENT_FLAG = "email_change_sent"
+
+
+def _email_page(request, form, current, *, status=200, throttled=False):
+ context = {"form": form, "current": current, "throttled": throttled}
+ return render(request, "profile/email.html", context, status=status)
+
+
+@login_required
+@sensitive_post_parameters("current_password")
+@require_http_methods(["GET", "POST"])
+def email(request):
+ user = request.portal_user.user
+ if request.method == "GET":
+ try:
+ details = services.load_details(user)
+ except DirectoryUnavailable:
+ return _unavailable(request)
+ if request.session.pop(EMAIL_SENT_FLAG, None):
+ return render(request, "profile/email_sent.html", {"current": details.mail})
+ return _email_page(request, EmailChangeForm(), details.mail)
+
+ form = EmailChangeForm(request.POST)
+ if not form.is_valid(): # no directory call before the throttle check (AD-7)
+ return _email_page(request, form, None, status=400)
+
+ try:
+ services.request_email_change(
+ user,
+ form.cleaned_data["new_email"],
+ form.cleaned_data["current_password"],
+ ip=request.client_ip,
+ )
+ except Throttled:
+ return _email_page(request, EmailChangeForm(), None, status=429, throttled=True)
+ except services.WrongCurrentPassword:
+ form.add_error("current_password", _("The current password is not correct."))
+ return _email_page(request, form, _current_address(user), status=400)
+ except services.EmailInvalid:
+ form.add_error("new_email", _("This is already your e-mail address."))
+ return _email_page(request, form, _current_address(user), status=400)
+ except DirectoryUnavailable:
+ return _unavailable(request)
+
+ request.session[EMAIL_SENT_FLAG] = True
+ return redirect("email")
+
+
+def _current_address(user) -> str | None:
+ try:
+ return services.load_details(user).mail
+ except DirectoryUnavailable:
+ return None
+
+
+def _dead_link(request):
+ return render(request, "profile/email_link_dead.html", status=410)
+
+
+@never_cache
+@sensitive_post_parameters()
+@require_http_methods(["GET", "POST"])
+def email_confirm(request, token):
+ """No sign-in needed: the link proves the mailbox. GET only shows (AD-9)."""
+ if request.method == "GET":
+ row = services.find_link(token, PendingEmailChange.Kind.CONFIRM)
+ if row is None:
+ return _dead_link(request)
+ return render(request, "profile/email_confirm.html", {"change": row})
+
+ try:
+ result = services.confirm_email_change(token, ip=request.client_ip)
+ except services.LinkDead:
+ return _dead_link(request)
+ except DirectoryUnavailable:
+ return _unavailable(request)
+ except services.OutcomeUnknown:
+ return render(request, "profile/email_unknown.html", status=500)
+ return render(request, "profile/email_confirmed.html", {"notice": result.notice_queued})
+
+
+@never_cache
+@sensitive_post_parameters()
+@require_http_methods(["GET", "POST"])
+def email_undo(request, token):
+ """No sign-in needed: the link proves the mailbox. GET only shows (AD-9)."""
+ if request.method == "GET":
+ row = services.find_link(token, PendingEmailChange.Kind.UNDO)
+ if row is None:
+ return _dead_link(request)
+ return render(request, "profile/email_undo.html", {"change": row})
+
+ try:
+ services.undo_email_change(token, ip=request.client_ip)
+ except services.LinkDead:
+ return _dead_link(request)
+ except services.ChangedAgain:
+ return render(request, "profile/email_changed_again.html", status=409)
+ except DirectoryUnavailable:
+ return _unavailable(request)
+ except services.OutcomeUnknown:
+ return render(request, "profile/email_unknown.html", status=500)
+ return render(request, "profile/email_undone.html")
diff --git a/portal/templates/mail/email_change_confirm/body.txt b/portal/templates/mail/email_change_confirm/body.txt
new file mode 100644
index 0000000..2626924
--- /dev/null
+++ b/portal/templates/mail/email_change_confirm/body.txt
@@ -0,0 +1,9 @@
+{% load i18n %}{% autoescape off %}{% blocktrans with name=name %}Hello {{ name }},{% endblocktrans %}
+
+{% trans "this e-mail address was entered as the new e-mail address of your portal account. To confirm it, open this link and press the button. The link is valid for one hour:" %}
+
+{{ link }}
+
+{% trans "Your address changes only after you confirm. If you did not ask for this, ignore this e-mail. Nothing will change." %}
+
+{% trans "This e-mail never contains your password." %}{% endautoescape %}
diff --git a/portal/templates/mail/email_change_confirm/subject.txt b/portal/templates/mail/email_change_confirm/subject.txt
new file mode 100644
index 0000000..707e306
--- /dev/null
+++ b/portal/templates/mail/email_change_confirm/subject.txt
@@ -0,0 +1 @@
+{% load i18n %}{% autoescape off %}{% trans "Please confirm your new e-mail address" %}{% endautoescape %}
diff --git a/portal/templates/mail/email_changed_notice/body.txt b/portal/templates/mail/email_changed_notice/body.txt
new file mode 100644
index 0000000..4bc8ac3
--- /dev/null
+++ b/portal/templates/mail/email_changed_notice/body.txt
@@ -0,0 +1,11 @@
+{% load i18n tz %}{% autoescape off %}{% blocktrans with name=name %}Hello {{ name }},{% endblocktrans %}
+
+{% timezone zone_name %}{% blocktrans with when=changed_at|date:"j. F Y, H:i" zone=changed_at|date:"T" address=new_address %}the e-mail address of your portal account was changed on {{ when }} ({{ zone }}) to {{ address }}.{% endblocktrans %}{% endtimezone %}
+
+{% trans "If you did this, you do not need to do anything. If you did not, open this link to restore this address. The link is valid for 24 hours:" %}
+
+{{ link }}
+
+{% trans "Undoing signs you out on all devices and informs the administrator." %}
+
+{% trans "This e-mail never contains your password." %}{% endautoescape %}
diff --git a/portal/templates/mail/email_changed_notice/subject.txt b/portal/templates/mail/email_changed_notice/subject.txt
new file mode 100644
index 0000000..55ba47a
--- /dev/null
+++ b/portal/templates/mail/email_changed_notice/subject.txt
@@ -0,0 +1 @@
+{% load i18n %}{% autoescape off %}{% trans "Your e-mail address was changed" %}{% endautoescape %}
diff --git a/portal/templates/mail/email_undone_admin/body.txt b/portal/templates/mail/email_undone_admin/body.txt
new file mode 100644
index 0000000..a9ca67a
--- /dev/null
+++ b/portal/templates/mail/email_undone_admin/body.txt
@@ -0,0 +1,5 @@
+{% load i18n tz %}{% autoescape off %}{% trans "Hello," %}
+
+{% timezone zone_name %}{% blocktrans with when=undone_at|date:"j. F Y, H:i" zone=undone_at|date:"T" username=username %}the user {{ username }} undid an e-mail change on {{ when }} ({{ zone }}). All sessions of this user were ended.{% endblocktrans %}{% endtimezone %}
+
+{% trans "The user may have lost control of the account. Please check it." %}{% endautoescape %}
diff --git a/portal/templates/mail/email_undone_admin/subject.txt b/portal/templates/mail/email_undone_admin/subject.txt
new file mode 100644
index 0000000..5a91dce
--- /dev/null
+++ b/portal/templates/mail/email_undone_admin/subject.txt
@@ -0,0 +1 @@
+{% load i18n %}{% autoescape off %}{% trans "An e-mail change was undone" %}{% endautoescape %}
diff --git a/portal/templates/profile/details.html b/portal/templates/profile/details.html
index 6e8ba0e..6c51fd7 100644
--- a/portal/templates/profile/details.html
+++ b/portal/templates/profile/details.html
@@ -29,7 +29,7 @@
{% trans "User name" %}
{{ details.username }}
{% trans "E-mail address" %}
- {% if details.mail %}{{ details.mail }}{% else %}–{% endif %}
+ {% if details.mail %}{{ details.mail }}{% else %}–{% endif %} {% trans "Change e-mail address" %}
{% endif %}
+{% endif %}
+{% endblock %}
diff --git a/portal/templates/profile/email_changed_again.html b/portal/templates/profile/email_changed_again.html
new file mode 100644
index 0000000..8c3fe14
--- /dev/null
+++ b/portal/templates/profile/email_changed_again.html
@@ -0,0 +1,10 @@
+{% extends "base.html" %}
+{% load i18n %}
+{% block title %}{% trans "Address changed again" %}{% endblock %}
+{% block content %}
+{% trans "Address changed again" %}
+
+
{% trans "The e-mail address was changed again since then. Nothing was changed now. If this was not you, contact the administrator." %}
+
+{% trans "Back to the start page" %}
+{% endblock %}
diff --git a/portal/templates/profile/email_confirm.html b/portal/templates/profile/email_confirm.html
new file mode 100644
index 0000000..11aa44a
--- /dev/null
+++ b/portal/templates/profile/email_confirm.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+{% load i18n %}
+{% block title %}{% trans "Confirm new e-mail address" %}{% endblock %}
+{% block content %}
+{% trans "Confirm new e-mail address" %}
+{% trans "The e-mail address of your account will change. Nothing changes until you press the button." %}
+
+ - {% trans "Current e-mail address" %}
+ - {% if change.old_address %}{{ change.old_address }}{% else %}–{% endif %}
+ - {% trans "New e-mail address" %}
+ - {{ change.new_address }}
+
+{% if change.old_address %}{% trans "A notice with a way to undo the change is sent to the current address." %}
{% endif %}
+
+{% endblock %}
diff --git a/portal/templates/profile/email_confirmed.html b/portal/templates/profile/email_confirmed.html
new file mode 100644
index 0000000..825cff1
--- /dev/null
+++ b/portal/templates/profile/email_confirmed.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% load i18n %}
+{% block title %}{% trans "E-mail address changed" %}{% endblock %}
+{% block content %}
+{% trans "E-mail address changed" %}
+
+
{% trans "Your e-mail address was changed." %}
+
+{% if notice %}{% trans "A notice with a way to undo the change was sent to your previous address." %}
{% endif %}
+{% trans "Back to the start page" %}
+{% endblock %}
diff --git a/portal/templates/profile/email_link_dead.html b/portal/templates/profile/email_link_dead.html
new file mode 100644
index 0000000..eae72c1
--- /dev/null
+++ b/portal/templates/profile/email_link_dead.html
@@ -0,0 +1,10 @@
+{% extends "base.html" %}
+{% load i18n %}
+{% block title %}{% trans "Link not valid" %}{% endblock %}
+{% block content %}
+{% trans "Link not valid" %}
+
+
{% trans "This link is not valid any more." %}
+
+{% trans "Back to the start page" %}
+{% endblock %}
diff --git a/portal/templates/profile/email_sent.html b/portal/templates/profile/email_sent.html
new file mode 100644
index 0000000..9cafe3b
--- /dev/null
+++ b/portal/templates/profile/email_sent.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% load i18n %}
+{% block title %}{% trans "Confirmation link sent" %}{% endblock %}
+{% block content %}
+{% trans "Confirmation link sent" %}
+
+
{% trans "We sent a link to your new e-mail address. It is valid for one hour." %}
+
+{% trans "Your address has not changed yet. It changes after you open the link and confirm." %}
+{% trans "Back to the profile" %}
+{% endblock %}
diff --git a/portal/templates/profile/email_undo.html b/portal/templates/profile/email_undo.html
new file mode 100644
index 0000000..c436f43
--- /dev/null
+++ b/portal/templates/profile/email_undo.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+{% load i18n %}
+{% block title %}{% trans "Undo e-mail change" %}{% endblock %}
+{% block content %}
+{% trans "Undo e-mail change" %}
+{% trans "The e-mail address of your account was changed. If you did not do this, undo the change now." %}
+
+ - {% trans "Previous e-mail address" %}
+ - {{ change.old_address }}
+ - {% trans "Current e-mail address" %}
+ - {{ change.new_address }}
+
+{% trans "Undoing restores the previous address, signs you out on all devices and informs the administrator." %}
+
+{% endblock %}
diff --git a/portal/templates/profile/email_undone.html b/portal/templates/profile/email_undone.html
new file mode 100644
index 0000000..e28a441
--- /dev/null
+++ b/portal/templates/profile/email_undone.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% load i18n %}
+{% block title %}{% trans "E-mail change undone" %}{% endblock %}
+{% block content %}
+{% trans "E-mail change undone" %}
+
+
{% trans "Your previous e-mail address is back. You were signed out on all devices." %}
+
+{% trans "Someone else may have had access to your account. Please change your password now." %}
+{% trans "Change password" %}
+{% endblock %}
diff --git a/portal/templates/profile/email_unknown.html b/portal/templates/profile/email_unknown.html
new file mode 100644
index 0000000..0cb5a97
--- /dev/null
+++ b/portal/templates/profile/email_unknown.html
@@ -0,0 +1,10 @@
+{% extends "base.html" %}
+{% load i18n %}
+{% block title %}{% trans "Result unclear" %}{% endblock %}
+{% block content %}
+{% trans "Result unclear" %}
+
+
{% trans "Your e-mail address may have been changed, but the portal could not finish. Please check your profile or contact the administrator." %}
+
+{% trans "Back to the start page" %}
+{% endblock %}
diff --git a/portal/tests/test_config.py b/portal/tests/test_config.py
index 50a77a1..2237f67 100644
--- a/portal/tests/test_config.py
+++ b/portal/tests/test_config.py
@@ -178,3 +178,13 @@ def test_the_mail_settings_are_required(valid_env, name):
del valid_env[name]
with pytest.raises(ConfigError, match=name):
load_config(valid_env)
+
+
+def test_the_admin_notice_address_is_optional_and_checked(valid_env):
+ assert load_config(valid_env).admin_notify_email == ""
+ assert load_config({**valid_env, "ADMIN_NOTIFY_EMAIL": " "}).admin_notify_email == ""
+ good = {**valid_env, "ADMIN_NOTIFY_EMAIL": "admin@example.test"}
+ assert load_config(good).admin_notify_email == "admin@example.test"
+ with pytest.raises(ConfigError, match="ADMIN_NOTIFY_EMAIL") as info:
+ load_config({**valid_env, "ADMIN_NOTIFY_EMAIL": "not an address"})
+ assert "not an address" not in str(info.value)
diff --git a/portal/tests/test_email_change.py b/portal/tests/test_email_change.py
new file mode 100644
index 0000000..4b2efa3
--- /dev/null
+++ b/portal/tests/test_email_change.py
@@ -0,0 +1,693 @@
+"""E-mail change: request, confirmation link, undo link, limits, sessions, audit and logs."""
+
+import logging
+import re
+from datetime import timedelta
+
+import pytest
+from django.core.management import call_command
+from django.test import Client
+from django.utils import timezone
+
+from portal.accounts.models import ThrottleCounter, UserSession
+from portal.adapters.directory import DirectoryUnavailable, ValidationFailed
+from portal.audit.models import AuditEntry
+from portal.mailer.models import OutboxMail
+from portal.profile import services
+from portal.profile.models import PendingEmailChange
+from portal.tests.helpers import sign_in
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+CURRENT = "Correct-Horse-9"
+WRONG = "Not-The-Password-1"
+OLD = "alice@example.test"
+NEW = "alice.new@example.test"
+ADMIN = "admin@example.test"
+LINK = re.compile(r"https://portal\.example\.test(/email/(?:confirm|undo)/[\w-]+)")
+DEAD = "This link is not valid any more."
+
+
+@pytest.fixture(autouse=True)
+def admin_address(settings):
+ settings.ADMIN_NOTIFY_EMAIL = ADMIN
+
+
+@pytest.fixture
+def people(adapters):
+ d = adapters.directory
+ d.add_user("alice", password=CURRENT, display_name="Alice Anders", mail=OLD)
+ d.add_user("bob", display_name="Bob Brown", mail="bob@example.test")
+ d.add_user("nomail", password=CURRENT, display_name="No Mail")
+ return d
+
+
+class Spy:
+ def __init__(self, directory):
+ self.calls = []
+ for name in ("password_policy", "verify_password", "find_user", "update_attributes"):
+ self._wrap(directory, name)
+
+ def _wrap(self, directory, name):
+ original = getattr(directory, name)
+
+ def wrapper(*args, **kwargs):
+ self.calls.append(name)
+ return original(*args, **kwargs)
+
+ setattr(directory, name, wrapper)
+
+
+def signed_in(adapters, name="alice"):
+ client, _ = sign_in(adapters, name)
+ return client
+
+
+def ask(client, new=NEW, password=CURRENT):
+ return client.post("/email", {"new_email": new, "current_password": password})
+
+
+def mails(to=None):
+ rows = OutboxMail.objects.all()
+ return list(rows.filter(recipient=to) if to else rows)
+
+
+def link_in(mail):
+ return LINK.search(mail.body_text).group(1)
+
+
+def request_link(adapters, name="alice", new=NEW):
+ """Sign in, ask for a change and return the confirmation path."""
+ client = signed_in(adapters, name)
+ assert ask(client, new).status_code == 302
+ return link_in(mails(new)[-1])
+
+
+def confirmed_undo_path(adapters):
+ path = request_link(adapters)
+ Client().post(path)
+ return link_in(mails(OLD)[-1])
+
+
+def actions():
+ names = AuditEntry.objects.values_list("action", flat=True)
+ return [name for name in names if name.startswith("email.")]
+
+
+def mail_of(adapters, name="alice"):
+ return adapters.directory.find_user(name).mail
+
+
+# -- the page -----------------------------------------
+
+
+def test_the_page_shows_the_current_address_and_is_reached_from_the_profile(people, adapters):
+ client = signed_in(adapters)
+ profile = client.get("/profile").content.decode()
+ assert 'href="/email"' in profile and "Change e-mail address" in profile
+ html = client.get("/email").content.decode()
+ assert OLD in html and 'name="new_email"' in html and 'name="current_password"' in html
+ assert 'autocomplete="current-password"' in html and "