Compare commits
6 commits
Author | SHA1 | Date | |
---|---|---|---|
93f08f7db4 | |||
dd63aac6a5 | |||
bab1abf825 | |||
2712f60654 | |||
29a75d66e6 | |||
9e02e56498 |
2178 changed files with 133 additions and 448 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -4,5 +4,5 @@ db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
venv/
|
venv/
|
||||||
config.ini
|
config.ini
|
||||||
/static/
|
/kumidc/static/
|
||||||
/certificates/
|
/kumidc/certificates/
|
|
@ -4,10 +4,10 @@ KumiDC is a simple Django-based OpenID Connect identity provider.
|
||||||
|
|
||||||
At its core, it uses [Django OpenID Connect Provider](https://github.com/juanifioren/django-oidc-provider) by [Juan Ignacio Fiorentino](https://github.com/juanifioren) to provide the actual OIDC functionality, and adds a few fancy things on top.
|
At its core, it uses [Django OpenID Connect Provider](https://github.com/juanifioren/django-oidc-provider) by [Juan Ignacio Fiorentino](https://github.com/juanifioren) to provide the actual OIDC functionality, and adds a few fancy things on top.
|
||||||
|
|
||||||
* "Pretty" [AdminLTE](https://github.com/ColorlibHQ/AdminLTE) user interface
|
- "Pretty" [AdminLTE](https://github.com/ColorlibHQ/AdminLTE) user interface
|
||||||
* Time-based One-Time Passwords for Two Factor Authentication
|
- Time-based One-Time Passwords for Two Factor Authentication
|
||||||
* Requirement to re-authenticate or enter 2FA token every five minutes
|
- Requirement to re-authenticate or enter 2FA token every five minutes
|
||||||
|
|
||||||
As it stands, this project is not complete. It works as an OIDC provider, although its security has not been tested to any extent.
|
As it stands, this project is not complete. It works as an OIDC provider, although its security has not been tested to any extent.
|
||||||
|
|
||||||
We currently use it, in conjunction with [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy), to add an authentication layer to applications on our internal network where protection against unauthorized access is not directly implemented, and not critical.
|
We currently use it, in conjunction with [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy), to add an authentication layer to applications on our internal network where protection against unauthorized access is not directly implemented, and not critical.
|
||||||
|
|
|
@ -1,104 +0,0 @@
|
||||||
from django.db import models
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
|
|
||||||
from annoying.fields import AutoOneToOneField
|
|
||||||
from pyotp import TOTP, random_base32
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
class TOTPSecret(models.Model):
|
|
||||||
"""A secret for a user's TOTP device
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
user: The user to whom this secret belongs
|
|
||||||
secret: The secret
|
|
||||||
active: Whether this secret is currently active
|
|
||||||
"""
|
|
||||||
|
|
||||||
user = AutoOneToOneField(get_user_model(), models.CASCADE, primary_key=True)
|
|
||||||
secret = models.CharField(max_length=32, default=random_base32)
|
|
||||||
active = models.BooleanField(default=False)
|
|
||||||
|
|
||||||
def verify(self, token: str, window: int = 1, set_used=True) -> bool:
|
|
||||||
"""Verify a TOTP token
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token (str): The token to verify
|
|
||||||
window (int, optional): The number of tokens to check before and after the current token. Defaults to 1.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: Whether the token was valid
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not self.active:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if UsedTOTPToken.is_used(self, token):
|
|
||||||
return False
|
|
||||||
|
|
||||||
if TOTP(self.secret).verify(token, valid_window=window):
|
|
||||||
if set_used:
|
|
||||||
UsedTOTPToken.objects.create(secret=self, token=token)
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_uri(self) -> str:
|
|
||||||
"""Get the provisioning URI for this secret
|
|
||||||
|
|
||||||
This is used to generate a QR code for the user to scan with their TOTP device.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The provisioning URI
|
|
||||||
"""
|
|
||||||
return TOTP(self.secret).provisioning_uri(
|
|
||||||
name=self.user.email, issuer_name="KumiDC"
|
|
||||||
)
|
|
||||||
|
|
||||||
def change_secret(self) -> str:
|
|
||||||
"""Change the secret for this user
|
|
||||||
|
|
||||||
This generates a new secret and saves it to the database.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The new secret
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.secret = random_base32()
|
|
||||||
self.save()
|
|
||||||
return self.secret
|
|
||||||
|
|
||||||
|
|
||||||
class UsedTOTPToken(models.Model):
|
|
||||||
"""A TOTP token that has been used
|
|
||||||
|
|
||||||
We log these so that we can prevent replay attacks.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
secret: The secret that was used
|
|
||||||
token: The token that was used
|
|
||||||
timestamp: When the token was used
|
|
||||||
expiry: How long we should keep this token for
|
|
||||||
"""
|
|
||||||
secret = models.ForeignKey(TOTPSecret, on_delete=models.CASCADE)
|
|
||||||
token = models.CharField(max_length=6)
|
|
||||||
timestamp = models.DateTimeField(auto_now_add=True)
|
|
||||||
expiry = models.DateTimeField()
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
self.expiry = self.timestamp + timedelta(seconds=60)
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_used(cls, secret: TOTPSecret, token: str) -> bool:
|
|
||||||
"""Check whether a token has been used
|
|
||||||
|
|
||||||
Args:
|
|
||||||
secret (TOTPSecret): The secret that was used
|
|
||||||
token (str): The token that was used
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: Whether the token has recently been used
|
|
||||||
"""
|
|
||||||
return cls.objects.filter(secret=secret, token=token, expiry__gte=datetime.now()).exists()
|
|
|
@ -3,4 +3,4 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
class AuthConfig(AppConfig):
|
class AuthConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'authentication'
|
name = 'kumidc.authentication'
|
|
@ -1,6 +1,6 @@
|
||||||
# Generated by Django 3.2.14 on 2022-08-02 16:31
|
# Generated by Django 3.2.14 on 2022-08-02 16:31
|
||||||
|
|
||||||
import authentication.helpers.session
|
from ..helpers import session
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
@ -30,7 +30,7 @@ class Migration(migrations.Migration):
|
||||||
name='AuthSession',
|
name='AuthSession',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||||
('expiry', models.DateTimeField(default=authentication.helpers.session.session_expiry)),
|
('expiry', models.DateTimeField(default=session.session_expiry)),
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
),
|
),
|
22
kumidc/authentication/models/otp.py
Normal file
22
kumidc/authentication/models/otp.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from annoying.fields import AutoOneToOneField
|
||||||
|
from pyotp import TOTP, random_base32
|
||||||
|
|
||||||
|
|
||||||
|
class TOTPSecret(models.Model):
|
||||||
|
user = AutoOneToOneField(get_user_model(), models.CASCADE, primary_key=True)
|
||||||
|
secret = models.CharField(max_length=32, default=random_base32)
|
||||||
|
active = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def verify(self, token):
|
||||||
|
return TOTP(self.secret).verify(token)
|
||||||
|
|
||||||
|
def get_uri(self):
|
||||||
|
return TOTP(self.secret).provisioning_uri(name=self.user.email, issuer_name="KumiDC")
|
||||||
|
|
||||||
|
def change_secret(self):
|
||||||
|
self.secret = random_base32()
|
||||||
|
self.save()
|
||||||
|
return self.secret
|
|
@ -7,6 +7,6 @@ from ..helpers.session import session_expiry
|
||||||
|
|
||||||
|
|
||||||
class AuthSession(models.Model):
|
class AuthSession(models.Model):
|
||||||
id = models.UUIDField(default=uuid4, primary_key=True)
|
id = models.CharField(max_length=36, default=uuid4, primary_key=True)
|
||||||
user = models.ForeignKey(get_user_model(), models.CASCADE)
|
user = models.ForeignKey(get_user_model(), models.CASCADE)
|
||||||
expiry = models.DateTimeField(default=session_expiry)
|
expiry = models.DateTimeField(default=session_expiry)
|
|
@ -4,7 +4,7 @@ from django.urls import reverse_lazy
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from frontend.mixins.views import TitleMixin
|
from ...frontend.mixins.views import TitleMixin
|
||||||
from ..mixins.session import OnlyLoggedOutMixin
|
from ..mixins.session import OnlyLoggedOutMixin
|
||||||
from ..models.session import AuthSession
|
from ..models.session import AuthSession
|
||||||
from ..helpers.otp import has_otp
|
from ..helpers.otp import has_otp
|
||||||
|
@ -26,4 +26,4 @@ class LoginView(OnlyLoggedOutMixin, TitleMixin, DjangoLoginView):
|
||||||
|
|
||||||
def form_invalid(self, form):
|
def form_invalid(self, form):
|
||||||
messages.error(self.request, "Could not log you in. Please check your email address and password, and try again.")
|
messages.error(self.request, "Could not log you in. Please check your email address and password, and try again.")
|
||||||
return super().form_invalid(form)
|
return super().form_invalid(form)
|
|
@ -3,7 +3,7 @@ from django.utils import timezone
|
||||||
|
|
||||||
from ..forms.otp import TOTPLoginForm
|
from ..forms.otp import TOTPLoginForm
|
||||||
from ..mixins.session import AuthSessionRequiredMixin
|
from ..mixins.session import AuthSessionRequiredMixin
|
||||||
from frontend.mixins.views import TitleMixin
|
from ...frontend.mixins.views import TitleMixin
|
||||||
|
|
||||||
|
|
||||||
class TOTPLoginView(TitleMixin, AuthSessionRequiredMixin, LoginView):
|
class TOTPLoginView(TitleMixin, AuthSessionRequiredMixin, LoginView):
|
||||||
|
@ -13,4 +13,4 @@ class TOTPLoginView(TitleMixin, AuthSessionRequiredMixin, LoginView):
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
self.request.session["LastActivity"] = timezone.now().timestamp()
|
self.request.session["LastActivity"] = timezone.now().timestamp()
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
|
@ -5,7 +5,7 @@ from django.urls import reverse_lazy
|
||||||
|
|
||||||
from ..forms.otp import TOTPLoginForm
|
from ..forms.otp import TOTPLoginForm
|
||||||
from ..models.app import AppSession
|
from ..models.app import AppSession
|
||||||
from frontend.mixins.views import TitleMixin
|
from ...frontend.mixins.views import TitleMixin
|
||||||
|
|
||||||
|
|
||||||
class ReverifyView(TitleMixin, LoginView):
|
class ReverifyView(TitleMixin, LoginView):
|
||||||
|
@ -26,4 +26,4 @@ class ReverifyView(TitleMixin, LoginView):
|
||||||
except (AppSession.DoesNotExist, KeyError):
|
except (AppSession.DoesNotExist, KeyError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return HttpResponseRedirect(self.get_success_url())
|
return HttpResponseRedirect(self.get_success_url())
|
|
@ -3,7 +3,7 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
class CoreConfig(AppConfig):
|
class CoreConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'core'
|
name = 'kumidc.core'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
from . import receivers
|
from . import receivers # noqa: F401
|
|
@ -3,4 +3,4 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
class FrontendConfig(AppConfig):
|
class FrontendConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'frontend'
|
name = 'kumidc.frontend'
|
Before Width: | Height: | Size: 953 B After Width: | Height: | Size: 953 B |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue