From 42e6287bdb9605caf3b2dc5703d1d30d64397c41 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 3 Jul 2019 16:46:37 -0600 Subject: [PATCH] Implement basic soft logout handling Fixes https://github.com/vector-im/riot-web/issues/10235 CSS and copy are left as an exercise for a later iteration. Login page handling is left for https://github.com/vector-im/riot-web/issues/10236 This implementation reuses as much of the Lifecycle flow as it can without causing problems. Most importantly, it requires https://github.com/matrix-org/matrix-js-sdk/pull/975 to be able to detect a soft logout and react to it. When it comes time to starting/stopping the Lifecycle, additional parameters are provided so that the auxiliary services can (re)start themselves without the client starting to sync. --- src/BasePlatform.js | 1 + src/Lifecycle.js | 49 ++++++- src/components/structures/MatrixChat.js | 45 ++++++- src/components/structures/auth/SoftLogout.js | 123 ++++++++++++++++++ .../views/dialogs/ConfirmWipeDeviceDialog.js | 60 +++++++++ src/i18n/strings/en_EN.json | 10 ++ src/stores/CustomRoomTagStore.js | 1 + src/stores/LifecycleStore.js | 1 + src/stores/RoomListStore.js | 1 + src/stores/RoomViewStore.js | 1 + src/stores/SessionStore.js | 1 + src/stores/TagOrderStore.js | 1 + 12 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 src/components/structures/auth/SoftLogout.js create mode 100644 src/components/views/dialogs/ConfirmWipeDeviceDialog.js diff --git a/src/BasePlatform.js b/src/BasePlatform.js index 54310d1849..803fe0e500 100644 --- a/src/BasePlatform.js +++ b/src/BasePlatform.js @@ -36,6 +36,7 @@ export default class BasePlatform { _onAction(payload: Object) { switch (payload.action) { + case 'on_client_not_viable': case 'on_logged_out': this.setNotificationCount(0); break; diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 32c96c1a8f..1b76c61071 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -352,11 +352,14 @@ export function setLoggedIn(credentials) { async function _doSetLoggedIn(credentials, clearStorage) { credentials.guest = Boolean(credentials.guest); + const softLogout = isSoftLogout(); + console.log( "setLoggedIn: mxid: " + credentials.userId + " deviceId: " + credentials.deviceId + " guest: " + credentials.guest + - " hs: " + credentials.homeserverUrl, + " hs: " + credentials.homeserverUrl + + " softLogout: " + softLogout, ); // This is dispatched to indicate that the user is still in the process of logging in @@ -414,7 +417,7 @@ async function _doSetLoggedIn(credentials, clearStorage) { dis.dispatch({ action: 'on_logged_in' }); - await startMatrixClient(); + await startMatrixClient(/*startSyncing=*/!softLogout); return MatrixClientPeg.get(); } @@ -487,6 +490,25 @@ export function logout() { ).done(); } +export function softLogout() { + if (!MatrixClientPeg.get()) return; + + // Track that we've detected and trapped a soft logout. This helps prevent other + // parts of the app from starting if there's no point (ie: don't sync if we've + // been soft logged out, despite having credentials and data for a MatrixClient). + localStorage.setItem("mx_soft_logout", "true"); + + _isLoggingOut = true; // to avoid repeated flags + stopMatrixClient(/*unsetClient=*/false); + dis.dispatch({action: 'on_client_not_viable'}); // generic version of on_logged_out + + // DO NOT CALL LOGOUT. A soft logout preserves data, logout does not. +} + +export function isSoftLogout() { + return localStorage.getItem("mx_soft_logout") === "true"; +} + export function isLoggingOut() { return _isLoggingOut; } @@ -494,8 +516,10 @@ export function isLoggingOut() { /** * Starts the matrix client and all other react-sdk services that * listen for events while a session is logged in. + * @param {boolean} startSyncing True (default) to actually start + * syncing the client. */ -async function startMatrixClient() { +async function startMatrixClient(startSyncing=true) { console.log(`Lifecycle: Starting MatrixClient`); // dispatch this before starting the matrix client: it's used @@ -513,11 +537,19 @@ async function startMatrixClient() { DMRoomMap.makeShared().start(); ActiveWidgetStore.start(); - await MatrixClientPeg.start(); + if (startSyncing) { + await MatrixClientPeg.start(); + } else { + console.warn("Caller requested only auxiliary services be started"); + } // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. dis.dispatch({action: 'client_started'}); + + if (isSoftLogout()) { + softLogout(); + } } /* @@ -551,8 +583,10 @@ function _clearStorage() { /** * Stop all the background processes related to the current client. + * @param {boolean} unsetClient True (default) to abandon the client + * on MatrixClientPeg after stopping. */ -export function stopMatrixClient() { +export function stopMatrixClient(unsetClient=true) { Notifier.stop(); UserActivity.sharedInstance().stop(); TypingStore.sharedInstance().reset(); @@ -563,6 +597,9 @@ export function stopMatrixClient() { if (cli) { cli.stopClient(); cli.removeAllListeners(); - MatrixClientPeg.unset(); + + if (unsetClient) { + MatrixClientPeg.unset(); + } } } diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index d942bb142e..2189c92b15 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -90,6 +90,10 @@ const VIEWS = { // we are logged in with an active matrix client. LOGGED_IN: 7, + + // We are logged out (invalid token) but have our local state again. The user + // should log back in to rehydrate the client. + SOFT_LOGOUT: 8, }; // Actions that are redirected through the onboarding process prior to being @@ -432,6 +436,7 @@ export default React.createClass({ switch (payload.action) { case 'logout': + console.log(payload); Lifecycle.logout(); break; case 'require_registration': @@ -615,7 +620,12 @@ export default React.createClass({ }); break; case 'on_logged_in': - this._onLoggedIn(); + if (!Lifecycle.isSoftLogout()) { + this._onLoggedIn(); + } + break; + case 'on_client_not_viable': + this._onSoftLogout(); break; case 'on_logged_out': this._onLoggedOut(); @@ -1258,6 +1268,22 @@ export default React.createClass({ this._setPageSubtitle(); }, + /** + * Called when the session is softly logged out + */ + _onSoftLogout: function() { + this.notifyNewScreen('soft_logout'); + this.setStateForNewView({ + view: VIEWS.SOFT_LOGOUT, + ready: false, + collapseLhs: false, + collapsedRhs: false, + currentRoomId: null, + page_type: PageTypes.RoomDirectory, + }); + this._setPageSubtitle(); + }, + /** * Called just before the matrix client is started * (useful for setting listeners) @@ -1337,8 +1363,16 @@ export default React.createClass({ call: call, }, true); }); - cli.on('Session.logged_out', function(call) { + cli.on('Session.logged_out', function(errObj) { if (Lifecycle.isLoggingOut()) return; + + if (errObj.httpStatus === 401 && errObj.data && errObj.data['soft_logout']) { + console.warn("Soft logout issued by server - avoiding data deletion"); + Lifecycle.softLogout(); + dis.dispatch({actions: 'soft_logout'}); + return; + } + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Signed out', '', ErrorDialog, { title: _t('Signed Out'), @@ -1908,6 +1942,13 @@ export default React.createClass({ ); } + if (this.state.view === VIEWS.SOFT_LOGOUT) { + const SoftLogout = sdk.getComponent('structures.auth.SoftLogout'); + return ( + + ); + } + console.error(`Unknown view ${this.state.view}`); }, }); diff --git a/src/components/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.js new file mode 100644 index 0000000000..51ad3bb8d1 --- /dev/null +++ b/src/components/structures/auth/SoftLogout.js @@ -0,0 +1,123 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import {_t} from '../../../languageHandler'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import * as Lifecycle from '../../../Lifecycle'; +import Modal from '../../../Modal'; +import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import SdkConfig from "../../../SdkConfig"; +import MatrixClientPeg from "../../../MatrixClientPeg"; + +export default class SoftLogout extends React.Component { + static propTypes = { + // Nothing. + }; + + constructor() { + super(); + + const defaultServerConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"]; + + const hsUrl = MatrixClientPeg.get().getHomeserverUrl(); + const domainName = hsUrl === defaultServerConfig.hsUrl + ? defaultServerConfig.hsName + : MatrixClientPeg.get().getHomeServerName(); + + const userId = MatrixClientPeg.get().getUserId(); + const user = MatrixClientPeg.get().getUser(userId); + + const displayName = user ? user.displayName : userId.substring(1).split(':')[0]; + + this.state = { + domainName, + userId, + displayName, + }; + } + + onClearAll = () => { + const ConfirmWipeDeviceDialog = sdk.getComponent('dialogs.ConfirmWipeDeviceDialog'); + Modal.createTrackedDialog('Clear Data', 'Soft Logout', ConfirmWipeDeviceDialog, { + onFinished: (wipeData) => { + if (!wipeData) return; + + console.log("Clearing data from soft-logged-out device"); + Lifecycle.logout(); + }, + }); + }; + + onLogin = () => { + dis.dispatch({action: 'start_login'}); + }; + + render() { + const AuthPage = sdk.getComponent("auth.AuthPage"); + const AuthHeader = sdk.getComponent("auth.AuthHeader"); + const AuthBody = sdk.getComponent("auth.AuthBody"); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + + return ( + + + +

