Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bypass 2FA Token in local development #82

Merged
merged 9 commits into from
Jun 1, 2024
43 changes: 22 additions & 21 deletions project/npda/templates/two_factor/_wizard_actions.html
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
{% load i18n %}

<div class="flex flex-col justify-center items-center">

<button type="submit" class="bg-rcpch_light_blue text-white font-semibold hover:text-white py-2.5 px-3 mt-5 border border-rcpch_light_blue hover:bg-rcpch_strong_blue hover:border-rcpch_strong_blue">
{% trans "Next" %}
</button>


<button type="submit"
class="bg-rcpch_light_blue text-white font-semibold hover:text-white py-2.5 px-3 mt-5 border border-rcpch_light_blue hover:bg-rcpch_strong_blue hover:border-rcpch_strong_blue">
{% trans "Next" %}
</button>

{% if wizard.steps.prev %}
<!-- <div> -->
<button
name="wizard_goto_step"
type="submit"
value="{{ wizard.steps.prev }}"
class="justify-center items-center bg-rcpch_light_blue text-white font-semibold hover:text-white py-2.5 px-3 mt-5 border border-rcpch_light_blue hover:bg-rcpch_strong_blue hover:border-rcpch_strong_blue"
>
Back
</button>
</div>
{% endif %}
<!-- </div> -->
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}"
class="justify-center items-center bg-rcpch_light_blue text-white font-semibold hover:text-white py-2.5 px-3 mt-5 border border-rcpch_light_blue hover:bg-rcpch_strong_blue hover:border-rcpch_strong_blue">
Back
</button>
</div>
{% endif %}

<!--IF NO PREV BUTTON -> MUST BE AT FIRST SIGNIN PAGE -->
{% if not wizard.steps.prev %}
<div class="flex flex-col justify-center items-center">
<a href="{% url 'password_reset' %}" class="bg-rcpch_light_blue text-white font-semibold hover:text-white py-2.5 px-3 mt-5 border border-rcpch_light_blue hover:bg-rcpch_strong_blue hover:border-rcpch_strong_blue">Reset Password</a>
<a href="{% url 'password_reset' %}"
class="bg-rcpch_light_blue text-white font-semibold hover:text-white py-2.5 px-3 mt-5 border border-rcpch_light_blue hover:bg-rcpch_strong_blue hover:border-rcpch_strong_blue">Reset
Password</a>
<div class="mt-5">
<div class="mt-2 bg-gray-100 border border-gray-200 text-sm text-gray-800 rounded-lg p-4 dark:bg-white/10 dark:border-white/20 dark:text-white font-montserrat" role="alert">
<span class="font-bold">NOTE</span> For users of the old platform, you will require a new username and password to access this new platform. Please contact your Lead Clinician or <a href="mailto:[email protected]">[email protected]</a> for assistance.
<div
class="mt-2 bg-gray-100 border border-gray-200 text-sm text-gray-800 rounded-lg p-4 dark:bg-white/10 dark:border-white/20 dark:text-white font-montserrat"
role="alert">
<span class="font-bold">NOTE</span> For users of the old platform, you will require a new username and password to
access this new platform. Please contact your Lead Clinician or <a
href="mailto:[email protected]">[email protected]</a> for assistance.
</div>
</div>
</div>
{% endif %}
{% endif %}
8 changes: 2 additions & 6 deletions project/npda/urls.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from django.urls import path, include
from django.contrib.auth.views import PasswordResetConfirmView, LogoutView
from django.contrib.auth.views import PasswordResetConfirmView
from django.contrib.auth import urls as auth_urls
from project.npda.views import (
VisitCreateView,
Expand All @@ -13,8 +13,6 @@
from .views import *

urlpatterns = [
path("captcha/", include("captcha.urls")),
path("account/", include(auth_urls)),
path("home", view=home, name="home"),
# Patient views
path("patients", view=PatientListView.as_view(), name="patients"),
Expand Down Expand Up @@ -68,7 +66,7 @@
view=NPDAUserDeleteView.as_view(),
name="npdauser-delete",
),
# authentication
# Authentication -> NOTE: 2FA is implemented in project-level URLS with tf_urls
path("captcha/", include("captcha.urls")),
path("account/", include(auth_urls)),
path(
Expand All @@ -84,6 +82,4 @@
),
name="password_reset_confirm",
),
path("account/login", view=RCPCHLoginView.as_view(), name="login"),
path("account/logout", view=LogoutView.as_view(), name="logout"),
]
50 changes: 50 additions & 0 deletions project/npda/views/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# python imports
import logging

