Skip to content

Commit

Permalink
Move 2FA auth to a dedicated module
Browse files Browse the repository at this point in the history
  • Loading branch information
Augustin-FL committed Aug 29, 2024
1 parent 5e66b21 commit f8571a0
Show file tree
Hide file tree
Showing 18 changed files with 236 additions and 160 deletions.
2 changes: 0 additions & 2 deletions docker/fir.env
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,5 @@ REDIS_DB=0

HTTPS=false

ENFORCE_2FA=false

EMAIL_HOST=fir_fake_smtp
EMAIL_PORT=1025
42 changes: 2 additions & 40 deletions fir/config/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,8 @@

# Django settings for fir project.


ENFORCE_2FA = bool(strtobool(os.getenv('ENFORCE_2FA', 'False')))

tf_error_message = """Django two factor is not installed and ENFORCE_2FA is set to True.
Either set ENFORCE_2FA to False or pip install django-two-factor-auth
"""

try:
import two_factor
TF_INSTALLED = True
except ImportError:
if ENFORCE_2FA:
raise RuntimeWarning(tf_error_message)
TF_INSTALLED = False


if TF_INSTALLED:
LOGIN_URL = 'two_factor:login'
LOGIN_REDIRECT_URL = 'two_factor:profile'
else:
LOGIN_URL = "/login/"
LOGOUT_URL = "/logout/"
LOGIN_URL = "/login/"
LOGOUT_URL = "/logout/"

# Local time zone for this installation. Choices can be found here:
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
Expand Down Expand Up @@ -85,10 +65,6 @@
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
)

if TF_INSTALLED:
TF_MIDDLEWARE = ('django_otp.middleware.OTPMiddleware',)
MIDDLEWARE = MIDDLEWARE + TF_MIDDLEWARE


# Authentication and authorization backends
AUTHENTICATION_BACKENDS = (
Expand Down Expand Up @@ -128,20 +104,6 @@
'colorfield'
)

if TF_INSTALLED:
TF_APPS = (
'django_otp',
'django_otp.plugins.otp_static',
'django_otp.plugins.otp_totp',
'two_factor'
)
INSTALLED_APPS = INSTALLED_APPS + TF_APPS
try:
import otp_yubikey
INSTALLED_APPS = INSTALLED_APPS + ('otp_yubikey', 'two_factor.plugins.yubikey')
except ImportError:
pass

apps_file = os.path.join(BASE_DIR, 'fir', 'config', 'installed_apps.txt')
if os.path.exists(apps_file):
apps = list(INSTALLED_APPS)
Expand Down
12 changes: 3 additions & 9 deletions fir/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,11 @@

from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.decorators import login_required
from fir.config.base import TF_INSTALLED, ENFORCE_2FA


def fir_auth_required(view=None, redirect_field_name=None, login_url=None):
if TF_INSTALLED:
from django_otp.decorators import otp_required
if ENFORCE_2FA:
decorator = otp_required(view=view, redirect_field_name=REDIRECT_FIELD_NAME, login_url=login_url, if_configured=False)
else:
decorator = otp_required(view=view, redirect_field_name=REDIRECT_FIELD_NAME, login_url=login_url, if_configured=True)
else:
decorator = login_required(function=view, redirect_field_name=REDIRECT_FIELD_NAME, login_url=None)
decorator = login_required(
function=view, redirect_field_name=REDIRECT_FIELD_NAME, login_url=None
)

return decorator
17 changes: 2 additions & 15 deletions fir/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from django.urls import include, re_path
from django.contrib import admin

from fir.config.base import INSTALLED_APPS, TF_INSTALLED
from fir.config.base import INSTALLED_APPS
from incidents import views


Expand All @@ -18,22 +18,9 @@
re_path(r'^dashboard/', include(('incidents.custom_urls.dashboard', 'dashboard'), namespace='dashboard')),
re_path(r'^admin/', admin.site.urls),
re_path(r'^$', views.dashboard_main),
re_path(r'^login/', views.user_login, name='login')
]

if TF_INSTALLED:
from two_factor.views import LoginView
from two_factor.urls import urlpatterns as tf_urls
custom_urls = []
for tf_url in tf_urls[0]:
if tf_url.name != "login":
custom_urls.append(tf_url)
custom_urls.append(re_path(r'^account/login/$',
view=views.CustomLoginView.as_view(),
name='login',))
urlpatterns.append(re_path(r'', include((custom_urls, 'two_factor'))))
else:
urlpatterns.append(re_path(r'^login/', views.user_login, name='login'))


