Django: Использование QR-кодов для быстрого входа на сайт с мобильных устройств

в 9:31, , рубрики: django, PIL, python, Python Imaging Library, QR-коды, авторизация по QR-коду, Веб-разработка, метки: , , , , , ,

Если у вас есть сайт, которым часто пользуются с мобильных устройств (таких как телефоны и планшетные ПК), то вы, возможно, задавались вопросом, как реализовать быстрый вход — так, чтобы пользователю не требовалось вводить ни адрес сайта, ни логин и пароль (либо E-mail и пароль).

На некоторых сайтах вы, возможно, видели возможность отправить SMS-сообщение со ссылкой для быстрого входа — это, по сути, приблизительно то же самое. Основное отличие описанного в данной заметке подхода в том, что вместо отправки SMS-сообщения мы будем генерировать QR-код, который содержит ссылку, позволяющую войти на сайт без ввода авторизационных данных.

Скриншот с экрана мобильного телефона

Кстати, весь процесс написания приложения, которое приводится далее, можно посмотреть в скринкасте на YouTube.

Перед тем, как начать реализовывать этот вариант авторизации, давайте рассмотрим, чем он отличается от варианта с отправкой SMS-сообщения:

  • SMS-сообщение получается немного безопаснее: с одной стороны, мы передаём ссылку через третьи стороны (в частности, SMS-гейт, оператор сотовой связи), но с другой стороны, во-первых, ссылка будет недоступна через JS (и, соответственно, даже если на сайте есть XSS, то получить ссылку из SMS-сообщения злоумышленнику не удастся — а вот из QR-кода её получить можно), а во-вторых получить физический доступ к компьютеру с браузером, в котором пользователь авторизован на сайте, в общем случае проще, чем получить доступ к компьютеру, и, одновременно с этим, к телефону пользователя
  • SMS-сообщение, в то же время, не настолько универсально: оно не подойдёт, если по какой-то причине нет сотовой связи, либо мобильное устройство, используемое пользователем, вообще не предполагает возможность работы в сотовых сетях (но зато имеет и камеру, и подключение к Интернету, и приложение для сканирования QR-кодов на нём есть или легко устанавливается)
  • Отправка SMS-сообщений стоит денег, генерирование QR-кодов абсолютно бесплатно
  • У SMS-сообщений в некоторых ситуациях могут значительно падать показатели скорости и надёжности (то есть сообщение может прийти со значительной задержкой, а может даже вообще не прийти), а сканирование QR-кодов предсказуемо и как правило работает хорошо (по крайней мере, если с камерой всё в порядке)
  • Для отправки SMS-сообщений сайту необходим номер телефона пользователя, однако далеко не всем пользователям нравится идея вводить свой номер телефона где-либо в Интернете

Получается, что вариант с QR-кодами весьма неплох. И даже если нам важна высокая безопасность, то теоретически никто не мешает отправлять QR-код по электронной почте, или, например, каждый раз запрашивать пароль (лишний раз ввести пароль на удобной большой клавиатуре компьютера, мне кажется, намного проще, чем вводить адрес сайта + логин/адрес электронной почты + пароль на виртуальной клавиатуре мобильного устройства). Тем не менее, сейчас предлагаю реализовать самый простой, базовый вариант быстрого входа по QR-кодам, на реализацию которого нам потребуется минимум времени.

Итак, давайте начнём.

Прежде всего перейдём в рабочую директорию Django-проекта, в котором мы хотим добавить такую авторизацию, и создадим новое приложение. Назовём его, например, qrauth:

python manage.py startapp qrauth

В появившейся директории создадим файл qr.py:

try:
    from PIL import Image, ImageDraw
except ImportError:
    import Image, ImageDraw

import qrcode.image.base
import qrcode.image.pil

class PilImage(qrcode.image.pil.PilImage):
    def __init__(self, border, width, box_size):
        if Image is None and ImageDraw is None:
            raise NotImplementedError("PIL not available")
        qrcode.image.base.BaseImage.__init__(self, border, width, box_size)
        self.kind = "PNG"

        pixelsize = (self.width + self.border * 2) * self.box_size
        self._img = Image.new("RGBA", (pixelsize, pixelsize))
        self._idr = ImageDraw.Draw(self._img)

def make_qr_code(string):
    return qrcode.make(string, box_size=10, border=1, image_factory=PilImage)

Здесь используется модуль python-qrcode. Установить его можно с помощью pip:

pip install qrcode

Для того, чтобы получались картинки с прозрачным (а не белым) фоном, мы специально используем свой класс для создания картинок, наследуя его от qrcode.image.pil.PilImage. Если картинки с белым фоном вас устраивает, то будет достаточно написать так:

import qrcode

def make_qr_code(string):
    return qrcode.make(string, box_size=10, border=1)

