Просте, безпечне та стійке автентифікування сокетів у Django-Channels

текст перекладу

Фон

Нещодавно я досліджував варіанти автентифікації сокетів у Django Channels. Спочатку це здавалось досить простим — як знайти печиво на кухні бабусі. Згідно з документацією Channels, управління сесіями здійснюється за допомогою функцій login і logout, а AuthMiddlewareStack зберігає сесію користувача до її закінчення (або поки ваша кішка не вирішить сісти на клавіатуру).

Моє завдання полягало в автентифікації користувачів за допомогою OTP-коду, надісланого на їх мобільні телефони. Мій початковий підхід виглядав так:

async def user_login(self, content):  
 verify_code = self.scope.get("verify_code")  
 code = content.get("code")  
 mobile = self.scope.get("MOBILE")  
 if mobile and verify_code and code == verify_code:  
 user = await load_user_by_mobile(mobile)  
 del self.scope["VERIFY_CODE"]  
 # Log in the user to this session.  
 await login(self.scope, user)  
 # Save the session.  
 await database_sync_to_async(self.scope["session"].save)()  
 await self.send_json(send_code(200))  
 else:  
 await self.send_json(send_code(SystemErrorCodes.InvalidVerifyCode))

Цей підхід успішно автентифікував сокет. Однак, ініціація іншого споживача (наприклад, RequestConsumer) призводила до того, що self.scope['user'] ставало AnonymousUser. Крім того, закриття вкладки браузера (у моєму випадку Chrome) призводило до втрати сесії. У браузері не з’являлись cookies — як відкрити банку з печивом і знайти її порожньою. 😢

Дослідження рішень

Пошуки в Інтернеті підказали JWT токени як поширене рішення. Проте я уникнув JWT через проблеми безпеки. Мій проєкт не вимагав рівня безпеки як у Форт-Ноксі, але я хотів більш безпечний підхід — жодних злодіїв у цій банці з печивом! 🍪

Тоді я знайшов Django Channels JWT, який на диво не використовує JWT токени. Натомість він використовує тимчасові одноразові UUID-ключі для автентифікації. Користувачі автентифікуються через HTTP-ендпоінт, генеруючи UUID, який зберігається в кеші на 10 хвилин. Клієнт передає цей ключ як рядок запиту під час з’єднання сокетів, а ключ видаляється після успішної автентифікації. Все досить просто і безпечно — як собака, що ховає свою улюблену кістку.

Цей підхід був простим і безпечним, але йому бракувало постійності. Надихнувшись ним, я розробив своє власне рішення. 😀

Запропонований підхід

Я зрозумів, що метод login у Django Channels не надає cookies для браузера. Щоб вирішити цю проблему, я використав основний метод login Django для автентифікації користувачів через HTTP і доставки cookie. Моя реалізація включала двоступеневу форму:

def mobile_login(request):  
 if request.method == 'POST':  
 form = LoginMobileForm(request.POST)  
 if form.is_valid():  
 mobile = form.cleaned_data['mobile']  
 code = randint(10000, 99999)  
 request.session['mobile'] = mobile  
 request.session['code'] = code  
 send_otp(mobile, code)  
 return HttpResponseRedirect(reverse('mobile-verify'))  
 else:  
 form = LoginMobileForm()
return render(request, 'mobile_login.html', {'form': form})def verify_login(request):  
 if request.method == 'POST':  
 form = LoginVerifyForm(request.POST)  
 if form.is_valid():  
 code = form.cleaned_data['code']  
 if request.session.get('code') == code:  
 mobile = request.session['mobile']  
 user = get_user_by_mobile(mobile)  
 login(request, user)  
 return redirect('panel')  
 else:  
 form = LoginVerifyForm()  
 return render(request, 'verify_login.html', {'form': form})

Ця реалізація успішно реєструвала користувачів та надсилала cookies до браузера. Однак, AuthMiddlewareStack в Channels все ще не міг автентифікувати сокет за допомогою цієї сесії. Це була справжня тверда печиво! 😀

Автентифікація на основі UUID

Я вирішив ввести автентифікованого користувача в підключення WebSocket.
текст перекладу

Оскільки моя view, яка називалась panel, вже була автентифікована, я додав UUID ключ:

def panel(request):  
 uuid = provide_uuid(request.user)  
 return render(request, 'panel.html', {'user': request.user, 'uuid': uuid})
def provide_uuid(user: User) -> str:  
 uuid = str(uuid4())  
 cache.set(uuid, user.id, 60)  
 return uuid

В HTML-шаблоні (panel.html):

const socket = new WebSocket('ws://localhost:8000/ws/panel/?uuid={{ uuid }}');

Кожного разу, коли викликається цей view, генерується новий одноразовий UUID ключ для автентифікації сокета. Потім я реалізував власний middleware для автентифікації на основі UUID:

import logging  
from urllib.parse import parse_qsl
from asgiref.sync import sync_to_async  
from channels.db import database_sync_to_async  
from channels.middleware import BaseMiddleware  
from django.contrib.auth import get_user_model  
from django.contrib.auth.models import AnonymousUser  
from django.core.cache import cache  
from django.db import close_old_connectionslogger = logging.getLogger(__name__)User = get_user_model()@database_sync_to_async  
def get_user(user_id):  
 try:  
 return User.objects.get(id=user_id)  
 except User.DoesNotExist:  
 return sync_to_async(AnonymousUser)()class UUIDAuthMiddleware(BaseMiddleware): def __init__(self, inner):  
 super().__init__(inner) async def auth(self, query_string):  
 query_params = dict(parse_qsl(query_string))  
 uuid = query_params.get('uuid')  
 if uuid in cache:  
 user_id = cache.get(uuid)  
 cache.delete(uuid)  
 return await get_user(user_id)  
 return AnonymousUser() async def __call__(self, scope, receive, send):  
 close_old_connections() query_string = scope['query_string'].decode('utf-8')  
 scope['user'] = await self.auth(query_string) return await super().__call__(scope, receive, send)def UUIDAuthMiddlewareStack(inner):  
 return UUIDAuthMiddleware(inner)

Висновок

Цей підхід поєднує безпечну обробку cookie в Django з одноразовими UUID ключами, що забезпечує посилене захист від несанкціонованого доступу. Це також гарантує постійну автентифікацію для підключень сокетів за допомогою вбудованих механізмів Django.

Більше жодних злодіїв cookie і зниклих сесій — лише плавне з’єднання для WebSockets! 😀

Джерела

Перекладено з: Simple, Secure and Persistence socket authentication in Django-channels

Leave a Reply

Your email address will not be published. Required fields are marked *