1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 03:36:48 +00:00
webvirtcloud/accounts/views.py
catborise e04adec87b feat(logs): changes to users, groups, owners, flavors and settings are logged
The Logs page now records the panel's own records too: users (add,
change with the changed fields, delete, block, unblock, an admin's
password set, a user's own password change including the first-login
one, own profile edits), groups with their members and permissions,
VM owners from the accounts pages and from the VM page, OTP QR code
mails, flavors from web and API, app settings and the theme,
removed-VM records and SSH keys. No row carries a password or a key.

- A user is created in one transaction: when a VM named in
  NEW_USER_DEFAULT_INSTANCES is missing, no half-made user stays.
- The OTP mail checks the email address before it creates a device;
  without one it writes a failed row and makes no device.
- The VM page's owner rows read "VM web-1 owner bob: add" and
  "...: remove" instead of "Add owner: bob" and "Delete owner: 17"; a
  second owner refused by the single-owner setting is a failed row.
  Owner rows keep the VM's host and name, so the VM's log tab shows
  them.
- A setting or theme posted with its current value is no change and
  writes no row; the old free-text setting rows are gone.

Host-side objects (networks, storage, interfaces, secrets, filters)
follow in the next layer.
2026-10-23 11:10:24 +03:00

270 lines
9.5 KiB
Python