Стоит отметить, что в данном случае картинки, которые возвращает qrcode.make (и, соответственно, функция make_qr_code) неоптимальны с точки зрения размера. Например, с помощью optipng их размер удаётся уменьшить примерно на 70% (разумеется, без потери качества). Тем не менее, в большинстве случаев это непринципиально — их размер в любом случае получается небольшим (в пределах нескольких кибибайтов).

Далее создадим файл utils.py и добавим функции, которые затем будем использовать в представлениях (views):

import os
import string
import hashlib

from django.conf import settings

def generate_random_string(length,
                           stringset="".join(
                               [string.ascii_letters+string.digits]
                           )):
    """
    Returns a string with `length` characters chosen from `stringset`
    >>> len(generate_random_string(20) == 20 
    """
    return "".join([stringset[i%len(stringset)] 
        for i in [ord(x) for x in os.urandom(length)]])

def salted_hash(string):
    return hashlib.sha1(":)".join([
        string,
        settings.SECRET_KEY,
    ])).hexdigest()

Функция generate_random_string генерирует строку случайных символов заданной длины. По умолчанию строка составляется из букв латинского алфавита (как нижнего, так и верхнего регистра) и цифр.

Функция salted_hash солит и хэширует строку.

Теперь откроем views.py и напишем представления:

import redis

from django.contrib.auth.decorators import login_required
from django.contrib.auth import login, get_backends
from django.contrib.sites.models import get_current_site
from django.template import RequestContext
from django.shortcuts import render_to_response
from django.http import HttpResponse, HttpResponseRedirect, Http404
from django.core.urlresolvers import reverse

from django.contrib.auth.models import User

from privatemessages.context_processors import 
number_of_new_messages_processor

from utils import generate_random_string, salted_hash
from qr import make_qr_code

@login_required
def qr_code_page(request):
    r = redis.StrictRedis()

    auth_code = generate_random_string(50)
    auth_code_hash = salted_hash(auth_code)

    r.setex(auth_code_hash, 300, request.user.id)

    return render_to_response("qrauth/page.html",
                              {"auth_code": auth_code},
                              context_instance=RequestContext(request))

@login_required
def qr_code_picture(request, auth_code):
    r = redis.StrictRedis()

    auth_code_hash = salted_hash(auth_code)

    user_id = r.get(auth_code_hash)

    if (user_id == None) or (int(user_id) != request.user.id):
        raise Http404("No such auth code")

    current_site = get_current_site(request)
    scheme = request.is_secure() and "https" or "http"

    login_link = "".join([
        scheme,
        "://",
        current_site.domain,
        reverse("qr_code_login", args=(auth_code_hash,)),
    ])

    img = make_qr_code(login_link)
    response = HttpResponse(mimetype="image/png")
    img.save(response, "PNG")
    return response

def login_view(request, auth_code_hash):
    r = redis.StrictRedis()
    user_id = r.get(auth_code_hash)

    if user_id == None:
        return HttpResponseRedirect(reverse("invalid_auth_code"))

    r.delete(auth_code_hash)

    try:
        user = User.objects.get(id=user_id)
    except User.DoesNotExist:
        return HttpResponseRedirect(reverse("invalid_auth_code"))

    # In lieu of a call to authenticate()
    backend = get_backends()[0]
    user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__)
    login(request, user)

    return HttpResponseRedirect(reverse("dating.views.index"))

При обращении к странице с QR-кодом (qr_code_page) генерируется случайная строка из 50 символов. Далее в Redis (установить клиент можно с помощью pip install redis) добавляется новая пара ключ-значение, где в качестве ключа задаётся солёный хэш сгенерированной случайной строки, а в качестве значения — идентификатор пользователя (это нужно для того, чтобы картинка с QR-кодом, которая добавляется на страницу, была доступна только этому пользователю). Это этого ключа устанавливается время истечения, в примере указано 300 секунд (5 минут).

В контексте шаблона при этом задаётся сгенерированная случайная строка: эта строка затем используется в адресе, по которому возвращается картинка с QR-кодом (а вот для авторизации как раз используется хэш, и в QR-код включается именно адрес с хэшем: таким образом, даже если кому-то ещё становится известен адрес картинки, то для авторизации этого будет недостаточно — для авторизации нужно знать солёный хэш для случайной строки, указанной в адресе картинки).

Далее, при загрузке картинки (qr_code_picture) случайная строка, содержащаяся в адресе картинки, опять же, хэшируется, и затем проверяется, есть ли в Redis соответствующий ключ. Если такой ключ есть, и содержит идентификатор текущего пользователя, то создаётся и возвращается QR-код, содержащий абсолютную ссылку для мгновенной авторизации на сайте. В ином случае возвращается ошибка 404.

