MM-65085: Support Pre Shared Password on server connect (#9082)
* feat: add shared server password to server setup * feat: allow editing the sever * refactor: changed password -> secret, styling and tests * e2e: draft e2e tests * chore: lint fix * feat: also send preauth secret header when using native share * fix: removed unused server database migration credentials are being stored in the keychain * i18n: added missing english translations * test(e2e): simplified connection tests * test(e2e): rework * refactor: remove setBearerToken * chore: restore migrations the way it was * chore: reverted file to original state * chore: removed unneeded test and renamed password to secret * chore: function version * chore: updated forms i18n keys * chore: remove if from test * chore: unneeded variable * fix: add missing key on object list * refactor: swift keychain access to retrieve all credentials in one call * revert: edit server screen * refactor: credentials use getGenericCredential * fix: objc code calling old method * fix: added scroll to login screen * chore: variable names * fix: avoid inline styles * fix: Improved appVersion positioning * Update app/screens/server/form.tsx Co-authored-by: Matthew Birtch <mattbirtch@gmail.com> * feat: show error message on 403 * Revert "feat: show error message on 403" This reverts commit f41630c767e10211adf1885321ceefd8a0931e32. --------- Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
This commit is contained in:
parent
2c1d896e9b
commit
f50056f57b
32 changed files with 602 additions and 107 deletions
|
|
@ -35,7 +35,7 @@ export async function loginEntry({serverUrl}: AfterLoginArgs): Promise<{error?:
|
|||
const credentials = await getServerCredentials(serverUrl);
|
||||
if (credentials?.token) {
|
||||
SecurityManager.addServer(serverUrl, clData.config, true);
|
||||
WebsocketManager.createClient(serverUrl, credentials.token);
|
||||
WebsocketManager.createClient(serverUrl, credentials.token, credentials.preauthSecret);
|
||||
await WebsocketManager.initializeClient(serverUrl, 'Login');
|
||||
SecurityManager.setActiveServer(serverUrl);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ async function getDeviceIdForPing(serverUrl: string, checkDeviceId: boolean) {
|
|||
}
|
||||
|
||||
// Default timeout interval for ping is 5 seconds
|
||||
export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeoutInterval = 5000) => {
|
||||
export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeoutInterval = 5000, preauthSecret?: string) => {
|
||||
let client: Client;
|
||||
try {
|
||||
client = await NetworkManager.createClient(serverUrl);
|
||||
client = await NetworkManager.createClient(serverUrl, undefined, preauthSecret);
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const throwFunc = () => {
|
|||
const mockClient = {
|
||||
login: jest.fn(() => user1),
|
||||
setCSRFToken: jest.fn(),
|
||||
setBearerToken: jest.fn(),
|
||||
setClientCredentials: jest.fn(),
|
||||
getClientConfigOld: jest.fn(() => ({})),
|
||||
getClientLicenseOld: jest.fn(() => ({})),
|
||||
getSessions: jest.fn(() => [session1]),
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@ export const sendPasswordResetEmail = async (serverUrl: string, email: string) =
|
|||
}
|
||||
};
|
||||
|
||||
export const ssoLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, bearerToken: string, csrfToken: string): Promise<LoginActionResponse> => {
|
||||
export const ssoLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, bearerToken: string, csrfToken: string, preauthSecret?: string): Promise<LoginActionResponse> => {
|
||||
const database = DatabaseManager.appDatabase?.database;
|
||||
if (!database) {
|
||||
return {error: 'App database not found', failed: true};
|
||||
|
|
@ -309,7 +309,7 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser
|
|||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
|
||||
client.setBearerToken(bearerToken);
|
||||
client.setClientCredentials(bearerToken, preauthSecret);
|
||||
client.setCSRFToken(csrfToken);
|
||||
|
||||
// Setting up active database for this SSO login flow
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import ClientTracking from './tracking';
|
|||
import type {APIClientInterface} from '@mattermost/react-native-network-client';
|
||||
|
||||
export default class ClientBase extends ClientTracking {
|
||||
constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string) {
|
||||
constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string, preauthSecret?: string) {
|
||||
super(apiClient);
|
||||
|
||||
if (bearerToken) {
|
||||
this.setBearerToken(bearerToken);
|
||||
if (bearerToken || preauthSecret) {
|
||||
this.setClientCredentials(bearerToken || '', preauthSecret || '');
|
||||
}
|
||||
if (csrfToken) {
|
||||
this.setCSRFToken(csrfToken);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const HEADER_REQUESTED_WITH = 'X-Requested-With';
|
|||
export const HEADER_TOKEN = 'Token';
|
||||
export const HEADER_USER_AGENT = 'User-Agent';
|
||||
export const HEADER_X_CSRF_TOKEN = 'X-CSRF-Token';
|
||||
export const HEADER_X_MATTERMOST_PREAUTH_SECRET = 'X-Mattermost-Preauth-Secret';
|
||||
export const HEADER_X_VERSION_ID = 'X-Version-Id';
|
||||
export const DEFAULT_LIMIT_BEFORE = 30;
|
||||
export const DEFAULT_LIMIT_AFTER = 30;
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@ class Client extends mix(ClientBase).with(
|
|||
ClientPlaybooks,
|
||||
) {
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string) {
|
||||
super(apiClient, serverUrl, bearerToken, csrfToken);
|
||||
constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string, preauthSecret?: string) {
|
||||
super(apiClient, serverUrl, bearerToken, csrfToken, preauthSecret);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,10 +92,10 @@ describe('ClientTracking', () => {
|
|||
|
||||
it('should set bearer token', () => {
|
||||
const token = 'testToken';
|
||||
client.setBearerToken(token);
|
||||
client.setClientCredentials(token);
|
||||
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} ${token}`);
|
||||
expect(require('@init/credentials').setServerCredentials).toHaveBeenCalledWith(apiClientMock.baseUrl, token);
|
||||
expect(require('@init/credentials').setServerCredentials).toHaveBeenCalledWith(apiClientMock.baseUrl, token, undefined);
|
||||
});
|
||||
|
||||
it('should set CSRF token', () => {
|
||||
|
|
@ -107,7 +107,7 @@ describe('ClientTracking', () => {
|
|||
|
||||
it('should get request headers', () => {
|
||||
client.setCSRFToken('csrfToken');
|
||||
client.setBearerToken('testToken');
|
||||
client.setClientCredentials('testToken');
|
||||
|
||||
const headers = client.getRequestHeaders('POST');
|
||||
expect(headers[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} testToken`);
|
||||
|
|
@ -823,5 +823,59 @@ describe('ClientTracking', () => {
|
|||
expect(result).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setClientCredentials', () => {
|
||||
it('should set shared password header when provided', () => {
|
||||
client.setClientCredentials('bearer-token', 'shared-password');
|
||||
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} bearer-token`);
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('shared-password');
|
||||
});
|
||||
|
||||
it('should remove shared password header when undefined', () => {
|
||||
// First set a shared password
|
||||
client.setClientCredentials('bearer-token', 'shared-password');
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('shared-password');
|
||||
|
||||
// Then remove it by setting undefined
|
||||
client.setClientCredentials('bearer-token', undefined);
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove shared password header when empty string', () => {
|
||||
// First set a shared password
|
||||
client.setClientCredentials('bearer-token', 'shared-password');
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('shared-password');
|
||||
|
||||
// Then remove it by setting empty string
|
||||
client.setClientCredentials('bearer-token', '');
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should always set bearer token correctly', () => {
|
||||
client.setClientCredentials('test-bearer', 'shared-password');
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} test-bearer`);
|
||||
|
||||
client.setClientCredentials('new-bearer', undefined);
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} new-bearer`);
|
||||
});
|
||||
|
||||
it('should handle multiple header updates correctly', () => {
|
||||
// Set initial credentials
|
||||
client.setClientCredentials('bearer1', 'password1');
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} bearer1`);
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('password1');
|
||||
|
||||
// Update to new password
|
||||
client.setClientCredentials('bearer2', 'password2');
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} bearer2`);
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('password2');
|
||||
|
||||
// Remove password
|
||||
client.setClientCredentials('bearer3', undefined);
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} bearer3`);
|
||||
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
/* eslint-enable max-lines */
|
||||
|
|
|
|||
|
|
@ -72,9 +72,17 @@ export default class ClientTracking {
|
|||
this.apiClient = apiClient;
|
||||
}
|
||||
|
||||
setBearerToken(bearerToken: string) {
|
||||
setClientCredentials(bearerToken: string, preauthSecret?: string) {
|
||||
this.requestHeaders[ClientConstants.HEADER_AUTH] = `${ClientConstants.HEADER_BEARER} ${bearerToken}`;
|
||||
setServerCredentials(this.apiClient.baseUrl, bearerToken);
|
||||
|
||||
if (preauthSecret) {
|
||||
this.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET] = preauthSecret;
|
||||
} else {
|
||||
// Remove shared password header when undefined
|
||||
delete this.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET];
|
||||
}
|
||||
|
||||
setServerCredentials(this.apiClient.baseUrl, bearerToken, preauthSecret);
|
||||
}
|
||||
|
||||
setCSRFToken(csrfToken: string) {
|
||||
|
|
@ -412,7 +420,8 @@ export default class ClientTracking {
|
|||
|
||||
const bearerToken = headers[ClientConstants.HEADER_TOKEN] || headers[ClientConstants.HEADER_TOKEN.toLowerCase()];
|
||||
if (bearerToken) {
|
||||
this.setBearerToken(bearerToken);
|
||||
const existingSharedPassword = this.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET];
|
||||
this.setClientCredentials(bearerToken, existingSharedPassword);
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import {type ClientHeaders, getOrCreateWebSocketClient, type WebSocketClientInterface, WebSocketReadyState} from '@mattermost/react-native-network-client';
|
||||
import {Platform} from 'react-native';
|
||||
|
||||
import * as ClientConstants from '@client/rest/constants';
|
||||
import {WebsocketEvents} from '@constants';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getConfigValue} from '@queries/servers/system';
|
||||
|
|
@ -26,6 +27,7 @@ export default class WebSocketClient {
|
|||
private connectionTimeout: NodeJS.Timeout | undefined;
|
||||
private connectionId = '';
|
||||
private token: string;
|
||||
private preauthSecret?: string;
|
||||
private stop = false;
|
||||
private url = '';
|
||||
private serverUrl: string;
|
||||
|
|
@ -58,9 +60,10 @@ export default class WebSocketClient {
|
|||
private closeCallback?: (connectFailCount: number) => void;
|
||||
private connectingCallback?: () => void;
|
||||
|
||||
constructor(serverUrl: string, token: string) {
|
||||
constructor(serverUrl: string, token: string, preauthSecret?: string) {
|
||||
this.token = token;
|
||||
this.serverUrl = serverUrl;
|
||||
this.preauthSecret = preauthSecret;
|
||||
}
|
||||
|
||||
public async initialize(opts = {}, shouldSkipSync = false) {
|
||||
|
|
@ -134,6 +137,13 @@ export default class WebSocketClient {
|
|||
// iOS is using he underlying cookieJar
|
||||
headers.Authorization = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
// Add shared password header if available
|
||||
if (this.preauthSecret) {
|
||||
headers[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET] = this.preauthSecret;
|
||||
logDebug('WebSocket: Added shared password header for', this.serverUrl);
|
||||
}
|
||||
|
||||
const {client} = await getOrCreateWebSocketClient(this.url, {headers, timeoutInterval: WEBSOCKET_TIMEOUT});
|
||||
|
||||
// Check again if the client is the same, to avoid race conditions
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export const getActiveServerUrl = async () => {
|
|||
return serverUrl || undefined;
|
||||
};
|
||||
|
||||
export const setServerCredentials = (serverUrl: string, token: string) => {
|
||||
export const setServerCredentials = (serverUrl: string, token: string, preauthSecret?: string) => {
|
||||
if (!(serverUrl && token)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -60,14 +60,29 @@ export const setServerCredentials = (serverUrl: string, token: string) => {
|
|||
accessGroup,
|
||||
securityLevel: KeyChain.SECURITY_LEVEL.SECURE_SOFTWARE,
|
||||
};
|
||||
|
||||
// Store main token credentials (clean format)
|
||||
KeyChain.setInternetCredentials(serverUrl, token, token, options);
|
||||
|
||||
// Store preauth secret separately if provided
|
||||
if (preauthSecret) {
|
||||
KeyChain.setGenericPassword('preshared_secret', preauthSecret, {
|
||||
server: serverUrl,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logWarning('could not set credentials', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeServerCredentials = async (serverUrl: string) => {
|
||||
return KeyChain.resetInternetCredentials({server: serverUrl});
|
||||
await KeyChain.resetInternetCredentials({server: serverUrl});
|
||||
try {
|
||||
await KeyChain.resetGenericPassword({server: serverUrl});
|
||||
} catch (e) {
|
||||
// Preauth secret might not exist, ignore errors
|
||||
}
|
||||
};
|
||||
|
||||
export const removeActiveServerCredentials = async () => {
|
||||
|
|
@ -79,23 +94,42 @@ export const removeActiveServerCredentials = async () => {
|
|||
|
||||
export const getServerCredentials = async (serverUrl: string): Promise<ServerCredential|null> => {
|
||||
try {
|
||||
// Get main credentials
|
||||
const credentials = await KeyChain.getInternetCredentials(serverUrl);
|
||||
|
||||
if (credentials) {
|
||||
// TODO: Pre-Gekidou we were concatenating the deviceToken and the userId in
|
||||
// credentials.username so we need to check the length of credentials.username.split(',').
|
||||
// This check should be removed at some point. https://mattermost.atlassian.net/browse/MM-43483
|
||||
const parts = credentials.username.split(',');
|
||||
const userId = parts[parts.length - 1];
|
||||
|
||||
const token = credentials.password;
|
||||
|
||||
if (token && token !== 'undefined') {
|
||||
return {serverUrl, userId, token};
|
||||
}
|
||||
if (!credentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
// TODO: Pre-Gekidou we were concatenating the deviceToken and the userId in
|
||||
// credentials.username so we need to check the length of credentials.username.split(',').
|
||||
// This check should be removed at some point. https://mattermost.atlassian.net/browse/MM-43483
|
||||
const parts = credentials.username.split(',');
|
||||
const userId = parts[parts.length - 1];
|
||||
const token = credentials.password;
|
||||
|
||||
if (!token || token === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get preauth secret separately
|
||||
let preauthSecret: string | undefined;
|
||||
try {
|
||||
const preauthCredentials = await KeyChain.getGenericPassword({
|
||||
server: serverUrl,
|
||||
});
|
||||
preauthSecret = preauthCredentials ? preauthCredentials.password : undefined;
|
||||
} catch (e) {
|
||||
// Preauth secret is optional, so ignore errors
|
||||
preauthSecret = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
userId,
|
||||
token,
|
||||
preauthSecret,
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,9 +76,9 @@ class NetworkManagerSingleton {
|
|||
};
|
||||
|
||||
public init = async (serverCredentials: ServerCredential[]) => {
|
||||
for await (const {serverUrl, token} of serverCredentials) {
|
||||
for await (const {serverUrl, token, preauthSecret} of serverCredentials) {
|
||||
try {
|
||||
await this.createClient(serverUrl, token);
|
||||
await this.createClient(serverUrl, token, preauthSecret);
|
||||
} catch (error) {
|
||||
logError('NetworkManager init error', error);
|
||||
}
|
||||
|
|
@ -99,12 +99,13 @@ class NetworkManagerSingleton {
|
|||
return client;
|
||||
};
|
||||
|
||||
public createClient = async (serverUrl: string, bearerToken?: string) => {
|
||||
const config = await this.buildConfig();
|
||||
public createClient = async (serverUrl: string, bearerToken?: string, preauthSecret?: string) => {
|
||||
const config = await this.buildConfig(preauthSecret);
|
||||
|
||||
try {
|
||||
const {client} = await getOrCreateAPIClient(serverUrl, config, this.clientErrorEventHandler);
|
||||
const csrfToken = await getCSRFFromCookie(serverUrl);
|
||||
this.clients[serverUrl] = new Client(client, serverUrl, bearerToken, csrfToken);
|
||||
this.clients[serverUrl] = new Client(client, serverUrl, bearerToken, csrfToken, preauthSecret);
|
||||
} catch (error) {
|
||||
throw new ClientError(serverUrl, {
|
||||
message: 'Can’t find this server. Check spelling and URL format.',
|
||||
|
|
@ -120,11 +121,12 @@ class NetworkManagerSingleton {
|
|||
return this.clients[serverUrl];
|
||||
};
|
||||
|
||||
private buildConfig = async () => {
|
||||
private buildConfig = async (preauthSecret?: string) => {
|
||||
const userAgent = `Mattermost Mobile/${nativeApplicationVersion}+${nativeBuildVersion} (${osName}; ${osVersion}; ${modelName})`;
|
||||
const managedConfig = ManagedApp.enabled ? Emm.getManagedConfig<ManagedConfig>() : undefined;
|
||||
const headers: Record<string, string> = {
|
||||
[ClientConstants.HEADER_USER_AGENT]: userAgent,
|
||||
...(preauthSecret ? {[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]: preauthSecret} : {}),
|
||||
...this.DEFAULT_CONFIG.headers,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ describe('WebsocketManager', () => {
|
|||
await manager.init(mockCredentials);
|
||||
|
||||
expect(NetInfo.fetch).toHaveBeenCalled();
|
||||
expect(WebSocketClient).toHaveBeenCalledWith(mockServerUrl, mockToken);
|
||||
expect(WebSocketClient).toHaveBeenCalledWith(mockServerUrl, mockToken, undefined);
|
||||
expect(NetInfo.addEventListener).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -48,10 +48,10 @@ class WebsocketManagerSingleton {
|
|||
this.netConnected = Boolean(netInfo.isConnected);
|
||||
this.netType = netInfo.type;
|
||||
serverCredentials.forEach(
|
||||
({serverUrl, token}) => {
|
||||
({serverUrl, token, preauthSecret}) => {
|
||||
try {
|
||||
DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
this.createClient(serverUrl, token);
|
||||
this.createClient(serverUrl, token, preauthSecret);
|
||||
} catch (error) {
|
||||
logError('WebsocketManager init error', error);
|
||||
}
|
||||
|
|
@ -80,12 +80,12 @@ class WebsocketManagerSingleton {
|
|||
this.getConnectedSubject(serverUrl).next('not_connected');
|
||||
};
|
||||
|
||||
public createClient = (serverUrl: string, bearerToken: string) => {
|
||||
public createClient = (serverUrl: string, bearerToken: string, preauthSecret?: string) => {
|
||||
if (this.clients[serverUrl]) {
|
||||
this.invalidateClient(serverUrl);
|
||||
}
|
||||
|
||||
const client = new WebSocketClient(serverUrl, bearerToken);
|
||||
const client = new WebSocketClient(serverUrl, bearerToken, preauthSecret);
|
||||
|
||||
client.setFirstConnectCallback(() => this.onFirstConnect(serverUrl));
|
||||
client.setEventCallback((evt: WebSocketMessage) => handleWebSocketEvent(serverUrl, evt));
|
||||
|
|
|
|||
|
|
@ -69,7 +69,10 @@ const SsoOptions = ({goToSso, ssoOnly, ssoOptions, theme}: Props) => {
|
|||
};
|
||||
|
||||
componentArray.push(
|
||||
<View style={styleButtonWrapper}>
|
||||
<View
|
||||
style={styleButtonWrapper}
|
||||
key={ssoType}
|
||||
>
|
||||
<Button
|
||||
key={ssoType}
|
||||
onPress={handlePress}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {type RefObject, useCallback, useRef} from 'react';
|
||||
import React, {type RefObject, useCallback, useRef, useState} from 'react';
|
||||
import {defineMessages, useIntl} from 'react-intl';
|
||||
import {Keyboard, View} from 'react-native';
|
||||
import {Keyboard, Pressable, View} from 'react-native';
|
||||
|
||||
import Button from '@components/button';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FloatingTextInput, {type FloatingTextInputRef} from '@components/floating_text_input_label';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {useAvoidKeyboard} from '@hooks/device';
|
||||
|
|
@ -23,8 +24,10 @@ type Props = {
|
|||
disableServerUrl: boolean;
|
||||
handleConnect: () => void;
|
||||
handleDisplayNameTextChanged: (text: string) => void;
|
||||
handlePreauthSecretTextChanged: (text: string) => void;
|
||||
handleUrlTextChanged: (text: string) => void;
|
||||
keyboardAwareRef: RefObject<KeyboardAwareScrollView>;
|
||||
preauthSecret?: string;
|
||||
theme: Theme;
|
||||
url?: string;
|
||||
urlError?: string;
|
||||
|
|
@ -49,6 +52,24 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
|||
marginTop: 8,
|
||||
...typography('Body', 75, 'Regular'),
|
||||
},
|
||||
advancedOptionsContainer: {
|
||||
width: '100%',
|
||||
marginTop: 16,
|
||||
},
|
||||
advancedOptionsHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
advancedOptionsTitle: {
|
||||
color: theme.linkColor,
|
||||
...typography('Body', 75, 'SemiBold'),
|
||||
},
|
||||
advancedOptionsContent: {
|
||||
width: '100%',
|
||||
},
|
||||
connectButtonContainer: {
|
||||
width: '100%',
|
||||
marginTop: 32,
|
||||
|
|
@ -66,6 +87,18 @@ const messages = defineMessages({
|
|||
id: 'mobile.components.select_server_view.connecting',
|
||||
defaultMessage: 'Connecting',
|
||||
},
|
||||
advancedOptions: {
|
||||
id: 'mobile.components.select_server_view.advancedOptions',
|
||||
defaultMessage: 'Advanced Options',
|
||||
},
|
||||
preauthSecret: {
|
||||
id: 'mobile.components.select_server_view.sharedSecret',
|
||||
defaultMessage: 'Pre-authentication secret',
|
||||
},
|
||||
preauthSecretHelp: {
|
||||
id: 'mobile.components.select_server_view.sharedSecretHelp',
|
||||
defaultMessage: 'The pre-authentication secret shared by the administrator',
|
||||
},
|
||||
});
|
||||
|
||||
const ServerForm = ({
|
||||
|
|
@ -77,17 +110,22 @@ const ServerForm = ({
|
|||
disableServerUrl,
|
||||
handleConnect,
|
||||
handleDisplayNameTextChanged,
|
||||
handlePreauthSecretTextChanged,
|
||||
handleUrlTextChanged,
|
||||
keyboardAwareRef,
|
||||
preauthSecret = '',
|
||||
theme,
|
||||
url = '',
|
||||
urlError,
|
||||
}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const displayNameRef = useRef<FloatingTextInputRef>(null);
|
||||
const preauthSecretRef = useRef<FloatingTextInputRef>(null);
|
||||
const urlRef = useRef<FloatingTextInputRef>(null);
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
|
||||
useAvoidKeyboard(keyboardAwareRef);
|
||||
|
||||
const onConnect = useCallback(() => {
|
||||
|
|
@ -99,6 +137,18 @@ const ServerForm = ({
|
|||
displayNameRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const onDisplayNameSubmit = useCallback(() => {
|
||||
if (showAdvancedOptions) {
|
||||
preauthSecretRef.current?.focus();
|
||||
} else {
|
||||
onConnect();
|
||||
}
|
||||
}, [showAdvancedOptions, onConnect]);
|
||||
|
||||
const toggleAdvancedOptions = useCallback(() => {
|
||||
setShowAdvancedOptions(!showAdvancedOptions);
|
||||
}, [showAdvancedOptions]);
|
||||
|
||||
const connectButtonTestId = buttonDisabled ? 'server_form.connect.button.disabled' : 'server_form.connect.button';
|
||||
|
||||
return (
|
||||
|
|
@ -108,7 +158,6 @@ const ServerForm = ({
|
|||
autoCorrect={false}
|
||||
autoCapitalize={'none'}
|
||||
autoFocus={autoFocus}
|
||||
blurOnSubmit={false}
|
||||
containerStyle={styles.enterServer}
|
||||
enablesReturnKeyAutomatically={true}
|
||||
editable={!disableServerUrl}
|
||||
|
|
@ -139,15 +188,16 @@ const ServerForm = ({
|
|||
defaultMessage: 'Display Name',
|
||||
})}
|
||||
onChangeText={handleDisplayNameTextChanged}
|
||||
onSubmitEditing={onConnect}
|
||||
onSubmitEditing={onDisplayNameSubmit}
|
||||
ref={displayNameRef}
|
||||
returnKeyType='done'
|
||||
returnKeyType={showAdvancedOptions ? 'next' : 'done'}
|
||||
spellCheck={false}
|
||||
testID='server_form.server_display_name.input'
|
||||
theme={theme}
|
||||
value={displayName}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!displayNameError &&
|
||||
<FormattedText
|
||||
defaultMessage={'Choose a display name for your server'}
|
||||
|
|
@ -156,6 +206,51 @@ const ServerForm = ({
|
|||
testID={'server_form.display_help'}
|
||||
/>
|
||||
}
|
||||
|
||||
<View style={styles.advancedOptionsContainer}>
|
||||
<Pressable
|
||||
onPress={toggleAdvancedOptions}
|
||||
style={styles.advancedOptionsHeader}
|
||||
testID='server_form.advanced_options.toggle'
|
||||
>
|
||||
<CompassIcon
|
||||
name={showAdvancedOptions ? 'chevron-up' : 'chevron-down'}
|
||||
size={20}
|
||||
style={styles.advancedOptionsTitle}
|
||||
/>
|
||||
<FormattedText
|
||||
defaultMessage='Advanced Options'
|
||||
id='mobile.components.select_server_view.advancedOptions'
|
||||
style={styles.advancedOptionsTitle}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{showAdvancedOptions && (
|
||||
<View style={styles.advancedOptionsContent}>
|
||||
<FloatingTextInput
|
||||
autoCorrect={false}
|
||||
autoCapitalize={'none'}
|
||||
enablesReturnKeyAutomatically={true}
|
||||
label={formatMessage(messages.preauthSecret)}
|
||||
onChangeText={handlePreauthSecretTextChanged}
|
||||
onSubmitEditing={onConnect}
|
||||
ref={preauthSecretRef}
|
||||
returnKeyType='done'
|
||||
secureTextEntry={true}
|
||||
spellCheck={false}
|
||||
testID='server_form.preauth_secret.input'
|
||||
theme={theme}
|
||||
value={preauthSecret}
|
||||
/>
|
||||
<FormattedText
|
||||
{...messages.preauthSecretHelp}
|
||||
style={styles.chooseText}
|
||||
testID='server_form.preauth_secret_help'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.connectButtonContainer}>
|
||||
<Button
|
||||
disabled={buttonDisabled}
|
||||
|
|
|
|||
|
|
@ -55,12 +55,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
|||
appInfo: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||
},
|
||||
appVersionContainer: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 24,
|
||||
},
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContainer: {
|
||||
alignItems: 'center',
|
||||
height: '90%',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}));
|
||||
|
|
@ -85,6 +90,7 @@ const Server = ({
|
|||
const [connecting, setConnecting] = useState(false);
|
||||
const [displayName, setDisplayName] = useState<string>('');
|
||||
const [buttonDisabled, setButtonDisabled] = useState(true);
|
||||
const [preauthSecret, setPreauthSecret] = useState<string>('');
|
||||
const [url, setUrl] = useState<string>('');
|
||||
const [displayNameError, setDisplayNameError] = useState<string | undefined>();
|
||||
const [urlError, setUrlError] = useState<string | undefined>();
|
||||
|
|
@ -191,6 +197,7 @@ const Server = ({
|
|||
launchType,
|
||||
license,
|
||||
serverDisplayName: displayName,
|
||||
serverPreauthSecret: preauthSecret.trim() || undefined,
|
||||
serverUrl,
|
||||
ssoOptions,
|
||||
theme,
|
||||
|
|
@ -268,6 +275,10 @@ const Server = ({
|
|||
setUrl(text);
|
||||
}, []);
|
||||
|
||||
const handlePreauthSecretTextChanged = useCallback((text: string) => {
|
||||
setPreauthSecret(text);
|
||||
}, []);
|
||||
|
||||
const isServerUrlValid = (serverUrl?: string) => {
|
||||
const testUrl = sanitizeUrl(serverUrl ?? url);
|
||||
if (!isValidUrl(testUrl)) {
|
||||
|
|
@ -289,7 +300,7 @@ const Server = ({
|
|||
cancelPing = undefined;
|
||||
};
|
||||
|
||||
const ping = await getServerUrlAfterRedirect(pingUrl, !retryWithHttp);
|
||||
const ping = await getServerUrlAfterRedirect(pingUrl, !retryWithHttp, preauthSecret.trim() || undefined);
|
||||
if (!ping.url) {
|
||||
cancelPing();
|
||||
if (retryWithHttp) {
|
||||
|
|
@ -302,7 +313,7 @@ const Server = ({
|
|||
}
|
||||
return;
|
||||
}
|
||||
const result = await doPing(ping.url, true, managedConfig?.timeout ? parseInt(managedConfig?.timeout, 10) : undefined);
|
||||
const result = await doPing(ping.url, true, managedConfig?.timeout ? parseInt(managedConfig?.timeout, 10) : undefined, preauthSecret.trim() || undefined);
|
||||
|
||||
if (canceled) {
|
||||
return;
|
||||
|
|
@ -402,14 +413,21 @@ const Server = ({
|
|||
disableServerUrl={disableServerUrl}
|
||||
handleConnect={handleConnect}
|
||||
handleDisplayNameTextChanged={handleDisplayNameTextChanged}
|
||||
handlePreauthSecretTextChanged={handlePreauthSecretTextChanged}
|
||||
handleUrlTextChanged={handleUrlTextChanged}
|
||||
keyboardAwareRef={keyboardAwareRef}
|
||||
preauthSecret={preauthSecret}
|
||||
theme={theme}
|
||||
url={url}
|
||||
urlError={urlError}
|
||||
/>
|
||||
<View style={styles.appVersionContainer}>
|
||||
<AppVersion
|
||||
textStyle={styles.appInfo}
|
||||
isWrapped={false}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAwareScrollView>
|
||||
<AppVersion textStyle={styles.appInfo}/>
|
||||
</AnimatedSafeArea>
|
||||
</View>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ interface SSOProps extends LaunchProps {
|
|||
license: Partial<ClientLicense>;
|
||||
ssoType: string;
|
||||
serverDisplayName: string;
|
||||
serverPreauthSecret?: string;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +46,7 @@ const styles = StyleSheet.create({
|
|||
const SSO = ({
|
||||
closeButtonId, componentId, config, extra,
|
||||
launchError, launchType, serverDisplayName,
|
||||
serverUrl, ssoType, theme,
|
||||
serverPreauthSecret, serverUrl, ssoType, theme,
|
||||
}: SSOProps) => {
|
||||
const [loginError, setLoginError] = useState<string>('');
|
||||
let loginUrl = '';
|
||||
|
|
@ -89,7 +90,7 @@ const SSO = ({
|
|||
};
|
||||
|
||||
const doSSOLogin = async (bearerToken: string, csrfToken: string) => {
|
||||
const result: LoginActionResponse = await ssoLogin(serverUrl!, serverDisplayName, config.DiagnosticId!, bearerToken, csrfToken);
|
||||
const result: LoginActionResponse = await ssoLogin(serverUrl!, serverDisplayName, config.DiagnosticId!, bearerToken, csrfToken, serverPreauthSecret);
|
||||
if (result?.error && result.failed) {
|
||||
onLoadEndError(result.error);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import GenericClient from '@mattermost/react-native-network-client';
|
|||
import {Linking} from 'react-native';
|
||||
import urlParse from 'url-parse';
|
||||
|
||||
import * as ClientConstants from '@client/rest/constants';
|
||||
import {Files} from '@constants';
|
||||
import {emptyFunction} from '@utils/general';
|
||||
import {logDebug} from '@utils/log';
|
||||
|
|
@ -58,11 +59,17 @@ export async function getUrlAfterRedirect(url: string, useHttp = false) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getServerUrlAfterRedirect(serverUrl: string, useHttp = false) {
|
||||
export async function getServerUrlAfterRedirect(serverUrl: string, useHttp = false, preauthSecret?: string) {
|
||||
let url = sanitizeUrl(serverUrl, useHttp);
|
||||
|
||||
const headers = {
|
||||
...(preauthSecret) ? {[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]: preauthSecret} : {},
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await GenericClient.head(url);
|
||||
const resp = await GenericClient.head(url, {
|
||||
headers,
|
||||
});
|
||||
if (resp.redirectUrls?.length) {
|
||||
url = resp.redirectUrls[resp.redirectUrls.length - 1];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -622,6 +622,7 @@
|
|||
"mobile.channel_list.recent": "Recent",
|
||||
"mobile.channel_list.unreads": "Unreads",
|
||||
"mobile.commands.error_title": "Error Executing Command",
|
||||
"mobile.components.select_server_view.advancedOptions": "Advanced Options",
|
||||
"mobile.components.select_server_view.connect": "Connect",
|
||||
"mobile.components.select_server_view.connecting": "Connecting",
|
||||
"mobile.components.select_server_view.displayHelp": "Choose a display name for your server",
|
||||
|
|
@ -631,6 +632,8 @@
|
|||
"mobile.components.select_server_view.msg_description": "A server is your team's communication hub accessed using a unique URL",
|
||||
"mobile.components.select_server_view.msg_welcome": "Welcome",
|
||||
"mobile.components.select_server_view.proceed": "Proceed",
|
||||
"mobile.components.select_server_view.sharedSecret": "Pre-authentication secret",
|
||||
"mobile.components.select_server_view.sharedSecretHelp": "The pre-authentication secret shared by the administrator",
|
||||
"mobile.create_channel": "Create",
|
||||
"mobile.create_channel.title": "New channel",
|
||||
"mobile.create_direct_message.max_limit_reached": "Group messages are limited to {maxCount} members",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ class ServerScreen {
|
|||
displayHelp: 'server_form.display_help',
|
||||
connectButton: 'server_form.connect.button',
|
||||
connectButtonDisabled: 'server_form.connect.button.disabled',
|
||||
advancedOptionsToggle: 'server_form.advanced_options.toggle',
|
||||
preauthSecretInput: 'server_form.preauth_secret.input',
|
||||
preauthSecretHelp: 'server_form.preauth_secret_help',
|
||||
usernameInput: 'login_form.username.input',
|
||||
usernameInputError: 'login_form.username.input.error',
|
||||
};
|
||||
|
|
@ -37,6 +40,9 @@ class ServerScreen {
|
|||
displayHelp = element(by.id(this.testID.displayHelp));
|
||||
connectButton = element(by.id(this.testID.connectButton));
|
||||
connectButtonDisabled = element(by.id(this.testID.connectButtonDisabled));
|
||||
advancedOptionsToggle = element(by.id(this.testID.advancedOptionsToggle));
|
||||
preauthSecretInput = element(by.id(this.testID.preauthSecretInput));
|
||||
preauthSecretHelp = element(by.id(this.testID.preauthSecretHelp));
|
||||
usernameInput = element(by.id(this.testID.usernameInput));
|
||||
|
||||
toBeVisible = async () => {
|
||||
|
|
@ -78,6 +84,47 @@ class ServerScreen {
|
|||
await this.connectButton.tap();
|
||||
await wait(timeouts.ONE_SEC);
|
||||
};
|
||||
|
||||
toggleAdvancedOptions = async () => {
|
||||
await this.advancedOptionsToggle.tap();
|
||||
await wait(timeouts.ONE_SEC);
|
||||
};
|
||||
|
||||
enterPreauthSecret = async (secret: string) => {
|
||||
await waitFor(this.preauthSecretInput).toBeVisible().withTimeout(timeouts.TEN_SEC);
|
||||
await this.preauthSecretInput.replaceText(secret);
|
||||
};
|
||||
|
||||
connectToServerWithPreauthSecret = async (serverUrl: string, serverDisplayName: string, preauthSecret: string) => {
|
||||
await this.toBeVisible();
|
||||
await this.serverUrlInput.replaceText(serverUrl);
|
||||
await this.serverDisplayNameInput.replaceText(serverDisplayName);
|
||||
|
||||
// Toggle advanced options to show preauth secret field
|
||||
await this.toggleAdvancedOptions();
|
||||
|
||||
// Enter preauth secret
|
||||
await this.enterPreauthSecret(preauthSecret);
|
||||
|
||||
// Connect
|
||||
if (isAndroid()) {
|
||||
await this.tapConnectButton();
|
||||
}
|
||||
if (isIos()) {
|
||||
await this.tapConnectButton();
|
||||
if (serverUrl.includes('127.0.0.1') || !process.env.CI) {
|
||||
try {
|
||||
// # Tap alert okay button
|
||||
await waitFor(Alert.okayButton).toExist().withTimeout(timeouts.TEN_SEC);
|
||||
await Alert.okayButton.tap();
|
||||
} catch (error) {
|
||||
/* eslint-disable no-console */
|
||||
console.log('Alert button did not appear!');
|
||||
}
|
||||
}
|
||||
}
|
||||
await waitFor(this.usernameInput).toExist().withTimeout(isAndroid()? timeouts.ONE_MIN : timeouts.HALF_MIN);
|
||||
};
|
||||
}
|
||||
|
||||
const serverScreen = new ServerScreen();
|
||||
|
|
|
|||
105
detox/e2e/test/server_login/preauth_secret_connection.e2e.ts
Normal file
105
detox/e2e/test/server_login/preauth_secret_connection.e2e.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// *******************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a screen)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element testID when selecting an element. Create one if none.
|
||||
// *******************************************************************
|
||||
|
||||
import {serverOneUrl} from '@support/test_config';
|
||||
import {
|
||||
LoginScreen,
|
||||
ServerScreen,
|
||||
} from '@support/ui/screen';
|
||||
import {expect} from 'detox';
|
||||
|
||||
describe('Server Login - Preauth Secret Connection', () => {
|
||||
const {
|
||||
serverUrlInput,
|
||||
serverDisplayNameInput,
|
||||
advancedOptionsToggle,
|
||||
preauthSecretInput,
|
||||
preauthSecretHelp,
|
||||
} = ServerScreen;
|
||||
|
||||
beforeEach(async () => {
|
||||
// * Verify on server screen
|
||||
await ServerScreen.toBeVisible();
|
||||
|
||||
// # FORCE advanced options to be CLOSED (clean state)
|
||||
try {
|
||||
await waitFor(preauthSecretInput).toBeVisible().withTimeout(500);
|
||||
await ServerScreen.toggleAdvancedOptions(); // Close them
|
||||
} catch (error) {
|
||||
// Preauth field not visible, advanced options already closed - good!
|
||||
}
|
||||
|
||||
// # Clear all fields
|
||||
await expect(serverUrlInput).toBeVisible();
|
||||
await expect(serverDisplayNameInput).toBeVisible();
|
||||
await serverUrlInput.clearText();
|
||||
await serverDisplayNameInput.clearText();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// # Navigate back to server screen if we ended up on login screen
|
||||
try {
|
||||
await LoginScreen.toBeVisible();
|
||||
await LoginScreen.back();
|
||||
await ServerScreen.toBeVisible();
|
||||
} catch (error) {
|
||||
// Not on login screen, no need to navigate back
|
||||
}
|
||||
});
|
||||
|
||||
it('MM-T5000_1 - should toggle advanced options and verify preauth secret field styling', async () => {
|
||||
// * Verify advanced options toggle is visible with correct text
|
||||
await expect(advancedOptionsToggle).toBeVisible();
|
||||
await expect(element(by.text('Advanced Options'))).toBeVisible();
|
||||
|
||||
// * Verify preauth secret field is initially hidden
|
||||
await expect(preauthSecretInput).not.toBeVisible();
|
||||
await expect(preauthSecretHelp).not.toBeVisible();
|
||||
|
||||
// # Toggle advanced options to show fields
|
||||
await ServerScreen.toggleAdvancedOptions();
|
||||
|
||||
// * Verify preauth secret field is now visible with correct text and styling
|
||||
await expect(preauthSecretInput).toBeVisible();
|
||||
await expect(preauthSecretHelp).toBeVisible();
|
||||
await expect(element(by.text('Pre-authentication secret'))).toBeVisible();
|
||||
await expect(preauthSecretHelp).toHaveText('The pre-authentication secret shared by the administrator');
|
||||
|
||||
// # Toggle advanced options again to hide
|
||||
await ServerScreen.toggleAdvancedOptions();
|
||||
|
||||
// * Verify preauth secret field is hidden again
|
||||
await expect(preauthSecretInput).not.toBeVisible();
|
||||
await expect(preauthSecretHelp).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('MM-T5000_2 - should connect to server with preauth secret and verify storage', async () => {
|
||||
const testPreauthSecret = 'test-secret-123';
|
||||
const serverDisplayName = 'Test Server with Preauth';
|
||||
|
||||
// # Connect to server using the dedicated method with preauth secret
|
||||
await ServerScreen.connectToServerWithPreauthSecret(serverOneUrl, serverDisplayName, testPreauthSecret);
|
||||
|
||||
// * Verify successful connection to login screen
|
||||
await LoginScreen.toBeVisible();
|
||||
|
||||
// TODO: Add verification that preauth secret is stored in keychain
|
||||
// This would require keychain testing utilities or verification through subsequent network requests
|
||||
});
|
||||
|
||||
it('MM-T5000_3 - should connect to server without preauth secret', async () => {
|
||||
const serverDisplayName = 'Server without Preauth';
|
||||
|
||||
// # Connect to server using the standard method (no preauth secret)
|
||||
await ServerScreen.connectToServer(serverOneUrl, serverDisplayName);
|
||||
|
||||
// * Verify successful connection to login screen
|
||||
await LoginScreen.toBeVisible();
|
||||
});
|
||||
});
|
||||
12
ios/Gekidou/Sources/Gekidou/Constants.swift
Normal file
12
ios/Gekidou/Sources/Gekidou/Constants.swift
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// Constants.swift
|
||||
// Gekidou
|
||||
//
|
||||
// Created by Claude on 21/08/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct GekidouConstants {
|
||||
public static let HEADER_X_MATTERMOST_PREAUTH_SECRET = "X-Mattermost-Preauth-Secret"
|
||||
}
|
||||
|
|
@ -7,6 +7,11 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct ServerCredentials {
|
||||
public let token: String?
|
||||
public let preauthSecret: String?
|
||||
}
|
||||
|
||||
enum KeychainError: Error {
|
||||
case CertificateForIdentityNotFound
|
||||
case IdentityNotFound
|
||||
|
|
@ -27,7 +32,7 @@ extension KeychainError: LocalizedError {
|
|||
case .FailedSecItemCopyMatching(status: let status): return status
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .CertificateForIdentityNotFound:
|
||||
|
|
@ -48,7 +53,7 @@ extension KeychainError: LocalizedError {
|
|||
|
||||
public class Keychain: NSObject {
|
||||
@objc public static let `default` = Keychain()
|
||||
|
||||
|
||||
public func getClientIdentityAndCertificate(for host: String) throws -> (SecIdentity, SecCertificate)? {
|
||||
let query = try buildIdentityQuery(for: host)
|
||||
|
||||
|
|
@ -61,7 +66,7 @@ public class Keychain: NSObject {
|
|||
|
||||
throw KeychainError.FailedSecItemCopyMatching(identityStatus)
|
||||
}
|
||||
|
||||
|
||||
let identity = result as! SecIdentity
|
||||
var certificate: SecCertificate?
|
||||
let certificateStatus = SecIdentityCopyCertificate(identity, &certificate)
|
||||
|
|
@ -71,19 +76,31 @@ public class Keychain: NSObject {
|
|||
guard certificate != nil else {
|
||||
throw KeychainError.CertificateForIdentityNotFound
|
||||
}
|
||||
|
||||
|
||||
return (identity, certificate!)
|
||||
}
|
||||
|
||||
@objc public func getTokenObjc(for serverUrl: String) -> String? {
|
||||
return try? getToken(for: serverUrl)
|
||||
|
||||
@objc public func getCredentialsObjc(for serverUrl: String) -> NSDictionary? {
|
||||
guard let credentials = try? getCredentials(for: serverUrl) else { return nil }
|
||||
return [
|
||||
"token": credentials.token as Any,
|
||||
"preauthSecret": credentials.preauthSecret as Any
|
||||
]
|
||||
}
|
||||
|
||||
public func getToken(for serverUrl: String) throws -> String? {
|
||||
|
||||
public func getCredentials(for serverUrl: String) throws -> ServerCredentials? {
|
||||
// Get main token from serverUrl key
|
||||
let token = try getMainToken(for: serverUrl)
|
||||
let preauthSecret = try? getPreauthSecret(for: serverUrl)
|
||||
|
||||
return ServerCredentials(token: token, preauthSecret: preauthSecret)
|
||||
}
|
||||
|
||||
private func getMainToken(for serverUrl: String) throws -> String? {
|
||||
var attributes = try buildTokenAttributes(for: serverUrl)
|
||||
attributes[kSecMatchLimit] = kSecMatchLimitOne
|
||||
attributes[kSecReturnData] = kCFBooleanTrue
|
||||
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(attributes as CFDictionary, &result)
|
||||
let data = result as? Data
|
||||
|
|
@ -91,15 +108,31 @@ public class Keychain: NSObject {
|
|||
let token = String(data: data!, encoding: .utf8)
|
||||
return token
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
private func getPreauthSecret(for serverUrl: String) throws -> String? {
|
||||
var attributes = try buildGenericPasswordAttributes(for: serverUrl, account: "preshared_secret")
|
||||
attributes[kSecMatchLimit] = kSecMatchLimitOne
|
||||
attributes[kSecReturnData] = kCFBooleanTrue
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(attributes as CFDictionary, &result)
|
||||
let data = result as? Data
|
||||
if status == errSecSuccess && data != nil {
|
||||
let preauthSecret = String(data: data!, encoding: .utf8)
|
||||
return preauthSecret
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func buildIdentityQuery(for host: String) throws -> [CFString: Any] {
|
||||
guard let hostData = host.data(using: .utf8) else {
|
||||
throw KeychainError.InvalidHost(host)
|
||||
}
|
||||
|
||||
|
||||
let query: [CFString:Any] = [
|
||||
kSecClass: kSecClassIdentity,
|
||||
kSecAttrLabel: hostData,
|
||||
|
|
@ -108,12 +141,12 @@ public class Keychain: NSObject {
|
|||
|
||||
return query
|
||||
}
|
||||
|
||||
|
||||
private func buildTokenAttributes(for serverUrl: String) throws -> [CFString: Any] {
|
||||
guard let serverUrlData = serverUrl.data(using: .utf8) else {
|
||||
throw KeychainError.InvalidServerUrl(serverUrl)
|
||||
}
|
||||
|
||||
|
||||
var attributes: [CFString: Any] = [
|
||||
kSecClass: kSecClassInternetPassword,
|
||||
kSecAttrServer: serverUrlData
|
||||
|
|
@ -122,7 +155,29 @@ public class Keychain: NSObject {
|
|||
if let accessGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as! String? {
|
||||
attributes[kSecAttrAccessGroup] = accessGroup
|
||||
}
|
||||
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
private func buildGenericPasswordAttributes(for service: String, account: String) throws -> [CFString: Any] {
|
||||
guard let serviceData = service.data(using: .utf8) else {
|
||||
throw KeychainError.InvalidServerUrl(service)
|
||||
}
|
||||
|
||||
guard let accountData = account.data(using: .utf8) else {
|
||||
throw KeychainError.InvalidServerUrl(account)
|
||||
}
|
||||
|
||||
var attributes: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: serviceData,
|
||||
kSecAttrAccount: accountData
|
||||
]
|
||||
|
||||
if let accessGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as! String? {
|
||||
attributes[kSecAttrAccessGroup] = accessGroup
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,8 +94,13 @@ public class Network: NSObject {
|
|||
}
|
||||
}
|
||||
|
||||
if let token = try? Keychain.default.getToken(for: serverUrl) {
|
||||
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
if let credentials = try? Keychain.default.getCredentials(for: serverUrl) {
|
||||
if let token = credentials.token {
|
||||
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
if let preauthSecret = credentials.preauthSecret {
|
||||
request.addValue(preauthSecret, forHTTPHeaderField: GekidouConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET)
|
||||
}
|
||||
}
|
||||
|
||||
return request as URLRequest
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
//
|
||||
// Created by Elias Nahum on 26-06-22.
|
||||
//
|
||||
|
|
@ -12,14 +12,15 @@ extension ShareExtension {
|
|||
public func uploadFiles(serverUrl: String, channelId: String, message: String,
|
||||
files: [String], completionHandler: @escaping () -> Void) -> String? {
|
||||
let id = "mattermost-share-upload-\(UUID().uuidString)"
|
||||
|
||||
|
||||
createUploadSessionData(
|
||||
id: id, serverUrl: serverUrl,
|
||||
channelId: channelId, message: message,
|
||||
files: files
|
||||
)
|
||||
|
||||
guard let token = try? Keychain.default.getToken(for: serverUrl) else {return "Could not retrieve the session token from the KeyChain"}
|
||||
|
||||
guard let credentials = try? Keychain.default.getCredentials(for: serverUrl),
|
||||
let token = credentials.token else {return "Could not retrieve the session token from the KeyChain"}
|
||||
|
||||
if !files.isEmpty {
|
||||
createBackroundSession(id: id)
|
||||
|
|
@ -31,15 +32,19 @@ extension ShareExtension {
|
|||
)
|
||||
for file in files {
|
||||
if let fileUrl = URL(string: file),
|
||||
fileUrl.isFileURL {
|
||||
fileUrl.isFileURL {
|
||||
let filename = fileUrl.lastPathComponent
|
||||
let safeFilename = filename.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
||||
if let safeFilename = safeFilename,
|
||||
let url = URL(string: "\(serverUrl)/api/v4/files?channel_id=\(channelId)&filename=\(safeFilename)") {
|
||||
let url = URL(string: "\(serverUrl)/api/v4/files?channel_id=\(channelId)&filename=\(safeFilename)") {
|
||||
var uploadRequest = URLRequest(url: url)
|
||||
uploadRequest.httpMethod = "POST"
|
||||
uploadRequest.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
if let preauthSecret = credentials.preauthSecret {
|
||||
uploadRequest.addValue(preauthSecret, forHTTPHeaderField: GekidouConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET)
|
||||
}
|
||||
|
||||
if let task = backgroundSession?.uploadTask(with: uploadRequest, fromFile: fileUrl) {
|
||||
os_log(
|
||||
OSLogType.default,
|
||||
|
|
@ -85,10 +90,10 @@ extension ShareExtension {
|
|||
)
|
||||
self.postMessageForSession(withId: id, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func postMessageForSession(withId id: String, completionHandler: (() -> Void)? = nil) {
|
||||
guard let data = getUploadSessionData(id: id)
|
||||
else {
|
||||
|
|
@ -99,12 +104,12 @@ extension ShareExtension {
|
|||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
self.removeUploadSessionData(id: id)
|
||||
self.deleteUploadedFiles(files: data.files)
|
||||
|
||||
|
||||
if let serverUrl = data.serverUrl,
|
||||
let channelId = data.channelId {
|
||||
let channelId = data.channelId {
|
||||
Network.default.createPost(
|
||||
serverUrl: serverUrl,
|
||||
channelId: channelId,
|
||||
|
|
@ -120,7 +125,7 @@ extension ShareExtension {
|
|||
err.localizedDescription
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if let handler = completionHandler {
|
||||
os_log(
|
||||
OSLogType.default,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import react_native_emm
|
|||
|
||||
@objc class GekidouWrapper: NSObject {
|
||||
@objc public static let `default` = GekidouWrapper()
|
||||
|
||||
|
||||
override init() {
|
||||
ScreenCaptureManager.startTrackingScreens()
|
||||
}
|
||||
|
|
@ -20,18 +20,18 @@ import react_native_emm
|
|||
@objc func postNotificationReceipt(_ userInfo: [AnyHashable:Any]) {
|
||||
PushNotification.default.postNotificationReceipt(userInfo)
|
||||
}
|
||||
|
||||
|
||||
@objc func fetchDataForPushNotification(_ notification: [AnyHashable:Any], withContentHandler contentHander: @escaping ((_ data: Data?) -> Void)) {
|
||||
PushNotification.default.fetchDataForPushNotification(notification, withContentHandler: { data in
|
||||
let jsonData = try? JSONEncoder().encode(data)
|
||||
contentHander(jsonData)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@objc func verifySignature(_ notification: [AnyHashable:Any]) -> Bool {
|
||||
return PushNotification.default.verifySignature(notification)
|
||||
}
|
||||
|
||||
|
||||
@objc func attachSession(_ id: String, completionHandler: @escaping () -> Void) {
|
||||
let shareExtension = ShareExtension()
|
||||
shareExtension.attachSession(
|
||||
|
|
@ -39,16 +39,16 @@ import react_native_emm
|
|||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@objc func setPreference(_ value: Any?, forKey name: String) {
|
||||
Preferences.default.set(value, forKey: name)
|
||||
}
|
||||
|
||||
|
||||
@objc func getToken(for url: String) -> String? {
|
||||
if let token = try? Keychain.default.getToken(for: url) {
|
||||
return token
|
||||
if let credentials = try? Keychain.default.getCredentials(for: url) {
|
||||
return credentials?.token
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,9 @@ static SendReplyCompletionHandlerIMP originalSendReplyCompletionHandlerImplement
|
|||
return;
|
||||
}
|
||||
|
||||
NSString *sessionToken = [[Keychain default] getTokenObjcFor:serverUrl];
|
||||
NSDictionary *credentials = [[Keychain default] getCredentialsObjcFor:serverUrl];
|
||||
NSString *sessionToken = [credentials objectForKey:@"token"];
|
||||
NSString *preauthSecret = [credentials objectForKey:@"preauthSecret"];
|
||||
if (sessionToken == nil) {
|
||||
[self handleReplyFailure:@"" completionHandler:notificationCompletionHandler];
|
||||
return;
|
||||
|
|
@ -95,6 +97,12 @@ static SendReplyCompletionHandlerIMP originalSendReplyCompletionHandlerImplement
|
|||
[request setHTTPMethod:@"POST"];
|
||||
[request setValue:[NSString stringWithFormat:@"Bearer %@", sessionToken] forHTTPHeaderField:@"Authorization"];
|
||||
[request setValue:@"application/json; charset=utf-8" forHTTPHeaderField:@"Content-Type"];
|
||||
|
||||
// Add preauth secret header if available
|
||||
if (preauthSecret != nil && ![preauthSecret isEqualToString:@""]) {
|
||||
[request setValue:preauthSecret forHTTPHeaderField:@"X-Mattermost-Preauth-Secret"];
|
||||
}
|
||||
|
||||
[request setHTTPBody:postData];
|
||||
|
||||
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration];
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ import java.security.cert.X509Certificate
|
|||
import java.util.Objects
|
||||
|
||||
class ShareWorker(private val context: Context, workerParameters: WorkerParameters) : Worker(context, workerParameters) {
|
||||
companion object {
|
||||
private const val HEADER_X_MATTERMOST_PREAUTH_SECRET = "X-Mattermost-Preauth-Secret"
|
||||
}
|
||||
private val jsonType: MediaType? = "application/json; charset=utf-8".toMediaTypeOrNull()
|
||||
private val okHttpClient: OkHttpClient
|
||||
get() {
|
||||
|
|
@ -90,13 +93,18 @@ class ShareWorker(private val context: Context, workerParameters: WorkerParamete
|
|||
val files = if (jsonObject.has("files")) jsonObject.getJSONArray("files") else null
|
||||
val serverUrl = jsonObject.getString("serverUrl")
|
||||
val token = jsonObject.getString("token")
|
||||
val preauthSecret = if (jsonObject.has("preauthSecret") && !jsonObject.isNull("preauthSecret")) {
|
||||
val secret = jsonObject.getString("preauthSecret")
|
||||
if (secret.isNotEmpty()) secret else null
|
||||
} else null
|
||||
val postData = buildPostObject(jsonObject)
|
||||
|
||||
if (files != null && files.length() > 0) {
|
||||
setForegroundAsync(createForegroundInfo())
|
||||
return uploadFiles(serverUrl, token, files, postData)
|
||||
return uploadFiles(serverUrl, token, preauthSecret, files, postData)
|
||||
} else {
|
||||
try {
|
||||
return post(serverUrl, token, postData)
|
||||
return post(serverUrl, token, preauthSecret, postData)
|
||||
} catch (e: IOException) {
|
||||
Log.e(MattermostShareImpl.NAME, "Error sending the post", e)
|
||||
return Result.failure()
|
||||
|
|
@ -123,13 +131,18 @@ class ShareWorker(private val context: Context, workerParameters: WorkerParamete
|
|||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun post(serverUrl: String, token: String, postData: JSONObject): Result {
|
||||
private fun post(serverUrl: String, token: String, preauthSecret: String?, postData: JSONObject): Result {
|
||||
val body = postData.toString().toRequestBody(jsonType)
|
||||
val request = Request.Builder()
|
||||
val requestBuilder = Request.Builder()
|
||||
.header("Authorization", "BEARER $token")
|
||||
.url("$serverUrl/api/v4/posts")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
if (preauthSecret != null) {
|
||||
requestBuilder.header(HEADER_X_MATTERMOST_PREAUTH_SECRET, preauthSecret)
|
||||
}
|
||||
|
||||
val request = requestBuilder.build()
|
||||
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
response.body?.close()
|
||||
|
|
@ -137,7 +150,7 @@ class ShareWorker(private val context: Context, workerParameters: WorkerParamete
|
|||
return Result.success()
|
||||
}
|
||||
|
||||
private fun uploadFiles(serverUrl: String, token: String, files: JSONArray, postData: JSONObject): Result {
|
||||
private fun uploadFiles(serverUrl: String, token: String, preauthSecret: String?, files: JSONArray, postData: JSONObject): Result {
|
||||
try {
|
||||
val builder = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
|
|
@ -156,11 +169,16 @@ class ShareWorker(private val context: Context, workerParameters: WorkerParamete
|
|||
|
||||
builder.addFormDataPart("channel_id", postData.getString("channel_id"))
|
||||
val body: RequestBody = builder.build()
|
||||
val request = Request.Builder()
|
||||
val requestBuilder = Request.Builder()
|
||||
.header("Authorization", "BEARER $token")
|
||||
.url("$serverUrl/api/v4/files")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
if (preauthSecret != null) {
|
||||
requestBuilder.header(HEADER_X_MATTERMOST_PREAUTH_SECRET, preauthSecret)
|
||||
}
|
||||
|
||||
val request = requestBuilder.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
|
|
@ -178,7 +196,7 @@ class ShareWorker(private val context: Context, workerParameters: WorkerParamete
|
|||
fileIds.put(fileInfo.getString("id"))
|
||||
}
|
||||
postData.put("file_ids", fileIds)
|
||||
return post(serverUrl, token, postData)
|
||||
return post(serverUrl, token, preauthSecret, postData)
|
||||
}
|
||||
return Result.failure()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export type ShareExtensionDataToSend = {
|
|||
serverUrl: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
preauthSecret?: string;
|
||||
}
|
||||
|
||||
export interface Spec extends TurboModule {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const PostButton = ({theme}: Props) => {
|
|||
files,
|
||||
message: text,
|
||||
userId,
|
||||
preauthSecret: credentials.preauthSecret,
|
||||
});
|
||||
}
|
||||
}, [serverUrl, channelId, message, files, linkPreviewUrl, userId]);
|
||||
|
|
|
|||
1
types/credentials/index.d.ts
vendored
1
types/credentials/index.d.ts
vendored
|
|
@ -5,4 +5,5 @@ type ServerCredential = {
|
|||
serverUrl: string;
|
||||
userId: string;
|
||||
token: string;
|
||||
preauthSecret?: string;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue