Repository: freedomofpress/securedrop

Issue:
Login throttling should be per-user
# Description

This is a denial of service bug that is fortunately behind the journalist interface ATHS. Login throttling is currently not per-user, it's global, i.e. if a single user is blocked from logging in due to login throttling, then all users are. We should fix this in 0.8.0.

# Steps to reproduce

1. Provision dev env
2. Try to login 6 times with incorrect credentials as journalist user. You will start seeing the following in the log output:

```
--------------------------------------------------------------------------------
ERROR in utils [/Users/redshiftzero/Documents/Github/securedrop/securedrop/journalist_app/utils.py:83]:
Login for 'journalist' failed: throttled (6 attempts in last 60 seconds)
--------------------------------------------------------------------------------
```

3. Try to login for the first time as dellsberg

# Expected behavior

Dellsberg can login and is not impacted by the tricksy behavior of the journalist user

# Actual behavior

Dellsberg is denied access to the journalist interface:

```
172.17.0.1 - - [23/May/2018 23:00:13] "POST /login HTTP/1.1" 200 -
172.17.0.1 - - [23/May/2018 23:00:13] "GET /org-logo HTTP/1.1" 302 -
--------------------------------------------------------------------------------
ERROR in utils [/Users/redshiftzero/Documents/Github/securedrop/securedrop/journalist_app/utils.py:83]:
Login for 'dellsberg' failed: throttled (7 attempts in last 60 seconds)
————————————————————————————————————————
```

# Resolution

The fix here is the addition of an additional filter in the ORM query we use to filter by user when computing `attempts_within_period` in `Journalist.throttle_login`. A fix _must_ include a regression test.
Login throttling should be per-user
# Description

This is a denial of service bug that is fortunately behind the journalist interface ATHS. Login throttling is currently not per-user, it's global, i.e. if a single user is blocked from logging in due to login throttling, then all users are. We should fix this in 0.8.0.

# Steps to reproduce

1. Provision dev env
2. Try to login 6 times with incorrect credentials as journalist user. You will start seeing the following in the log output:

```
--------------------------------------------------------------------------------
ERROR in utils [/Users/redshiftzero/Documents/Github/securedrop/securedrop/journalist_app/utils.py:83]:
Login for 'journalist' failed: throttled (6 attempts in last 60 seconds)
--------------------------------------------------------------------------------
```

3. Try to login for the first time as dellsberg

# Expected behavior

Dellsberg can login and is not impacted by the tricksy behavior of the journalist user

# Actual behavior

Dellsberg is denied access to the journalist interface:

```
172.17.0.1 - - [23/May/2018 23:00:13] "POST /login HTTP/1.1" 200 -
172.17.0.1 - - [23/May/2018 23:00:13] "GET /org-logo HTTP/1.1" 302 -
--------------------------------------------------------------------------------
ERROR in utils [/Users/redshiftzero/Documents/Github/securedrop/securedrop/journalist_app/utils.py:83]:
Login for 'dellsberg' failed: throttled (7 attempts in last 60 seconds)
————————————————————————————————————————
```

# Resolution

The fix here is the addition of an additional filter in the ORM query we use to filter by user when computing `attempts_within_period` in `Journalist.throttle_login`. A fix _must_ include a regression test.


Discussion:

Resolving patch:
diff --git a/securedrop/models.py b/securedrop/models.py
--- a/securedrop/models.py
+++ b/securedrop/models.py
@@ -407,6 +407,7 @@ def throttle_login(cls, user):
         login_attempt_period = datetime.datetime.utcnow() - \
             datetime.timedelta(seconds=cls._LOGIN_ATTEMPT_PERIOD)
         attempts_within_period = JournalistLoginAttempt.query.filter(
+            JournalistLoginAttempt.journalist_id == user.id).filter(
             JournalistLoginAttempt.timestamp > login_attempt_period).all()
         if len(attempts_within_period) > cls._MAX_LOGIN_ATTEMPTS_PER_PERIOD:
             raise LoginThrottledException(


Test patch:
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
@@ -188,6 +188,49 @@ def test_login_throttle(journalist_app, test_journo):
         models.LOGIN_HARDENING = False
 
 
+def test_login_throttle_is_not_global(journalist_app, test_journo, test_admin):
+    """The login throttling should be per-user, not global. Global login
+    throttling can prevent all users logging into the application."""
+
+    # Overwrite the default value used during testing
+    # Note that this may break other tests if doing parallel testing
+    models.LOGIN_HARDENING = True
+    try:
+        with journalist_app.test_client() as app:
+            for _ in range(Journalist._MAX_LOGIN_ATTEMPTS_PER_PERIOD):
+                resp = app.post(
+                    url_for('main.login'),
+                    data=dict(username=test_journo['username'],
+                              password='invalid',
+                              token='invalid'))
+                assert resp.status_code == 200
+                text = resp.data.decode('utf-8')
+                assert "Login failed" in text
+
+            resp = app.post(
+                url_for('main.login'),
+                data=dict(username=test_journo['username'],
+                          password='invalid',
+                          token='invalid'))
+            assert resp.status_code == 200
+            text = resp.data.decode('utf-8')
+            assert ("Please wait at least {} seconds".format(
+                Journalist._LOGIN_ATTEMPT_PERIOD) in text)
+
+            # A different user should be able to login
+            resp = app.post(
+                url_for('main.login'),
+                data=dict(username=test_admin['username'],
+                          password=test_admin['password'],
+                          token=TOTP(test_admin['otp_secret']).now()),
+                follow_redirects=True)
+            assert resp.status_code == 200
+            text = resp.data.decode('utf-8')
+            assert "Sources" in text
+    finally:
+        models.LOGIN_HARDENING = False
+
+
 def test_login_invalid_credentials(journalist_app, test_journo):
     with journalist_app.test_client() as app:
         resp = app.post(url_for('main.login'),