Repository: freedomofpress/securedrop

Issue:
There is no minimum username requirement for the JI
We specify `username = Column(String(255), nullable=False, unique=True)` among the `db.Journalist` class attributes, but `nullable=False` doesn't mean that `''` is an unacceptable string.

I think a nice way to do it would be to rename the `username` column as `_username`, and then a new `username` property whose `getter` returns `_username`, and whose `setter` will raise a new `InvalidUsernameException` if the value passed is the empty string. Since this would require a database migration, but is also not a pressing problem, it makes sense to wait for that to fix this.

An alternative is simply to put such a check into place everywhere username may be set.


Discussion:

Resolving patch:
diff --git a/securedrop/db.py b/securedrop/db.py
--- a/securedrop/db.py
+++ b/securedrop/db.py
@@ -264,10 +264,14 @@ class Journalist(Base):
         "JournalistLoginAttempt",
         backref="journalist")
 
+    MIN_USERNAME_LEN = 3
+
     def __init__(self, username, password, is_admin=False, otp_secret=None):
+        self.check_username_acceptable(username)
         self.username = username
         self.set_password(password)
         self.is_admin = is_admin
+
         if otp_secret:
             self.set_hotp_secret(otp_secret)
 
@@ -299,6 +303,13 @@ def set_password(self, password):
         self.pw_salt = self._gen_salt()
         self.pw_hash = self._scrypt_hash(password, self.pw_salt)
 
+    @classmethod
+    def check_username_acceptable(cls, username):
+        if len(username) < cls.MIN_USERNAME_LEN:
+            raise InvalidUsernameException(
+                        'Username "{}" must be at least {} characters long.'
+                        .format(username, cls.MIN_USERNAME_LEN))
+
     @classmethod
     def check_password_acceptable(cls, password):
         # Enforce a reasonable maximum length for passwords to avoid DoS
diff --git a/securedrop/journalist.py b/securedrop/journalist.py
--- a/securedrop/journalist.py
+++ b/securedrop/journalist.py
@@ -19,7 +19,7 @@
 import template_filters
 from db import (db_session, Source, Journalist, Submission, Reply,
                 SourceStar, get_one_or_else, LoginThrottledException,
-                PasswordError)
+                PasswordError, InvalidUsernameException)
 import worker
 
 app = Flask(__name__, template_folder=config.JOURNALIST_TEMPLATES_DIR)
@@ -164,11 +164,7 @@ def admin_index():
 def admin_add_user():
     if request.method == 'POST':
         form_valid = True
-
         username = request.form['username']
-        if len(username) == 0:
-            form_valid = False
-            flash("Missing username", "error")
 
         password = request.form['password']
         is_admin = bool(request.form.get('is_admin'))
@@ -188,6 +184,9 @@ def admin_add_user():
                 flash('There was an error with the autogenerated password. '
                       'User not created. Please try again.', 'error')
                 form_valid = False
+            except InvalidUsernameException as e:
+                form_valid = False
+                flash('Invalid username: ' + str(e), "error")
             except IntegrityError as e:
                 db_session.rollback()
                 form_valid = False
@@ -298,6 +297,13 @@ def admin_edit_user(user_id):
     if request.method == 'POST':
         if request.form['username']:
             new_username = request.form['username']
