Skip to content

Commit

Permalink
Make 2FA configurable & optional for user accounts (#23)
Browse files Browse the repository at this point in the history
* Make 2FA configurable & optional for user accounts

* Separate 2FA from default login flow

* Update url pattern for 2FA verification

* Remove OTP from registration view

* Update list display in users app admin

* Add tests for TwoFactorVerificationForm

* Update tests for CustomAuthenticationForm

* Update tests for CustomUserChangeForm

* Update test_forms.py

* Update tests for CustomUser model

* Update tests for account view

* Fix typos in form tests
  • Loading branch information
KafetzisThomas authored Jan 2, 2025
1 parent 71e0e14 commit 90e34ef
Show file tree
Hide file tree
Showing 13 changed files with 280 additions and 124 deletions.
1 change: 1 addition & 0 deletions users/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class CustomUserAdmin(UserAdmin):
list_display = (
"username",
"email",
"enable_2fa",
"otp_secret",
"session_timeout",
"is_active",
Expand Down
44 changes: 32 additions & 12 deletions users/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,27 +48,40 @@ def save(self, commit=True):
class CustomAuthenticationForm(AuthenticationForm):
username = forms.EmailField(label="Email Address", widget=forms.EmailInput)
password = forms.CharField(label="Master Password", widget=forms.PasswordInput)
otp = forms.CharField(label="Generated OTP", widget=forms.TextInput)

def clean(self):
cleaned_data = super().clean()
email = cleaned_data.get("username")
password = cleaned_data.get("password")
otp = cleaned_data.get("otp")
User = get_user_model()

if email and password and otp:
User = get_user_model()
try:
user = User.objects.get(email=email)
totp = pyotp.TOTP(user.otp_secret)
if not totp.verify(otp):
raise forms.ValidationError("Invalid OTP")
except User.DoesNotExist:
raise forms.ValidationError("Invalid email or password")
user = User.objects.get(email=email)
if not user or not user.check_password(password):
raise forms.ValidationError("Invalid email or password.")

self.cleaned_data["user"] = user
return cleaned_data


class TwoFactorVerificationForm(forms.Form):
otp = forms.CharField(label="Generated OTP", widget=forms.TextInput)

def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)

def clean(self):
otp = self.cleaned_data.get("otp")
if not self.user or not self.user.otp_secret:
raise forms.ValidationError("Invalid user or OTP configuration.")

totp = pyotp.TOTP(self.user.otp_secret)
if not totp.verify(otp):
raise forms.ValidationError("Invalid OTP.")

return self.cleaned_data


class CustomUserChangeForm(forms.ModelForm):
password1 = forms.CharField(
label="New Master Password", widget=forms.PasswordInput, required=False
Expand All @@ -79,7 +92,14 @@ class CustomUserChangeForm(forms.ModelForm):

class Meta:
model = CustomUser
fields = ("email", "username", "password1", "password2", "session_timeout")
fields = (
"email",
"username",
"password1",
"password2",
"session_timeout",
"enable_2fa",
)

def clean(self):
cleaned_data = super().clean()
Expand Down
18 changes: 18 additions & 0 deletions users/migrations/0005_customuser_enable_2fa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.17 on 2025-01-01 15:54

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('users', '0004_customuser_session_timeout'),
]

operations = [
migrations.AddField(
model_name='customuser',
name='enable_2fa',
field=models.BooleanField(default=False),
),
]
18 changes: 18 additions & 0 deletions users/migrations/0006_alter_customuser_enable_2fa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.17 on 2025-01-01 16:14

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('users', '0005_customuser_enable_2fa'),
]

operations = [
migrations.AlterField(
model_name='customuser',
name='enable_2fa',
field=models.BooleanField(default=False, verbose_name='Enable 2FA'),
),
]
1 change: 1 addition & 0 deletions users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

class CustomUser(AbstractUser):
email = models.EmailField(_("email address"), unique=True)
enable_2fa = models.BooleanField(default=False, verbose_name="Enable 2FA")
otp_secret = models.CharField(max_length=32)
session_timeout = models.IntegerField(
choices=[(key, value) for value, key in SESSION_TIMEOUT_CHOICES],
Expand Down
52 changes: 52 additions & 0 deletions users/templates/registration/2fa_verification.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{% extends "passmanager/base.html" %}
{% load crispy_forms_tags %}

{% block title %}2FA Verification{% endblock title %}

{% block page_header %}
<h2>Log in to your account.</h2>
{% endblock page_header %}

{% block content %}
{% if messages %}
{% for message in messages %}
<div class="alert alert-success">{{ message }}</div>
{% endfor %}
{% endif %}

<form action="{% url 'users:2fa_verification' %}" method="post" class="form">
{% csrf_token %}
{{ form|crispy }}
<button name="submit" class="btn btn-dark w-100">Verify</button>
<input type="hidden" name="next" value="{% url 'passmanager:vault' %}" />
</form>
{% endblock content %}

{% block footer %}
<!-- footer start -->
<footer class="footer bg-light text-dark py-2 fixed-bottom">
<div class="container">
<hr class="border-dark mb-1" width="100%">
<div class="row justify-content-between align-items-center">
<div class="col-12 col-md-6 mt-3 text-center text-md-start">
<p>&copy; <script>document.write(new Date().getFullYear());</script> KafetzisThomas</p>
</div>
<div class="col-12 col-md-6 text-center text-md-end mt-md-0">
<a href="https://www.paypal.me/kafetzisthomas" target="_blank" class="btn btn-link btn-floating btn-md text-dark m-1 border" role="button" style="text-decoration: none;">
<i class="bi bi-heart-fill"></i> Donate
</a>
<a href="mailto: passmanagerweb@gmail.com" target="_blank" class="btn btn-link btn-floating btn-md text-dark m-1 border" role="button">
<i class="bi bi-envelope-fill"></i>
</a>
<a href="https://x.com/PassManagerWeb" target="_blank" class="btn btn-link btn-floating btn-md text-dark m-1 border" role="button">
<i class="bi bi-twitter-x"></i>
</a>
<a href="https://github.com/KafetzisThomas/PassManagerWeb" target="_blank" class="btn btn-link btn-floating btn-md text-dark m-1 border" role="button">
<i class="bi bi-github"></i>
</a>
</div>
</div>
</div>
</footer>
<!-- footer end -->
{% endblock footer %}
2 changes: 1 addition & 1 deletion users/templates/registration/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ <h2>Log in to your account.</h2>

{% block footer %}
<!-- footer start -->
<footer class="footer bg-light text-dark py-2 mt-lg-5">
<footer class="footer bg-light text-dark py-2 fixed-bottom">
<div class="container">
<hr class="border-dark mb-1" width="100%">
<div class="row justify-content-between align-items-center">
Expand Down
1 change: 1 addition & 0 deletions users/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def test_admin_list_display(self):
(
"username",
"email",
"enable_2fa",
"otp_secret",
"session_timeout",
"is_active",
Expand Down
Loading

0 comments on commit 90e34ef

Please sign in to comment.