Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

When token invalid user must log back in #750

Merged
merged 5 commits into from
Feb 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions securedrop_client/gui/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def autosize_window(self):
screen = QDesktopWidget().screenGeometry()
self.resize(screen.width(), screen.height())

def show_login(self):
def show_login(self, error: str = ''):
"""
Show the login form.
"""
Expand All @@ -122,9 +122,10 @@ def show_login(self):
x_center = (screen_size.width() - login_dialog_size.width()) / 2
y_center = (screen_size.height() - login_dialog_size.height()) / 2
self.login_dialog.move(x_center, y_center)

self.login_dialog.setup(self.controller)
self.login_dialog.reset()
if error:
self.login_dialog.error(error)
self.login_dialog.exec()

def show_login_error(self, error):
Expand Down
70 changes: 46 additions & 24 deletions securedrop_client/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

from securedrop_client import storage
from securedrop_client import db
from securedrop_client.api_jobs.base import ApiInaccessibleError
from securedrop_client.api_jobs.downloads import FileDownloadJob, MessageDownloadJob, \
ReplyDownloadJob, DownloadChecksumMismatchException, MetadataSyncJob
from securedrop_client.api_jobs.sources import DeleteSourceJob
Expand Down Expand Up @@ -279,13 +280,24 @@ def call_api(self,
new_api_thread.start()

def on_queue_paused(self) -> None:
if self.api is None:
self.gui.update_error_status(_('The SecureDrop server cannot be reached.'))
else:
self.gui.update_error_status(
_('The SecureDrop server cannot be reached.'),
duration=0,
retry=True)
# TODO: remove if block once https://github.com/freedomofpress/securedrop-client/pull/739
# is merged and rely on continuous metadata sync to encounter same auth error from the
# server which will log the user out in the on_sync_failure handler
if (
not self.api or
not self.api_job_queue.main_queue.api_client or
not self.api_job_queue.download_file_queue.api_client or
not self.api_job_queue.metadata_queue.api_client
):
self.invalidate_token()
self.logout()
self.gui.show_login(error=_('Your session expired. Please log in again.'))
return

self.gui.update_error_status(
_('The SecureDrop server cannot be reached.'),
duration=0,
retry=True)

def resume_queues(self) -> None:
self.api_job_queue.resume_queues()
Expand Down Expand Up @@ -344,9 +356,8 @@ def on_authenticate_success(self, result):

def on_authenticate_failure(self, result: Exception) -> None:
# Failed to authenticate. Reset state with failure message.
self.api = None
error = _('There was a problem signing in. '
'Please verify your credentials and try again.')
self.invalidate_token()
error = _('There was a problem signing in. Please verify your credentials and try again.')
self.gui.show_login_error(error=error)

def login_offline_mode(self):
Expand Down Expand Up @@ -424,14 +435,16 @@ def on_sync_success(self) -> None:

def on_sync_failure(self, result: Exception) -> None:
"""
Called when syncronisation of data via the API fails after a background sync. Resume the
queues so that we continue to retry syncing with the server in the background.
Called when syncronisation of data via the API fails after a background sync. If the reason
a sync fails is ApiInaccessibleError then we need to log the user out for security reasons
and show them the login window in order to get a new token.
"""
logger.debug('The SecureDrop server cannot be reached due to Error: {}'.format(result))
self.gui.update_error_status(
_('The SecureDrop server cannot be reached.'),
duration=0,
retry=True)

if isinstance(result, ApiInaccessibleError):
self.invalidate_token()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very minor nit: maybe invalidate_token_locally or something? (just because otherwise the dev might think this method calls Api.logout)

self.logout()
self.gui.show_login(error=_('Your session expired. Please log in again.'))

def update_sync(self):
"""
Expand Down Expand Up @@ -481,18 +494,26 @@ def update_star(self, source_db_object, callback):

def logout(self):
"""
Call logout function in the API, reset the API object, and force the UI
to update into a logged out state.
If the token is not already invalid, make an api call to logout and invalidate the token.
Then mark all pending draft replies as failed, stop the queues, and show the user as logged
out in the GUI.
"""
self.call_api(self.api.logout,
self.on_logout_success,
self.on_logout_failure)
self.api = None
if self.api is not None:
self.call_api(self.api.logout, self.on_logout_success, self.on_logout_failure)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotcha, so if self.api is already None, we don't need to invalidate the token, because the token is already invalid/expired? (from inspecting the logic in on_sync_failure and on_queue_paused).

i think it would be good to write that this is what's going on in a comment somewhere (as it's only obvious from tracing the logic and could be easily missed in future modifications). alternatively we could move the setting of self.api = None into this method and make it more explicit that this is what's happening via an invalidate_token kwarg or something. Let me know if you have a better idea here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think only setting api to none in one place is a good idea. i was thinking about how I'd like to refactor how we check whether or not we're in offline_mode vs just offline (no token) but i think make a small change to only set api to None in one place would be a good addition to this pr.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more on the whole offline-mode vs no-token: this is relevant if we want to provide a way for users to regenerate a new token without losing jobs in the api queue (so just pause and wait for a new token), but this is definitely a post-pilot issue

self.invalidate_token()

failed_replies = storage.mark_all_pending_drafts_as_failed(self.session)
for failed_reply in failed_replies:
self.reply_failed.emit(failed_reply.uuid)

self.api_job_queue.logout()
storage.mark_all_pending_drafts_as_failed(self.session)
self.gui.logout()

self.is_authenticated = False

def invalidate_token(self):
self.api = None

def set_status(self, message, duration=5000):
"""
Set a textual status message to be displayed to the user for a certain
Expand All @@ -507,7 +528,7 @@ def _submit_download_job(self,
if object_type == db.Reply:
job = ReplyDownloadJob(
uuid, self.data_dir, self.gpg
) # type: Union[ReplyDownloadJob, MessageDownloadJob, FileDownloadJob]
) # type: Union[ReplyDownloadJob, MessageDownloadJob, FileDownloadJob]
job.success_signal.connect(self.on_reply_download_success, type=Qt.QueuedConnection)
job.failure_signal.connect(self.on_reply_download_failure, type=Qt.QueuedConnection)
elif object_type == db.Message:
Expand Down Expand Up @@ -697,6 +718,7 @@ def on_delete_source_success(self, result) -> None:

def on_delete_source_failure(self, result: Exception) -> None:
logging.info("failed to delete source at server")

error = _('Failed to delete source at server')
self.gui.update_error_status(error)

Expand Down
20 changes: 16 additions & 4 deletions securedrop_client/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ def process(self) -> None:
session = self.session_maker()
job._do_call_api(self.api_client, session)
self.pinged.emit()
except (RequestTimeoutError, ApiInaccessibleError) as e:
except ApiInaccessibleError as e:
logger.debug('Job {} raised an exception: {}: {}'.format(self, type(e).__name__, e))
self.api_client = None
redshiftzero marked this conversation as resolved.
Show resolved Hide resolved
self.add_job(PauseQueueJob())
except RequestTimeoutError as e:
logger.debug('Job {} raised an exception: {}: {}'.format(self, type(e).__name__, e))
self.add_job(PauseQueueJob())
self.re_add_job(job)
Expand Down Expand Up @@ -179,9 +183,17 @@ def __init__(self, api_client: API, session_maker: scoped_session) -> None:
self.metadata_queue.pinged.connect(self.resume_queues)

def logout(self) -> None:
self.main_queue.api_client = None
self.download_file_queue.api_client = None
self.metadata_queue.api_client = None
if self.main_thread.isRunning():
logger.debug('Stopping main queue thread')
self.main_thread.quit()

if self.download_file_thread.isRunning():
logger.debug('Stopping download queue thread')
self.download_file_thread.quit()

if self.metadata_thread.isRunning():
logger.debug('Stopping metadata queue thread')
self.metadata_thread.quit()

def login(self, api_client: API) -> None:
logger.debug('Passing API token to queues')
Expand Down
11 changes: 5 additions & 6 deletions securedrop_client/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,20 +529,19 @@ def get_reply(session: Session, uuid: str) -> Reply:
return session.query(Reply).filter_by(uuid=uuid).one()


def mark_all_pending_drafts_as_failed(session: Session) -> None:
def mark_all_pending_drafts_as_failed(session: Session) -> List[DraftReply]:
"""
When we login (offline or online) or logout, we need to set all
the pending replies as failed.
When we login (offline or online) or logout, we need to set all the pending replies as failed.
"""
pending_status = session.query(ReplySendStatus).filter_by(
name=ReplySendStatusCodes.PENDING.value).one()
failed_status = session.query(ReplySendStatus).filter_by(
name=ReplySendStatusCodes.FAILED.value).one()

pending_drafts = session.query(DraftReply).filter_by(
send_status=pending_status
).all()
pending_drafts = session.query(DraftReply).filter_by(send_status=pending_status).all()
for pending_draft in pending_drafts:
pending_draft.send_status = failed_status

session.commit()

return pending_drafts
16 changes: 16 additions & 0 deletions tests/gui/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,22 @@ def test_show_login(mocker):
w.login_dialog.exec.assert_called_once_with()


def test_show_login_with_error_message(mocker):
"""
The login dialog is displayed with a clean state.
"""
w = Window()
w.controller = mocker.MagicMock()
mock_ld = mocker.patch('securedrop_client.gui.main.LoginDialog')

w.show_login('this-is-an-error-message-to-show-on-login-window')

mock_ld.assert_called_once_with(w)
w.login_dialog.reset.assert_called_once_with()
w.login_dialog.exec.assert_called_once_with()
w.login_dialog.error.assert_called_once_with('this-is-an-error-message-to-show-on-login-window')


def test_show_login_error(mocker):
"""
Ensures that an error message is displayed in the login dialog.
Expand Down
107 changes: 98 additions & 9 deletions tests/test_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from securedrop_client import db
from securedrop_client.logic import APICallRunner, Controller
from securedrop_client.api_jobs.base import ApiInaccessibleError
from securedrop_client.api_jobs.downloads import DownloadChecksumMismatchException
from securedrop_client.api_jobs.uploads import SendReplyJobError

Expand Down Expand Up @@ -426,6 +427,25 @@ def test_Controller_on_sync_failure(homedir, config, mocker, session_maker):
assert mock_storage.update_local_storage.call_count == 0


def test_Controller_on_sync_failure_due_to_invalid_token(homedir, config, mocker, session_maker):
"""
If the sync fails because the api is inaccessible then ensure user is logged out and shown the
login window.
"""
gui = mocker.MagicMock()
co = Controller('http://localhost', gui, session_maker, homedir)
co.logout = mocker.MagicMock()
co.gui = mocker.MagicMock()
co.gui.show_login = mocker.MagicMock()
mock_storage = mocker.patch('securedrop_client.logic.storage')

co.on_sync_failure(ApiInaccessibleError())

assert mock_storage.update_local_storage.call_count == 0
co.logout.assert_called_once_with()
co.gui.show_login.assert_called_once_with(error='Your session expired. Please log in again.')


def test_Controller_on_sync_success(homedir, config, mocker):
"""
If there's a result to syncing, then update local storage.
Expand Down Expand Up @@ -533,6 +553,68 @@ def test_Controller_on_update_star_failed(homedir, config, mocker, session_maker
mock_gui.update_error_status.assert_called_once_with('Failed to update star.')


def test_Controller_invalidate_token(mocker, homedir, session_maker):
'''
Ensure the controller's api token is set to None.
'''
co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir)
co.api = 'not None'

co.invalidate_token()

assert co.api is None


def test_Controller_logout_with_pending_replies(mocker, session_maker, homedir, reply_status_codes):
'''
Ensure draft reply fails on logout and that the reply_failed signal is emitted.
'''
co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir)
co.api_job_queue = mocker.MagicMock()
co.api_job_queue.logout = mocker.MagicMock()
co.call_api = mocker.MagicMock()
co.reply_failed = mocker.MagicMock()

