Repository: freedomofpress/securedrop

Issue:
Journalist API datetime format has changed, breaking client login.
## Description

See freedomofpress/securedrop-sdk#171 - the datetime format being returned by the Journalist API includes timezone info as a result of the Flask 2.0 update - previously it dd not, and the SecureDrop Client expects naive datetimes with no timezones.

## Steps to Reproduce

Attempt to use the client with a server running 2.2.0-rc1 or latest develop

## Expected Behavior

Client works as normal

## Actual Behavior

Client crashes with error in linked issue above

## Comments

A simple fix would be to force the datetime format to not include timezones in the API. There's a larger issue here of how to handle tz-aware datetimes in the client, but that might be too much to take on during the client process, it seems better to preserve the existing behaviour.

Looks like there are two potential areas to consider: the API token expiry datetime (which is probably causing the issue above), and the source last_updated field (which is used in the client). The first is a change in securedrop/journalist_app/api.py, the second is a change in models.py, in Source.to_json(). I don't think there are any other affected areas.


Discussion:

Resolving patch:
diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py
--- a/securedrop/journalist_app/api.py
+++ b/securedrop/journalist_app/api.py
@@ -20,11 +20,10 @@
 from models import (Journalist, Reply, SeenReply, Source, Submission,
                     LoginThrottledException, InvalidUsernameException,
                     BadTokenException, InvalidOTPSecretException,
-                    WrongPasswordException)
+                    WrongPasswordException, API_DATETIME_FORMAT)
 from sdconfig import SDConfig
 from store import NotEncrypted, Storage
 
-
 TOKEN_EXPIRATION_MINS = 60 * 8
 
 
@@ -121,7 +120,7 @@ def get_token() -> Tuple[flask.Response, int]:
 
             response = jsonify({
                 'token': journalist.generate_api_token(expiration=TOKEN_EXPIRATION_MINS * 60),
-                'expiration': token_expiry.isoformat() + 'Z',
+                'expiration': token_expiry.strftime(API_DATETIME_FORMAT),
                 'journalist_uuid': journalist.uuid,
                 'journalist_first_name': journalist.first_name,
                 'journalist_last_name': journalist.last_name,
diff --git a/securedrop/models.py b/securedrop/models.py
--- a/securedrop/models.py
+++ b/securedrop/models.py
@@ -48,6 +48,9 @@
 # but existing Journalist users may still have 80-bit (16-char) secrets
 OTP_SECRET_MIN_ASCII_LENGTH = 16  # 80 bits == 40 hex digits (== 16 ascii-encoded chars in db)
 
+# Timezone-naive datetime format expected by SecureDrop Client
+API_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
+
 
 def get_one_or_else(query: Query,
                     logger: 'Logger',
@@ -138,9 +141,9 @@ def to_json(self) -> 'Dict[str, object]':
         docs_msg_count = self.documents_messages_count()
 
         if self.last_updated:
-            last_updated = self.last_updated.isoformat() + 'Z'
+            last_updated = self.last_updated.strftime(API_DATETIME_FORMAT)
         else:
-            last_updated = datetime.datetime.utcnow().isoformat() + 'Z'
+            last_updated = datetime.datetime.utcnow().strftime(API_DATETIME_FORMAT)
 
         if self.star and self.star.starred:
             starred = True
@@ -777,7 +780,7 @@ def to_json(self, all_info: bool = True) -> Dict[str, Any]:
         if all_info is True:
             json_user['is_admin'] = self.is_admin
             if self.last_access:
-                json_user['last_login'] = self.last_access.isoformat() + 'Z'
+                json_user['last_login'] = self.last_access.strftime(API_DATETIME_FORMAT)
             else:
                 json_user['last_login'] = None
 


Test patch:
diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py
--- a/securedrop/tests/test_journalist_api.py
+++ b/securedrop/tests/test_journalist_api.py
@@ -2,6 +2,7 @@
 import json
 import random
 
+from datetime import datetime
 from flask import url_for
 from itsdangerous import TimedJSONWebSignatureSerializer
 from pyotp import TOTP
@@ -20,6 +21,8 @@
 
 from .utils.api_helper import get_api_headers
 
+API_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
+
 random.seed('◔ ⌣ ◔')
 
 
@@ -53,6 +56,15 @@ def test_valid_user_can_get_an_api_token(journalist_app, test_journo):
         assert response.json['journalist_first_name'] == test_journo['first_name']
         assert response.json['journalist_last_name'] == test_journo['last_name']
 
+        def _valid_date(date_str, date_format):
+            try:
+                if date_str != datetime.strptime(date_str, date_format).strftime(date_format):
+                    raise ValueError
+                return True
+            except ValueError:
+                return False
+        assert _valid_date(response.json['expiration'], API_DATETIME_FORMAT)
+
 
 def test_user_cannot_get_an_api_token_with_wrong_password(journalist_app,
                                                           test_journo):