infrasynth-backend-kit/infrasynth/security/views.py
2026-08-28 14:38:47 -05:00

378 lines
14 KiB
Python

import secrets
from django.contrib.auth import authenticate, get_user_model, login, logout
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import AuthenticationFailed, PermissionDenied
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken
from infrasynth.shared.settings_utils import get_setting
from .altcha.services import ALTCHAService
from .models import APIKey, Grant, Revoke, Role, TwoFactorConfig
from .serializers import (
APIKeySerializer,
GrantSerializer,
LoginSerializer,
RevokeSerializer,
RoleSerializer,
)
from .services import AuthorizationService
from .signals import login_failed, user_logged_in, user_logged_out
from .two_factor.services import RecoveryCodeService, TOTPService
UserModel = get_user_model()
class AuthViewSet(viewsets.GenericViewSet):
permission_classes = [AllowAny]
@action(detail=False, methods=["post"])
def login(self, request):
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
request,
username=serializer.validated_data["username"],
password=serializer.validated_data["password"],
)
if not user:
login_failed.send(
sender=self.__class__,
credentials_key=serializer.validated_data["username"],
ip=request.META.get("REMOTE_ADDR"),
reason="invalid_credentials",
)
raise AuthenticationFailed("Invalid credentials.")
if not user.is_active:
raise AuthenticationFailed("User is disabled.")
login(request, user)
refresh = RefreshToken.for_user(user)
access_token = str(refresh.access_token)
refresh_token = str(refresh)
cookie_secure = get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True)
cookie_httponly = get_setting("INFRASYNTH_SECURITY", "COOKIE_HTTPONLY", True)
cookie_samesite = get_setting("INFRASYNTH_SECURITY", "COOKIE_SAMESITE", "Lax")
access_cookie = get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token")
refresh_cookie = get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token")
try:
from infrasynth.shared.crypto import encrypt as crypto_encrypt
encrypted_access = crypto_encrypt(access_token)
encrypted_refresh = crypto_encrypt(refresh_token)
except Exception:
encrypted_access = access_token
encrypted_refresh = refresh_token
response = Response({"detail": "Login successful."})
response.set_cookie(
access_cookie,
encrypted_access,
max_age=3600,
secure=cookie_secure,
httponly=cookie_httponly,
samesite=cookie_samesite,
path="/",
)
response.set_cookie(
refresh_cookie,
encrypted_refresh,
max_age=86400 * 7,
secure=cookie_secure,
httponly=cookie_httponly,
samesite=cookie_samesite,
path="/",
)
user_logged_in.send(
sender=self.__class__,
user=user,
ip=request.META.get("REMOTE_ADDR"),
user_agent=request.META.get("HTTP_USER_AGENT", ""),
)
return response
@action(detail=False, methods=["post"])
def logout(self, request):
logout(request)
access_cookie = get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token")
refresh_cookie = get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token")
response = Response({"detail": "Logout successful."})
response.delete_cookie(access_cookie, path="/")
response.delete_cookie(refresh_cookie, path="/")
user_logged_out.send(sender=self.__class__, user=getattr(request, "user", None))
return response
@action(detail=False, methods=["post"])
def refresh(self, request):
cookie_secure = get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True)
cookie_httponly = get_setting("INFRASYNTH_SECURITY", "COOKIE_HTTPONLY", True)
cookie_samesite = get_setting("INFRASYNTH_SECURITY", "COOKIE_SAMESITE", "Lax")
access_cookie = get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token")
refresh_cookie_name = get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token")
raw_refresh = request.COOKIES.get(refresh_cookie_name)
if not raw_refresh:
raise AuthenticationFailed("Refresh token not found.")
try:
from infrasynth.shared.crypto import decrypt as crypto_decrypt
decrypted = crypto_decrypt(raw_refresh)
except Exception:
decrypted = raw_refresh
try:
refresh = RefreshToken(decrypted) # type: ignore[arg-type]
access_token = str(refresh.access_token)
if get_setting("INFRASYNTH_SECURITY", "ROTATE_REFRESH_TOKENS", True):
new_refresh = str(refresh)
else:
new_refresh = raw_refresh
except Exception:
raise AuthenticationFailed("Invalid or expired refresh token.")
try:
from infrasynth.shared.crypto import encrypt as crypto_encrypt
encrypted_access = crypto_encrypt(access_token)
encrypted_refresh = crypto_encrypt(new_refresh)
except Exception:
encrypted_access = access_token
encrypted_refresh = new_refresh
response = Response({"detail": "Token refreshed."})
response.set_cookie(
access_cookie,
encrypted_access,
max_age=3600,
secure=cookie_secure,
httponly=cookie_httponly,
samesite=cookie_samesite,
path="/",
)
response.set_cookie(
refresh_cookie_name,
encrypted_refresh,
max_age=86400 * 7,
secure=cookie_secure,
httponly=cookie_httponly,
samesite=cookie_samesite,
path="/",
)
return response
@action(detail=False, methods=["get"])
def check(self, request):
if not request.user or not request.user.is_authenticated:
return Response({"authenticated": False}, status=status.HTTP_401_UNAUTHORIZED)
authz = AuthorizationService()
effective_permissions = list(authz.get_effective_permissions(request.user))
user = request.user
return Response(
{
"id": user.pk,
"email": getattr(user, "email", ""),
"name": getattr(user, "get_full_name", lambda: str(user))(),
"effective_permissions": effective_permissions,
}
)
class TwoFactorViewSet(viewsets.GenericViewSet):
permission_classes = [IsAuthenticated]
@action(detail=False, methods=["post"])
def setup(self, request): # type: ignore[override]
totp = TOTPService()
secret = totp.generate_secret()
qr_base64 = totp.generate_qr_base64(secret, request.user.email)
provisioning_uri = totp.get_provisioning_uri(secret, request.user.email)
request.session["_2fa_setup_secret"] = secret
return Response(
{
"secret": secret,
"qr_base64": qr_base64,
"provisioning_uri": provisioning_uri,
}
)
@action(detail=False, methods=["post"])
def verify_setup(self, request):
secret = request.session.get("_2fa_setup_secret")
if not secret:
raise AuthenticationFailed("No setup in progress.")
code = request.data.get("code")
if not code:
raise AuthenticationFailed("Code is required.")
totp = TOTPService()
if not totp.verify(secret, code):
raise AuthenticationFailed("Invalid code.")
rcs = RecoveryCodeService()
recovery_codes = rcs.generate_codes()
config, _ = TwoFactorConfig.objects.get_or_create(user=request.user)
config.is_enabled = True
config.is_configured = True
config.secret_key_encrypted = totp.encrypt_secret(secret)
config.recovery_codes_encrypted = rcs.encrypt_codes(recovery_codes)
config.save()
del request.session["_2fa_setup_secret"]
return Response({"recovery_codes": recovery_codes})
@action(detail=False, methods=["post"], permission_classes=[AllowAny])
def verify(self, request):
user_id = request.session.get("_2fa_user_id")
if not user_id:
raise AuthenticationFailed("No pre-auth session.")
try:
config = TwoFactorConfig.objects.get(user_id=user_id, is_enabled=True)
except TwoFactorConfig.DoesNotExist:
raise AuthenticationFailed("2FA not configured.")
code = request.data.get("code")
if not code:
raise AuthenticationFailed("Code is required.")
totp = TOTPService()
secret = totp.decrypt_secret(config.secret_key_encrypted)
if not totp.verify(secret, code):
raise AuthenticationFailed("Invalid code.")
user = UserModel.objects.get(pk=user_id)
login(request, user)
request._2fa_verified = True
request.session.pop("_2fa_user_id", None)
request.session.pop("_2fa_pre_auth_token", None)
return Response({"detail": "2FA verified."})
@action(detail=False, methods=["post"])
def disable(self, request):
try:
config = TwoFactorConfig.objects.get(user=request.user)
config.is_enabled = False
config.is_configured = False
config.secret_key_encrypted = None
config.recovery_codes_encrypted = None
config.save()
except TwoFactorConfig.DoesNotExist:
pass
return Response({"detail": "2FA disabled."})
@action(detail=False, methods=["post"], permission_classes=[AllowAny])
def recovery(self, request):
recovery_code = request.data.get("recovery_code")
if not recovery_code:
raise AuthenticationFailed("Recovery code is required.")
user_id = request.session.get("_2fa_user_id")
if not user_id:
raise AuthenticationFailed("No pre-auth session.")
try:
config = TwoFactorConfig.objects.get(user_id=user_id, is_enabled=True)
except TwoFactorConfig.DoesNotExist:
raise AuthenticationFailed("2FA not configured.")
rcs = RecoveryCodeService()
if not rcs.verify_code(recovery_code, config.recovery_codes_encrypted):
raise AuthenticationFailed("Invalid recovery code.")
updated = rcs.remove_used_code(recovery_code, config.recovery_codes_encrypted)
if updated:
config.recovery_codes_encrypted = updated
config.save(update_fields=["recovery_codes_encrypted"])
user = UserModel.objects.get(pk=user_id)
login(request, user)
request._2fa_verified = True
request.session.pop("_2fa_user_id", None)
request.session.pop("_2fa_pre_auth_token", None)
return Response({"detail": "Recovered access."})
class ALTCHAViewSet(viewsets.GenericViewSet):
permission_classes = [AllowAny]
@action(detail=False, methods=["post"])
def challenge(self, request):
svc = ALTCHAService()
challenge = svc.create_challenge()
return Response(challenge)
@action(detail=False, methods=["post"])
def verify(self, request):
svc = ALTCHAService()
result = svc.verify(
request.data.get("challenge_id"),
request.data.get("solution"),
request.data.get("number"),
)
return Response({"verified": result})
class APIKeyViewSet(viewsets.ModelViewSet):
queryset = APIKey.objects.order_by("-id").all()
serializer_class = APIKeySerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return APIKey.objects.order_by("-id").all()
def perform_create(self, serializer):
from django.contrib.auth.hashers import make_password
prefix = secrets.token_hex(4)
secret = secrets.token_hex(32)
full_key = f"{prefix}.{secret}"
instance = serializer.save(
prefix=prefix,
key_hash=make_password(secret),
created_by=self.request.user,
)
instance.key = full_key
return instance
class RoleViewSet(viewsets.ModelViewSet):
queryset = Role.objects.order_by("name").all()
serializer_class = RoleSerializer
permission_classes = [IsAuthenticated]
lookup_field = "slug"
def get_queryset(self):
return Role.objects.order_by("name").all()
def perform_destroy(self, instance):
if instance.is_system:
raise PermissionDenied("System roles cannot be deleted.")
instance.delete()
class GrantViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
queryset = Grant.objects.all()
serializer_class = GrantSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Grant.objects.select_related("user", "granted_by").order_by("-id").all()
def perform_create(self, serializer):
serializer.save(granted_by=self.request.user)
class RevokeViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
queryset = Revoke.objects.all()
serializer_class = RevokeSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Revoke.objects.select_related("user", "revoked_by").order_by("-id").all()
def perform_create(self, serializer):
serializer.save(revoked_by=self.request.user)