369 lines
9.9 KiB
Markdown
369 lines
9.9 KiB
Markdown
---
|
|
title: "Test QRCODE Svg in Django"
|
|
date: 2021-04-19
|
|
draft: false
|
|
tags: ["django", "python", "testing", "pillow", "beautifulsoup", "opencv", "cairosvg", "pyotp"]
|
|
authors: ["trent"]
|
|
---
|
|
date: 2021-04-19
|
|
|
|
## **Introduction**
|
|
I worked out a solution in django-testing, for testing a view that renders a qrcode
|
|
as an svg as an inline svg xml string.
|
|
|
|
In case you are not familiar with
|
|
[svg](https://en.wikipedia.org/wiki/Scalable_Vector_Graphics){target=_blank},
|
|
scalable vector graphics, it is an image that is
|
|
completely rendered from a
|
|
[string](https://en.wikipedia.org/wiki/String_(computer_science)){target=_blank}
|
|
of text, or more accurately [XML](https://en.wikipedia.org/wiki/XML){target=_blank}
|
|
text, unlike [PNG](https://en.wikipedia.org/wiki/Portable_Network_Graphics){target=_blank}
|
|
or [JPG](https://en.wikipedia.org/wiki/JPEG){target=_blank}, which are
|
|
[binary file](https://en.wikipedia.org/wiki/Binary_file){target=_blank}s.
|
|
|
|
### Python Libraries Used
|
|
* [BeautifulSoup](https://pypi.org/project/beautifulsoup4/){target=_blank}
|
|
to consume html
|
|
* [CairoSVG](https://pypi.org/project/CairoSVG/){target=_blank}
|
|
to convert svg to png
|
|
* [python-pillow](https://pypi.org/project/Pillow/){target=_blank}
|
|
to convert transparent png background to white
|
|
* [opencv-python](https://pypi.org/project/opencv-python/){target=_blank}
|
|
to extract data from qrcode
|
|
* [python-pyotp](https://pypi.org/project/pyotp/){target=_blank}
|
|
|
|
## **Form ScreenShot**
|
|
This is the form we will be testing.
|
|
|
|
In real life you would confirm that you want to use
|
|
[two-factor-authentication](https://en.wikipedia.org/wiki/Multi-factor_authentication){target=_blank}
|
|
by scanning the qrcode with an
|
|
[authentication app](https://play.google.com/store/apps/details?id=org.shadowice.flocke.andotp){target=_blank}
|
|
on your smartphone, and then enter the
|
|
[time-based one-time password](https://en.wikipedia.org/wiki/Time-based_One-Time_Password){target=_blank}.
|
|
|
|
totp codes are derived from two things and two things only. A secret key and a time such as the current time.
|
|
For instance, an authentication application can tell you what your totp code is, but of course
|
|
in this testing scenario we use
|
|
[python-pyotp](https://pypi.org/project/pyotp/){target=_blank}, after extracting
|
|
the secret key from the qrcode.
|
|
|
|
<div align="center">
|
|
<em>enable totp confirmation form</em>
|
|
</div>
|
|
|
|
!["ScreenShot of a confirmation form for enabling totp (in a django app)"](
|
|
../../photos/IMG_screenshot_enable_totp_confirmation_form.jpg){: .center }
|
|
|
|
## **Import Python Libraries**
|
|
```python
|
|
# tp/accounts/tests/test_enable_totp_view.py
|
|
# python manage.py test accounts.tests.test_enable_totp_view
|
|
from django.test import TestCase
|
|
from django.contrib.auth.models import User
|
|
from accounts.models import Account
|
|
from django.urls import reverse
|
|
from bs4 import BeautifulSoup
|
|
import cv2
|
|
from cairosvg import svg2png
|
|
from PIL import Image
|
|
import pyotp
|
|
import pathlib
|
|
```
|
|
|
|
The Account model has a one-to-one relationship with the Django built-in
|
|
User model. This is where we keep track of the totp secret key and
|
|
boolean value for having totp authentication enabled.
|
|
|
|
## **setUp TestCase**
|
|
```python
|
|
...
|
|
import pathlib
|
|
|
|
|
|
class TestEnableTOTPViewTestCase(TestCase):
|
|
|
|
def setUp(self):
|
|
user_a = User.objects.create(
|
|
username='user_a')
|
|
user_a.set_password('password_user_a')
|
|
user_a.save()
|
|
Account.objects.create(user=user_a)
|
|
```
|
|
|
|
We create a test user and save that in the test database.
|
|
|
|
## **GET Form**
|
|
```python
|
|
class TestEnableTOTPViewTestCase(TestCase):
|
|
...
|
|
def test_enable_totp_view(self):
|
|
self.client.login(
|
|
username='user_a',
|
|
password='password_user_a'
|
|
)
|
|
get_response = self.client.get(
|
|
reverse('accounts:enable_totp')
|
|
)
|
|
self.assertEquals(
|
|
get_response.status_code, 200
|
|
)
|
|
self.assertTemplateUsed(
|
|
get_response, 'accounts/totp_form.html'
|
|
)
|
|
self.assertEquals(
|
|
get_response.request['PATH_INFO'],
|
|
'/accounts/enable-totp/'
|
|
)
|
|
```
|
|
The TestCase requires two requests. In the first request we GET the form
|
|
so that we can consume the qrcode.
|
|
|
|
## **Consume SVG with BeautifulSoup**
|
|
```python
|
|
class TestEnableTOTPViewTestCase(TestCase):
|
|
...
|
|
def test_enable_totp_view(self):
|
|
...
|
|
soup = BeautifulSoup(
|
|
get_response.content, features="lxml"
|
|
)
|
|
svg_container = soup.find(
|
|
"div", {"id": "svgcontainer"}
|
|
)
|
|
self.assertIsNotNone(svg_container)
|
|
scsvg = svg_container.findChild(
|
|
"svg", recursive=False
|
|
)
|
|
self.assertIsNotNone(scsvg)
|
|
|
|
with open('qr.svg', 'w') as f:
|
|
x_string = "<?xml version='1.0'"
|
|
x_string += " encoding='utf-8'?>\n"
|
|
f.write(x_string + str(scsvg))
|
|
```
|
|
The inline xml for the svg comes to us as a child of a
|
|
`div` with an `id` of *svgcontainer*. We capture the xml of the svg in the variable
|
|
`scsvg`, and then write it out to disc.
|
|
|
|
## **svg2png**
|
|
```python
|
|
class TestEnableTOTPViewTestCase(TestCase):
|
|
...
|
|
def test_enable_totp_view(self):
|
|
...
|
|
svg2png(
|
|
url='qr.svg', write_to='qr.png',
|
|
scale=8
|
|
)
|
|
```
|
|
With svg2png from CairoSVG, we convert the svg to png format. Opencv seems
|
|
unable to consume the qrcode unless you scale it up.
|
|
|
|
## **Add White Background**
|
|
```python
|
|
class TestEnableTOTPViewTestCase(TestCase):
|
|
...
|
|
def test_enable_totp_view(self):
|
|
...
|
|
t_image = Image.open('qr.png')
|
|
t_image.load()
|
|
background = Image.new(
|
|
"RGB", t_image.size,
|
|
(255, 255, 255)
|
|
)
|
|
background.paste(
|
|
t_image,
|
|
mask=t_image.split()[3]
|
|
)
|
|
background.save(
|
|
'qr.jpg', "JPEG",
|
|
quality=100
|
|
)
|
|
```
|
|
We use Image from python-pillow to change the background from transparent
|
|
to white. Opencv seems unable to consume the qrcode when it has a transparent
|
|
background.
|
|
|
|
## **Extract Data From QRCODE**
|
|
```python
|
|
class TestEnableTOTPViewTestCase(TestCase):
|
|
...
|
|
def test_enable_totp_view(self):
|
|
...
|
|
image = cv2.imread('qr.jpg')
|
|
qr_det = cv2.QRCodeDetector()
|
|
qrdata = qr_det.detectAndDecode(image)
|
|
totp_code = pyotp.TOTP(
|
|
pyotp.parse_uri(qrdata[0]).secret
|
|
).now()
|
|
```
|
|
|
|
* `qrdata[0]` will be the *otpauth_uri*.
|
|
* `pyotp.parse_uri(qrdata[0]).secret` is the secret key.
|
|
* `totp_code` is the one-time password.
|
|
|
|
## **POST the `totp_code`**
|
|
```python
|
|
class TestEnableTOTPViewTestCase(TestCase):
|
|
...
|
|
def test_enable_totp_view(self):
|
|
...
|
|
totp_code = pyotp.TOTP(
|
|
pyotp.parse_uri(qrdata[0]).secret
|
|
).now()
|
|
response = self.client.post(
|
|
reverse('accounts:enable_totp'),
|
|
{'totp_code': totp_code},
|
|
follow=True
|
|
)
|
|
self.assertEquals(
|
|
response.status_code, 200
|
|
)
|
|
self.assertTemplateUsed(
|
|
response, 'base_form.html'
|
|
)
|
|
self.assertEquals(
|
|
response.request['PATH_INFO'],
|
|
'/accounts/edit-profile/'
|
|
)
|
|
user_a = User.objects.get(
|
|
username='user_a'
|
|
)
|
|
self.assertTrue(user_a.account.use_totp)
|
|
self.assertEquals(
|
|
len(user_a.account.totp_key), 16
|
|
)
|
|
pathlib.Path('qr.svg').unlink()
|
|
pathlib.Path('qr.png').unlink()
|
|
pathlib.Path('qr.jpg').unlink()
|
|
```
|
|
Post the `totp_code` back to the form, and then verify
|
|
that the database is updated, and then delete the image
|
|
files.
|
|
|
|
## **Complete TestCase**
|
|
```python
|
|
# tp/accounts/tests/test_enable_totp_view.py
|
|
# python manage.py test accounts.tests.test_enable_totp_view
|
|
from django.test import TestCase
|
|
from django.contrib.auth.models import User
|
|
from accounts.models import Account
|
|
from django.urls import reverse
|
|
from bs4 import BeautifulSoup
|
|
import cv2
|
|
from cairosvg import svg2png
|
|
from PIL import Image
|
|
import pyotp
|
|
|
|
|
|
class TestEnableTOTPViewTestCase(TestCase):
|
|
|
|
# setUP TestCase
|
|
def setUp(self):
|
|
user_a = User.objects.create(
|
|
username='user_a')
|
|
user_a.set_password('password_user_a')
|
|
user_a.save()
|
|
Account.objects.create(user=user_a)
|
|
|
|
def test_enable_totp_view(self):
|
|
self.client.login(
|
|
username='user_a',
|
|
password='password_user_a'
|
|
)
|
|
|
|
# GET Form
|
|
get_response = self.client.get(
|
|
reverse('accounts:enable_totp')
|
|
)
|
|
self.assertEquals(
|
|
get_response.status_code, 200
|
|
)
|
|
self.assertTemplateUsed(
|
|
get_response, 'accounts/totp_form.html'
|
|
)
|
|
self.assertEquals(
|
|
get_response.request['PATH_INFO'],
|
|
'/accounts/enable-totp/'
|
|
)
|
|
|
|
# Consume SVG with BeautifulSoup
|
|
soup = BeautifulSoup(
|
|
get_response.content, features="lxml"
|
|
)
|
|
svg_container = soup.find(
|
|
"div", {"id": "svgcontainer"}
|
|
)
|
|
self.assertIsNotNone(svg_container)
|
|
scsvg = svg_container.findChild(
|
|
"svg", recursive=False
|
|
)
|
|
self.assertIsNotNone(scsvg)
|
|
|
|
with open('qr.svg', 'w') as f:
|
|
x_string = "<?xml version='1.0'"
|
|
x_string += " encoding='utf-8'?>\n"
|
|
f.write(x_string + str(scsvg))
|
|
|
|
# svg2png
|
|
svg2png(
|
|
url='qr.svg', write_to='qr.png',
|
|
scale=8
|
|
)
|
|
|
|
# add white background
|
|
t_image = Image.open('qr.png')
|
|
t_image.load()
|
|
background = Image.new(
|
|
"RGB", t_image.size,
|
|
(255, 255, 255)
|
|
)
|
|
background.paste(
|
|
t_image,
|
|
mask=t_image.split()[3]
|
|
)
|
|
background.save(
|
|
'qr.jpg', "JPEG",
|
|
quality=100
|
|
)
|
|
|
|
# extract data from qrcode
|
|
image = cv2.imread('qr.jpg')
|
|
qr_det = cv2.QRCodeDetector()
|
|
qrdata = qr_det.detectAndDecode(image)
|
|
totp_code = pyotp.TOTP(
|
|
pyotp.parse_uri(qrdata[0]).secret
|
|
).now()
|
|
totp_code = pyotp.TOTP(
|
|
pyotp.parse_uri(qrdata[0]).secret
|
|
).now()
|
|
|
|
# POST the `totp_code`
|
|
response = self.client.post(
|
|
reverse('accounts:enable_totp'),
|
|
{'totp_code': totp_code},
|
|
follow=True
|
|
)
|
|
self.assertEquals(
|
|
response.status_code, 200
|
|
)
|
|
self.assertTemplateUsed(
|
|
response, 'base_form.html'
|
|
)
|
|
self.assertEquals(
|
|
response.request['PATH_INFO'],
|
|
'/accounts/edit-profile/'
|
|
)
|
|
user_a = User.objects.get(
|
|
username='user_a'
|
|
)
|
|
self.assertTrue(user_a.account.use_totp)
|
|
self.assertEquals(
|
|
len(user_a.account.totp_key), 16
|
|
)
|
|
pathlib.Path('qr.svg').unlink()
|
|
pathlib.Path('qr.png').unlink()
|
|
pathlib.Path('qr.jpg').unlink()
|
|
```
|