+
+            try:
+                Journalist.check_username_acceptable(new_username)
+            except InvalidUsernameException as e:
+                flash('Invalid username: ' + str(e), 'error')
+                return redirect(url_for("admin_edit_user", user_id=user_id))
+
             if new_username == user.username:
                 pass
             elif Journalist.query.filter_by(
diff --git a/securedrop/manage.py b/securedrop/manage.py
--- a/securedrop/manage.py
+++ b/securedrop/manage.py
@@ -18,7 +18,8 @@
 os.environ['SECUREDROP_ENV'] = 'dev'  # noqa
 import config
 import crypto_util
-from db import db_session, init_db, Journalist, PasswordError
+from db import (db_session, init_db, Journalist, PasswordError,
+                InvalidUsernameException)
 from management.run import run
 
 logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s')
@@ -105,7 +106,14 @@ def add_journalist(args):
 
 
 def _get_username():
-    return raw_input('Username: ')
+    while True:
+        username = raw_input('Username: ')
+        try:
+            Journalist.check_username_acceptable(username)
+        except InvalidUsernameException as e:
+            print('Invalid username: ' + str(e))
+        else:
+            return username
 
 
 def _get_yubikey_usage():
@@ -121,19 +129,23 @@ def _get_yubikey_usage():
             print 'Invalid answer. Please type "y" or "n"'
 
 
-def _add_user(is_admin=False):
-    username = _get_username()
-    print("Note: Journalist passwords are now autogenerated.")
-
+def _make_password():
     while True:
         password = crypto_util.genrandomid(7)
         try:
             Journalist.check_password_acceptable(password)
-            print("This journalist's password is: {}".format(password))
-            break
+            return password
         except PasswordError:
             continue
 
+
+def _add_user(is_admin=False):
+    username = _get_username()
+
+    print("Note: Journalist passwords are now autogenerated.")
+    password = _make_password()
+    print("This journalist's password is: {}".format(password))
+
     is_hotp = _get_yubikey_usage()
     otp_secret = None
     if is_hotp:


Test patch:
diff --git a/securedrop/tests/test_integration.py b/securedrop/tests/test_integration.py
--- a/securedrop/tests/test_integration.py
+++ b/securedrop/tests/test_integration.py
@@ -49,7 +49,7 @@ def setUp(self):
         # Add a test user to the journalist interface and log them in
         # print Journalist.query.all()
         self.user_pw = "corret horse battery staple haha cultural reference"
-        self.user = Journalist(username="foo",
+        self.user = Journalist(username="some-username",
                                password=self.user_pw)
         db_session.add(self.user)
         db_session.commit()
diff --git a/securedrop/tests/test_journalist.py b/securedrop/tests/test_journalist.py
--- a/securedrop/tests/test_journalist.py
+++ b/securedrop/tests/test_journalist.py
@@ -532,6 +532,8 @@ def test_admin_add_user(self):
                                           password=VALID_PASSWORD,
                                           is_admin=False))
 
+        print resp.data.decode('utf-8')
+
         self.assertRedirects(resp, url_for('admin_new_user_two_factor',
                                            uid=max_journalist_pk+1))
 
@@ -541,7 +543,17 @@ def test_admin_add_user_without_username(self):
                                 data=dict(username='',
                                           password=VALID_PASSWORD,
                                           is_admin=False))
-        self.assertIn('Missing username', resp.data)
+        self.assertIn('Invalid username', resp.data)
+
+    def test_admin_add_user_too_short_username(self):
+        self._login_admin()
+        username = 'a' * (Journalist.MIN_USERNAME_LEN - 1)
+        resp = self.client.post(url_for('admin_add_user'),
+                                data=dict(username=username,
+                                          password='pentagonpapers',
+                                          password_again='pentagonpapers',
+                                          is_admin=False))
+        self.assertIn('Invalid username', resp.data)
 
     @patch('journalist.app.logger.error')
     @patch('journalist.Journalist',
diff --git a/securedrop/tests/test_manage.py b/securedrop/tests/test_manage.py
--- a/securedrop/tests/test_manage.py
+++ b/securedrop/tests/test_manage.py
@@ -14,9 +14,10 @@
 import time
 import unittest
 import version
-
 import utils
 
+from db import Journalist
+
 
 class TestManagePy(object):
     def test_parse_args(self):
@@ -43,9 +44,14 @@ def setUp(self):
     def tearDown(self):
         utils.env.teardown()
 
-    @mock.patch("__builtin__.raw_input", return_value='test')
-    def test_get_username(self, mock_stdin):
-        assert manage._get_username() == 'test'
+    @mock.patch("__builtin__.raw_input", return_value='jen')
+    def test_get_username_success(self, mock_stdin):
+        assert manage._get_username() == 'jen'
+
+    @mock.patch("__builtin__.raw_input",
+                side_effect=['a' * (Journalist.MIN_USERNAME_LEN - 1), 'jen'])
+    def test_get_username_fail(self, mock_stdin):
+        assert manage._get_username() == 'jen'
 
     @mock.patch("__builtin__.raw_input", return_value='y')
     def test_get_yubikey_usage_yes(self, mock_stdin):
@@ -84,6 +90,10 @@ def setup(self):
     def teardown(self):
         utils.env.teardown()
 
+    @mock.patch("__builtin__.raw_input", return_value='foo-bar-baz')
+    def test_get_username(self, mock_get_usernam):
+        assert manage._get_username() == 'foo-bar-baz'
+
     def test_translate_compile_code_and_template(self):
         source = [
             'tests/i18n/code.py',