"""The password, profile and e-mail pages (FR-5..FR-11). No business logic here (AD-17).""" from __future__ import annotations 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 from portal.accounts.decorators import login_required from portal.accounts.identity import remember from portal.accounts.throttle import Throttled from portal.adapters.directory import DirectoryUnavailable from portal.adapters.registry import get_directory from . import services from .forms import DETAIL_FIELDS, DetailsForm, EmailChangeForm, PasswordChangeForm from .models import PendingEmailChange logger = logging.getLogger(__name__) DONE_FLAG = "password_changed" # Rule keys from services.local_hints, in plain words. HINT_MESSAGES = { "too_short": lambda: _("The password is too short."), "too_few_groups": lambda: _( "The password needs characters from more groups: upper case, lower case, digits, special." ), } def _page(request, form, *, policy=None, status=200, throttled=False): rules = services.describe_policy(policy) if policy is not None else [] partly_known = policy is not None and policy.fine_grained and not policy.fine_grained_readable context = { "form": form, "rules": rules, "partly_known": partly_known, "throttled": throttled, } return render(request, "profile/password.html", context, status=status) def _unavailable(request): return render(request, "accounts/unavailable.html", status=503) @login_required @sensitive_post_parameters("current_password", "new_password", "confirm_password") @require_http_methods(["GET", "POST"]) def password(request): user = request.portal_user.user if request.method == "GET": try: policy = get_directory().password_policy(user.sam) except DirectoryUnavailable: return _unavailable(request) return _page(request, PasswordChangeForm(), policy=policy) form = PasswordChangeForm(request.POST) if not form.is_valid(): return _page(request, form, policy=_policy_or_none(user), status=400) try: result = services.change_password( user, form.cleaned_data["current_password"], form.cleaned_data["new_password"], ip=request.client_ip, session_key=request.session.session_key, ) except Throttled: return _page(request, PasswordChangeForm(), status=429, throttled=True) except services.WrongCurrentPassword as exc: form.add_error("current_password", _("The current password is not correct.")) return _page(request, form, policy=exc.policy, status=400) except services.PasswordNotAccepted as exc: messages = [HINT_MESSAGES[key]() for key in exc.reasons] or [ _( "The new password does not meet the rules above, " "or it was used or changed too recently." ) ] for message in messages: form.add_error("new_password", message) return _page(request, form, policy=exc.policy, status=400) except DirectoryUnavailable: return _unavailable(request) except services.OutcomeUnknown: return render(request, "profile/password_unknown.html", status=500) request.session[DONE_FLAG] = {"mail": result.mail_queued} return redirect("password-done") def _policy_or_none(user): try: return get_directory().password_policy(user.sam) except DirectoryUnavailable: return None @login_required def password_done(request): flag = request.session.pop(DONE_FLAG, None) if flag is None: # opened directly: nothing to report return redirect("password") return render(request, "profile/password_done.html", {"mail": flag["mail"]}) # -- profile details (FR-9) ------------------------------------------------------------------- NOTICE_FLAG = "profile_notice" # "saved" or "unchanged", shown once after the redirect def _details_page(request, form, details, *, status=200, notice=None): context = { "form": form, "details": details, "notice": notice, "fields": [ {"field": form[name], "label": label, "max_length": max_length} for name, (label, max_length, _required) in DETAIL_FIELDS.items() ], } return render(request, "profile/details.html", context, status=status) @login_required @require_http_methods(["GET", "POST"]) def profile(request): user = request.portal_user.user # the session user only: nothing is read from the request if request.method == "GET": try: details = services.load_details(user) except DirectoryUnavailable: return _unavailable(request) notice = request.session.pop(NOTICE_FLAG, None) return _details_page(request, DetailsForm(initial=details.values), details, notice=notice) form = DetailsForm(request.POST) form.is_valid() # trims; the checks need the directory value and run in the service try: result = services.update_details(user, form.cleaned_data, ip=request.client_ip) except services.DetailsInvalid as exc: form.apply_errors(exc.errors) return _details_page(request, form, exc.details, status=400) except services.DetailsNotAccepted as exc: form.add_error(None, _("A value was not accepted. Please check your entries.")) return _details_page(request, form, exc.details, status=400) except DirectoryUnavailable: return _unavailable(request) except services.OutcomeUnknown: return render(request, "profile/details_unknown.html", status=500) if result.changed: 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")