diff --git a/nativeauthenticator/handlers.py b/nativeauthenticator/handlers.py index f9e21e8..7e454c1 100644 --- a/nativeauthenticator/handlers.py +++ b/nativeauthenticator/handlers.py @@ -70,6 +70,7 @@ def get_result_message( user, username_already_taken, confirmation_matches, + password_was_pwned, assume_user_is_human=True, ): """Helper function to discern exactly what message and alert level are @@ -91,6 +92,13 @@ def get_result_message( elif not confirmation_matches: alert = "alert-danger" message = "Your password did not match the confirmation. Please try again." + elif password_was_pwned: + alert = "alert-danger" + message = ( + "That password is known to be included in prominent leaks of user data " + "(via haveibeenpwned.com) and is therefore not considered secure. " + "Please use a different password." + ) # Error if user creation was not successful. elif not user: alert = "alert-danger" @@ -185,10 +193,15 @@ async def post(self): "signup_password_confirmation", strip=False ) confirmation_matches = password == confirmation + password_was_pwned = self.authenticator.is_password_pwned(password) # Call helper function from above for precise alert-level and message. alert, message = self.get_result_message( - user, username_already_taken, confirmation_matches, assume_user_is_human + user, + username_already_taken, + confirmation_matches, + password_was_pwned, + assume_user_is_human, ) otp_secret, user_2fa = "", "" @@ -343,6 +356,7 @@ async def post(self): ).is_valid_password(old_password) new_password_matches_confirmation = new_password == confirmation + password_was_pwned = self.authenticator.is_password_pwned(new_password) if not correct_password_provided: alert = "alert-danger" @@ -352,6 +366,13 @@ async def post(self): message = ( "Your new password didn't match the confirmation. Please try again." ) + elif password_was_pwned: + alert = "alert-danger" + message = ( + "That password is known to be included in prominent leaks of user data " + "(via haveibeenpwned.com) and is therefore not considered secure. " + "Please use a different password." + ) else: success = self.authenticator.change_password(user.name, new_password) if success: @@ -411,12 +432,20 @@ async def post(self, user_name): confirmation = self.get_body_argument("new_password_confirmation", strip=False) new_password_matches_confirmation = new_password == confirmation + password_was_pwned = self.authenticator.is_password_pwned(new_password) if not new_password_matches_confirmation: alert = "alert-danger" message = ( "The new password didn't match the confirmation. Please try again." ) + elif password_was_pwned: + alert = "alert-danger" + message = ( + "That password is known to be included in prominent leaks of user data " + "(via haveibeenpwned.com) and is therefore not considered secure. " + "Please use a different password." + ) else: success = self.authenticator.change_password(user_name, new_password) if success: diff --git a/nativeauthenticator/nativeauthenticator.py b/nativeauthenticator/nativeauthenticator.py index f678bee..60db237 100644 --- a/nativeauthenticator/nativeauthenticator.py +++ b/nativeauthenticator/nativeauthenticator.py @@ -9,6 +9,7 @@ from pathlib import Path import bcrypt +import pwnedpasswords from jupyterhub.auth import Authenticator from sqlalchemy import inspect from tornado import web @@ -269,6 +270,11 @@ def is_password_strong(self, password): return all(checks) + def is_password_pwned(self, password): + """Checks against HaveIBeenPwned.com's database if this + password appears in any prominent data leaks.""" + return pwnedpasswords.check(password, plain_text=True) > 0 + def get_user(self, username): return UserInfo.find(self.db, self.normalize_username(username)) diff --git a/nativeauthenticator/tests/test_authenticator.py b/nativeauthenticator/tests/test_authenticator.py index 72781a4..d95f2c5 100644 --- a/nativeauthenticator/tests/test_authenticator.py +++ b/nativeauthenticator/tests/test_authenticator.py @@ -139,6 +139,16 @@ async def test_create_user_with_strong_passwords( assert bool(user) == expected +async def test_pwned_passwords(tmpcwd, app): + """Tests the library interface to HaveIBeenPwned.com""" + auth = NativeAuthenticator(db=app.db) + + assert auth.is_password_pwned("password") + assert auth.is_password_pwned("Daenerys") + assert not auth.is_password_pwned("9n+VX7QgVaaqy7PU#S8Bm5GB") + assert not auth.is_password_pwned("?JWR%9_gEQ-t%c4eJ%%CAqq=") + + async def test_change_password(tmpcwd, app): auth = NativeAuthenticator(db=app.db) user = auth.create_user("johnsnow", "password") diff --git a/setup.py b/setup.py index feb8aa4..29b0c8a 100644 --- a/setup.py +++ b/setup.py @@ -15,6 +15,11 @@ author_email="leportella@protonmail.com", license="3 Clause BSD", packages=find_packages(), - install_requires=["jupyterhub>=1.3", "bcrypt", "onetimepass"], + install_requires=[ + "jupyterhub>=1.3", + "bcrypt", + "onetimepass", + "pwnedpasswords>=2.0", + ], include_package_data=True, )