mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
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.
260 lines
8.4 KiB
Python
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)
|