+ {_t("You're signed out")} +

+
+ {_t( + "Your homeserver (%(domainName)s) admin has signed you out of your " + + "account %(displayName)s (%(userId)s).", + { + domainName: this.state.domainName, + displayName: this.state.displayName, + userId: this.state.userId, + }, + )} +
+ +

{_t("I don't want to sign in")}

+
+ {_t( + "If this is a shared device, or you don't want to access your account " + + "again from it, clear all data stored locally on this device.", + )} +
+
+ + {_t("Clear all data")} + +
+ +

{_t("Sign in")}

+
+ {_t( + "Sign in again to regain access to your account, or a different one.", + )} +
+
+ + {_t("Sign in")} + +
+
+
+ ); + } +} diff --git a/src/components/views/dialogs/ConfirmWipeDeviceDialog.js b/src/components/views/dialogs/ConfirmWipeDeviceDialog.js new file mode 100644 index 0000000000..337591be53 --- /dev/null +++ b/src/components/views/dialogs/ConfirmWipeDeviceDialog.js @@ -0,0 +1,60 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import {_t} from "../../../languageHandler"; +import sdk from "../../../index"; + +export default class ConfirmWipeDeviceDialog extends React.Component { + static propTypes = { + onFinished: PropTypes.func.isRequired, + }; + + _onConfirm = () => { + this.props.onFinished(true); + }; + + _onDecline = () => { + this.props.onFinished(false); + }; + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + + return ( + +
+

+ {_t( + "Deleting all data from this device is permanent. Encrypted messages will be lost " + + "unless their keys have been backed up.", + )} +

+
+ +
+ ); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c84125d820..f0b229bc11 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -147,6 +147,7 @@ "Changes your display nickname": "Changes your display nickname", "Changes your display nickname in the current room only": "Changes your display nickname in the current room only", "Changes your avatar in this current room only": "Changes your avatar in this current room only", + "Changes your avatar in all rooms": "Changes your avatar in all rooms", "Changes colour scheme of current room": "Changes colour scheme of current room", "Gets or sets the room topic": "Gets or sets the room topic", "This room has no topic.": "This room has no topic.", @@ -1127,6 +1128,9 @@ "Start Chatting": "Start Chatting", "Confirm Removal": "Confirm Removal", "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.", + "Clear all data on this device?": "Clear all data on this device?", + "Deleting all data from this device is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Deleting all data from this device is permanent. Encrypted messages will be lost unless their keys have been backed up.", + "Delete everything": "Delete everything", "Community IDs cannot be empty.": "Community IDs cannot be empty.", "Community IDs may only contain characters a-z, 0-9, or '=_-./'": "Community IDs may only contain characters a-z, 0-9, or '=_-./'", "Something went wrong whilst creating your community": "Something went wrong whilst creating your community", @@ -1581,6 +1585,12 @@ "You can now close this window or log in to your new account.": "You can now close this window or log in to your new account.", "Registration Successful": "Registration Successful", "Create your account": "Create your account", + "You're signed out": "You're signed out", + "Your homeserver (%(domainName)s) admin has signed you out of your account %(displayName)s (%(userId)s).": "Your homeserver (%(domainName)s) admin has signed you out of your account %(displayName)s (%(userId)s).", + "I don't want to sign in": "I don't want to sign in", + "If this is a shared device, or you don't want to access your account again from it, clear all data stored locally on this device.": "If this is a shared device, or you don't want to access your account again from it, clear all data stored locally on this device.", + "Clear all data": "Clear all data", + "Sign in again to regain access to your account, or a different one.": "Sign in again to regain access to your account, or a different one.", "Commands": "Commands", "Results from DuckDuckGo": "Results from DuckDuckGo", "Emoji": "Emoji", diff --git a/src/stores/CustomRoomTagStore.js b/src/stores/CustomRoomTagStore.js index 0f7f99aad9..909282c085 100644 --- a/src/stores/CustomRoomTagStore.js +++ b/src/stores/CustomRoomTagStore.js @@ -122,6 +122,7 @@ class CustomRoomTagStore extends EventEmitter { } } break; + case 'on_client_not_viable': case 'on_logged_out': { // we assume to always have a tags object in the state this._state = {tags: {}}; diff --git a/src/stores/LifecycleStore.js b/src/stores/LifecycleStore.js index fcdfe93cf9..91dcf0aebb 100644 --- a/src/stores/LifecycleStore.js +++ b/src/stores/LifecycleStore.js @@ -63,6 +63,7 @@ class LifecycleStore extends Store { dis.dispatch(deferredAction); break; } + case 'on_client_not_viable': case 'on_logged_out': this.reset(); break; diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index fe3caaca9c..6d41532852 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -261,6 +261,7 @@ class RoomListStore extends Store { // console.log("!! Optimistic tag failure: ", payload); // } // break; + case 'on_client_not_viable': case 'on_logged_out': { // Reset state without pushing an update to the view, which generally assumes that // the matrix client isn't `null` and so causing a re-render will cause NPEs. diff --git a/src/stores/RoomViewStore.js b/src/stores/RoomViewStore.js index 77b02a8744..166833325e 100644 --- a/src/stores/RoomViewStore.js +++ b/src/stores/RoomViewStore.js @@ -103,6 +103,7 @@ class RoomViewStore extends Store { case 'join_room_error': this._joinRoomError(payload); break; + case 'on_client_not_viable': case 'on_logged_out': this.reset(); break; diff --git a/src/stores/SessionStore.js b/src/stores/SessionStore.js index c4bd39b72c..ad58f1e93d 100644 --- a/src/stores/SessionStore.js +++ b/src/stores/SessionStore.js @@ -68,6 +68,7 @@ class SessionStore extends Store { cachedPassword: null, }); break; + case 'on_client_not_viable': case 'on_logged_out': this._setState({ cachedPassword: null, diff --git a/src/stores/TagOrderStore.js b/src/stores/TagOrderStore.js index d26394b0d4..48a8817270 100644 --- a/src/stores/TagOrderStore.js +++ b/src/stores/TagOrderStore.js @@ -166,6 +166,7 @@ class TagOrderStore extends Store { }); Analytics.trackEvent('FilterStore', 'deselect_tags'); break; + case 'on_client_not_viable': case 'on_logged_out': { // Reset state without pushing an update to the view, which generally assumes that // the matrix client isn't `null` and so causing a re-render will cause NPEs.