Repository: freedomofpress/securedrop

Issue:
Create a better session expiry page
# Feature request
Create a better session expiry page. 

## Description
Currently, when a journalist session expires, they run into a blank "CSRF token expired" page. Ideally, should instead be given a more context-specific, rather than tech-specific error message e.g. "Your SecureDrop session has expired, please log back in to continue." and redirected or given a link to go back to a login page so that they're not stuck/worried not knowing what happened or what to do next.

## User Stories
As a journalist I'd like to be notified when my session expires and what to do when it does. Also I'd like to not worry about weird messages that sounds scary but aren't actually anything to worry about.



Discussion:
Closed by #1494, users will as of 0.4.4 get a friendlier message as suggested here
Reopening because this is *not* actually closed. We just *happened* to work around it. Flask-WTF sets the CSRF protection tokens to have a time limit of 60 minutes by default (set with `WTF_CSRF_TIME_LIMIT`). We set the session timeout to be 30 minutes by default. This means we never see the error page unless an admin adjusts the timeout.

This can be verified with:

```python
app.config['WTF_CSRF_TIME_LIMIT'] = 10 # seconds
```

In `journalist.py`. Run the app, load the login page, wait over 10 seconds, then attempt to log in (either good or bad credentials). This will display the ugly 400 Bad Request page.

![screenshot_2017-10-07_15-27-01](https://user-images.githubusercontent.com/3998464/31308268-043a1b0c-ab74-11e7-96cf-62b2b6cf093d.png)

A possible solution would be to set the CSRF timeout to be equal to the session timeout. I also know the CSRF value is set to 24 hours for sources, but I suspect that setting it lower won't break the uploads because it would be very strange for Flask-WTF to set a callback that breaks the connection at the instant the token expires. This would need to be investigated.

Or, we could just use a custom error handler [as described in the Flask-WTF docs](https://flask-wtf.readthedocs.io/en/stable/csrf.html#customize-the-error-response).
It would also be good to clear the session in the aforementioned handler since the CSRF token expiring should indicate that the session is unattended as well.

Resolving patch:
diff --git a/securedrop/journalist.py b/securedrop/journalist.py
--- a/securedrop/journalist.py
+++ b/securedrop/journalist.py
@@ -5,7 +5,7 @@
 
 from flask import (Flask, request, render_template, send_file, redirect, flash,
                    url_for, g, abort, session)
-from flask_wtf.csrf import CSRFProtect
+from flask_wtf.csrf import CSRFProtect, CSRFError
 from flask_assets import Environment
 from jinja2 import Markup
 from sqlalchemy.orm.exc import NoResultFound
@@ -33,6 +33,16 @@
 app.config.from_object(config.JournalistInterfaceFlaskConfig)
 CSRFProtect(app)
 
+
+@app.errorhandler(CSRFError)
+def handle_csrf_error(e):
+    # render the message first to ensure it's localized.
+    msg = gettext('You have been logged out due to inactivity')
+    session.clear()
+    flash(msg, 'error')
+    return redirect(url_for('login'))
+
+
 i18n.setup_app(app)
 
 assets = Environment(app)
diff --git a/securedrop/source_app/__init__.py b/securedrop/source_app/__init__.py
--- a/securedrop/source_app/__init__.py
+++ b/securedrop/source_app/__init__.py
@@ -3,7 +3,7 @@
                    url_for, redirect)
 from flask_babel import gettext
 from flask_assets import Environment
-from flask_wtf.csrf import CSRFProtect
+from flask_wtf.csrf import CSRFProtect, CSRFError
 from jinja2 import evalcontextfilter
 from os import path
 from sqlalchemy.orm.exc import NoResultFound
@@ -31,8 +31,16 @@ def create_app(config):
     # The default CSRF token expiration is 1 hour. Since large uploads can
     # take longer than an hour over Tor, we increase the valid window to 24h.
     app.config['WTF_CSRF_TIME_LIMIT'] = 60 * 60 * 24
+
     CSRFProtect(app)
 
+    @app.errorhandler(CSRFError)
+    def handle_csrf_error(e):
+        msg = render_template('session_timeout.html')
+        session.clear()
+        flash(Markup(msg), "important")
+        return redirect(url_for('main.index'))
+
     assets = Environment(app)
     app.config['assets'] = assets
 


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
@@ -1002,6 +1002,21 @@ def test_journalist_session_expiration(self):
             else:
                 del config.SESSION_EXPIRATION_MINUTES
 
+    def test_csrf_error_page(self):
+        old_enabled = self.app.config['WTF_CSRF_ENABLED']
+        self.app.config['WTF_CSRF_ENABLED'] = True
+
+        try:
+            with self.app.test_client() as app:
+                resp = app.post(url_for('login'))
+                self.assertRedirects(resp, url_for('login'))
+
+                resp = app.post(url_for('login'), follow_redirects=True)
+                self.assertIn('You have been logged out due to inactivity',
+                              resp.data)
+        finally:
+            self.app.config['WTF_CSRF_ENABLED'] = old_enabled
+
 
 class TestJournalistAppTwo(unittest.TestCase):
 
diff --git a/securedrop/tests/test_source.py b/securedrop/tests/test_source.py
--- a/securedrop/tests/test_source.py
+++ b/securedrop/tests/test_source.py
@@ -5,7 +5,7 @@
 import re
 
 from bs4 import BeautifulSoup
-from flask import session, escape
+from flask import session, escape, url_for
 from flask_testing import TestCase
 
 import crypto_util
@@ -493,3 +493,18 @@ def _test_source_session_expiration(self):
                 config.SESSION_EXPIRATION_MINUTES = old_expiration
             else:
                 del config.SESSION_EXPIRATION_MINUTES
+
+    def test_csrf_error_page(self):
+        old_enabled = self.app.config['WTF_CSRF_ENABLED']
+        self.app.config['WTF_CSRF_ENABLED'] = True
+
+        try:
+            with self.app.test_client() as app:
+                resp = app.post(url_for('main.create'))
+                self.assertRedirects(resp, url_for('main.index'))
+
+                resp = app.post(url_for('main.create'), follow_redirects=True)
+                self.assertIn('Your session timed out due to inactivity',
+                              resp.data)
+        finally:
+            self.app.config['WTF_CSRF_ENABLED'] = old_enabled