[ADD] BASE: 2 Factor Authentication via email (beta)

This commit is contained in:
Horilla
2025-05-21 11:19:12 +05:30
parent 9f66406cd6
commit 97f1843db2
8 changed files with 265 additions and 18 deletions

View File

@@ -245,7 +245,9 @@ def new_init(
if request and request.user and request.user.is_authenticated:
user_id = request.user.pk
reply_to = cache.get(f"reply_to{user_id}") if not reply_to else reply_to
from_email = cache.get(f"dynamic_display_name{user_id}")
if not from_email:
from_email = cache.get(f"dynamic_display_name{user_id}")
message_init(
self,

View File

@@ -85,6 +85,10 @@ def update_selected_company(request):
This method is used to update the selected company on the session
"""
company_id = request.GET.get("company_id")
user = request.user.employee_get
user_company = getattr(
getattr(user, "employee_work_info", None), "company_id", None
)
request.session["selected_company"] = company_id
company = (
AllCompany()
@@ -103,21 +107,13 @@ def update_selected_company(request):
if re.match(pattern, previous_path):
employee_id = get_last_section(previous_path)
employee = Employee.objects.filter(id=employee_id).first()
if (
not EmployeeWorkInformation.objects.filter(
employee_id=employee_id
).exists()
or employee.employee_work_info.company_id != company
):
emp_company = getattr(
getattr(employee, "employee_work_info", None), "company_id", None
)
if emp_company != company:
text = "Other Company"
if (
company_id
== request.user.employee_get.employee_work_info.company_id
):
if company_id == user_company:
text = "My Company"
if company_id == "all":
text = "All companies"
company = {
"company": company.company,
"icon": company.icon.url,
@@ -134,11 +130,13 @@ def update_selected_company(request):
"""
)
text = "Other Company"
if company_id == request.user.employee_get.employee_work_info.company_id:
text = "My Company"
if company_id == "all":
text = "All companies"
elif company_id == user_company:
text = "My Company"
else:
text = "Other Company"
company = {
"company": company.company,
"icon": company.icon.url,

View File

@@ -1007,3 +1007,12 @@ def template_pdf(template, context={}, html=False, filename="payslip.pdf"):
return response
except Exception as e:
return HttpResponse(f"Error generating PDF: {str(e)}", status=500)
def generate_otp():
"""
Function to generate a random 6-digit OTP (One-Time Password).
Returns:
str: A 6-digit random OTP as a string.
"""
return str(random.randint(100000, 999999))

View File

@@ -3,10 +3,12 @@ middleware.py
"""
from django.apps import apps
from django.contrib import messages
from django.core.cache import cache
from django.db.models import Q
from django.shortcuts import redirect
from base.backends import ConfiguredEmailBackend
from base.context_processors import AllCompany
from base.horilla_company_manager import HorillaCompanyManager
from base.models import Company, ShiftRequest, WorkTypeRequest
@@ -16,6 +18,7 @@ from employee.models import (
EmployeeBankDetails,
EmployeeWorkInformation,
)
from horilla.horilla_apps import TWO_FACTORS_AUTHENTICATION
from horilla.horilla_settings import APPS
from horilla.methods import get_horilla_model_class
from horilla_documents.models import DocumentRequest
@@ -55,12 +58,23 @@ class CompanyMiddleware:
"""
Set the company session data based on the company ID.
"""
user = request.user.employee_get
user_company_id = getattr(
getattr(user, "employee_work_info", None), "company_id", None
)
if company_id and request.session.get("selected_company") != "all":
if company_id == "all":
text = "All companies"
elif company_id == user_company_id:
text = "My Company"
else:
text = "Other Company"
request.session["selected_company"] = str(company_id.id)
request.session["selected_company_instance"] = {
"company": company_id.company,
"icon": company_id.icon.url,
"text": "My company",
"text": text,
"id": company_id.id,
}
else:
@@ -189,3 +203,37 @@ class ForcePasswordChangeMiddleware:
return redirect("change-password")
return self.get_response(request)
class TwoFactorAuthMiddleware:
"""
Middleware to enforce two-factor authentication for specific users.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
excluded_paths = [
"/change-password",
"/login",
"/logout",
"/two-factor",
"/send-otp",
]
if request.path.rstrip("/") in excluded_paths:
return self.get_response(request)
if TWO_FACTORS_AUTHENTICATION:
try:
if ConfiguredEmailBackend().configuration is not None:
if hasattr(request, "user") and request.user.is_authenticated:
if not request.session.get("otp_code_verified", False):
return redirect("/two-factor")
else:
return self.get_response(request)
except Exception as e:
return self.get_response(request)
return self.get_response(request)

View File

@@ -0,0 +1,187 @@
{% load static %} {% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login - {{white_label_company_name}} Dashboard</title>
<link
rel="apple-touch-icon"
sizes="180x180"
href="{% if white_label_company.icon %}{{white_label_company.icon.url}} {% else %}{% static 'favicons/apple-touch-icon.png' %}{% endif %}"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="{% if white_label_company.icon %}{{white_label_company.icon.url}} {% else %}{% static 'favicons/favicon-32x32.png' %}{% endif %}"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="{% if white_label_company.icon %}{{white_label_company.icon.url}} {% else %}{% static 'favicons/favicon-16x16.png' %}{% endif %}"
/>
<link rel="stylesheet" href="{% static '/build/css/style.min.css' %}" />
<link rel="manifest" href="{% static 'build/manifest.json' %}" />
</head>
<style>
.oh-opacity-0 {
opacity: 0;
}
</style>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<div id="main">
<div class="oh-alert-container">
{% for message in messages %}
<div class="oh-alert oh-alert--animated {{ message.tags }}">
{{ message }}
</div>
{% endfor %}
</div>
<main class="oh-auth">
<div
class="oh-onboarding-card"
style="max-height: 790px; max-width: 975px"
id="ohAuthCard"
>
<div class="oh-onboarding-card__header">
<span class="oh-onboarding-card__company-name"
>{{white_label_company_name}} HRMS</span
>
</div>
<h1
class="oh-onboarding-card__title oh-onboarding-card__title--h2 text-center my-3"
>
{% trans "Two Factor Authentication" %}
</h1>
<p class="text-muted text-center">
{% trans "Enter the OTP send to your email: " %}
<b>{{request.user.employee_get.get_mail}}</b>.
</p>
<div id="OtpContainer" >
{% if request.session.otp_code %}
<form
class="oh-form-group"
method="POST"
action="{% url 'two-factor' %}"
>
{% csrf_token %}
<div class="row">
<div class="col-12 col-sm-12 col-md-12 col-lg-12">
<div class="oh-input-group">
<label class="oh-label" for="password"
>{% trans "Enter OTP" %}</label
>
<div class="oh-password-input-container">
<input
type="password"
id="otp"
name="otp"
class="oh-input oh-input--password w-100"
placeholder="Enter OTP"
required
/>
<button
type="button"
class="oh-btn oh-btn--transparent oh-password-input--toggle"
>
<ion-icon
class="oh-passowrd-input__show-icon"
title="Show Password"
name="eye-outline"
></ion-icon>
<ion-icon
class="oh-passowrd-input__hide-icon d-none"
title="Hide Password"
name="eye-off-outline"
></ion-icon>
</button>
</div>
</div>
</div>
</div>
<div class="oh-modal__dialog-footer p-0 mt-3 justify-content-between">
<div class="mt-3 oh-text--secondary oh-opacity-0" id="otp-timer">
<span class=""> {% trans "Resend OTP in" %} <span class="time fw-bold">20</span></span>
</div>
<button
type="submit"
class="oh-btn oh-btn--secondary-outline"
role="button"
>
{% trans "Authenticate" %}
<ion-icon
class="ms-2"
name="arrow-forward-outline"
></ion-icon>
</button>
</div>
</form>
{% else %}
<form
class="oh-form-group"
hx-post="{% url 'send-otp' %}"
hx-target="#OtpContainer"
hx-select="#OtpContainer"
hx-swap="outerHTML"
>
{% csrf_token %}
<div class="oh-modal__dialog-footer p-0 mt-3 justify-content-center">
<button
type="submit"
class="oh-btn oh-btn--secondary-outline m-2"
role="button"
>
{% trans "Send OTP" %}
<ion-icon
class="ms-2"
name="arrow-forward-outline"
></ion-icon>
</button>
</div>
</form>
{% endif %}
</div>
</div>
<img src={% if white_label_company.icon %}
"{{white_label_company.icon.url}}" {% else %} "{% static 'images/ui/auth-logo.png' %}" {% endif %} alt="Horilla" />
</main>
</div>
<script src="{% static '/build/js/web.frontend.min.js' %}"></script>
<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
<script src="{% static 'htmx/htmx.min.js' %}"></script>
<script src="{% static 'build/js/hxSelect2.js' %}"></script>
<script src="{% static '/index/index.js' %}"></script>
<script type="module" src="{% static 'images/ionicons/ui_icons/ionicons/ionicons.esm.js' %}"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@10"></script>
<script>
$(document).on("htmx:beforeRequest", function (event, data) {
var response = event.detail.xhr.response;
var target = $(event.detail.elt.getAttribute("hx-target"));
if (!target.closest("form").length) {
target.html();
}
});
setTimeout(function () {
var timer = $("#otp-timer");
timer.removeClass("oh-opacity-0");
let counter = 20;
let $timeSpan = timer.find(".time");
let resendUrl = "{% url 'send-otp' %}";
let interval = setInterval(function () {
counter--;
if (counter <= 0) {
clearInterval(interval);
$timeSpan.html(`<a href='${resendUrl}' class="oh-text--secondary">{% trans "Resend" %}`);
} else {
$timeSpan.text(`${counter}`);
}
}, 1000);
}, 5000);
</script>
</body>
</html>

View File

@@ -60,3 +60,4 @@ SIDEBARS = [
WHITE_LABELLING = False
NESTED_SUBORDINATE_VISIBILITY = False
TWO_FACTORS_AUTHENTICATION = False

View File

@@ -17,6 +17,7 @@ MIDDLEWARE.append("horilla.horilla_middlewares.ThreadLocalMiddleware")
MIDDLEWARE.append("accessibility.middlewares.AccessibilityMiddleware")
MIDDLEWARE.append("accessibility.middlewares.AccessibilityMiddleware")
MIDDLEWARE.append("base.middleware.ForcePasswordChangeMiddleware")
MIDDLEWARE.append("base.middleware.TwoFactorAuthMiddleware")
_thread_locals = threading.local()

View File

@@ -684,6 +684,7 @@ $(document).on("htmx:beforeRequest", function (event, data) {
"BiometricDeviceTestFormTarget",
"reloadMessages",
"infinite",
"OtpContainer"
];
var avoid_target_class = ["oh-badge--small"];
if (