source = factory.Source()
session = session_maker()
pending_status = session.query(db.ReplySendStatus).filter_by(
name=db.ReplySendStatusCodes.PENDING.value).one()
failed_status = session.query(db.ReplySendStatus).filter_by(
name=db.ReplySendStatusCodes.FAILED.value).one()
pending_draft_reply = factory.DraftReply(source=source, send_status=pending_status)
session.add(source)
session.add(pending_draft_reply)

co.logout()

for draft in session.query(db.DraftReply).all():
assert draft.send_status == failed_status

co.reply_failed.emit.assert_called_once_with(pending_draft_reply.uuid)


def test_Controller_logout_with_no_api(homedir, config, mocker, session_maker):
'''
Ensure we don't attempt to make an api call to logout when the api has been set to None
because token is invalid.
'''
mock_gui = mocker.MagicMock()
co = Controller('http://localhost', mock_gui, session_maker, homedir)
co.api = None
co.api_job_queue = mocker.MagicMock()
co.api_job_queue.logout = mocker.MagicMock()
co.call_api = mocker.MagicMock()
fail_draft_replies = mocker.patch(
'securedrop_client.storage.mark_all_pending_drafts_as_failed')

co.logout()

co.call_api.assert_not_called()
co.api_job_queue.logout.assert_called_once_with()
co.gui.logout.assert_called_once_with()
fail_draft_replies.called_once_with(co.session)


