Skip to content

Commit

Permalink
Merge pull request #275 from rcpch/eatyourpeas/issue267
Browse files Browse the repository at this point in the history
user-activity
  • Loading branch information
mbarton authored Sep 20, 2024
2 parents d3f3e35 + 9558367 commit 9a0c9e0
Show file tree
Hide file tree
Showing 11 changed files with 152 additions and 52 deletions.
22 changes: 21 additions & 1 deletion documentation/docs/developer/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,24 @@ This has the basic django user functions but has the following extra custome fie
- `is_rcpch_staff`: boolean - a custom field that defines the user is an RCPCH staff member. This is as opposed to a clinician who may be a member of the audit team, but not an RCPCH employee
- `is_patient_or_carer`: boolean - a custom field that defines the user is a patient or carer
- `role` - user type as above
- `organisation_employer` - this is a relational field with an Organisation. Only applies to clinicians and therefore is None for RCPCH employees.
- `organisation_employer` - this is a relational field with an Organisation. Only applies to clinicians and therefore is None for RCPCH employees.

#### Passwords and Two factor authentication

Password access is required to access all areas of the NPDA platform apart from the documentation/user guide. Rules for passwords are:
Minimum of 10 characters (minimum 16 for RCPCH Audit team)
Must contain ONE capital
Must contain ONE number
Must contain ONE symbol from !@£$%^&*()_-+=|~
Must NOT be exclusively numbers
Must NOT be same as your email, name, surname

User accounts allow a maximum of 5 consecutive attempts after which the account is locked for 5 minutes.

Two Factor authentication is required for all login access. This is set up only once at first login. A user can change their 2 Factor Authentication settings once logged in by clicking on the their name in the top right of the screen and navigating to Two Factor Authentication.

Two Factor Authentication is either by email or Microsoft Authenticator on a mobile phone. If a user successfully logs in with their passwords, they must either check their email for a Token or generate one on their Microsoft Authenticator app.

#### Captcha

In addition to the above methods of authentication, a rotating image of numbers or letters is used to ensure only humans can gain access.
55 changes: 30 additions & 25 deletions project/npda/forms/npda_user_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,9 @@
# third party imports
from captcha.fields import CaptchaField

from project.npda.general_functions import organisations_adapter

# RCPCH imports
from ...constants.styles.form_styles import *
from ..models import NPDAUser
from project.npda.general_functions import (
organisations_adapter,
)
from ..models import NPDAUser, VisitActivity


# Logging setup
Expand Down Expand Up @@ -126,6 +121,11 @@ def save(self, *args, commit=True, **kwargs):
user.password_last_set = timezone.now()
if commit:
logger.debug(f"Updating password_last_set to {timezone.now()}")
VisitActivity.objects.create(
npdauser=user,
activity=5,
ip_address=None, # cannot get ip address here as it is not a request
) # password reset successful - activity 5
user.save()
return user

Expand Down Expand Up @@ -171,23 +171,28 @@ def clean_username(self) -> dict[str]:

user = NPDAUser.objects.get(email=email.lower())

# visit_activities = VisitActivity.objects.filter(
# npda12user=user
# ).order_by("-activity_datetime")[:5]

# failed_login_activities = [
# activity for activity in visit_activities if activity.activity == 2
# ]

# if failed_login_activities:
# first_activity = failed_login_activities[-1]

# if len(
# failed_login_activities
# ) >= 5 and timezone.now() <= first_activity.activity_datetime + timezone.timedelta(
# minutes=10
# ):
# raise forms.ValidationError(
# "You have failed to login 5 or more consecutive times. You have been locked out for 10 minutes"
# )
visit_activities = VisitActivity.objects.filter(npdauser=user).order_by(
"-activity_datetime"
)[:5]

failed_login_activities = [
activity for activity in visit_activities if activity.activity == 2
]

if failed_login_activities:
first_activity = failed_login_activities[-1]

if len(
failed_login_activities
) >= 5 and timezone.now() <= first_activity.activity_datetime + timezone.timedelta(
minutes=5
):
VisitActivity.objects.create(
activity=6,
ip_address=self.request.META.get("REMOTE_ADDR"),
npdauser=user, # password lockout - activity 6
)
raise forms.ValidationError(
"You have failed to login 5 or more consecutive times. You have been locked out for 10 minutes"
)
return email.lower()
4 changes: 3 additions & 1 deletion project/npda/models/npda_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def create_user(self, email, password, first_name, role, **extra_fields):
user.email_confirmed = False
# set time password has been updated
user.password_last_set = timezone.now()
print(f"{user} password updated")
user.date_joined = timezone.now()
user.save()

