Files
ACORE/apps/visit_manager/api/views.py
T
lokses faa06b9f82 Security hardening: fix open redirect, API auth, MIME validation, PII exposure
- core/views.py: fix open redirect in login — validate next_url with
  url_has_allowed_host_and_scheme before redirecting
- visit_manager/api/views.py: add explicit permission_classes/
  authentication_classes on all 3 API views; add MIME magic-byte
  validation + Pillow verify for photo uploads; validate employee_id/
  location_id/date query params; restrict photo access to is_staff,
  own record, or direct manager
- employees/serializers.py: split into EmployeeSerializer (public
  fields only) and EmployeeDetailSerializer (PII fields, for HR/admin)
- mattermost_service.py: sanitize sensitive fields (password, token)
  before logging; log only error message not full response body
- settings/base.py: add SESSION/CSRF cookie HttpOnly + SameSite flags;
  set file upload size limits (15MB); remove BasicAuthentication from
  DRF (sends credentials in plaintext)
2026-05-31 18:05:54 +03:00

157 lines
5.7 KiB
Python

"""
REST API для регистрации посещений с PPOS/терминалов.
"""
import base64
import io
from datetime import datetime
from django.http import HttpResponseRedirect, Http404
from PIL import Image
from rest_framework import serializers, status
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from ..services import register_attendance
from ..models import AttendanceLog
_ALLOWED_MIME_SIGNATURES = {
b'\xff\xd8\xff': 'image/jpeg',
b'\x89PNG': 'image/png',
}
_MAX_PHOTO_BYTES = 10 * 1024 * 1024 # 10 MB
class AttendanceCreateSerializer(serializers.Serializer):
employee_id = serializers.IntegerField(min_value=1)
location_id = serializers.IntegerField(min_value=1)
direction = serializers.ChoiceField(choices=['in', 'out'])
photo_b64 = serializers.CharField(write_only=True)
device_id = serializers.CharField(required=False, default='', max_length=100)
def validate_photo_b64(self, value: str) -> bytes:
try:
if ',' in value:
value = value.split(',', 1)[1]
photo_bytes = base64.b64decode(value)
except Exception:
raise serializers.ValidationError('Невалидный base64')
if len(photo_bytes) > _MAX_PHOTO_BYTES:
raise serializers.ValidationError('Фото превышает допустимый размер (10 МБ)')
# Проверяем сигнатуру файла (magic bytes)
mime = None
for sig, mime_type in _ALLOWED_MIME_SIGNATURES.items():
if photo_bytes[:len(sig)] == sig:
mime = mime_type
break
if mime is None:
raise serializers.ValidationError('Допускаются только JPEG и PNG изображения')
# Проверяем что файл действительно является изображением
try:
img = Image.open(io.BytesIO(photo_bytes))
img.verify()
except Exception:
raise serializers.ValidationError('Файл повреждён или не является изображением')
return photo_bytes
class AttendanceLogSerializer(serializers.ModelSerializer):
photo_thumb = serializers.SerializerMethodField()
class Meta:
model = AttendanceLog
fields = [
'id', 'employee_id', 'location_id', 'direction',
'timestamp', 'photo', 'photo_thumb', 'device_id',
]
def get_photo_thumb(self, obj):
return obj.photo_thumb_b64
class AttendanceRegisterView(APIView):
"""
POST /api/attendance/register/
Принимает фото в base64, создаёт запись посещения.
"""
authentication_classes = [SessionAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = AttendanceCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
log = register_attendance(
employee_id=data['employee_id'],
location_id=data['location_id'],
direction=data['direction'],
photo_bytes=data['photo_b64'],
device_id=data.get('device_id', ''),
ip_address=request.META.get('REMOTE_ADDR'),
)
return Response(AttendanceLogSerializer(log).data, status=status.HTTP_201_CREATED)
class AttendanceListView(APIView):
"""
GET /api/attendance/?employee_id=&date=YYYY-MM-DD&location_id=
Список записей с превью (без оригиналов фото).
"""
authentication_classes = [SessionAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request):
qs = AttendanceLog.objects.select_related('employee', 'location')
if emp := request.query_params.get('employee_id'):
try:
qs = qs.filter(employee_id=int(emp))
except (ValueError, TypeError):
return Response({'error': 'employee_id должен быть числом'}, status=status.HTTP_400_BAD_REQUEST)
if loc := request.query_params.get('location_id'):
try:
qs = qs.filter(location_id=int(loc))
except (ValueError, TypeError):
return Response({'error': 'location_id должен быть числом'}, status=status.HTTP_400_BAD_REQUEST)
if date_str := request.query_params.get('date'):
try:
datetime.strptime(date_str, '%Y-%m-%d')
qs = qs.filter(timestamp__date=date_str)
except ValueError:
return Response({'error': 'Формат даты: YYYY-MM-DD'}, status=status.HTTP_400_BAD_REQUEST)
qs = qs.order_by('-timestamp')[:200]
return Response(AttendanceLogSerializer(qs, many=True).data)
class AttendancePhotoView(APIView):
"""
GET /api/attendance/<uuid>/photo/
Redirect к оригиналу фото — только для is_staff или руководителя сотрудника.
"""
authentication_classes = [SessionAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, pk):
try:
log = AttendanceLog.objects.select_related('employee').get(pk=pk)
except AttendanceLog.DoesNotExist:
raise Http404
if not log.photo:
raise Http404
user = request.user
if not (user.is_staff or user.pk == log.employee_id or user.pk == log.employee.manager_id):
raise Http404
return HttpResponseRedirect(log.photo.url)