Profile
This commit is contained in:
+137
-2
@@ -11,6 +11,7 @@ import stripe
|
||||
from authlib.integrations.django_client import OAuth
|
||||
from django.conf import settings
|
||||
from django.core.validators import validate_email
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.mail import send_mail
|
||||
from django.db import ProgrammingError, transaction
|
||||
from django.http import HttpResponse
|
||||
@@ -23,9 +24,10 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
from django.views.decorators.http import require_GET, require_POST, require_http_methods
|
||||
|
||||
from . import billing, directory, shkeeper
|
||||
from . import billing, directory, profiles, shkeeper
|
||||
from .forms import PasswordForm, RegistrationForm
|
||||
from .models import Account, CryptoInvoice, ManagedGroup, RateWindow, Registration, Subscription, WebhookEvent
|
||||
from .profile_forms import CurrentPasswordForm, ProfileEmailForm, ProfileForm, ProfilePasswordForm
|
||||
from .models import Account, CryptoInvoice, ManagedGroup, ProfileEmailChange, RateWindow, Registration, Subscription, WebhookEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -240,6 +242,139 @@ def logout(request):
|
||||
return redirect("members:home")
|
||||
|
||||
|
||||
def _profile_context(account, values):
|
||||
return {"account": account, "profile_loaded": True, "form": ProfileForm(initial=profiles.initial(values)), "email_form": ProfileEmailForm(), "password_form": ProfilePasswordForm(), "has_photo": bool(values["jpegPhoto"]), "email_values": values["mail"], "revision": profiles.revision(account, values)}
|
||||
|
||||
|
||||
@sensitive_post_parameters()
|
||||
@require_http_methods(["GET", "POST"])
|
||||
@account_required
|
||||
def profile(request):
|
||||
member = request.member
|
||||
# Bound before any password verification; never log posted values or LDAP
|
||||
# diagnostic messages. Account and IP limits protect directory binds.
|
||||
if limited("profile-read", str(member.pk), 120):
|
||||
return HttpResponse("Please try again later.", status=429)
|
||||
try:
|
||||
values = profiles.read_profile(member)
|
||||
except Exception as exc:
|
||||
logger.error("Profile read failed account=%s kind=%s", member.pk, type(exc).__name__)
|
||||
return render(request, "profile.html", {"account": member, "profile_loaded": False}, status=503)
|
||||
context = _profile_context(member, values)
|
||||
context.update(saved=request.GET.get("saved") == "1", email_sent=request.GET.get("email_sent") == "1")
|
||||
if request.method == "POST":
|
||||
if limited("profile-write", str(member.pk), 20) or limited("profile-write-ip", client_ip(request), 40):
|
||||
return HttpResponse("Please try again later.", status=429)
|
||||
action = request.POST.get("action")
|
||||
forms = {"profile": ("form", ProfileForm), "email": ("email_form", ProfileEmailForm), "password": ("password_form", ProfilePasswordForm)}
|
||||
if action not in forms:
|
||||
return HttpResponse(status=400)
|
||||
form_key, form_class = forms[action]
|
||||
form = form_class(request.POST, request.FILES) if action == "profile" else form_class(request.POST)
|
||||
context[form_key] = form
|
||||
if form.is_valid():
|
||||
try:
|
||||
# Serialize directory pinning and edits against the worker.
|
||||
with transaction.atomic():
|
||||
member = Account.objects.select_for_update().get(pk=member.pk)
|
||||
if action == "profile":
|
||||
profiles.update_profile(member, form.cleaned_data, request.POST.get("revision", ""))
|
||||
elif action == "password":
|
||||
profiles.change_password(member, form.cleaned_data["current_password"], form.cleaned_data["password"])
|
||||
else:
|
||||
profiles.verify_password(member, form.cleaned_data["current_password"])
|
||||
token = secrets.token_urlsafe(32)
|
||||
digest = hashlib.sha256(token.encode()).hexdigest()
|
||||
ProfileEmailChange.objects.filter(account=member).delete()
|
||||
ProfileEmailChange.objects.create(token_hash=digest, account=member, email=form.cleaned_data["email"], ldap_uuid=member.ldap_uuid, expires_at=timezone.now() + timedelta(hours=1))
|
||||
url = settings.PUBLIC_URL + reverse("members:verify_profile_email", args=[token])
|
||||
send_mail("Verify your new eom.dev email", f"Confirm this email address for your account (expires in one hour):\n\n{url}\n\nYou must sign in to the same account and enter your current password to confirm. If you did not request this, ignore this message.", settings.DEFAULT_FROM_EMAIL, [form.cleaned_data["email"]])
|
||||
logger.info("Profile change completed account=%s action=%s", member.pk, action)
|
||||
if action == "password":
|
||||
request.session.flush()
|
||||
return page(request, title="Password changed", message="Your password was changed. Sign in again. Existing Dex and other service sessions are not automatically signed out.")
|
||||
return redirect(reverse("members:profile") + ("?email_sent=1" if action == "email" else "?saved=1"))
|
||||
except Exception as exc:
|
||||
logger.error("Profile change failed account=%s kind=%s", member.pk, type(exc).__name__)
|
||||
form.add_error(None, "Changes could not be completed. Check your current password and reload your profile before retrying. Contact the administrator if this continues.")
|
||||
# Keep the original revision on validation errors, never silently allow
|
||||
# a stale bound form to overwrite concurrently updated directory data.
|
||||
if action == "profile":
|
||||
context["revision"] = request.POST.get("revision", "")
|
||||
return render(request, "profile.html", context)
|
||||
|
||||
|
||||
@require_GET
|
||||
@account_required
|
||||
def profile_photo(request):
|
||||
if limited("profile-photo", str(request.member.pk), 120):
|
||||
return HttpResponse(status=429)
|
||||
try:
|
||||
values = profiles.read_profile(request.member)
|
||||
if not values["jpegPhoto"]:
|
||||
return HttpResponse(status=404)
|
||||
# Existing LDAP photos are untrusted too: rewrite before browser delivery.
|
||||
raw = values["jpegPhoto"][0]
|
||||
if not isinstance(raw, bytes):
|
||||
raise ValueError("Invalid stored image.")
|
||||
form = ProfileForm(files={"jpegPhoto": SimpleUploadedFile("photo.jpg", raw, content_type="image/jpeg")})
|
||||
form.is_valid()
|
||||
image = form.cleaned_data.get("jpegPhoto")
|
||||
if not image:
|
||||
return HttpResponse(status=404)
|
||||
response = HttpResponse(image, content_type="image/jpeg")
|
||||
response["Content-Disposition"] = 'inline; filename="profile.jpg"'
|
||||
return response
|
||||
except Exception as exc:
|
||||
logger.error("Profile photo failed account=%s kind=%s", request.member.pk, type(exc).__name__)
|
||||
return HttpResponse(status=404)
|
||||
|
||||
|
||||
@require_GET
|
||||
@account_required
|
||||
def verify_profile_email(request, token):
|
||||
if limited("profile-email-verify", client_ip(request), 60):
|
||||
return HttpResponse(status=429)
|
||||
digest = hashlib.sha256(token.encode()).hexdigest()
|
||||
if not ProfileEmailChange.objects.filter(pk=digest, account=request.member, expires_at__gt=timezone.now()).exists():
|
||||
return page(request, title="Link unavailable", message="Request a new email change from your profile.")
|
||||
request.session["profile_email_hash"] = digest
|
||||
return redirect("members:confirm_profile_email")
|
||||
|
||||
|
||||
@sensitive_post_parameters("current_password")
|
||||
@require_http_methods(["GET", "POST"])
|
||||
@account_required
|
||||
def confirm_profile_email(request):
|
||||
digest = request.session.get("profile_email_hash")
|
||||
pending = ProfileEmailChange.objects.filter(pk=digest, account=request.member, expires_at__gt=timezone.now()).first()
|
||||
if not pending:
|
||||
return page(request, title="Link unavailable", message="Request a new email change from your profile.")
|
||||
form = CurrentPasswordForm(request.POST if request.method == "POST" else None)
|
||||
if request.method == "POST":
|
||||
if limited("profile-write", str(request.member.pk), 20) or limited("profile-write-ip", client_ip(request), 40):
|
||||
return HttpResponse("Please try again later.", status=429)
|
||||
if form.is_valid():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
member = Account.objects.select_for_update().get(pk=request.member.pk)
|
||||
pending = ProfileEmailChange.objects.select_for_update().get(pk=digest, account=member, expires_at__gt=timezone.now())
|
||||
profiles.read_profile(member)
|
||||
if member.ldap_uuid != pending.ldap_uuid:
|
||||
raise ValueError("Directory identity changed.")
|
||||
profiles.change_email(member, form.cleaned_data["current_password"], pending.email)
|
||||
member.email = pending.email
|
||||
member.save(update_fields=["email"])
|
||||
ProfileEmailChange.objects.filter(account=member).delete()
|
||||
request.session.pop("profile_email_hash", None)
|
||||
request.session.cycle_key()
|
||||
return redirect(reverse("members:profile") + "?saved=1")
|
||||
except Exception as exc:
|
||||
logger.error("Profile email confirmation failed account=%s kind=%s", request.member.pk, type(exc).__name__)
|
||||
form.add_error(None, "Email change could not be completed. Check your current password or request a new verification link.")
|
||||
return render(request, "profile.html", {"account": request.member, "confirm_email": True, "confirmation_email": pending.email, "confirmation_form": form})
|
||||
|
||||
|
||||
def stripe_redirect(url, host):
|
||||
parsed = urlsplit(url or "")
|
||||
if parsed.scheme != "https" or parsed.hostname != host or parsed.username:
|
||||
|
||||
Reference in New Issue
Block a user