From 8c9ee1668c53ac16a23842f7ab776ee5f847af84 Mon Sep 17 00:00:00 2001 From: Holger Frey Date: Wed, 1 Apr 2020 18:21:55 +0200 Subject: [PATCH] added have i been pawend api call --- ordr3/models.py | 3 ++ ordr3/services.py | 59 +++++++++++++++++++++++++++++++-- tests/test_services.py | 74 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 3 deletions(-) diff --git a/ordr3/models.py b/ordr3/models.py index 21b33d3..22908f3 100644 --- a/ordr3/models.py +++ b/ordr3/models.py @@ -1,5 +1,8 @@ import enum from datetime import datetime +from collections import namedtuple + +FlashMessage = namedtuple("FlashMessage", ["message", "description"]) @enum.unique diff --git a/ordr3/services.py b/ordr3/services.py index 9703f80..2ceb2bf 100644 --- a/ordr3/services.py +++ b/ordr3/services.py @@ -1,10 +1,11 @@ +import hashlib from datetime import datetime from collections import namedtuple from urllib.parse import urlparse -from sqlalchemy.orm.exc import NoResultFound +import requests -from . import models +from . import models, security CONSUMABLE_STATI = { models.OrderStatus.ORDERED, @@ -43,7 +44,7 @@ def create_log_entry(order, status, user): def verify_credentials(repo, pass_ctx, username, password): try: user = repo.get_user_by_username(username) - except (NoResultFound, StopIteration): + except StopIteration: # user was not found return None valid, new_hash = pass_ctx.verify_and_update(password, user.password) @@ -87,3 +88,55 @@ def check_vendor_name(repo, to_check): return CheckVendorResult(canonical_name, False) else: return CheckVendorResult(vendor, True) + + +def set_new_password(user, password): + crypt_context = security.get_passlib_context() + user.password = crypt_context.hash(password) + + messages = [] + + pwd_length = len(password) + if pwd_length < 16: + messages.append( + models.FlashMessage( + f"Your password is quite short.", + ( + f"Your Password has only {pwd_length} characters. " + 'Have a look at ' + "this comic strip why longer passwords are better." + ), + ) + ) + + sha1_hash = hashlib.sha1(password.encode()).hexdigest() + if have_i_been_pwned(sha1_hash): + + messages.append( + models.FlashMessage( + "This password appears in a breach or has been compromised.", + ( + "Please consider changing your password. " + 'Read this page on why you ' + "see this message." + ), + ) + ) + + return messages + + +def have_i_been_pwned(password_hash): + head, tails = password_hash[:5], password_hash[5:].lower() + url = f"https://api.pwnedpasswords.com/range/{head}" + headers = {"Add-Padding": "true"} + try: + response = requests.get(url, headers=headers, timeout=2) + response.raise_for_status() + except requests.RequestException: + return False + lower_case_lines = (line.lower() for line in response.text.splitlines()) + for line in lower_case_lines: + if line.startswith(tails): + return True + return False diff --git a/tests/test_services.py b/tests/test_services.py index 6064a58..82d1b28 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -216,3 +216,77 @@ def test_check_vendor_name(input, name, found): assert result.name == name assert result.found == found + + +def test_have_i_been_pwned_ok(): + from ordr3.services import have_i_been_pwned + + assert not have_i_been_pwned("21BD2x") + + +def test_have_i_been_pwned_not_ok(): + from ordr3.services import have_i_been_pwned + + assert have_i_been_pwned("21BD2008F2FF3F9F3AE0A2072D19CD17E971B33A") + + +def test_have_i_been_pwned_request_exception(): + from ordr3.services import have_i_been_pwned + + assert not have_i_been_pwned("xxxxx") + + +def test_set_new_password_ok(monkeypatch): + from ordr3 import services + from ordr3.models import User + from ordr3.security import get_passlib_context + + user = User(*list("ABCDEFG")) + monkeypatch.setattr(services, "have_i_been_pwned", lambda x: False) + result = services.set_new_password(user, "1234567890123456") + + assert get_passlib_context().verify("1234567890123456", user.password) + assert result == [] + + +def test_set_new_password_to_short(monkeypatch): + from ordr3 import services + from ordr3.models import User + from ordr3.security import get_passlib_context + + user = User(*list("ABCDEFG")) + monkeypatch.setattr(services, "have_i_been_pwned", lambda x: False) + result = services.set_new_password(user, "1") + + assert get_passlib_context().verify("1", user.password) + assert len(result) == 1 + assert result[0].message.startswith("Your password is quite short") + + +def test_set_new_password_breached(monkeypatch): + from ordr3 import services + from ordr3.models import User + from ordr3.security import get_passlib_context + + user = User(*list("ABCDEFG")) + monkeypatch.setattr(services, "have_i_been_pwned", lambda x: True) + result = services.set_new_password(user, "1234567890123456") + + assert get_passlib_context().verify("1234567890123456", user.password) + assert len(result) == 1 + assert result[0].message.startswith("This password appears in a breach") + + +def test_set_new_password_to_short_and_breached(monkeypatch): + from ordr3 import services + from ordr3.models import User + from ordr3.security import get_passlib_context + + user = User(*list("ABCDEFG")) + monkeypatch.setattr(services, "have_i_been_pwned", lambda x: True) + result = services.set_new_password(user, "1") + + assert get_passlib_context().verify("1", user.password) + assert len(result) == 2 + assert result[0].message.startswith("Your password is quite short") + assert result[1].message.startswith("This password appears in a breach")