from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import login_required

# Logging setup
logger = logging.getLogger(__name__)


def login_and_otp_required():
"""
Must have verified via 2FA
"""

def decorator(view):
# First use login_required on decorator
login_required(view)

def wrapper(request, *args, **kwargs):
# Then, ensure 2fa verified
user = request.user

# Bypass 2fa if local dev, with warning message
if settings.DEBUG:
logger.warning(
"User %s has bypassed 2FA for %s as settings.DEBUG is %s",
user,
view,
settings.DEBUG,
)
return view(request, *args, **kwargs)

# Prevent unverified users
if not user.is_verified():
user_list = user.__dict__
epilepsy12_user = user_list["_wrapped"]
logger.info(
"User %s is unverified. Tried accessing %s",
epilepsy12_user,
view.__qualname__,
)
raise PermissionDenied("Unverified user")

return view(request, *args, **kwargs)

return wrapper

return decorator
34 changes: 16 additions & 18 deletions project/npda/views/home.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,26 @@
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.contrib.auth.decorators import login_required
from django_otp.decorators import otp_required
from django.contrib import messages

from ..general_functions import csv_upload
from ..forms.upload import UploadFileForm
from ..models import Patient, Visit
from .decorators import login_and_otp_required


@login_required
@login_and_otp_required()
def home(request):
if request.user.is_verified():
file_uploaded = False
if request.method == "POST":
form = UploadFileForm(request.POST, request.FILES)
file = request.FILES["csv_upload"]
file_uploaded = csv_upload(csv_file=file)
if file_uploaded["status"]==500:
messages.error(request=request,message=f"{file_uploaded["errors"]}")
return redirect('home')
else:
form = UploadFileForm()
context = {"file_uploaded": file_uploaded, "form": form}
template = "home.html"
return render(request=request, template_name=template, context=context)

file_uploaded = False
if request.method == "POST":
form = UploadFileForm(request.POST, request.FILES)
file = request.FILES["csv_upload"]
file_uploaded = csv_upload(csv_file=file)
if file_uploaded["status"]==500:
messages.error(request=request,message=f"{file_uploaded["errors"]}")
return redirect('home')
else:
return redirect(reverse("two_factor:profile"))
form = UploadFileForm()
context = {"file_uploaded": file_uploaded, "form": form}
template = "home.html"
return render(request=request, template_name=template, context=context)
44 changes: 44 additions & 0 deletions project/npda/views/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Defines custom mixins used throughout our Class Based Views"""

import logging

from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.contrib.auth.mixins import AccessMixin

logger = logging.getLogger(__name__)


class LoginAndOTPRequiredMixin(AccessMixin):
"""
Mixin that ensures the user is logged in and has verified via OTP.

Bypassed in local development is user.is_superuser AND settings.DEBUG==True.
"""

def dispatch(self, request, *args, **kwargs):

# Check if the user is authenticated
if not request.user.is_authenticated:
return self.handle_no_permission()

# Check if the user is superuser and bypass 2FA in debug mode
if settings.DEBUG and request.user.is_superuser:
logger.warning(
"User %s has bypassed 2FA for %s as settings.DEBUG is %s and user is superuser",
request.user,
self.__class__.__name__,
settings.DEBUG,
)
return super().dispatch(request, *args, **kwargs)

# Check if the user is verified
if not request.user.is_verified():
logger.info(
"User %s is unverified. Tried accessing %s",
request.user,
self.__class__.__name__,
)
raise PermissionDenied()

return super().dispatch(request, *args, **kwargs)
46 changes: 33 additions & 13 deletions project/npda/views/npda_users.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
from datetime import datetime, timedelta
import logging

from django.http import HttpRequest, HttpResponse
from django.utils import timezone
from django.shortcuts import redirect
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.views.generic import ListView
from django.contrib.auth.views import PasswordResetView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy, reverse
from django.contrib.messages.views import SuccessMessageMixin
from django.contrib import messages
from django.contrib.auth import login, authenticate
from django.utils.html import strip_tags
from django.conf import settings
from two_factor.views import LoginView as TwoFactorLoginView
from two_factor.views.mixins import OTPRequiredMixin


from ..models import NPDAUser, VisitActivity
from ..forms.npda_user_form import NPDAUserForm, CaptchaAuthenticationForm
from ..general_functions import (
Expand All @@ -23,6 +25,10 @@
construct_transfer_npda_site_outcome_email,
group_for_role,
)
from .mixins import LoginAndOTPRequiredMixin
from django.utils.decorators import method_decorator
from .decorators import login_and_otp_required
from django.contrib.auth.decorators import login_required

logger = logging.getLogger(__name__)

Expand All @@ -31,16 +37,14 @@
"""


