378 lines
14 KiB
Python
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)
|