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")