def test_create_duplicate_codename_logged_in_in_session(source_app): with source_app.test_client() as app: # Given a user who generated a codename in a browser tab resp = app.post(url_for("main.generate"), data=GENERATE_DATA) assert resp.status_code == 200 first_tab_id, first_codename = list(session["codenames"].items())[0] # And then they opened a new browser tab to generate a second codename resp = app.post(url_for("main.generate"), data=GENERATE_DATA) assert resp.status_code == 200 second_tab_id, second_codename = list(session["codenames"].items())[1] assert first_codename != second_codename # And the user then completed the account creation flow in the first tab resp = app.post(url_for("main.create"), data={"tab_id": first_tab_id}, follow_redirects=True) assert resp.status_code == 200 first_tab_account = SessionManager.get_logged_in_user( db_session=db.session) # When the user tries to complete the account creation flow again, in the second tab resp = app.post(url_for("main.create"), data={"tab_id": second_tab_id}, follow_redirects=True) # Then the user is shown the "already logged in" message assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "You are already logged in." in text # And no new account was created second_tab_account = SessionManager.get_logged_in_user( db_session=db.session) assert second_tab_account.filesystem_id == first_tab_account.filesystem_id
def test_create_duplicate_codename_logged_in_not_in_session(source_app): with patch.object(source_app.logger, "error") as logger: with source_app.test_client() as app: resp = app.post(url_for("main.generate"), data=GENERATE_DATA) assert resp.status_code == 200 tab_id, codename = next(iter(session["codenames"].items())) # Create a source the first time resp = app.post(url_for("main.create"), data={"tab_id": tab_id}, follow_redirects=True) assert resp.status_code == 200 with source_app.test_client() as app: # Attempt to add the same source with app.session_transaction() as sess: sess["codenames"] = {tab_id: codename} sess["codenames_expire"] = datetime.utcnow() + timedelta( hours=1) resp = app.post(url_for("main.create"), data={"tab_id": tab_id}, follow_redirects=True) logger.assert_called_once() assert "Could not create a source" in logger.call_args[0][0] assert resp.status_code == 200 assert not SessionManager.is_user_logged_in(db_session=db.session)
def test_login_with_missing_reply_files(source_app, app_storage): """ Test that source can log in when replies are present in database but missing from storage. """ source, codename = utils.db_helper.init_source(app_storage) journalist, _ = utils.db_helper.init_journalist() replies = utils.db_helper.reply(app_storage, journalist, source, 1) assert len(replies) > 0 # Delete the reply file reply_file_path = Path( app_storage.path(source.filesystem_id, replies[0].filename)) reply_file_path.unlink() assert not reply_file_path.exists() with source_app.test_client() as app: resp = app.get(url_for("main.login")) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Enter Codename" in text resp = app.post(url_for("main.login"), data=dict(codename=codename), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Submit Files" in text assert SessionManager.is_user_logged_in(db_session=db.session)
def generate() -> Union[str, werkzeug.Response]: if request.method == "POST": # Try to detect Tor2Web usage by looking to see if tor2web_check got mangled tor2web_check = request.form.get("tor2web_check") if tor2web_check is None: # Missing form field abort(403) elif tor2web_check != 'href="fake.onion"': return redirect(url_for("info.tor2web_warning")) if SessionManager.is_user_logged_in(db_session=db.session): flash_msg( "notification", None, gettext( "You were redirected because you are already logged in. " "If you want to create a new account, you should log out first." ), ) return redirect(url_for(".lookup")) codename = PassphraseGenerator.get_default().generate_passphrase( preferred_language=g.localeinfo.language ) # Generate a unique id for each browser tab and associate the codename with this id. # This will allow retrieval of the codename displayed in the tab from which the source has # clicked to proceed to /generate (ref. issue #4458) tab_id = urlsafe_b64encode(os.urandom(64)).decode() codenames = session.get("codenames", {}) codenames[tab_id] = codename session["codenames"] = fit_codenames_into_cookie(codenames) session["codenames_expire"] = datetime.now(timezone.utc) + timedelta( minutes=config.SESSION_EXPIRATION_MINUTES ) return render_template("generate.html", codename=codename, tab_id=tab_id)
def login() -> Union[str, werkzeug.Response]: form = LoginForm() if form.validate_on_submit(): try: SessionManager.log_user_in( db_session=db.session, supplied_passphrase=DicewarePassphrase(request.form["codename"].strip()), ) except InvalidPassphraseError: current_app.logger.info("Login failed for invalid codename") flash_msg("error", None, gettext("Sorry, that is not a recognized codename.")) else: # Success: a valid passphrase was supplied return redirect(url_for(".lookup", from_login="******")) return render_template("login.html", form=form)
def test_log_user_in(self, source_app, app_storage): # Given a source user passphrase = PassphraseGenerator.get_default().generate_passphrase() source_user = create_source_user( db_session=db.session, source_passphrase=passphrase, source_app_storage=app_storage, ) with source_app.test_request_context(): # When they log in, it succeeds SessionManager.log_user_in(db_session=db.session, supplied_passphrase=passphrase) # And the SessionManager returns them as the current user assert SessionManager.is_user_logged_in(db_session=db.session) logged_in_user = SessionManager.get_logged_in_user(db_session=db.session) assert logged_in_user.db_record_id == source_user.db_record_id
def create() -> werkzeug.Response: if SessionManager.is_user_logged_in(db_session=db.session): flash_msg( "notification", None, gettext( "You are already logged in. Please verify your codename as it " "may differ from the one displayed on the previous page." ), ) else: # Ensure the codenames have not expired date_codenames_expire = session.get("codenames_expire") if not date_codenames_expire or datetime.now(timezone.utc) >= date_codenames_expire: return clear_session_and_redirect_to_logged_out_page(flask_session=session) tab_id = request.form["tab_id"] codename = session["codenames"][tab_id] del session["codenames"] try: current_app.logger.info("Creating new source user...") create_source_user( db_session=db.session, source_passphrase=codename, source_app_storage=Storage.get_default(), ) except (SourcePassphraseCollisionError, SourceDesignationCollisionError) as e: current_app.logger.error("Could not create a source: {}".format(e)) flash_msg( "error", None, gettext( "There was a temporary problem creating your account. Please try again." ), ) return redirect(url_for(".index")) # All done - source user was successfully created current_app.logger.info("New source user created") session["new_user_codename"] = codename SessionManager.log_user_in( db_session=db.session, supplied_passphrase=DicewarePassphrase(codename) ) return redirect(url_for(".lookup"))
def logout() -> Union[str, werkzeug.Response]: """ If a user is logged in, show them a logout page that prompts them to click the New Identity button in Tor Browser to complete their session. Otherwise redirect to the main Source Interface page. """ if SessionManager.is_user_logged_in(db_session=db.session): SessionManager.log_user_out() # Clear the session after we render the message so it's localized # If a user specified a locale, save it and restore it session.clear() session["locale"] = g.localeinfo.id return render_template("logout.html") else: return redirect(url_for(".index"))
def test_login_and_logout(source_app): with source_app.test_client() as app: resp = app.get(url_for("main.login")) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Enter Codename" in text codename = new_codename(app, session) resp = app.post(url_for("main.login"), data=dict(codename=codename), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Submit Files" in text assert SessionManager.is_user_logged_in(db_session=db.session) with source_app.test_client() as app: resp = app.post(url_for("main.login"), data=dict(codename="invalid"), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Sorry, that is not a recognized codename." in text assert not SessionManager.is_user_logged_in(db_session=db.session) with source_app.test_client() as app: resp = app.post(url_for("main.login"), data=dict(codename=codename), follow_redirects=True) assert resp.status_code == 200 assert SessionManager.is_user_logged_in(db_session=db.session) resp = app.post(url_for("main.login"), data=dict(codename=codename), follow_redirects=True) assert resp.status_code == 200 assert SessionManager.is_user_logged_in(db_session=db.session) resp = app.get(url_for("main.logout"), follow_redirects=True) assert not SessionManager.is_user_logged_in(db_session=db.session) text = resp.data.decode("utf-8") # This is part of the logout page message instructing users # to click the 'New Identity' icon assert "This will clear your Tor Browser activity data" in text
def test_source_can_only_delete_own_replies(source_app, app_storage): """This test checks for a bug an authenticated source A could delete replies send to source B by "guessing" the filename. """ source0, codename0 = utils.db_helper.init_source(app_storage) source1, codename1 = utils.db_helper.init_source(app_storage) journalist, _ = utils.db_helper.init_journalist() replies = utils.db_helper.reply(app_storage, journalist, source0, 1) filename = replies[0].filename confirmation_msg = "Reply deleted" with source_app.test_client() as app: resp = app.post(url_for("main.login"), data={"codename": codename1}, follow_redirects=True) assert resp.status_code == 200 assert SessionManager.get_logged_in_user( db_session=db.session).db_record_id == source1.id resp = app.post(url_for("main.delete"), data={"reply_filename": filename}, follow_redirects=True) assert resp.status_code == 404 assert confirmation_msg not in resp.data.decode("utf-8") reply = Reply.query.filter_by(filename=filename).one() assert not reply.deleted_by_source with source_app.test_client() as app: resp = app.post(url_for("main.login"), data={"codename": codename0}, follow_redirects=True) assert resp.status_code == 200 assert SessionManager.get_logged_in_user( db_session=db.session).db_record_id == source0.id resp = app.post(url_for("main.delete"), data={"reply_filename": filename}, follow_redirects=True) assert resp.status_code == 200 assert confirmation_msg in resp.data.decode("utf-8") reply = Reply.query.filter_by(filename=filename).one() assert reply.deleted_by_source
def test_source_is_deleted_while_logged_in(source_app): """If a source is deleted by a journalist when they are logged in, a NoResultFound will occur. The source should be redirected to the index when this happens, and a warning logged.""" with source_app.test_client() as app: codename = new_codename(app, session) app.post("login", data=dict(codename=codename), follow_redirects=True) # Now that the source is logged in, the journalist deletes the source source_user = SessionManager.get_logged_in_user(db_session=db.session) delete_collection(source_user.filesystem_id) # Source attempts to continue to navigate resp = app.get(url_for("main.lookup"), follow_redirects=True) assert resp.status_code == 200 assert not SessionManager.is_user_logged_in(db_session=db.session) text = resp.data.decode("utf-8") assert "First submission" in text assert not SessionManager.is_user_logged_in(db_session=db.session)
def test_get_logged_in_user_but_user_deleted(self, source_app, app_storage): # Given a source user passphrase = PassphraseGenerator.get_default().generate_passphrase() source_user = create_source_user( db_session=db.session, source_passphrase=passphrase, source_app_storage=app_storage, ) with source_app.test_request_context(): # Who previously logged in SessionManager.log_user_in(db_session=db.session, supplied_passphrase=passphrase) # But since then their account was deleted source_in_db = source_user.get_db_record() source_in_db.deleted_at = datetime.utcnow() db.session.commit() # When querying the current user from the SessionManager, it fails with the right error with pytest.raises(UserHasBeenDeleted): SessionManager.get_logged_in_user(db_session=db.session)
def decorated_function(*args: Any, **kwargs: Any) -> Any: try: logged_in_source = SessionManager.get_logged_in_user( db_session=db.session) except (UserSessionExpired, UserHasBeenDeleted): return clear_session_and_redirect_to_logged_out_page( flask_session=session) except UserNotLoggedIn: return redirect(url_for("main.login")) return f(*args, **kwargs, logged_in_source=logged_in_source)
def login_test(app, codename): resp = app.get(url_for("main.login")) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Enter Codename" in text resp = app.post(url_for("main.login"), data=dict(codename=codename), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Submit Files" in text assert SessionManager.is_user_logged_in(db_session=db.session)
def test_get_logged_in_user_but_session_expired(self, source_app, app_storage): # Given a source user passphrase = PassphraseGenerator.get_default().generate_passphrase() create_source_user( db_session=db.session, source_passphrase=passphrase, source_app_storage=app_storage, ) with source_app.test_request_context(): # Who previously logged in SessionManager.log_user_in(db_session=db.session, supplied_passphrase=passphrase) # But we're now 6 hours later hence their session expired with mock.patch("source_app.session_manager.datetime") as mock_datetime: six_hours_later = datetime.now(timezone.utc) + timedelta(hours=6) mock_datetime.now.return_value = six_hours_later # When querying the current user from the SessionManager # it fails with the right error with pytest.raises(UserSessionExpired): SessionManager.get_logged_in_user(db_session=db.session)
def test_create_new_source(source_app): with source_app.test_client() as app: resp = app.post(url_for("main.generate"), data=GENERATE_DATA) assert resp.status_code == 200 tab_id = next(iter(session["codenames"].keys())) resp = app.post(url_for("main.create"), data={"tab_id": tab_id}, follow_redirects=True) assert SessionManager.is_user_logged_in(db_session=db.session) # should be redirected to /lookup text = resp.data.decode("utf-8") assert "Submit Files" in text assert "codenames" not in session
def test_submit_codename_second_login(source_app): """ Test codename submissions *not* prevented on second session """ with source_app.test_client() as app: InstanceConfig.get_default().update_submission_prefs( allow_uploads=True, min_length=0, reject_codenames=True) codename = new_codename(app, session) resp = app.post( url_for("main.submit"), data=dict(msg=codename, fh=(StringIO(""), "")), follow_redirects=True, ) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Please do not submit your codename!" in text resp = app.get(url_for("main.logout"), follow_redirects=True) assert not SessionManager.is_user_logged_in(db_session=db.session) text = resp.data.decode("utf-8") assert "This will clear your Tor Browser activity data" in text resp = app.post(url_for("main.login"), data=dict(codename=codename), follow_redirects=True) assert resp.status_code == 200 assert SessionManager.is_user_logged_in(db_session=db.session) resp = app.post( url_for("main.submit"), data=dict(msg=codename, fh=(StringIO(""), "")), follow_redirects=True, ) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Thank you for sending this information" in text
def test_log_user_out(self, source_app, app_storage): # Given a source user passphrase = PassphraseGenerator.get_default().generate_passphrase() create_source_user( db_session=db.session, source_passphrase=passphrase, source_app_storage=app_storage, ) with source_app.test_request_context(): # Who previously logged in SessionManager.log_user_in(db_session=db.session, supplied_passphrase=passphrase) # When they log out, it succeeds SessionManager.log_user_out() # And the SessionManager no longer returns a current user assert not SessionManager.is_user_logged_in(db_session=db.session) with pytest.raises(UserNotLoggedIn): SessionManager.get_logged_in_user(db_session=db.session)
def test_normalize_timestamps(source_app, app_storage): """ Check function of source_app.utils.normalize_timestamps. All submissions for a source should have the same timestamp. Any existing submissions' files that did not exist at the time of a new submission should not be created by normalize_timestamps. """ with source_app.test_client() as app: # create a source source, codename = utils.db_helper.init_source(app_storage) # create one submission first_submission = submit(app_storage, source, 1)[0] # delete the submission's file from the store first_submission_path = Path( app_storage.path(source.filesystem_id, first_submission.filename)) first_submission_path.unlink() assert not first_submission_path.exists() # log in as the source resp = app.post(url_for("main.login"), data=dict(codename=codename), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Submit Files" in text assert SessionManager.is_user_logged_in(db_session=db.session) # submit another message resp = _dummy_submission(app) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Thanks! We received your message" in text # sleep to ensure timestamps would differ time.sleep(1) # submit another message resp = _dummy_submission(app) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Thanks! We received your message" in text # only two of the source's three submissions should have files in the store assert 3 == len(source.submissions) submission_paths = [ Path(app_storage.path(source.filesystem_id, s.filename)) for s in source.submissions ] extant_paths = [p for p in submission_paths if p.exists()] assert 2 == len(extant_paths) # verify that the deleted file has not been recreated assert not first_submission_path.exists() assert first_submission_path not in extant_paths # and the timestamps of all existing files should match exactly assert extant_paths[0].stat().st_atime_ns == extant_paths[1].stat( ).st_atime_ns assert extant_paths[0].stat().st_ctime_ns == extant_paths[1].stat( ).st_ctime_ns assert extant_paths[0].stat().st_mtime_ns == extant_paths[1].stat( ).st_mtime_ns
def _helper_test_reply(journalist_app, source_app, config, test_journo, test_reply, expected_success=True): test_msg = "This is a test message." with source_app.test_client() as app: app.post("/generate", data=GENERATE_DATA) tab_id, codename = next(iter(session["codenames"].items())) app.post("/create", data={"tab_id": tab_id}, follow_redirects=True) # redirected to submission form resp = app.post( "/submit", data=dict( msg=test_msg, fh=(BytesIO(b""), ""), ), follow_redirects=True, ) assert resp.status_code == 200 source_user = SessionManager.get_logged_in_user(db_session=db.session) filesystem_id = source_user.filesystem_id app.get("/logout") with journalist_app.test_client() as app: _login_user(app, test_journo) resp = app.get("/") assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Sources" in text soup = BeautifulSoup(resp.data, "html.parser") col_url = soup.select( "table#collections tr.source > th.designation a")[0]["href"] resp = app.get(col_url) assert resp.status_code == 200 assert EncryptionManager.get_default().get_source_key_fingerprint( filesystem_id) # Create 2 replies to test deleting on journalist and source interface with journalist_app.test_client() as app: _login_user(app, test_journo) for i in range(2): resp = app.post( "/reply", data=dict(filesystem_id=filesystem_id, message=test_reply), follow_redirects=True, ) assert resp.status_code == 200 if not expected_success: pass else: text = resp.data.decode("utf-8") assert "The source will receive your reply" in text resp = app.get(col_url) text = resp.data.decode("utf-8") assert "reply-" in text soup = BeautifulSoup(text, "html.parser") # Download the reply and verify that it can be decrypted with the # journalist's key as well as the source's reply key filesystem_id = soup.select('input[name="filesystem_id"]')[0]["value"] checkbox_values = [ soup.select('input[name="doc_names_selected"]')[1]["value"] ] resp = app.post( "/bulk", data=dict(filesystem_id=filesystem_id, action="download", doc_names_selected=checkbox_values), follow_redirects=True, ) assert resp.status_code == 200 zf = zipfile.ZipFile(BytesIO(resp.data), "r") data = zf.read(zf.namelist()[0]) _can_decrypt_with_journalist_secret_key(data) _can_decrypt_with_source_secret_key(data, source_user.gpg_secret) # Test deleting reply on the journalist interface last_reply_number = len( soup.select('input[name="doc_names_selected"]')) - 1 _helper_filenames_delete(app, soup, last_reply_number) with source_app.test_client() as app: resp = app.post("/login", data=dict(codename=codename), follow_redirects=True) assert resp.status_code == 200 resp = app.get("/lookup") assert resp.status_code == 200 text = resp.data.decode("utf-8") if not expected_success: # there should be no reply assert "You have received a reply." not in text else: assert "You have received a reply. To protect your identity" in text assert test_reply in text, text soup = BeautifulSoup(text, "html.parser") msgid = soup.select( 'form > input[name="reply_filename"]')[0]["value"] resp = app.post( "/delete", data=dict(filesystem_id=filesystem_id, reply_filename=msgid), follow_redirects=True, ) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Reply deleted" in text app.get("/logout")
def test_submit_message(journalist_app, source_app, test_journo, app_storage): """When a source creates an account, test that a new entry appears in the journalist interface""" test_msg = "This is a test message." with source_app.test_client() as app: app.post("/generate", data=GENERATE_DATA) tab_id = next(iter(session["codenames"].keys())) app.post("/create", data={"tab_id": tab_id}, follow_redirects=True) source_user = SessionManager.get_logged_in_user(db_session=db.session) filesystem_id = source_user.filesystem_id # redirected to submission form resp = app.post( "/submit", data=dict( msg=test_msg, fh=(BytesIO(b""), ""), ), follow_redirects=True, ) assert resp.status_code == 200 app.get("/logout") # Request the Journalist Interface index with journalist_app.test_client() as app: _login_user(app, test_journo) resp = app.get("/") assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "Sources" in text soup = BeautifulSoup(text, "html.parser") # The source should have a "download unread" link that # says "1 unread" col = soup.select("table#collections tr.source")[0] unread_span = col.select("td.unread a")[0] assert "1 unread" in unread_span.get_text() col_url = soup.select("table#collections th.designation a")[0]["href"] resp = app.get(col_url) assert resp.status_code == 200 text = resp.data.decode("utf-8") soup = BeautifulSoup(text, "html.parser") submission_url = soup.select( "table#submissions th.filename a")[0]["href"] assert "-msg" in submission_url size = soup.select("table#submissions td.info")[0] assert re.compile(r"\d+ bytes").match(size["title"]) resp = app.get(submission_url) assert resp.status_code == 200 encryption_mgr = EncryptionManager.get_default() with import_journalist_private_key(encryption_mgr): decryption_result = encryption_mgr._gpg.decrypt(resp.data) assert decryption_result.ok assert decryption_result.data.decode("utf-8") == test_msg # delete submission resp = app.get(col_url) assert resp.status_code == 200 text = resp.data.decode("utf-8") soup = BeautifulSoup(text, "html.parser") doc_name = soup.select( 'table#submissions > tr.submission > td.status input[name="doc_names_selected"]' )[0]["value"] resp = app.post( "/bulk", data=dict( action="delete", filesystem_id=filesystem_id, doc_names_selected=doc_name, ), follow_redirects=True, ) assert resp.status_code == 200 text = resp.data.decode("utf-8") soup = BeautifulSoup(text, "html.parser") assert "The item has been deleted." in text # confirm that submission deleted and absent in list of submissions resp = app.get(col_url) assert resp.status_code == 200 text = resp.data.decode("utf-8") assert "No submissions to display." in text # the file should be deleted from the filesystem # since file deletion is handled by a polling worker, this test # needs to wait for the worker to get the job and execute it def assertion(): assert not (os.path.exists( app_storage.path(filesystem_id, doc_name))) utils.asynchronous.wait_for_assertion(assertion)