Browse Source
feat: add authentication, user management, and phase 1 auth workflows to account app
main
feat: add authentication, user management, and phase 1 auth workflows to account app
main
19 changed files with 1553 additions and 12 deletions
-
1apps/account/admin/role.py
-
1apps/account/admin/user.py
-
2apps/account/management/commands/seed_roles.py
-
58apps/account/migrations/0007_role_require_2fa_and_user_2fa_fields.py
-
6apps/account/models/role.py
-
136apps/account/models/user.py
-
177apps/account/serializers/auth_serializers.py
-
28apps/account/serializers/user.py
-
28apps/account/tasks.py
-
225apps/account/tests/test_phase1_auth.py
-
18apps/account/urls_auth.py
-
536apps/account/views/auth_views.py
-
29apps/account/views/user.py
-
5apps/cms/models/post.py
-
BINdump.rdb
-
2requirements.txt
-
25run_server_and_redis.bat
-
122utils/redis.py
-
166utils/totp.py
@ -0,0 +1,58 @@ |
|||
from django.db import migrations, models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('account', '0006_user_managed_regions_user_regional_permissions'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AddField( |
|||
model_name='role', |
|||
name='require_2fa', |
|||
field=models.BooleanField( |
|||
default=False, |
|||
help_text='If enabled, users with this role cannot log in without completing Two-Factor Authentication (2FA) and cannot disable 2FA.', |
|||
verbose_name='Force Two-Factor Authentication (2FA)', |
|||
), |
|||
), |
|||
migrations.AddField( |
|||
model_name='user', |
|||
name='two_factor_enabled', |
|||
field=models.BooleanField( |
|||
default=False, |
|||
help_text='Designates whether the user has enabled Two-Factor Authentication (2FA) for login.', |
|||
verbose_name='Two-Factor Authentication Enabled', |
|||
), |
|||
), |
|||
migrations.AddField( |
|||
model_name='user', |
|||
name='two_factor_method', |
|||
field=models.CharField( |
|||
choices=[('email', 'Email OTP'), ('totp', 'Authenticator App (TOTP)')], |
|||
default='email', |
|||
max_length=16, |
|||
verbose_name='Preferred 2FA Method', |
|||
), |
|||
), |
|||
migrations.AddField( |
|||
model_name='user', |
|||
name='totp_secret_encrypted', |
|||
field=models.TextField( |
|||
blank=True, |
|||
default='', |
|||
null=True, |
|||
verbose_name='Encrypted TOTP Secret', |
|||
), |
|||
), |
|||
migrations.AddField( |
|||
model_name='user', |
|||
name='two_factor_backup_codes', |
|||
field=models.JSONField( |
|||
blank=True, |
|||
default=list, |
|||
verbose_name='2FA Backup Recovery Codes (Hashed)', |
|||
), |
|||
), |
|||
] |
|||
@ -0,0 +1,25 @@ |
|||
@echo off |
|||
title Launch Server & Redis (Network) |
|||
echo ==================================================== |
|||
echo Launching Redis and Django Backend Server on Network... |
|||
echo ==================================================== |
|||
|
|||
set BACKEND_DIR=C:\Users\sajjadi\projects\muslin_connect_hub_backend |
|||
|
|||
cd /d "%BACKEND_DIR%" |
|||
|
|||
echo [1/2] Starting Redis Server... |
|||
start "Redis Server" cmd /k "cd /d %BACKEND_DIR% && redis-server --bind 0.0.0.0 --protected-mode no" |
|||
|
|||
echo Waiting for Redis to initialize... |
|||
timeout /t 2 /nobreak >nul |
|||
|
|||
echo [2/2] Starting Django Development Server on 0.0.0.0:8000... |
|||
start "Django Server" cmd /k "cd /d %BACKEND_DIR% && .\.venv\Scripts\python.exe manage.py runserver 0.0.0.0:8000" |
|||
|
|||
echo ==================================================== |
|||
echo Both services are running on the network! |
|||
echo - Redis: port 6379 (0.0.0.0) |
|||
echo - Backend: port 8000 (0.0.0.0:8000) |
|||
echo ==================================================== |
|||
timeout /t 5 |
|||
@ -0,0 +1,166 @@ |
|||
import base64 |
|||
import hashlib |
|||
import hmac |
|||
import io |
|||
import secrets |
|||
import struct |
|||
import time |
|||
from typing import Optional |
|||
from urllib.parse import quote |
|||
|
|||
try: |
|||
import pyotp # type: ignore |
|||
except ImportError: |
|||
pyotp = None |
|||
|
|||
try: |
|||
import qrcode # type: ignore |
|||
import qrcode.image.svg # type: ignore |
|||
except ImportError: |
|||
qrcode = None |
|||
|
|||
|
|||
BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" |
|||
|
|||
|
|||
def generate_totp_secret(length: int = 32) -> str: |
|||
"""Generate a cryptographically secure RFC 4648 Base32 secret for TOTP.""" |
|||
if pyotp is not None: |
|||
return pyotp.random_base32(length=length) |
|||
return "".join(secrets.choice(BASE32_ALPHABET) for _ in range(length)) |
|||
|
|||
|
|||
def _decode_base32_secret(secret: str) -> bytes: |
|||
cleaned = "".join(secret.strip().split()).upper() |
|||
padding = (8 - len(cleaned) % 8) % 8 |
|||
cleaned += "=" * padding |
|||
return base64.b32decode(cleaned, casefold=True) |
|||
|
|||
|
|||
def get_totp_code( |
|||
secret: str, |
|||
for_time: Optional[int] = None, |
|||
interval: int = 30, |
|||
digits: int = 6, |
|||
) -> str: |
|||
"""Compute the 6-digit RFC 6238 TOTP code for a given timestamp.""" |
|||
if not secret: |
|||
return "" |
|||
ts = int(for_time if for_time is not None else time.time()) |
|||
if pyotp is not None: |
|||
try: |
|||
totp = pyotp.TOTP(secret, digits=digits, interval=interval) |
|||
return totp.at(ts) |
|||
except Exception: |
|||
pass |
|||
|
|||
key = _decode_base32_secret(secret) |
|||
counter = int(ts // interval) |
|||
msg = struct.pack(">Q", counter) |
|||
digest = hmac.new(key, msg, hashlib.sha1).digest() |
|||
offset = digest[-1] & 0x0F |
|||
binary = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF |
|||
otp = binary % (10 ** digits) |
|||
return f"{otp:0{digits}d}" |
|||
|
|||
|
|||
def verify_totp_code( |
|||
secret: str, |
|||
code: str, |
|||
valid_window: int = 1, |
|||
interval: int = 30, |
|||
digits: int = 6, |
|||
) -> bool: |
|||
"""Verify a TOTP code within the allowed time-step window (RFC 6238).""" |
|||
if not secret or not code: |
|||
return False |
|||
normalized = "".join(str(code).strip().split()).replace("-", "") |
|||
if not normalized.isdigit() or len(normalized) != digits: |
|||
return False |
|||
|
|||
if pyotp is not None: |
|||
try: |
|||
totp = pyotp.TOTP(secret, digits=digits, interval=interval) |
|||
return bool(totp.verify(normalized, valid_window=valid_window)) |
|||
except Exception: |
|||
pass |
|||
|
|||
now = int(time.time()) |
|||
for offset in range(-valid_window, valid_window + 1): |
|||
candidate = get_totp_code( |
|||
secret=secret, |
|||
for_time=now + (offset * interval), |
|||
interval=interval, |
|||
digits=digits, |
|||
) |
|||
if candidate and hmac.compare_digest(candidate, normalized): |
|||
return True |
|||
return False |
|||
|
|||
|
|||
def build_otpauth_uri( |
|||
secret: str, |
|||
account_name: str, |
|||
issuer: str = "Muslim Connect Hub", |
|||
) -> str: |
|||
"""Build a standard otpauth:// URI for Google/Microsoft Authenticator.""" |
|||
if pyotp is not None: |
|||
try: |
|||
return pyotp.TOTP(secret).provisioning_uri( |
|||
name=account_name, |
|||
issuer_name=issuer, |
|||
) |
|||
except Exception: |
|||
pass |
|||
label = f"{quote(issuer)}:{quote(account_name)}" |
|||
issuer_param = quote(issuer) |
|||
return ( |
|||
f"otpauth://totp/{label}" |
|||
f"?secret={secret}&issuer={issuer_param}&algorithm=SHA1&digits=6&period=30" |
|||
) |
|||
|
|||
|
|||
def generate_qr_data_url(otpauth_uri: str) -> str: |
|||
""" |
|||
Generate a base64 Data URL (PNG or SVG) for the given otpauth:// URI. |
|||
""" |
|||
if qrcode is not None: |
|||
try: |
|||
qr = qrcode.QRCode( |
|||
version=1, |
|||
error_correction=qrcode.constants.ERROR_CORRECT_M, |
|||
box_size=8, |
|||
border=2, |
|||
) |
|||
qr.add_data(otpauth_uri) |
|||
qr.make(fit=True) |
|||
img = qr.make_image(fill_color="black", back_color="white") |
|||
buf = io.BytesIO() |
|||
img.save(buf, format="PNG") |
|||
b64 = base64.b64encode(buf.getvalue()).decode("ascii") |
|||
return f"data:image/png;base64,{b64}" |
|||
except Exception: |
|||
try: |
|||
img = qrcode.make(otpauth_uri, image_factory=qrcode.image.svg.SvgImage) |
|||
buf = io.BytesIO() |
|||
img.save(buf) |
|||
b64 = base64.b64encode(buf.getvalue()).decode("ascii") |
|||
return f"data:image/svg+xml;base64,{b64}" |
|||
except Exception: |
|||
pass |
|||
|
|||
# Fallback SVG placeholder if qrcode package is unavailable |
|||
svg = ( |
|||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">' |
|||
'<rect width="200" height="200" fill="#ffffff" stroke="#e2e8f0" stroke-width="4"/>' |
|||
'<rect x="20" y="20" width="50" height="50" fill="none" stroke="#0f172a" stroke-width="8"/>' |
|||
'<rect x="35" y="35" width="20" height="20" fill="#0f172a"/>' |
|||
'<rect x="130" y="20" width="50" height="50" fill="none" stroke="#0f172a" stroke-width="8"/>' |
|||
'<rect x="145" y="35" width="20" height="20" fill="#0f172a"/>' |
|||
'<rect x="20" y="130" width="50" height="50" fill="none" stroke="#0f172a" stroke-width="8"/>' |
|||
'<rect x="35" y="145" width="20" height="20" fill="#0f172a"/>' |
|||
'<rect x="85" y="85" width="30" height="30" fill="#0f172a"/>' |
|||
'</svg>' |
|||
) |
|||
b64 = base64.b64encode(svg.encode("utf-8")).decode("ascii") |
|||
return f"data:image/svg+xml;base64,{b64}" |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue