From b0f0b088dfebf28244dac294a3ceda4568bb5361 Mon Sep 17 00:00:00 2001 From: Mo Bitar Date: Fri, 22 Sep 2017 12:13:45 -0500 Subject: [PATCH] Android authentication --- android/app/build.gradle | 1 + .../com/standardnotes/MainApplication.java | 22 +++- android/settings.gradle | 3 + src/app.js | 120 ++++++++++++++---- src/containers/account/CompanySection.js | 4 - src/lib/keysManager.js | 25 +++- src/screens/Abstract.js | 23 ++++ src/screens/Account.js | 20 +-- src/screens/Authenticate.js | 14 +- src/screens/Compose.js | 5 + src/screens/Filter.js | 35 ++--- src/screens/Fingerprint.js | 19 +-- src/screens/Notes.js | 13 +- vendor/react-native-flag-secure-android | 1 + 14 files changed, 225 insertions(+), 80 deletions(-) create mode 160000 vendor/react-native-flag-secure-android diff --git a/android/app/build.gradle b/android/app/build.gradle index 1357b336..2134e2b3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -158,6 +158,7 @@ dependencies { compile project(':react-native-vector-icons') compile project(':react-native-aes-crypto') compile project(':react-native-fingerprint-scanner') + compile project(':react-native-flag-secure-android') } // Run this once to be able to run the application with BUCK diff --git a/android/app/src/main/java/com/standardnotes/MainApplication.java b/android/app/src/main/java/com/standardnotes/MainApplication.java index 2bbf15f9..c8ee52a9 100644 --- a/android/app/src/main/java/com/standardnotes/MainApplication.java +++ b/android/app/src/main/java/com/standardnotes/MainApplication.java @@ -7,6 +7,8 @@ import android.support.annotation.Nullable; import android.view.WindowManager; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.modules.core.DeviceEventManagerModule; import com.facebook.react.modules.storage.ReactDatabaseSupplier; import com.chirag.RNMail.RNMail; @@ -22,6 +24,7 @@ import com.reactnativenavigation.controllers.ActivityCallbacks; import com.standardnotes.sntextview.SNTextViewPackage; import com.tectiv3.aes.RCTAesPackage; import com.hieuvp.fingerprint.ReactNativeFingerprintScannerPackage; +import com.kristiansorens.flagsecure.FlagSecurePackage; import java.util.Arrays; import java.util.List; @@ -49,7 +52,8 @@ public class MainApplication extends NavigationApplication { new RCTAesPackage(), new RNMail(), new ReactNativeFingerprintScannerPackage(), - new SNTextViewPackage() + new SNTextViewPackage(), + new FlagSecurePackage() ); } @@ -71,17 +75,17 @@ public class MainApplication extends NavigationApplication { @Override public void onActivityStarted(Activity activity) { - +// activity.getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE); } @Override public void onActivityResumed(Activity activity) { - +// sendEvent("onActivityResumed"); } @Override public void onActivityPaused(Activity activity) { - +// sendEvent("onActivityPaused"); } @Override @@ -90,13 +94,21 @@ public class MainApplication extends NavigationApplication { } public void onActivitySaveInstanceState(Activity activity, Bundle bundle) { - +// sendEvent("onActivitySaveInstanceState"); } @Override public void onActivityDestroyed(Activity activity) { } + + public void sendEvent(String eventName) { + ReactContext context = getReactGateway().getReactContext(); + if(context != null) { + context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, null); + } + } + }); BugsnagReactNative.start(this); diff --git a/android/settings.gradle b/android/settings.gradle index 9527fcbf..0beb2545 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -27,4 +27,7 @@ project(':RNMail').projectDir = new File(rootProject.projectDir, '../vendor/reac include ':react-native-fingerprint-scanner' project(':react-native-fingerprint-scanner').projectDir = new File(rootProject.projectDir, '../vendor/react-native-fingerprint-scanner/android') +include ':react-native-flag-secure-android' +project(':react-native-flag-secure-android').projectDir = new File(rootProject.projectDir, '../vendor/react-native-flag-secure-android/android') + include ':app' diff --git a/src/app.js b/src/app.js index 4ba7eb89..9de2c513 100644 --- a/src/app.js +++ b/src/app.js @@ -5,10 +5,12 @@ import { AppState, Platform, - StatusBar + StatusBar, + BackHandler, + DeviceEventEmitter } from 'react-native'; -import {Navigation} from 'react-native-navigation'; +import {Navigation, ScreenVisibilityListener} from 'react-native-navigation'; import {registerScreens} from './screens'; import KeysManager from './lib/keysManager' @@ -26,6 +28,9 @@ if(!__DEV__) { registerScreens(); +const COLD_LAUNCH_STATE = "COLD_LAUNCH_STATE"; +const WARM_LAUNCH_STATE = "WARM_LAUNCH_STATE"; + export default class App { static instance = null; @@ -43,6 +48,7 @@ export default class App { KeysManager.get().registerAccountRelatedStorageKeys(["options"]); this.readyObservers = []; + this.lockStatusObservers = []; this.optionsState = new OptionsState(); this._isAndroid = Platform.OS === "android"; @@ -53,6 +59,19 @@ export default class App { } }) + this.listener = new ScreenVisibilityListener({ + willAppear: ({screen, startTime, endTime, commandType}) => { + // This handles authentication for the initial app launch. We wait for the Notes component to be ready + // (meaning the app UI is ready), then present the authentication modal + if(screen == "sn.Notes" && this.authenticationQueued) { + this.authenticationQueued = false; + this.handleAuthentication(this.queuedAuthenticationLaunchState); + this.queuedAuthenticationLaunchState = null; + } + } + }); + this.listener.register(); + this.signoutObserver = Auth.getInstance().addEventObserver([Auth.DidSignOutEvent, Auth.WillSignInEvent], function(event){ if(event == Auth.DidSignOutEvent) { this.optionsState.reset(); @@ -60,6 +79,30 @@ export default class App { }.bind(this)); } + handleAppStateChange = (nextAppState) => { + console.log("handleAppStateChange|App.js", nextAppState, "starting app?", this.isStartingApp); + + // Hide screen content as we go to the background + if(nextAppState == "background" && !this.isStartingApp) { + if(this.shouldLockContent()) { + this.notifyLockStatusObserverOfLockState(true, null); + } + } + + // Handle authentication as we come back from the background + if (nextAppState === "active" && this.previousAppState == "background" && !this.isStartingApp) { + this.handleAuthentication(WARM_LAUNCH_STATE); + } + + this.previousAppState = nextAppState; + } + + notifyLockStatusObserverOfLockState(lock, unlock) { + this.lockStatusObservers.forEach(function(observer){ + observer.callback(lock, unlock); + }) + } + static get isAndroid() { return this.get().isAndroid; } @@ -72,6 +115,22 @@ export default class App { return !this._isAndroid; } + shouldLockContent() { + var showPasscode = KeysManager.get().hasOfflinePasscode() && KeysManager.get().passcodeTiming == "immediately"; + var showFingerprint = KeysManager.get().hasFingerprint() && KeysManager.get().fingerprintTiming == "immediately"; + return showPasscode || showFingerprint; + } + + addLockStatusObserver(callback) { + var observer = {key: Math.random, callback: callback}; + this.lockStatusObservers.push(observer); + return observer; + } + + removeLockStatusObserver(observer) { + _.pull(this.lockStatusObservers, observer); + } + addApplicationReadyObserver(callback) { var observer = {key: Math.random, callback: callback}; this.readyObservers.push(observer); @@ -92,17 +151,6 @@ export default class App { return this.optionsState; } - handleAppStateChange = (nextAppState) => { - console.log("handleAppStateChange|App.js", nextAppState, "starting app?", this.isStartingApp); - if (nextAppState === "background" && !this.isStartingApp) { - var showPasscode = KeysManager.get().hasOfflinePasscode() && KeysManager.get().passcodeTiming == "immediately"; - var showFingerprint = KeysManager.get().hasFingerprint() && KeysManager.get().fingerprintTiming == "immediately"; - if(showPasscode || showFingerprint) { - this.beginAuthentication(showPasscode, showFingerprint); - } - } - } - get tabStyles() { var statusBarColor = GlobalStyles.constants().mainBackgroundColor; if(this.isAndroid) { @@ -147,9 +195,7 @@ export default class App { this.loading = false; var run = () => { this.startApp(); - var hasPasscode = KeysManager.get().hasOfflinePasscode(); - var hasFingerprint = KeysManager.get().hasFingerprint(); - this.beginAuthentication(hasPasscode, hasFingerprint); + this.handleAuthentication(COLD_LAUNCH_STATE, true); } if(KeysManager.get().isFirstRun()) { KeysManager.get().handleFirstRun().then(run); @@ -165,18 +211,46 @@ export default class App { this.readyObservers.forEach(function(observer){ observer.callback(); }) + + this.notifyLockStatusObserverOfLockState(null, true); } - beginAuthentication(hasPasscode, hasFingerprint) { - if(hasPasscode) { + handleAuthentication(fromState, queue = false) { + var hasPasscode = KeysManager.get().hasOfflinePasscode(); + var hasFingerprint = KeysManager.get().hasFingerprint(); + + var showPasscode, showFingerprint; + + if(fromState == COLD_LAUNCH_STATE) { + showPasscode = hasPasscode; + showFingerprint = hasFingerprint; + } else if(fromState == WARM_LAUNCH_STATE) { + showPasscode = hasPasscode && KeysManager.get().passcodeTiming == "immediately"; + showFingerprint = hasFingerprint && KeysManager.get().fingerprintTiming == "immediately"; + } + + if(!showPasscode && !showFingerprint) { + return; + } + + if(queue) { + this.authenticationQueued = true; + this.queuedAuthenticationLaunchState = fromState; + return; + } + + if(showPasscode) { this.showPasscodeLock(function(){ - if(hasFingerprint) { - this.showFingerprintScanner(this.applicationIsReady.bind(this)); + if(showFingerprint) { + // wait for passcode modal dismissal + setTimeout(() => { + this.showFingerprintScanner(this.applicationIsReady.bind(this)); + }, 0); } else { this.applicationIsReady(); } }.bind(this)); - } else if(hasFingerprint) { + } else if(showFingerprint) { this.showFingerprintScanner(this.applicationIsReady.bind(this)); } else { this.applicationIsReady(); @@ -193,7 +267,7 @@ export default class App { mode: "authenticate", onAuthenticateSuccess: onAuthenticate }, - animationType: 'slide-down', + animationType: 'slide-up', tabsStyle: _.clone(this.tabStyles), // for iOS appStyle: _.clone(this.tabStyles) // for Android }) @@ -209,7 +283,7 @@ export default class App { mode: "authenticate", onAuthenticateSuccess: onAuthenticate }, - animationType: 'slide-down', + animationType: 'slide-up', tabsStyle: _.clone(this.tabStyles), // for iOS appStyle: _.clone(this.tabStyles) // for Android }) diff --git a/src/containers/account/CompanySection.js b/src/containers/account/CompanySection.js index dd530cd6..d01bb368 100644 --- a/src/containers/account/CompanySection.js +++ b/src/containers/account/CompanySection.js @@ -27,10 +27,6 @@ export default class CompanySection extends Component { this.props.onAction("privacy")} /> - - this.props.onAction("twitter")} /> - - ); } diff --git a/src/lib/keysManager.js b/src/lib/keysManager.js index a882e4cb..ac273559 100644 --- a/src/lib/keysManager.js +++ b/src/lib/keysManager.js @@ -5,7 +5,8 @@ import ModelManager from './modelManager' import {Platform} from 'react-native'; import Keychain from "./keychain" var _ = require('lodash') - +import FlagSecure from 'react-native-flag-secure-android'; +import App from "../app" let OfflineParamsKey = "pc_params"; let FirstRunKey = "first_run"; @@ -50,6 +51,10 @@ export default class KeysManager { } async loadInitialData() { + this.readyObserver = App.get().addApplicationReadyObserver(() => { + this.updateScreenshotPrivacy(); + }) + var storageKeys = ["auth_params", OfflineParamsKey, "user", FirstRunKey]; return Promise.all([ @@ -130,9 +135,27 @@ export default class KeysManager { } async persistKeysToKeychain() { + // This funciton is called when changes are made to authentication state + this.updateScreenshotPrivacy(); return Keychain.setKeys(this.generateKeychainStoreValue()); } + updateScreenshotPrivacy(enabled) { + if(App.isIOS) { + return; + } + + var hasImmediatePasscode = this.hasOfflinePasscode() && this.passcodeTiming == "immediately"; + var hasImmedateFingerprint = this.hasFingerprint() && this.fingerprintTiming == "immediately"; + var enabled = hasImmediatePasscode || hasImmedateFingerprint; + + if(enabled) { + FlagSecure.activate(); + } else { + FlagSecure.deactivate(); + } + } + async persistAccountKeys(keys) { this.accountKeys = keys; return this.persistKeysToKeychain(); diff --git a/src/screens/Abstract.js b/src/screens/Abstract.js index b89bff0b..0cf596f9 100644 --- a/src/screens/Abstract.js +++ b/src/screens/Abstract.js @@ -1,6 +1,8 @@ import React, { Component } from 'react'; +import {DeviceEventEmitter} from 'react-native'; var _ = require('lodash') import GlobalStyles from "../Styles" +import App from "../app" export default class Abstract extends Component { @@ -8,6 +10,18 @@ export default class Abstract extends Component { super(props); this.initialLoad = true; this.props.navigator.setOnNavigatorEvent(this.onNavigatorEvent.bind(this)); + + this.lockObserver = App.get().addLockStatusObserver((lock, unlock) => { + if(!this.isMounted()) { + return; + } + + if(lock == true) { + this.mergeState({lockContent: true}); + } else if(unlock == true) { + this.mergeState({lockContent: false}); + } + }) } mergeState(state) { @@ -18,6 +32,7 @@ export default class Abstract extends Component { componentWillUnmount() { this.willUnmount = true; + App.get().removeLockStatusObserver(this.lockObserver); } componentWillMount() { @@ -34,10 +49,18 @@ export default class Abstract extends Component { this.configureNavBar(true); } + isMounted() { + return this.mounted; + } + configureNavBar(initial) { } + dismissModal() { + this.props.navigator.dismissModal({animationType: "slide-down"}) + } + onNavigatorEvent(event) { switch(event.id) { diff --git a/src/screens/Account.js b/src/screens/Account.js index 8b0cfce8..9505703a 100644 --- a/src/screens/Account.js +++ b/src/screens/Account.js @@ -38,9 +38,9 @@ export default class Account extends Abstract { this.state = {ready: false}; this.readyObserver = App.get().addApplicationReadyObserver(() => { - this.ready = true; + this.applicationIsReady = true; - if(this.mounted) { + if(this.isMounted()) { this.loadInitialState(); } }) @@ -63,12 +63,14 @@ export default class Account extends Abstract { } componentDidMount() { - if(!this.state.ready) { + super.componentDidMount(); + if(this.applicationIsReady && !this.state.ready) { this.loadInitialState(); } } componentWillUnmount() { + super.componentWillUnmount(); Sync.getInstance().removeDataLoadObserver(this.dataLoadObserver); App.get().removeApplicationReadyObserver(this.readyObserver); } @@ -102,9 +104,7 @@ export default class Account extends Abstract { if (event.type == 'NavBarButtonPress') { if (event.id == 'cancel') { - this.props.navigator.dismissModal({ - animationType: 'slide-down' - }); + this.dismissModal(); } } } @@ -191,9 +191,7 @@ export default class Account extends Abstract { onAuthSuccess = () => { this.markAllDataDirtyAndSync(); - this.props.navigator.switchToTab({ - tabIndex: 0 - }); + this.dismissModal(); } onSignOutPress = () => { @@ -326,6 +324,10 @@ export default class Account extends Abstract { } render() { + if(this.state.lockContent) { + return (); + } + let signedIn = !Auth.getInstance().offline(); var themes = GlobalStyles.get().themes(); diff --git a/src/screens/Authenticate.js b/src/screens/Authenticate.js index ffe4d507..cf29880b 100644 --- a/src/screens/Authenticate.js +++ b/src/screens/Authenticate.js @@ -38,15 +38,11 @@ export default class Authenticate extends Abstract { super.onNavigatorEvent(event); if (event.type == 'NavBarButtonPress') { // this is the event type for button presses if (event.id == 'cancel') { // this is the same id field from the static navigatorButtons definition - this.dismiss(); + this.dismissModal(); } } } - dismiss() { - this.props.navigator.dismissModal({animationType: "slide-down"}) - } - configureNavBar() { if(this.props.mode === "setup") { this.props.navigator.setButtons({ @@ -55,8 +51,8 @@ export default class Authenticate extends Abstract { title: 'Cancel', id: 'cancel', showAsAction: 'ifRoom', - buttonColor: GlobalStyles.constants().mainTintColor, - buttonFontSize: 17 + // buttonColor: GlobalStyles.constants().mainTintColor, + // buttonFontSize: 17 } ], animated: false @@ -82,7 +78,7 @@ export default class Authenticate extends Abstract { this.props.onSetupSuccess(); - this.dismiss(); + this.dismissModal(); } else { Alert.alert("Passcode Error", "There was an error setting up your passcode. Please try again."); } @@ -106,7 +102,7 @@ export default class Authenticate extends Abstract { if(keys.pw === KeysManager.get().offlinePasscodeHash()) { KeysManager.get().setOfflineKeys(keys); this.props.onAuthenticateSuccess(); - this.dismiss(); + this.dismissModal(); } else { invalid(); } diff --git a/src/screens/Compose.js b/src/screens/Compose.js index aee0b2d8..05c84df2 100644 --- a/src/screens/Compose.js +++ b/src/screens/Compose.js @@ -34,6 +34,7 @@ export default class Compose extends Abstract { constructor(props) { super(props); + this.state = {}; var note = ModelManager.getInstance().findItem(this.props.noteId); if(!note) { note = new Note({}); @@ -251,6 +252,10 @@ export default class Compose extends Abstract { } render() { + if(this.state.lockContent) { + return (); + } + var textBottomPadding = 10; var keyboardBehavior = Platform.OS == "android" ? "height" : "padding"; var keyboardOffset = this.rawStyles.noteTitle.height + this.rawStyles.noteText.paddingTop + (Platform.OS == "android" ? 15 : 0); diff --git a/src/screens/Filter.js b/src/screens/Filter.js index efc55ecf..8807e895 100644 --- a/src/screens/Filter.js +++ b/src/screens/Filter.js @@ -28,12 +28,20 @@ export default class Filter extends Abstract { this.state = {ready: false}; this.readyObserver = App.get().addApplicationReadyObserver(() => { - if(this.mounted) { + this.applicationIsReady = true; + if(this.isMounted()) { this.loadInitialState(); } }) } + componentDidMount() { + super.componentDidMount(); + if(this.applicationIsReady && !this.state.ready) { + this.loadInitialState(); + } + } + loadInitialState() { this.options = new OptionsState(JSON.parse(this.props.options)); @@ -49,20 +57,6 @@ export default class Filter extends Abstract { if(this.props.noteId) { this.note = ModelManager.getInstance().findItem(this.props.noteId); } - } - - getTags() { - var tags = ModelManager.getInstance().tags.slice(); - if(this.props.singleSelectMode) { - tags.unshift({title: "All notes", key: "all", uuid: 100}) - } - return tags; - } - - componentDidMount() { - if(!this.state.ready) { - this.loadInitialState(); - } // React Native Navigation has an issue where navigation pushes are pushed first, then rendered. // This causes an undesired flash while content loads. To reduce the flash, we load the easy stuff first @@ -76,7 +70,16 @@ export default class Filter extends Abstract { }.bind(this)) } + getTags() { + var tags = ModelManager.getInstance().tags.slice(); + if(this.props.singleSelectMode) { + tags.unshift({title: "All notes", key: "all", uuid: 100}) + } + return tags; + } + componentWillUnmount() { + super.componentWillUnmount(); App.get().removeApplicationReadyObserver(this.readyObserver); Sync.getInstance().removeDataLoadObserver(this.dataLoadObserver); } @@ -280,7 +283,7 @@ export default class Filter extends Abstract { } render() { - if(!this.state.ready) { + if(!this.state.ready || this.state.lockContent) { return (); } return ( diff --git a/src/screens/Fingerprint.js b/src/screens/Fingerprint.js index 5eb598b2..67d70f86 100644 --- a/src/screens/Fingerprint.js +++ b/src/screens/Fingerprint.js @@ -50,14 +50,13 @@ export default class Fingerprint extends Abstract { } componentDidMount() { + super.componentDidMount(); this.authenticate(); } authenticate() { if(Platform.OS == "android") { - FingerprintScanner - .authenticate({ onAttempt: this.handleInvalidAttempt }) - .then(() => { + FingerprintScanner.authenticate({ onAttempt: this.handleInvalidAttempt }).then(() => { this.handleSuccessfulAuth(); }) .catch((error) => { @@ -65,23 +64,27 @@ export default class Fingerprint extends Abstract { if(error.name == "UserCancel") { this.authenticate(); } else { - this.setState({ error: error.message }); + if(this.isMounted()) { + this.setState({ error: error.message }); + } } }); } else { - FingerprintScanner - .authenticate({fallbackEnabled: false, description: 'Fingerprint is required to access your notes.' }) + FingerprintScanner.authenticate({fallbackEnabled: false, description: 'Fingerprint is required to access your notes.' }) .then(() => { this.handleSuccessfulAuth(); }) .catch((error) => { console.log("Error:", error); - this.setState({ error: error.message }); + if(this.isMounted()) { + this.setState({ error: error.message }); + } }); } } componentWillUnmount() { + super.componentWillUnmount(); FingerprintScanner.release(); } @@ -91,7 +94,7 @@ export default class Fingerprint extends Abstract { handleSuccessfulAuth = () => { this.props.onAuthenticateSuccess(); - this.props.navigator.dismissModal({animationType: "slide-down"}) + this.dismissModal(); } render() { diff --git a/src/screens/Notes.js b/src/screens/Notes.js index dec03f4a..a46433c8 100644 --- a/src/screens/Notes.js +++ b/src/screens/Notes.js @@ -24,35 +24,38 @@ export default class Notes extends Abstract { this.state = {ready: false}; this.readyObserver = App.get().addApplicationReadyObserver(() => { - if(this.mounted) { + this.applicationIsReady = true; + if(this.isMounted() && !this.state.ready) { this.loadInitialState(); } }) } loadInitialState() { - console.log("LOADING INTITIAL STATE"); + this.options = App.get().globalOptions(); this.mergeState({ ready: true, refreshing: false, decrypting: false, loading: true }); - this.options = App.get().globalOptions(); this.registerObservers(); this.loadTabbarIcons(); this.initializeNotes(); this.beginSyncTimer(); + super.loadInitialState(); } componentDidMount() { - if(!this.state.ready) { + super.componentDidMount(); + if(this.applicationIsReady && !this.state.ready) { this.loadInitialState(); } } componentWillUnmount() { + super.componentWillUnmount(); AppState.removeEventListener('change', this._handleAppStateChange); App.get().removeApplicationReadyObserver(this.readyObserver); Sync.getInstance().removeSyncObserver(this.syncObserver); @@ -338,7 +341,7 @@ export default class Notes extends Abstract { } render() { - if(!this.state.ready) { + if(!this.state.ready || this.state.lockContent) { return (); } var notes = ModelManager.getInstance().getNotes(this.options); diff --git a/vendor/react-native-flag-secure-android b/vendor/react-native-flag-secure-android new file mode 160000 index 00000000..36b280d6 --- /dev/null +++ b/vendor/react-native-flag-secure-android @@ -0,0 +1 @@ +Subproject commit 36b280d6f32d38278ccdfc170b4f0c90db92598c