1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/accounts/views.py
catborise 40287dbd2a fix(accounts): OTP login works per user and "Lost OTP?" cannot impose 2FA
With OTP_ENABLED the login used django-otp's OTPAuthenticationForm. Under the
pinned django-otp 1.7.3 that form could not log anyone in through the shipped
page: the page posts only a 6-digit otp_token, but the form now needs the
device named explicitly, so an enrolled user got "Please select a device.",
and a user who never enrolled got "Please enter your OTP token." with no
device to produce one. Verified in an isolated test database.

SingleDeviceOTPForm makes OTP per user: a user with a confirmed TOTP device
must enter a valid code; a user without one logs in with the password alone.
The form chooses the device itself (the user's oldest confirmed TOTP device,
locked with select_for_update inside clean_otp's transaction so replay and
throttling counters cannot race) and ignores any posted otp_device, which
django-otp does not check for confirmed status or type. The login page drops
the HTML "required" on the token field (the field is optional for an
unenrolled user) and says so.

The "Lost OTP?" page (email_otp) created a confirmed device when none existed.
With per-user OTP that let anyone who knows an address turn a password-only
account into an OTP-required one and, if the mail never arrived, lock it out.
It now resends the QR only to a user who already enrolled and never creates a
device (get_existing_totp_device); admin enrollment (admin_email_otp) still
creates one. Email QR distribution and confirmed-on-create enrollment are
unchanged by user decision.

Tests: per-user login (no device -> password only; device -> valid token
passes, wrong/empty token fails), a posted or another user's device is not
used, a token cannot be replayed, and the real login page over HTTP with
OTP_ENABLED; email_otp sends nothing and creates no device for an unenrolled
user. The new tests fail on the previous code.
2026-10-07 08:48:55 +03:00

260 lines
8.4 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, login as auth_login
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, HttpResponseRedirect
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 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
from django.contrib.auth.views import LoginView
from logs.views import addlogmsg
class CustomLoginView(LoginView):
def form_valid(self, form):
username = form.cleaned_data['username']
addlogmsg(username, "-", "-", "Logged In")
auth_login(self.request, form.get_user())
return HttpResponseRedirect(self.get_success_url())
def form_invalid(self, form):
username = form.cleaned_data['username']
addlogmsg(username, "-", "-", "Failed Login Attempt")
return self.render_to_response(self.get_context_data(form=form))
def profile(request):
publickeys = UserSSHKey.objects.filter(user_id=request.user.id)
profile_form = ProfileForm(request.POST or None, instance=request.user)
ssh_key_form = UserSSHKeyForm()
if profile_form.is_valid():
profile_form.save()
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():
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":
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"),
},
)
@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 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():
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)
form = forms.UserInstanceForm(request.POST or None, instance=user_instance)
if form.is_valid():
form.save()
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 = user_instance.user
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},
)
# 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)
device = get_user_totp_device(user)
if user.email != "":
send_email_with_otp(user, device)
messages.success(
request, _("OTP QR code was emailed to user %(user)s") % {"user": user}
)
else:
messages.error(request, _("User email not set, failed to send QR code"))
return redirect("accounts:account", user.id)