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)