def test_Controller_logout_success(homedir, config, mocker, session_maker):
"""
Ensure the API is called on logout and if the API call succeeds,
Expand Down Expand Up @@ -1359,22 +1441,29 @@ def test_Controller_on_queue_paused(homedir, config, mocker, session_maker):
'''
mock_gui = mocker.MagicMock()
co = Controller('http://localhost', mock_gui, session_maker, homedir)
co.api = 'mock'
mocker.patch.object(co, 'api_job_queue')
co.api = 'not none'
co.on_queue_paused()
mock_gui.update_error_status.assert_called_once_with(
'The SecureDrop server cannot be reached.', duration=0, retry=True)


def test_Controller_on_queue_paused_when_logged_out(homedir, config, mocker, session_maker):
'''
Check that a paused queue is communicated to the user via the error status bar. There should not
be a retry option displayed to the user
'''
mock_gui = mocker.MagicMock()
co = Controller('http://localhost', mock_gui, session_maker, homedir)
def test_Controller_on_queue_paused_due_to_invalid_token(homedir, config, mocker, session_maker):
"""
If the api is inaccessible then ensure user is logged out and shown the login window. Also check
that "SecureDrop server cannot be reached" is not shown when the user is not authenticated.
"""
gui = mocker.MagicMock()
co = Controller('http://localhost', gui, session_maker, homedir)
co.api = None
co.logout = mocker.MagicMock()
co.gui = mocker.MagicMock()
co.gui.show_login = mocker.MagicMock()

co.on_queue_paused()
mock_gui.update_error_status.assert_called_once_with('The SecureDrop server cannot be reached.')

co.logout.assert_called_once_with()
co.gui.show_login.assert_called_once_with(error='Your session expired. Please log in again.')


def test_Controller_call_update_star_success(homedir, config, mocker, session_maker, session):
Expand Down
Loading