class NPDAUserListView(LoginRequiredMixin, OTPRequiredMixin, ListView):
class NPDAUserListView(LoginAndOTPRequiredMixin, ListView):
template_name = "npda_users.html"

def get_queryset(self):
return NPDAUser.objects.all().order_by("surname")


class NPDAUserCreateView(
LoginRequiredMixin, OTPRequiredMixin, SuccessMessageMixin, CreateView
):
class NPDAUserCreateView(LoginAndOTPRequiredMixin, SuccessMessageMixin, CreateView):
"""
Handle creation of new patient in audit
"""
Expand Down Expand Up @@ -114,9 +118,7 @@ def get_success_url(self) -> str:
)


class NPDAUserUpdateView(
LoginRequiredMixin, OTPRequiredMixin, SuccessMessageMixin, UpdateView
):
class NPDAUserUpdateView(LoginAndOTPRequiredMixin, SuccessMessageMixin, UpdateView):
"""
Handle update of patient in audit
"""
Expand Down Expand Up @@ -165,9 +167,7 @@ def post(self, request: HttpRequest, *args: str, **kwargs) -> HttpResponse:
return super().post(request, *args, **kwargs)


class NPDAUserDeleteView(
LoginRequiredMixin, OTPRequiredMixin, SuccessMessageMixin, DeleteView
):
class NPDAUserDeleteView(LoginAndOTPRequiredMixin, SuccessMessageMixin, DeleteView):
"""
Handle deletion of child from audit
"""
Expand All @@ -177,7 +177,7 @@ class NPDAUserDeleteView(
success_url = reverse_lazy("npda_users")


class NPDAUserLogsListView(LoginRequiredMixin, OTPRequiredMixin, ListView):
class NPDAUserLogsListView(LoginAndOTPRequiredMixin, ListView):
template_name = "npda_user_logs.html"
model = VisitActivity

Expand Down Expand Up @@ -228,6 +228,26 @@ def __init__(self, **kwargs):
# Override original Django Auth Form with Captcha field inserted
self.form_list["auth"] = CaptchaAuthenticationForm

def post(self, *args, **kwargs):

# In local development, override the token workflow, just sign in
# the user without 2FA token
if settings.DEBUG:
request = self.request

user = authenticate(
request,
username=request.POST.get("auth-username"),
password=request.POST.get("auth-password"),
)
if user is not None:
login(request, user)
return redirect("home")

# Otherwise, continue with usual workflow
response = super().post(*args, **kwargs)
return self.delete_cookies_from_response(response)

# Override successful login redirect to org summary page
def done(self, form_list, **kwargs):
response = super().done(form_list)
Expand Down
9 changes: 5 additions & 4 deletions project/npda/views/patient.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
# RCPCH imports
from ..models import Patient
from ..forms.patient_form import PatientForm
from .mixins import LoginAndOTPRequiredMixin


class PatientListView(LoginRequiredMixin, OTPRequiredMixin, ListView):
class PatientListView(LoginAndOTPRequiredMixin, ListView):
model = Patient
template_name = "patients.html"

Expand Down Expand Up @@ -49,7 +50,7 @@ def get_context_data(self, **kwargs):


class PatientCreateView(
LoginRequiredMixin, OTPRequiredMixin, SuccessMessageMixin, CreateView
LoginAndOTPRequiredMixin, SuccessMessageMixin, CreateView
):
"""
Handle creation of new patient in audit
Expand All @@ -76,7 +77,7 @@ def form_valid(self, form: BaseForm) -> HttpResponse:


class PatientUpdateView(
LoginRequiredMixin, OTPRequiredMixin, SuccessMessageMixin, UpdateView
LoginAndOTPRequiredMixin, SuccessMessageMixin, UpdateView
):
"""
Handle update of patient in audit
Expand All @@ -103,7 +104,7 @@ def form_valid(self, form: BaseForm) -> HttpResponse:


class PatientDeleteView(
LoginRequiredMixin, OTPRequiredMixin, SuccessMessageMixin, DeleteView
LoginAndOTPRequiredMixin, SuccessMessageMixin, DeleteView
):
"""
Handle deletion of child from audit
Expand Down
Loading