diff --git a/docs/index.md b/docs/index.md index 3eb4b24..cdf0b46 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,6 +5,7 @@ authors: ["trent"] # Trent's Blog ## **Posts By Date** +* [2021-04-19: Test QRCODE Svg in Django](posts/test-qr-svg-django.md){target=_blank} * [2021-01-25: Prosody Photo Uploads](posts/prosody-photo-uploads.md){target=_blank} * [2021-01-09: Xmpp Apt Notifications](posts/xmpp-apt-notifications.md){target=_blank} * [2020-12-20: Apache Virtual Hosts](posts/apache-virtual-hosts.md){target=_blank} diff --git a/docs/links.md b/docs/links.md index d2d5472..75a4a9d 100644 --- a/docs/links.md +++ b/docs/links.md @@ -7,6 +7,7 @@ authors: ["trent"] ## **Links** * [Home](index.md){target=_blank} +* [AudioBooks](https://trentpalmer.org){target=_blank} * [GitHub](https://github.com/TrentSPalmer){target=_blank} * [Twitter](https://twitter.com/boringtrent){target=_blank} * [Facebook](https://www.facebook.com/trentspalmer){target=_blank} diff --git a/docs/photos/IMG_screenshot_enable_totp_confirmation_form.jpg b/docs/photos/IMG_screenshot_enable_totp_confirmation_form.jpg new file mode 100644 index 0000000..a05af87 Binary files /dev/null and b/docs/photos/IMG_screenshot_enable_totp_confirmation_form.jpg differ diff --git a/docs/posts/test-qr-svg-django.md b/docs/posts/test-qr-svg-django.md new file mode 100644 index 0000000..d66b05d --- /dev/null +++ b/docs/posts/test-qr-svg-django.md @@ -0,0 +1,368 @@ +--- +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. + +
+ enable totp confirmation form +
+ +!["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 = "\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 = "\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() +``` diff --git a/mkdocs.yml b/mkdocs.yml index 6b083e6..618e85f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,7 @@ markdown_extensions: nav: - Home: - Home: index.md + - posts/test-qr-svg-django.md - posts/prosody-photo-uploads.md - posts/xmpp-apt-notifications.md - posts/apache-virtual-hosts.md