import logging
import os
from admin.decorators import superuser_only
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model, update_session_auth_hash
from django.contrib.auth.decorators import login_not_required, login_required
from django.contrib.auth.forms import PasswordChangeForm
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.core.cache import cache
from django.http import Http404
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST
from accounts.apps import admin_password_path
from instances.models import Instance
from logs.audit import audit, changed_fields, failed, snapshot
from accounts.forms import EmailOTPForm, ProfileForm, UserSSHKeyForm
from accounts.models import User, UserAttributes, UserInstance, UserSSHKey
from . import forms
from .utils import get_existing_totp_device, get_user_totp_device, send_email_with_otp
def profile(request):
publickeys = UserSSHKey.objects.filter(user_id=request.user.id)
before = snapshot(request.user)
profile_form = ProfileForm(request.POST or None, instance=request.user)
ssh_key_form = UserSSHKeyForm()
if profile_form.is_valid():
with audit(request, None, f"User {request.user.username}", "change") as entry:
profile_form.save()
fields = changed_fields(before, snapshot(request.user))
entry.action = "change " + ", ".join(fields)
entry.skip = not fields
messages.success(request, _("Profile updated"))
return redirect("accounts:profile")
return render(
request,
"profile.html",
{
"publickeys": publickeys,
"profile_form": profile_form,
"ssh_key_form": ssh_key_form,
},
)
def ssh_key_create(request):
key_form = UserSSHKeyForm(request.POST or None, user=request.user)
if key_form.is_valid():
with audit(request, None, f"SSH key {key_form.cleaned_data['keyname']}", "add"):
key_form.save()
messages.success(request, _("SSH key added"))
return redirect("accounts:profile")
return render(
request,
"common/form.html",
{
"form": key_form,
"title": _("Add SSH key"),
},
)
def ssh_key_delete(request, pk):
ssh_key = get_object_or_404(UserSSHKey, pk=pk, user=request.user)
if request.method == "POST":
with audit(request, None, f"SSH key {ssh_key.keyname}", "delete"):
ssh_key.delete()
messages.success(request, _("SSH key deleted"))
return redirect("accounts:profile")
return render(
request,
"common/confirm_delete.html",
{
"object": ssh_key,
"title": _("Delete SSH key"),
"consequence": _("VMs that already have it keep it; VMs created from now on do not get it."),
},
)
@superuser_only
def account(request, user_id):
user = User.objects.get(id=user_id)
user_insts = UserInstance.objects.filter(user_id=user_id)
instances = Instance.objects.all().order_by("name")
publickeys = UserSSHKey.objects.filter(user_id=user_id)
return render(
request,
"account.html",
{
"user": user,
"user_insts": user_insts,
"instances": instances,
"publickeys": publickeys,
"otp_enabled": settings.OTP_ENABLED,
},
)
@login_required
def change_password(request):
must_change = UserAttributes.objects.filter(
user=request.user, must_change_password=True
).exists()
if not must_change and not request.user.has_perm("accounts.change_password"):
raise PermissionDenied
form = PasswordChangeForm(request.user, request.POST or None)
if form.is_valid():
with audit(request, None, f"User {request.user.username}", "change password"), transaction.atomic():
user = form.save()
UserAttributes.objects.filter(user=user).update(must_change_password=False)
update_session_auth_hash(request, user)
request.session.pop("must_change_password", None)
if must_change and not UserAttributes.objects.filter(must_change_password=True).exists():
try:
os.remove(admin_password_path())
except FileNotFoundError:
pass
except OSError:
logging.getLogger(__name__).warning("Could not remove the generated admin password file")
messages.success(request, _("Password Changed"))
return redirect("accounts:profile")
return render(request, "accounts/change_password_form.html", {"form": form})
@superuser_only
def user_instance_create(request, user_id):
user = get_object_or_404(User, pk=user_id)
form = forms.UserInstanceForm(request.POST or None, initial={"user": user})
if form.is_valid():
instance = form.cleaned_data["instance"]
subject = f"VM {instance.name} owner {form.cleaned_data['user'].username}"
with audit(request, instance.compute, subject, "add", instance=instance.name):
form.save()
return redirect(reverse("accounts:account", args=[user.id]))
return render(
request,
"common/form.html",
{
"form": form,
"title": _("Create User Instance"),
},
)
@superuser_only
def user_instance_update(request, pk):
user_instance = get_object_or_404(UserInstance, pk=pk)
instance, before = user_instance.instance, snapshot(user_instance)
form = forms.UserInstanceForm(request.POST or None, instance=user_instance)
if form.is_valid():
subject = f"VM {instance.name} owner {user_instance.user.username}"
with audit(request, instance.compute, subject, "change", instance=instance.name) as entry:
form.save()
fields = changed_fields(before, snapshot(user_instance))
entry.action = "change " + ", ".join(fields)
entry.skip = not fields
return redirect(reverse("accounts:account", args=[user_instance.user.id]))
return render(
request,
"common/form.html",
{
"form": form,
"title": _("Update User Instance"),
},
)
@superuser_only
def user_instance_delete(request, pk):
user_instance = get_object_or_404(UserInstance, pk=pk)
if request.method == "POST":
user, instance = user_instance.user, user_instance.instance
with audit(request, instance.compute, f"VM {instance.name} owner {user.username}", "remove",
instance=instance.name):
user_instance.delete()
next_url = request.GET.get("next", None)
if next_url and url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(next_url)
return redirect(reverse("accounts:account", args=[user.id]))
return render(
request,
"common/confirm_delete.html",
{"object": user_instance,
"consequence": _("The user loses access to this VM; the VM itself is not changed.")},
)
# a user gets at most one OTP mail per this many seconds
EMAIL_OTP_INTERVAL = 300
@login_not_required
def email_otp(request):
"""
"Lost OTP?": mail the OTP QR code to the user with this address. Open to
anyone, so it mails only one exact, active match, at most once per
EMAIL_OTP_INTERVAL, and answers the same in every case.
"""
if not settings.OTP_ENABLED:
raise Http404
form = EmailOTPForm(request.POST or None)
if form.is_valid():
users = list(get_user_model().objects.filter(email__iexact=form.cleaned_data["email"], is_active=True)[:2])
# the cache is per process: with several workers the bound is per worker
if len(users) == 1 and cache.add(f"email_otp:{users[0].pk}", True, EMAIL_OTP_INTERVAL):
user = users[0]
# resend only to a user who already enrolled: creating a device here
# would let anyone who knows an email force OTP on that account
device = get_existing_totp_device(user)
if device is not None:
try:
send_email_with_otp(user, device)
except Exception:
logging.getLogger(__name__).exception("Could not mail the OTP QR code to user %s", user.pk)
messages.success(
request, _("OTP Sent to %(email)s") % {"email": form.cleaned_data["email"]}
)
return redirect("accounts:login")
return render(
request,
"accounts/email_otp_form.html",
{
"form": form,
"title": _("Email OTP"),
},
)
@require_POST
@superuser_only
def admin_email_otp(request, user_id):
user = get_object_or_404(get_user_model(), pk=user_id)
subject = f"User {user.username}"
if user.email != "":
with audit(request, None, subject, "mail OTP QR code"):
send_email_with_otp(user, get_user_totp_device(user))
messages.success(
request, _("OTP QR code was emailed to user %(user)s") % {"user": user}
)
else:
failed(request, None, subject, "mail OTP QR code", "no email address")
messages.error(request, _("User email not set, failed to send QR code"))
return redirect("accounts:account", user.id)