"""
Expand Down Expand Up @@ -123,6 +123,8 @@ def create_superuser(self, email, password, **extra_fields):
defaults={"is_primary_employer": True},
)

logged_in_user.date_joined = timezone.now()

"""
Allocate Roles
"""
Expand Down
18 changes: 15 additions & 3 deletions project/npda/models/visitactivity.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,23 @@ class VisitActivity(models.Model):
SUCCESSFUL_LOGIN = 1
UNSUCCESSFUL_LOGIN = 2
LOGOUT = 3
PASSWORD_RESET_LINK_SENT = 4
PASSWORD_RESET = 5
PASSWORD_LOCKOUT = 6
SETUP_TWO_FACTOR_AUTHENTICATION = 7
UPLOADED_CSV = 8
TOUCHED_PATIENT_RECORD = 9

ACTIVITY = (
(SUCCESSFUL_LOGIN, "SUCCESSFUL_LOGIN"),
(UNSUCCESSFUL_LOGIN, "UNSUCCESSFUL_LOGIN"),
(LOGOUT, "LOGOUT"),
(SUCCESSFUL_LOGIN, "Successful login"),
(UNSUCCESSFUL_LOGIN, "Login failed"),
(LOGOUT, "Logout"),
(PASSWORD_RESET_LINK_SENT, "Password reset link sent"),
(PASSWORD_RESET, "Password reset successfully"),
(PASSWORD_LOCKOUT, "Password lockout"),
(SETUP_TWO_FACTOR_AUTHENTICATION, "Two factor authentication set up"),
(UPLOADED_CSV, "Uploaded CSV"),
(TOUCHED_PATIENT_RECORD, "Touched patient record"),
)

activity_datetime = models.DateTimeField(auto_created=True, default=timezone.now)
Expand Down
28 changes: 27 additions & 1 deletion project/npda/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
user_login_failed,
)
from django.dispatch import receiver
from django.apps import apps

# third party imports
from two_factor.signals import user_verified

# RCPCH
from .models import VisitActivity, NPDAUser
Expand All @@ -17,6 +19,15 @@
# Logging setup
logger = logging.getLogger(__name__)

"""
This file contains signals that are triggered when a user logs in, logs out, or fails to log in.
These signals are used to log user activity in the VisitActivity model.
They are also used to track if users have touched patient records.
"""

# Custom signals
from django.dispatch import Signal


@receiver(user_logged_in)
def log_user_login(sender, request, user, **kwargs):
Expand Down Expand Up @@ -66,6 +77,21 @@ def log_user_logout(sender, request, user, **kwargs):
)


# Two factor auth receiver
@receiver(user_verified)
def two_factor_auth_setup(request, user, device, **kwargs):
if (
user_verified
and VisitActivity.objects.filter(npdauser=user, activity=7).count() < 1
):
logger.info(
f"{user} ({user.email}) has logged in the for the first time with two factor authentication."
)
VisitActivity.objects.create(
activity=7, ip_address=get_client_ip(request), npdauser=user
) # Two factor authentication set up


# helper functions
def get_client_ip(request):
return request.META.get("REMOTE_ADDR")
3 changes: 1 addition & 2 deletions project/npda/templates/nav.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@
<ul
tabindex="0"
class="menu dropdown-content bg-base-100 rounded-none z-[1] mt-4 w-52 p-2 hover:bg-white">
<li><a href="{% url 'two_factor:profile' %}">2FA</a></li>
<li><a href="{% url 'password_reset' %}">Reset Password</a></li>
<li><a href="{% url 'two_factor:profile' %}">Two Factor Authentication</a></li>
<form method="POST" action="{% url 'logout' %}">
<li >{% csrf_token%}
<button type="submit">Log Out</button>
Expand Down
4 changes: 4 additions & 0 deletions project/npda/templates/npda_user_logs.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
<div class="inline-block min-w-full py-2 sm:px-6 lg:px-8">
<div class="relative overflow-x-auto">
<strong>NPDA Audit Access Logs for {{npdauser.get_full_name}} (NPDA User ID-{{npdauser.pk}})</strong>
<p class="text-gray-400 font-montserrat">
<small>Password last set: {{npdauser.password_last_set}}</small><br/>
<small>Account created: {{npdauser.date_joined}}</small>
</p>
{% if visitactivities %}
<table class="lg-table-fixed table-fixed w-full text-sm text-left rtl:text-left text-gray-500 text-gray-400 mb-5 font-montserrat">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 bg-rcpch_dark_blue text-white">
Expand Down
9 changes: 8 additions & 1 deletion project/npda/templates/partials/patient_table.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
{% load static %}
{% load npda_tags %}
{% url 'patients' as patients_url %}
<h1 class="text-md font-montserrat font-semibold text-rcpch_dark_blue">Patients under the care of {{pz_code}}</h1>
{% if request.user.view_preference == 1 %}
<!-- PDU view -->
<h1 class="text-md font-montserrat font-semibold text-rcpch_dark_blue">Patients under the care of {{pz_code}}</h1>
{% elif request.user.view_preference == 2 %}
<!-- national view -->
<h1 class="text-md font-montserrat font-semibold text-rcpch_dark_blue">All patients nationally</h1>
{% endif %}

{% if patient_list %}
<table class="table table-md w-full text-sm text-left rtl:text-right text-gray-500 text-gray-400 mb-5 font-montserrat">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 bg-rcpch_dark_blue text-white">
Expand Down
29 changes: 15 additions & 14 deletions project/npda/templates/partials/submission_history.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ <h1 class="text-md font-montserrat font-semibold text-rcpch_dark_blue">All Submi
<th>Audit Year</th>
<th>Patient Number</th>
<th>Active</th>
{% if request.user.view_preference == 2 %}<th>PDU</th>{% endif %}
<th>Download</th>
</tr>
</thead>
Expand All @@ -41,27 +42,27 @@ <h1 class="text-md font-montserrat font-semibold text-rcpch_dark_blue">All Submi
</span>
{% endif %}
</td>
{% if request.user.view_preference == 2 %}<td>{{ submission.paediatric_diabetes_unit.pz_code }} ({{submission.paediatric_diabetes_unit.lead_organisation_name}})</td>{% endif %}
<td class="flex flex-row">
<form method="post" action="{% url 'submissions' %}" class="join">
{% csrf_token %}
<input type="hidden" name="audit_id" value="{{submission.pk}}">
{% if submission.submission_active %}
<button
name="submit-data"
value="download-data"
type="submit"
class="join-item bg-rcpch_light_blue text-white font-semibold hover:text-white py-1 px-2 border border-rcpch_light_blue hover:bg-rcpch_strong_blue hover:border-rcpch_strong_blue btn-sm rounded-none">Download</button>
<button
name="submit-data"
value="download-data"
type="submit"
class="join-item bg-rcpch_light_blue text-white font-semibold hover:text-white py-1 px-2 border border-rcpch_light_blue hover:bg-rcpch_strong_blue hover:border-rcpch_strong_blue btn-sm rounded-none">Download</button>
{% else %}
<button
name="submit-data"
disabled="true"
value="delete-data"
type="submit"
class="join-item bg-rcpch_red text-white font-semibold hover:text-white py-1 px-2 border border-rcpch_red hover:bg-rcpch_red_dark_tint hover:border-rcpch_red_dark_tint btn-sm rounded-none {% if submission.submission_active %}opacity-50 cursor-not-allowed {% endif %}">Delete</button>
{% endif %}
<button
name="submit-data"
{% if submission.submission_active %}
disabled="true"
{% endif %}
value="delete-data"
type="submit"
class="join-item bg-rcpch_red text-white font-semibold hover:text-white py-1 px-2 border border-rcpch_red hover:bg-rcpch_red_dark_tint hover:border-rcpch_red_dark_tint btn-sm rounded-none {% if submission.submission_active %}opacity-50 cursor-not-allowed {% endif %}">Delete</button>
</form>
{% if forloop.first %}
{% if submission.submission_active %}
<a href="{% url 'patients' %}">
<svg
class="h-8 w-8 text-rcpch_pink self-center hover:text-white"
Expand Down
14 changes: 12 additions & 2 deletions project/npda/views/home.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
import logging

# Django imports
from django.urls import reverse
from django.apps import apps
from django.contrib import messages
from django.shortcuts import render
from django.core.exceptions import ValidationError
from django.shortcuts import render
from django.urls import reverse

# HTMX imports
from django_htmx.http import trigger_client_event
Expand Down Expand Up @@ -64,6 +65,15 @@ def home(request):
pdu_pz_code=pz_code,
)
messages.success(request=request, message="File uploaded successfully")
VisitActivity = apps.get_model("npda", "VisitActivity")
try:
VisitActivity.objects.create(
activity=8,
ip_address=request.META.get("REMOTE_ADDR"),
npdauser=request.user,
) # uploaded csv - activity 8
except Exception as e:
logger.error(f"Failed to log user activity: {e}")
except ValidationError as error:
errors = error_list(error)

Expand Down
18 changes: 16 additions & 2 deletions project/npda/views/npda_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
from project.constants import VIEW_PREFERENCES
from .mixins import LoginAndOTPRequiredMixin

# from ..signals import password_reset_sent

logger = logging.getLogger(__name__)

"""
Expand Down Expand Up @@ -450,6 +452,10 @@ def get_context_data(self, **kwargs):


class ResetPasswordView(SuccessMessageMixin, PasswordResetView):
"""
Custom password reset view that sends a password reset email to the user
"""

template_name = "registration/password_reset.html"
html_email_template_name = "registration/password_reset_email.html"
email_template_name = strip_tags("registration/password_reset_email.html")
Expand All @@ -468,8 +474,16 @@ class ResetPasswordView(SuccessMessageMixin, PasswordResetView):

# extend form_valid to set user.password_last_set
def form_valid(self, form):
self.request.user.password_last_set = timezone.now()

# self.request.user.password_last_set = timezone.now()
user_email_to_reset_password = form.cleaned_data["email"]
# check if user exists
if NPDAUser.objects.filter(email=user_email_to_reset_password).exists():
user = NPDAUser.objects.get(email=user_email_to_reset_password)
VisitActivity.objects.create(
npdauser=user,
activity=4,
ip_address=self.request.META.get("REMOTE_ADDR"),
) # password reset link sent
return super().form_valid(form)


Expand Down

0 comments on commit 9a0c9e0

Please sign in to comment.