Repository: Netflix/lemur

Issue:
Weak password policy for user accounts
Currently there is a weak password policy implemented when created user accounts. The password policy can be increased to include the following:

- At least 12 characters (required for your Muhlenberg password)—the more characters, the better
- A mixture of both uppercase and lowercase letters
- A mixture of letters and numbers 
- Inclusion of at least one special character, e.g., ! @ # ? ]

---

The referenced issue was found via a pen test conducted in collaboration with [Infor](https://www.infor.com/) and [Cobalt.io](https://www.cobalt.io/)


Discussion:

Resolving patch:
diff --git a/lemur/auth/views.py b/lemur/auth/views.py
--- a/lemur/auth/views.py
+++ b/lemur/auth/views.py
@@ -6,31 +6,28 @@
 .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
 """
 
-import jwt
 import base64
-import requests
 import time
 
+import jwt
+import requests
 from cryptography.exceptions import InvalidSignature
 from cryptography.hazmat.backends import default_backend
 from cryptography.hazmat.primitives import hashes, hmac
-
 from flask import Blueprint, current_app
-
-from flask_restful import reqparse, Resource, Api
 from flask_principal import Identity, identity_changed
+from flask_restful import reqparse, Resource, Api
 
+from lemur.auth import ldap
+from lemur.auth.service import create_token, fetch_token_header, get_rsa_public_key
+from lemur.common.utils import get_psuedo_random_string, get_state_token_secret
 from lemur.constants import SUCCESS_METRIC_STATUS, FAILURE_METRIC_STATUS
 from lemur.exceptions import TokenExchangeFailed
 from lemur.extensions import metrics
-from lemur.common.utils import get_psuedo_random_string, get_state_token_secret
-
-from lemur.users import service as user_service
-from lemur.roles import service as role_service
 from lemur.logs import service as log_service
-from lemur.auth.service import create_token, fetch_token_header, get_rsa_public_key
-from lemur.auth import ldap
 from lemur.plugins.base import plugins
+from lemur.roles import service as role_service
+from lemur.users import service as user_service
 
 mod = Blueprint("auth", __name__)
 api = Api(mod)
diff --git a/lemur/users/schemas.py b/lemur/users/schemas.py
--- a/lemur/users/schemas.py
+++ b/lemur/users/schemas.py
@@ -5,7 +5,10 @@
     :license: Apache, see LICENSE for more details.
 .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
 """
-from marshmallow import fields
+import re
+
+from flask import current_app
+from marshmallow import fields, validates, ValidationError
 
 from lemur.common.schema import LemurInputSchema, LemurOutputSchema
 from lemur.schemas import (
@@ -19,13 +22,34 @@ class UserInputSchema(LemurInputSchema):
     id = fields.Integer()
     username = fields.String(required=True)
     email = fields.Email(required=True)
-    password = fields.String()  # TODO add complexity requirements
+    password = fields.String()
     active = fields.Boolean()
     roles = fields.Nested(AssociatedRoleSchema, many=True, missing=[])
     certificates = fields.Nested(AssociatedCertificateSchema, many=True, missing=[])
     authorities = fields.Nested(AssociatedAuthoritySchema, many=True, missing=[])
 
 
+class UserCreateInputSchema(UserInputSchema):
+    @validates('password')
+    def validate_password(self, value):
+        if current_app.config.get('CHECK_PASSWORD_STRENGTH', True):
+            # At least 12 characters
+            if len(value) < 12:
+                raise ValidationError('Password must be at least 12 characters long.')
+
+            # A mixture of both uppercase and lowercase letters
+            if not any(map(str.isupper, value)) or not any(map(str.islower, value)):
+                raise ValidationError('Password must contain both uppercase and lowercase characters.')
+
+            # A mixture of letters and numbers
+            if not any(map(str.isdigit, value)):
+                raise ValidationError('Password must contain at least one digit.')
+
+            # Inclusion of at least one special character
+            if not re.findall(r'[!@#?\]]', value):
+                raise ValidationError('Password must contain at least one special character (!@#?]).')
+
+
 class UserOutputSchema(LemurOutputSchema):
     id = fields.Integer()
     username = fields.String()
@@ -36,6 +60,7 @@ class UserOutputSchema(LemurOutputSchema):
 
 
 user_input_schema = UserInputSchema()
+user_create_input_schema = UserCreateInputSchema()
 user_output_schema = UserOutputSchema()
 users_output_schema = UserOutputSchema(many=True)
 
diff --git a/lemur/users/views.py b/lemur/users/views.py
--- a/lemur/users/views.py
+++ b/lemur/users/views.py
@@ -18,7 +18,7 @@
 from lemur.users.schemas import (
     user_input_schema,
     user_output_schema,
-    users_output_schema,
+    users_output_schema, user_create_input_schema,
 )
 
 mod = Blueprint("users", __name__)
@@ -89,7 +89,7 @@ def get(self):
         args = parser.parse_args()
         return service.render(args)
 
-    @validate_schema(user_input_schema, user_output_schema)
+    @validate_schema(user_create_input_schema, user_output_schema)
     @admin_permission.require(http_exception=403)
     def post(self, data=None):
         """


Test patch:
diff --git a/lemur/tests/test_users.py b/lemur/tests/test_users.py
--- a/lemur/tests/test_users.py
+++ b/lemur/tests/test_users.py
@@ -1,8 +1,10 @@
 import json
 
 import pytest
+from marshmallow import ValidationError
 
 from lemur.tests.factories import UserFactory, RoleFactory
+from lemur.users.schemas import UserInputSchema, UserCreateInputSchema
 from lemur.users.views import *  # noqa
 from .vectors import (
     VALID_ADMIN_API_TOKEN,
@@ -12,8 +14,6 @@
 
 
 def test_user_input_schema(client):
-    from lemur.users.schemas import UserInputSchema
-
     input_data = {
         "username": "example",
         "password": "1233432",
@@ -25,6 +25,30 @@ def test_user_input_schema(client):
     assert not errors
 
 
+def test_valid_password():
+    schema = UserCreateInputSchema()
+    good_password = "ABcdefg123456@#]"
+    # This password should not raise an exception
+    schema.validate_password(good_password)
+
+
+@pytest.mark.parametrize(
+    "bad_password",
+    [
+        "ABCD1234!#]",  # No lowercase
+        "abcd1234@#]",  # No uppercase
+        "!@#]Abcdefg",  # No digit
+        "ABCDabcd1234",  # No special character
+        "Ab1!@#]",  # less than 12 characters
+    ],
+)
+def test_invalid_password(bad_password):
+    schema = UserCreateInputSchema()
+    # All these passwords should raise an exception
+    with pytest.raises(ValidationError):
+        schema.validate_password(bad_password)
+
+
 @pytest.mark.parametrize(
     "token,status",
     [