Add JWT auth with per-user data isolation and account settings.

Users can register, log in, and manage profile/password in a personal account page; server data is scoped by owner across contacts, maps, tags, and import.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-28 21:10:16 +03:00
co-authored by Cursor
parent 85b8b2e9b8
commit 4eb4c145b6
40 changed files with 2301 additions and 117 deletions
+19
View File
@@ -0,0 +1,19 @@
from django.conf import settings
def use_jwt_auth():
return getattr(settings, 'USE_JWT_AUTH', False)
def scope_by_owner(queryset, user, owner_field='owner'):
if not use_jwt_auth():
return queryset
if user and user.is_authenticated:
return queryset.filter(**{owner_field: user})
return queryset.none()
def user_workspace_id(user):
if user and user.is_authenticated:
return str(user.pk)
return settings.DEFAULT_WORKSPACE_ID
+94
View File
@@ -0,0 +1,94 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from rest_framework import serializers
User = get_user_model()
class RegisterSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150)
email = serializers.EmailField(required=False, allow_blank=True)
password = serializers.CharField(write_only=True, min_length=8)
def validate_username(self, value):
username = value.strip()
if not username:
raise serializers.ValidationError('Укажите имя пользователя.')
if User.objects.filter(username__iexact=username).exists():
raise serializers.ValidationError('Это имя пользователя уже занято.')
return username
def validate_password(self, value):
validate_password(value)
return value
def create(self, validated_data):
email = (validated_data.get('email') or '').strip()
return User.objects.create_user(
username=validated_data['username'],
email=email,
password=validated_data['password'],
)
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email']
read_only_fields = fields
class UpdateProfileSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150, required=False)
email = serializers.EmailField(required=False, allow_blank=True)
current_password = serializers.CharField(write_only=True)
def validate_current_password(self, value):
user = self.context['request'].user
if not user.check_password(value):
raise serializers.ValidationError('Неверный текущий пароль.')
return value
def validate_username(self, value):
username = value.strip()
if not username:
raise serializers.ValidationError('Укажите имя пользователя.')
user = self.context['request'].user
if User.objects.filter(username__iexact=username).exclude(pk=user.pk).exists():
raise serializers.ValidationError('Это имя пользователя уже занято.')
return username
def validate(self, data):
if 'username' not in data and 'email' not in data:
raise serializers.ValidationError('Укажите новое имя пользователя или email.')
return data
def save(self):
user = self.context['request'].user
if 'username' in self.validated_data:
user.username = self.validated_data['username']
if 'email' in self.validated_data:
user.email = (self.validated_data.get('email') or '').strip()
user.save(update_fields=['username', 'email'])
return user
class ChangePasswordSerializer(serializers.Serializer):
current_password = serializers.CharField(write_only=True)
new_password = serializers.CharField(write_only=True, min_length=8)
def validate_current_password(self, value):
user = self.context['request'].user
if not user.check_password(value):
raise serializers.ValidationError('Неверный текущий пароль.')
return value
def validate_new_password(self, value):
validate_password(value, self.context['request'].user)
return value
def save(self):
user = self.context['request'].user
user.set_password(self.validated_data['new_password'])
user.save(update_fields=['password'])
return user
+9 -12
View File
@@ -1,15 +1,12 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
urlpatterns = []
from .auth_views import ChangePasswordView, MeView, RegisterView
try:
from django.conf import settings
if getattr(settings, 'USE_JWT_AUTH', False):
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
urlpatterns = [
path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
except ImportError:
pass
urlpatterns = [
path('auth/register/', RegisterView.as_view(), name='auth_register'),
path('auth/me/', MeView.as_view(), name='auth_me'),
path('auth/me/password/', ChangePasswordView.as_view(), name='auth_change_password'),
path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
+66
View File
@@ -0,0 +1,66 @@
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
from contacts.bootstrap import ensure_user_defaults
from core.drf_mixins import JwtAuthMixin
from .auth_serializers import (
ChangePasswordSerializer,
RegisterSerializer,
UpdateProfileSerializer,
UserSerializer,
)
def tokens_for_user(user):
refresh = RefreshToken.for_user(user)
return {
'refresh': str(refresh),
'access': str(refresh.access_token),
'user': UserSerializer(user).data,
}
class RegisterView(JwtAuthMixin, APIView):
def get_permissions(self):
return [AllowAny()]
def post(self, request):
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
ensure_user_defaults(user)
return Response(tokens_for_user(user), status=status.HTTP_201_CREATED)
class MeView(JwtAuthMixin, APIView):
def get_permissions(self):
return [IsAuthenticated()]
def get(self, request):
return Response(UserSerializer(request.user).data)
def patch(self, request):
serializer = UpdateProfileSerializer(
data=request.data,
context={'request': request},
)
serializer.is_valid(raise_exception=True)
user = serializer.save()
return Response(UserSerializer(user).data)
class ChangePasswordView(JwtAuthMixin, APIView):
def get_permissions(self):
return [IsAuthenticated()]
def post(self, request):
serializer = ChangePasswordSerializer(
data=request.data,
context={'request': request},
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response({'detail': 'Пароль изменён.'})
+16
View File
@@ -0,0 +1,16 @@
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework_simplejwt.authentication import JWTAuthentication
from core.access import use_jwt_auth
class JwtAuthMixin:
def get_permissions(self):
if use_jwt_auth():
return [IsAuthenticated()]
return [AllowAny()]
def get_authenticators(self):
if use_jwt_auth():
return [JWTAuthentication()]
return super().get_authenticators()
+3 -3
View File
@@ -3,7 +3,7 @@ from django.urls import path
from . import views
urlpatterns = [
path('meta/choices/', views.meta_choices, name='meta-choices'),
path('relation-types/', views.relation_types, name='relation-types'),
path('network-map-choices/', views.network_map_choices, name='network-map-choices'),
path('meta/choices/', views.MetaChoicesView.as_view(), name='meta-choices'),
path('relation-types/', views.RelationTypesView.as_view(), name='relation-types'),
path('network-map-choices/', views.NetworkMapChoicesView.as_view(), name='network-map-choices'),
]
+16 -15
View File
@@ -1,6 +1,8 @@
from rest_framework.decorators import api_view
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from core.drf_mixins import JwtAuthMixin
from .choices import (
RELATION_TYPES,
LIFE_SPHERES,
@@ -10,21 +12,20 @@ from .choices import (
)
@api_view(['GET'])
def meta_choices(request):
"""Unified meta endpoint for all domain enums."""
return Response(choices_payload())
class MetaChoicesView(JwtAuthMixin, APIView):
def get(self, request):
return Response(choices_payload())
@api_view(['GET'])
def relation_types(request):
return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES])
class RelationTypesView(JwtAuthMixin, APIView):
def get(self, request):
return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES])
@api_view(['GET'])
def network_map_choices(request):
return Response({
'life_spheres': [{'value': v, 'label': l} for v, l in LIFE_SPHERES],
'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES],
'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY],
})
class NetworkMapChoicesView(JwtAuthMixin, APIView):
def get(self, request):
return Response({
'life_spheres': [{'value': v, 'label': l} for v, l in LIFE_SPHERES],
'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES],
'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY],
})