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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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': 'Пароль изменён.'})
|
||||
@@ -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,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
@@ -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],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user