for app in INSTALLED_APPS:
if app.startswith('fir_'):
Expand Down
34 changes: 34 additions & 0 deletions fir_auth_2fa/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
This module allows users to use 2FA when connecting to FIR.

The module supports the following second-factors:
- Yubikeys, which are validated against YubiCloud by default
- Webauthn
- TOTP

## Install

Follow the generic plugin installation instructions in [the FIR wiki](https://github.com/certsocietegenerale/FIR/wiki/Plugins).

Once installed, please set the following settings in `production.py`

```
ENFORCE_2FA = True # If False, 2FA will be enabled but not enforced
LOGIN_URL = "two_factor:login"
LOGIN_REDIRECT_URL = "two_factor:profile"
MIDDLEWARE += (
"django_otp.middleware.OTPMiddleware",
)
INSTALLED_APPS += (
"django_otp",
"django_otp.plugins.otp_static",
"django_otp.plugins.otp_totp",
"two_factor",
"otp_yubikey",
"two_factor.plugins.yubikey", # <- for yubikey capability
"two_factor.plugins.webauthn", # <- for webauthn capability
)
# Webauthn Relying Party
TWO_FACTOR_WEBAUTHN_RP_NAME = 'YOURFIRINSTALL'
```
1 change: 1 addition & 0 deletions fir_auth_2fa/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import fir_auth_2fa.decorator
28 changes: 28 additions & 0 deletions fir_auth_2fa/decorator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django_otp.decorators import otp_required
from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME

import fir.decorators


def fir_auth_required_2fa(view=None, redirect_field_name=None, login_url=None):
if hasattr(settings, "ENFORCE_2FA") and settings.ENFORCE_2FA:
decorator = otp_required(
view=view,
redirect_field_name=REDIRECT_FIELD_NAME,
login_url=login_url,
if_configured=False,
)
else:
decorator = otp_required(
view=view,
redirect_field_name=REDIRECT_FIELD_NAME,
login_url=login_url,
if_configured=True,
)

return decorator


# Patch decorator to enable 2FA
fir.decorators.fir_auth_required = fir_auth_required_2fa
9 changes: 9 additions & 0 deletions fir_auth_2fa/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from two_factor.forms import AuthenticationTokenForm


class CustomAuthenticationTokenForm(AuthenticationTokenForm):
def __init__(self, user, initial_device, **kwargs):
super(CustomAuthenticationTokenForm, self).__init__(
user, initial_device, **kwargs
)
self.fields["otp_token"].widget.attrs.update({"class": "form-control"})
3 changes: 3 additions & 0 deletions fir_auth_2fa/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
django-otp-yubikey
django-two-factor-auth[phonenumbers]
django-two-factor-auth[webauthn]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{% load i18n %}
<li><a href="{% url 'two_factor:profile' %}" id="user_manage_2fa"><i class="glyphicon glyphicon-phone"></i>{% trans "Manage 2FA" %}</a></li>
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<link href="{% static "css/bootstrap.min.css" %}" rel="stylesheet">
<link href="{% static "custom_css/fir.css" %}" rel="stylesheet">
<link href="{% static "custom_css/login.css" %}" rel="stylesheet">

{% block extra_media %}{% endblock %}
<!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
<!--[if lt IE 9]>
<script src="{% static "js/html5.js" %}"></script>
Expand Down
24 changes: 24 additions & 0 deletions fir_auth_2fa/templates/two_factor/core/setup_complete.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{% extends "two_factor/_base_focus.html" %}
{% load i18n %}

{% block content %}
<h1>{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %}</h1>

<p>{% blocktrans trimmed %}Congratulations, you've successfully enabled two-factor
authentication.{% endblocktrans %}</p>

{% if not phone_methods %}
<p><a href="{% url 'dashboard:main' %}"
class="btn btn-block btn-secondary">{% trans "Back to FIR homepage" %}</a></p>
{% else %}
<p>{% blocktrans trimmed %}However, it might happen that you don't have access to
your primary token device. To enable account recovery, add a phone
number.{% endblocktrans %}</p>

<a href="{% url 'dashboard:main' %}"
class="float-right btn btn-link">{% trans "Back to FIR homepage" %}</a>
<p><a href="{% url 'two_factor:phone_create' %}"
class="btn btn-success">{% trans "Add Phone Number" %}</a></p>
{% endif %}

{% endblock %}
File renamed without changes.
30 changes: 30 additions & 0 deletions fir_auth_2fa/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from django.apps import apps
from django.urls import include, re_path
from two_factor.views import LoginView
from two_factor.urls import urlpatterns as tf_urls
import fir.urls as fir_urls
from incidents import views as incidents_views
from fir_auth_2fa import views


# Remove the original login URL
fir_urls.urlpatterns = [
x for x in fir_urls.urlpatterns if x.callback != incidents_views.user_login
]

custom_urls = []
for tf_url in tf_urls[0]:
if getattr(tf_url, "name", "") != "login":
custom_urls.append(tf_url)

custom_urls.append(
re_path(
r"^account/login/$",
view=views.CustomLoginView.as_view(),
name="login",
)
)

fir_urls.urlpatterns.append(re_path(r"", include((custom_urls, "two_factor"))))

urlpatterns = []
90 changes: 90 additions & 0 deletions fir_auth_2fa/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from django.conf import settings
from django.utils.http import url_has_allowed_host_and_scheme
from django.shortcuts import redirect, resolve_url
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.auth import login, logout

from two_factor import signals
from two_factor.forms import AuthenticationTokenForm, BackupTokenForm
from two_factor.views.core import LoginView
from otp_yubikey.models import ValidationService

from fir_auth_2fa.forms import CustomAuthenticationTokenForm
from incidents.models import Profile
from incidents.forms import CustomAuthenticationForm
from incidents.views import init_session, log


class CustomLoginView(LoginView):
template_name = "two_factor/login.html"

form_list = (
("auth", CustomAuthenticationForm),
("token", CustomAuthenticationTokenForm),
("backup", BackupTokenForm),
)

def __init__(self, **kwargs):
try:

if ValidationService.objects.count() == 0:
# Validate Yubikey against YubiCloud by default
ValidationService.objects.create(
name="default", use_ssl=True, param_sl="", param_timeout=""
)
except ImportError:
pass
super(CustomLoginView, self).__init__(**kwargs)

def post(self, *args, **kwargs):
if not self.request.POST.get(
"auth-remember", None
) and not "token" in self.request.POST.get(
"custom_login_view-current_step", []
):
self.request.session.set_expiry(0)
return super(CustomLoginView, self).post(**kwargs)

def done(self, form_list, **kwargs):
"""
Login the user and redirect to the desired page.
"""
login(self.request, self.get_user())

redirect_to = self.request.POST.get(
self.redirect_field_name, self.request.GET.get(self.redirect_field_name, "")
)
if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=self.request.get_host()
):
redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL)

