mattermost-mobile-upstream/app/init/managed_app.ts
Elias Nahum f00701ec9a
On iOS, resuming after the 5-minute window showed no biometric prompt — (#10102)
* On iOS, resuming after the 5-minute window showed no biometric prompt —
just a blocking alert whose only button logged the user out. A failure to
evaluate device security was indistinguishable from a verdict that the
device is not secured. Android had the same class of bug, where
user-cancel codes led to logout.

Outcomes are now classified three ways, so only a genuine credential
rejection or a genuinely unsecured device acts destructively; cancels and
indeterminate results offer a retry. SecurityManager and ManagedApp share
the policy-free parts via app/utils/security/.

Also fixes a resume stacking a second prompt behind an open alert, a
backgroundSince bypass, and a latched flag that disabled the MDM PIN.

* feedback review

* gate app state

* fix tests
2026-09-02 15:03:13 +08:00

235 lines
8.2 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Emm, {AuthenticationOutcome} from '@mattermost/react-native-emm';
import deepEqual from 'deep-equal';
import {isRootedExperimentalAsync} from 'expo-device';
import {defineMessages} from 'react-intl';
import {Alert, AppState, type AppStateStatus, type EventSubscription, type NativeEventSubscription, Platform} from 'react-native';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import {showAuthenticationInterruptedAlert, showNotSecuredAlert} from '@utils/alerts';
import {getFullErrorMessage} from '@utils/errors';
import {isMainActivity} from '@utils/helpers';
import {logDebug} from '@utils/log';
import {getIOSAppGroupDetails} from '@utils/mattermost_managed';
import {handleAppStateResume} from '@utils/security/app_state';
import {getAuthenticationOutcome, probeDeviceSecured} from '@utils/security/authentication';
import {AuthenticationSource} from '@utils/security/constants';
const messages = defineMessages({
blocked: {
id: 'mobile.managed.blocked_by',
defaultMessage: 'Blocked by {vendor}',
},
jailbreak: {
id: 'mobile.managed.jailbreak.emm',
defaultMessage: 'Jailbroken or rooted devices are not trusted by {vendor}.\n\nThe app will now close.',
},
exit: {
id: 'mobile.managed.exit',
defaultMessage: 'Exit',
},
securedBy: {
id: 'mobile.managed.secured_by',
defaultMessage: 'Secured by {vendor}',
},
});
class ManagedAppSingleton {
backgroundSince = 0;
enabled = false;
inAppPinCode = false;
performingAuthentication = false;
previousAppState?: AppStateStatus;
processConfigTimeout?: NodeJS.Timeout;
vendor = 'Mattermost';
cacheConfig?: ManagedConfig = undefined;
private emmListener: EventSubscription | undefined;
private appStateChangeListener: NativeEventSubscription | undefined;
constructor() {
this.emmListener = Emm.addListener((cfg: ManagedConfig) => {
if (!deepEqual(cfg, this.cacheConfig)) {
this.processConfig(cfg);
this.cacheConfig = cfg;
}
});
this.setIOSAppGroupIdentifier();
this.appStateChangeListener = AppState.addEventListener('change', this.onAppStateChange);
}
init() {
logDebug('ManagedApp: Initializing');
this.cacheConfig = Emm.getManagedConfig<ManagedConfig>();
this.processConfig(this.cacheConfig);
}
cleanup() {
this.emmListener?.remove();
this.appStateChangeListener?.remove();
}
setIOSAppGroupIdentifier = () => {
if (Platform.OS === 'ios') {
const {appGroupIdentifier} = getIOSAppGroupDetails();
if (appGroupIdentifier) {
Emm.setAppGroupId(appGroupIdentifier);
}
}
};
processConfig = async (config?: ManagedConfig) => {
// If the managed configuration changed while authentication was
// being performed, delay the processing of this new configuration
// until authentication is complete.
if (this.performingAuthentication) {
if (this.processConfigTimeout) {
clearTimeout(this.processConfigTimeout);
}
this.processConfigTimeout = setTimeout(() => this.processConfig(config), 500);
}
this.enabled = Boolean(config && Object.keys(config).length);
if (!this.enabled) {
return;
}
const blurScreen = config!.blurApplicationScreen === 'true';
Emm.enableBlurScreen(blurScreen);
const vendor = config!.vendor;
if (vendor) {
this.vendor = vendor;
}
const jailbreakProtection = config!.jailbreakProtection === 'true';
if (jailbreakProtection && (await isRootedExperimentalAsync())) {
this.alertDeviceIsNotTrusted();
return;
}
this.inAppPinCode = config!.inAppPinCode === 'true';
if (this.inAppPinCode && !this.performingAuthentication) {
await this.handleDeviceAuthentication();
}
};
alertDeviceIsNotTrusted = () => {
// We use the default device locale as this is an app wide setting
// and does not require any server data
const locale = DEFAULT_LOCALE;
const translations = getTranslations(locale);
Alert.alert(
translations[messages.blocked.id].replace('{vendor}', this.vendor),
translations[messages.jailbreak.id].
replace('{vendor}', this.vendor),
[{
text: translations[messages.exit.id],
style: 'destructive',
onPress: () => {
Emm.exitApp();
},
}],
{cancelable: false},
);
};
handleDeviceAuthentication = async (authExpired = true) => {
this.performingAuthentication = true;
try {
return await this.runAuthenticationGate(authExpired);
} catch (error) {
// Nobody failed authentication here, so the app stays open; the finally below
// keeps the flag from latching and blocking every future prompt.
logDebug('ManagedApp: Authentication gate failed', {reason: getFullErrorMessage(error)});
return false;
} finally {
this.performingAuthentication = false;
}
};
/**
* Unbounded: every iteration needs a Retry tap, and the alternative on a final attempt
* would be closing the app on a user who never failed to authenticate.
*/
private runAuthenticationGate = async (authExpired: boolean) => {
const locale = DEFAULT_LOCALE;
const translations = getTranslations(locale);
/* eslint-disable no-await-in-loop */
for (;;) {
const secured = await probeDeviceSecured(AuthenticationSource.ManagedApp);
if (secured === 'notSecured') {
await showNotSecuredAlert('', this.vendor, locale, true);
Emm.exitApp();
return false;
}
if (secured === 'secured' && !authExpired) {
return true;
}
if (secured === 'secured') {
const outcome = await this.runAuthenticationAttempt(translations);
if (outcome === 'success') {
this.backgroundSince = 0;
return true;
}
if (outcome === 'failed') {
Emm.exitApp();
return false;
}
}
// Cancelled, interrupted, or the secured check could not be determined: nobody
// failed, so offer a retry instead of closing the app.
const choice = await showAuthenticationInterruptedAlert('', this.vendor, locale);
if (choice !== 'retry') {
return false;
}
}
};
private runAuthenticationAttempt = async (translations: Record<string, string>) => {
try {
await Emm.authenticate({
reason: translations[messages.securedBy.id].replace('{vendor}', this.vendor),
fallback: true,
supressEnterPassword: true,
});
return 'success';
} catch (error) {
const outcome = getAuthenticationOutcome(error);
logDebug('ManagedApp: Authentication attempt did not succeed', {outcome, reason: getFullErrorMessage(error)});
return outcome === AuthenticationOutcome.Failed ? 'failed' : 'interrupted';
}
};
onAppStateChange = async (appState: AppStateStatus) => {
await handleAppStateResume(appState, this, {
isEnabled: () => this.enabled && this.inAppPinCode && isMainActivity(),
isGateOpen: () => this.performingAuthentication,
authenticate: (authExpired) => this.handleDeviceAuthentication(authExpired),
// The device-secured check still runs on every resume, even inside the window.
promptWhenNotExpired: true,
source: AuthenticationSource.ManagedApp,
});
};
}
const ManagedApp = new ManagedAppSingleton();
export default ManagedApp;