Кстати, понимаете, что здесь можно легко улучшить?

При выполнении int(user_id) != request.user.id может возникнуть ValueError — в том случае, если в Redis есть такой ключ, но его значение не подходит для преобразования к десятичному целому числу. Решается с помощью try…except, либо с помощью строкового метода isdigit. Либо можно наоборот получать строку от reques.t.user.id и сравниват её с полученным значением ключа.
Также можно использовать не просто хэш, а, например, хэш с префиксом — особенно актуально в том случае, если где-то в другом месте тоже создаются аналогичные ключи.

Получение домена здесь происходит с помощью django.contrib.sites. Указать домен можно через административный интерфейс (/admin/sites/site/).

Если ваш сервер находится за reverse proxy (например, nginx), и вы используете SSL, то убедитесь, что информация об этом включается в запросы к upstream-серверу — это нужно для того, чтобы request.is_secure() выдавал правильное значение (для этого определите в настройках https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header, но учитывайте, что вам нужно будет обязательно устанавливать/удалять этот заголовок на стороне прокси-сервера — иначе, если, например, ваш сайт доступен и по HTTP, и по HTTPS, то пользователь, который заходит по HTTP, сможет установить этот заголовок таким образом, что request.is_secure() будет выдавать значение True, а это плохо с точки зрения безопасности).

И да, начиная с Python 2.6 вместо request.is_secure() and «https» or «http» можно писать «https» if request.is_secure() else «http».

При переходе по ссылке для мгновенной авторизации проверяется, есть ли в Redis ключ, соответствующий указанному в ссылке хэшу. Если нет — пользователь перенаправляется на страницу с сообщением о том, что QR-код недействителен (в данном примере эта страница не требует написания отдельного представления). Если есть — то ключ в Redis удаляется, после чего проверяется, есть ли в базе данных пользователь с таким идентификатором. Если нет — опять же, происходит перенаправление на страницу с сообщением о том, что QR-код недействителен. Если есть — то происходит авторизация и пользователь перенаправляется на главную страницу сайта.

Теперь добавим файл urls.py и определим в нём схему URL приложения:

from django.conf.urls import patterns, url
from django.views.generic.simple import direct_to_template

urlpatterns = patterns('qrauth.views',
    url(
        r'^pic/(?P<auth_code>[a-zA-Zd]{50})/$',
        'qr_code_picture',
        name='auth_qr_code'
    ),
    url(
        r'^(?P<auth_code_hash>[a-fd]{40})/$',
        'login_view',
        name='qr_code_login'
    ),
    url(
        r'invalid_code/$',
        direct_to_template,
        {'template': 'qrauth/invalid_code.html'},
        name='invalid_auth_code'
    ),
    url(
        r'^$',
        'qr_code_page',
        name='qr_code_page'
    ),
)

Также не забудьте открыть ваш главный urls.py (который указывается в ROOT_URLCONF), и включить туда urlpatterns из urls.py созданного приложения:

urlpatterns = patterns('',
    # …
    url(r'^qr/', include('qrauth.urls')),
    # …
)

Теперь откройте директорию с шаблонами и добавьте туда каталог qrauth.

Пример для invalid_code.html:

{% extends "base.html" %}

{% block title %}QR-код недействителен{% endblock %}

{% block content %}
<div class="error">
    <h1>QR-код недействителен</h1>
    <p>QR-код, который вы используете для авторизации, недействителен. Пожалуйста, попробуйте ещё раз открыть страницу с QR-кодом для входа и отсканировать код повторно.</p>
</div>
{% endblock %}

Пример для page.html:

{% extends "base.html" %}

{% block title %}QR-код для входа{% endblock %}

{% block content %}
<div class="qr_code">
    <h1>QR-код для входа</h1>
    <p>Для быстрого входа на сайт с мобильного устройства (например, телефона или планшета) отсканируйте этот QR-код:</p>
    <div><img src="{% url auth_qr_code auth_code %}" alt="QR"></div>
    <p>Каждый сгенерированный QR-код работает только один раз и только 5 минут. Если вам требуется другой QR-код, то просто откройте <a href="{% url qr_code_page %}">эту страницу</a> снова.</p>
</div>
{% endblock %}

Собственно, теперь остаётся открыть сайт в браузере и проверить. Если QR-код успешно генерируется и отображается, попробуйте сосканировать его с помощью телефона или ещё чего-нибудь с камерой и Интернетом.

Если будут какие-то вопросы или мысли о том, какие ещё могут быть варианты для быстрой и удобной авторизации с мобильных устройств — буду рад комментариям.

Всем удачи и приятного программирования!

С наступающим вас летом! :)

Автор: MaGIc2laNTern

Источник

Поделиться

* - обязательные к заполнению поля