mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
The "Lost OTP?" page writes a row when it mails a user their QR code link, or fails to, under the anonymous requester and their address. A password change refused for a wrong old password writes a failed row.
393 lines
15 KiB
Python
393 lines
15 KiB
Python
import logging
|
|
import os
|
|
from datetime import timedelta
|
|
|
|
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.core.signing import BadSignature
|
|
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 import timezone
|
|
from django.utils.http import url_has_allowed_host_and_scheme
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.views.decorators.cache import never_cache
|
|
from django.views.decorators.http import require_POST
|
|
from django.views.decorators.debug import sensitive_post_parameters
|
|
|
|
from accounts.apps import admin_password_path
|
|
from computes.utils import lock_ownership
|
|
from logs.audit import audit, audit_vm, changed_fields, failed, snapshot
|
|
|
|
from accounts.forms import ApiTokenForm, EmailOTPForm, ProfileForm, UserSSHKeyForm
|
|
from accounts.models import ApiToken, User, UserAttributes, UserInstance, UserSSHKey
|
|
|
|
from . import forms
|
|
from .utils import OTP_LINK, OTP_LINK_MAX_AGE, get_existing_totp_device, get_user_totp_device, otp_qr_svg, 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,
|
|
"api_tokens": ApiToken.objects.filter(user=request.user),
|
|
"api_token_form": ApiTokenForm(),
|
|
},
|
|
)
|
|
|
|
|
|
@require_POST
|
|
@never_cache
|
|
def api_token_create(request):
|
|
"""A REST API token, shown once: only its digest is kept. It is made in
|
|
a session that passed the login, and its OTP when OTP is on."""
|
|
form = ApiTokenForm(request.POST)
|
|
if not form.is_valid():
|
|
return render(request, "common/form.html", {"form": form, "title": _("Create an API token")})
|
|
name, days = form.cleaned_data["name"], form.cleaned_data["days"]
|
|
with transaction.atomic():
|
|
# the user's row: two forms sent at once (a double click) take their turns
|
|
get_user_model().objects.select_for_update().get(pk=request.user.pk)
|
|
ApiToken.objects.filter(user=request.user, expiry__lt=timezone.now()).delete()
|
|
if ApiToken.objects.filter(user=request.user).count() >= ApiToken.LIMIT:
|
|
form.add_error(None, _("A user has at most %(limit)s API tokens: delete one first.") % {"limit": ApiToken.LIMIT})
|
|
elif ApiToken.objects.filter(user=request.user, name=name).exists():
|
|
form.add_error("name", _("You have a token named %(name)s: choose another name.") % {"name": name})
|
|
else:
|
|
with audit(request, None, f"API token {name}", "add"):
|
|
_token, key = ApiToken.objects.create(request.user, expiry=timedelta(days=days) if days else None,
|
|
name=name)
|
|
if form.errors:
|
|
return render(request, "common/form.html", {"form": form, "title": _("Create an API token")})
|
|
# shown by a GET, so reloading the page does not create another token
|
|
request.session["api_token_created"] = {"name": name, "key": key}
|
|
return redirect("accounts:api_token_created")
|
|
|
|
|
|
@never_cache
|
|
def api_token_created(request):
|
|
"""The token just created, shown once."""
|
|
created = request.session.pop("api_token_created", None)
|
|
if created is None:
|
|
return redirect(reverse("accounts:profile") + "#apitokens")
|
|
return render(request, "api_token_created.html", created)
|
|
|
|
|
|
def api_token_delete(request, token_key, user_id=None):
|
|
"""Delete one of the user's own tokens or, given user_id, a superuser
|
|
deletes one of that user's."""
|
|
if user_id is None:
|
|
token = get_object_or_404(ApiToken, token_key=token_key, user=request.user)
|
|
subject = f"API token {token.name}"
|
|
back = reverse("accounts:profile")
|
|
elif request.user.is_superuser:
|
|
token = get_object_or_404(ApiToken, token_key=token_key, user_id=user_id)
|
|
subject = f"API token {token.name} of {token.user.username}"
|
|
back = reverse("accounts:account", args=[user_id])
|
|
else:
|
|
raise PermissionDenied
|
|
if request.method == "POST":
|
|
with audit(request, None, subject, "delete"):
|
|
token.delete()
|
|
messages.success(request, _("API token deleted"))
|
|
return redirect(back + "#apitokens")
|
|
|
|
return render(
|
|
request,
|
|
"common/confirm_delete.html",
|
|
{
|
|
"object": subject,
|
|
"title": _("Delete API token"),
|
|
"consequence": _("Scripts and tools that use it can no longer reach the API."),
|
|
},
|
|
)
|
|
|
|
|
|
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(reverse("accounts:profile") + "#sshkeys")
|
|
|
|
return render(
|
|
request,
|
|
"common/form.html",
|
|
{
|
|
"form": key_form,
|
|
"title": _("Add SSH key"),
|
|
},
|
|
)
|
|
|
|
|
|
def ssh_key_delete(request, pk, user_id=None):
|
|
"""Delete one of the user's own keys or, given user_id, a superuser
|
|
deletes one of that user's."""
|
|
if user_id is None:
|
|
ssh_key = get_object_or_404(UserSSHKey, pk=pk, user=request.user)
|
|
subject = f"SSH key {ssh_key.keyname}"
|
|
back = reverse("accounts:profile") + "#sshkeys"
|
|
elif request.user.is_superuser:
|
|
ssh_key = get_object_or_404(UserSSHKey, pk=pk, user_id=user_id)
|
|
subject = f"SSH key {ssh_key.keyname} of {ssh_key.user.username}"
|
|
back = reverse("accounts:account", args=[user_id]) + "#public-keys"
|
|
else:
|
|
raise PermissionDenied
|
|
if request.method == "POST":
|
|
with audit(request, None, subject, "delete"):
|
|
ssh_key.delete()
|
|
messages.success(request, _("SSH key deleted"))
|
|
return redirect(back)
|
|
|
|
return render(
|
|
request,
|
|
"common/confirm_delete.html",
|
|
{
|
|
"object": subject,
|
|
"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 = get_object_or_404(User, id=user_id)
|
|
user_insts = UserInstance.objects.filter(user_id=user_id).select_related("instance__compute")
|
|
publickeys = UserSSHKey.objects.filter(user_id=user_id)
|
|
|
|
return render(
|
|
request,
|
|
"account.html",
|
|
{
|
|
"user": user,
|
|
"user_insts": user_insts,
|
|
"publickeys": publickeys,
|
|
"api_tokens": ApiToken.objects.filter(user_id=user_id),
|
|
"otp_enabled": settings.OTP_ENABLED,
|
|
},
|
|
)
|
|
|
|
|
|
@sensitive_post_parameters()
|
|
@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
|
|
if not request.user.has_usable_password(): # an LDAP user: the password is changed in LDAP
|
|
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")
|
|
if form.has_error("old_password", "password_incorrect"): # someone at an open session guessing it
|
|
failed(request, None, f"User {request.user.username}", "change password", "wrong old password")
|
|
|
|
return render(request, "accounts/change_password_form.html", {"form": form, "must_change": must_change})
|
|
|
|
|
|
@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"]
|
|
# the form's one-owner check again, with the insert in one step, as on the VM page
|
|
with audit_vm(request, instance, f"add owner {form.cleaned_data['user'].username}") as entry, \
|
|
transaction.atomic():
|
|
lock_ownership()
|
|
form.full_clean()
|
|
entry.skip = not form.is_valid()
|
|
if form.is_valid():
|
|
form.save()
|
|
if form.is_valid():
|
|
return redirect(reverse("accounts:account", args=[user.id]))
|
|
|
|
return render(
|
|
request,
|
|
"common/form.html",
|
|
{
|
|
"form": form,
|
|
"title": _("Create User Instance"),
|
|
},
|
|
)
|
|
|
|
|
|
def _next_or(request, default):
|
|
"""To the ?next page, the VM page an owner was changed from, when it is
|
|
on this site; else to default."""
|
|
next_url = request.GET.get("next")
|
|
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(default)
|
|
|
|
|
|
@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():
|
|
with audit_vm(request, instance, "change owner") as entry:
|
|
form.save()
|
|
fields = changed_fields(before, snapshot(user_instance))
|
|
entry.action = f"change owner {user_instance.user.username}: " + ", ".join(fields)
|
|
entry.skip = not fields
|
|
return _next_or(request, 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_vm(request, instance, f"remove owner {user.username}"):
|
|
user_instance.delete()
|
|
return _next_or(request, 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 a link to 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:
|
|
# the row's user is the requester: anonymous
|
|
with audit(request, None, f"User {user.username}", "mail OTP QR code (lost OTP)"):
|
|
send_email_with_otp(request, user)
|
|
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"),
|
|
},
|
|
)
|
|
|
|
|
|
@login_not_required
|
|
def otp_qr(request, token):
|
|
"""The OTP QR code of the user a mailed link (send_email_with_otp) names."""
|
|
if not settings.OTP_ENABLED:
|
|
raise Http404
|
|
try:
|
|
user_id = OTP_LINK.unsign(token, max_age=OTP_LINK_MAX_AGE)
|
|
except BadSignature:
|
|
raise Http404
|
|
user = get_object_or_404(get_user_model(), pk=user_id, is_active=True)
|
|
device = get_existing_totp_device(user)
|
|
if device is None:
|
|
raise Http404
|
|
response = render(request, "accounts/otp_qr.html", {"user": user, "qr_svg": otp_qr_svg(device)})
|
|
response["Cache-Control"] = "no-store"
|
|
return response
|
|
|
|
|
|
@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 != "":
|
|
try:
|
|
with audit(request, None, subject, "mail OTP QR code"):
|
|
get_user_totp_device(user)
|
|
send_email_with_otp(request, user)
|
|
except OSError as err: # the SMTP server refused it, or did not answer within EMAIL_TIMEOUT
|
|
messages.error(request, _("The OTP QR code could not be mailed to %(user)s: %(error)s") % {
|
|
"user": user, "error": err})
|
|
else:
|
|
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)
|