is_auth = False
user = self.get_user()
device = getattr(self.get_user(), "otp_device", None)
if device:
signals.user_verified.send(
sender=__name__,
request=self.request,
user=self.get_user(),
device=device,
)
redirect_to = resolve_url("dashboard:main")
is_auth = True
elif hasattr(settings, "ENFORCE_2FA") and settings.ENFORCE_2FA:
redirect_to = resolve_url("two_factor:profile")
else:
redirect_to = resolve_url("dashboard:main")
is_auth = True
try:
Profile.objects.get(user=user)
except ObjectDoesNotExist:
profile = Profile()
profile.user = user
profile.hide_closed = False
profile.incident_number = 50
profile.save()
if user.is_active:
log("Login success", user)
init_session(self.request)
return redirect(redirect_to)
15 changes: 3 additions & 12 deletions incidents/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from django import forms
from incidents.models import IncidentCategory, Incident, Comments, BusinessLine
from django.contrib.auth.forms import AuthenticationForm
from fir.config.base import TF_INSTALLED


class CustomAuthenticationForm(AuthenticationForm):
Expand All @@ -21,17 +20,9 @@ class CustomAuthenticationForm(AuthenticationForm):
widget=forms.CheckboxInput(attrs={'class': 'checkbox',
'name': 'remember'}))

if TF_INSTALLED:
from two_factor.forms import AuthenticationTokenForm

class CustomAuthenticationTokenForm(AuthenticationTokenForm):
def __init__(self, user, initial_device, **kwargs):
super(CustomAuthenticationTokenForm, self).__init__(user, initial_device, **kwargs)
self.fields['otp_token'].widget.attrs.update({'class': 'form-control'})
else:
class CustomAuthenticationTokenForm(ModelForm):
def __init__(self, user, initial_device, **kwargs):
super(CustomAuthenticationTokenForm, self).__init__(user, initial_device, **kwargs)
class CustomAuthenticationTokenForm(ModelForm):
def __init__(self, user, initial_device, **kwargs):
super(CustomAuthenticationTokenForm, self).__init__(user, initial_device, **kwargs)


class IncidentForm(ModelForm):
Expand Down
Loading

0 comments on commit f8571a0

Please sign in to comment.