[MM-37984] Allow Desktop App to authenticate via external providers outside of the app on supported servers (#24140)

* [MM-37984] Allow Desktop App to authenticate via external providers outside of the app on supported servers

* PR feedback

* Add support for mattermost-dev protocol for development use

* Update server/channels/db/migrations/postgres/000110_create_desktop_tokens.up.sql

* Fix silly typo

* Update server/channels/db/migrations/postgres/000110_create_desktop_tokens.up.sql

* Remove storage of client token, only validate it on the client

* Update migrations

* Add concurrently create index

* Remove CONCURRENTLY for now

* Fix issue with changing history

* Remove old migration

* Use idempotent statement to drop old index

* Remove reference to old table
Этот коммит содержится в:
Devin Binnie
2023-08-30 11:21:43 -04:00
коммит произвёл GitHub
родитель 105fa4a195
Коммит a3b194581f
41 изменённых файлов: 1569 добавлений и 24 удалений

Просмотреть файл

@@ -45,6 +45,40 @@ export function login(loginId: string, password: string, mfaToken = ''): ActionF
};
}
export function loginWithDesktopToken(token: string): ActionFunc {
return async (dispatch: DispatchFunc) => {
dispatch({type: UserTypes.LOGIN_REQUEST, data: null});
try {
// This is partial user profile we recieved when we login. We still need to make getMe for complete user profile.
const loggedInUserProfile = await Client4.loginWithDesktopToken(token);
dispatch(
batchActions([
{
type: UserTypes.LOGIN_SUCCESS,
},
{
type: UserTypes.RECEIVED_ME,
data: loggedInUserProfile,
},
]),
);
dispatch(loadRolesIfNeeded(loggedInUserProfile.roles.split(' ')));
} catch (error) {
dispatch({
type: UserTypes.LOGIN_FAILURE,
error,
});
dispatch(logError(error as ServerError));
return {error};
}
return {data: true};
};
}
export function loginById(id: string, password: string): ActionFunc {
return async (dispatch: DispatchFunc) => {
dispatch({type: UserTypes.LOGIN_REQUEST, data: null});

Просмотреть файл

@@ -0,0 +1,38 @@
.DesktopAuthToken {
display: flex;
width: 540px;
height: 80vh;
flex-direction: column;
align-items: center;
justify-content: center;
margin: auto;
text-align: center;
}
.DesktopAuthToken__main {
display: flex;
margin-top: auto;
margin-bottom: 14px;
color: var(--sidebar-header-bg);
font-size: 40px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 48px;
}
.DesktopAuthToken__sub {
margin-bottom: 24px;
color: rgba(var(--center-channel-color-rgb), 0.72);
font-size: 18px;
line-height: 28px;
&.complete {
max-width: 442px;
}
}
.DesktopAuthToken__bottom {
margin-top: auto;
font-size: 16px;
line-height: 24px;
}

Просмотреть файл

@@ -0,0 +1,224 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {useHistory, useLocation} from 'react-router-dom';
import crypto from 'crypto';
import classNames from 'classnames';
import {UserProfile} from '@mattermost/types/users';
import {DispatchFunc} from 'mattermost-redux/types/actions';
import {loginWithDesktopToken} from 'actions/views/login';
import './desktop_auth_token.scss';
const BOTTOM_MESSAGE_TIMEOUT = 10000;
const DESKTOP_AUTH_PREFIX = 'desktop_auth_client_token';
declare global {
interface Window {
desktopAPI?: {
isDev?: () => Promise<boolean>;
};
}
}
enum DesktopAuthStatus {
None,
WaitingForBrowser,
LoggedIn,
Authenticating,
Error,
}
type Props = {
href: string;
onLogin: (userProfile: UserProfile) => void;
}
const DesktopAuthToken: React.FC<Props> = ({href, onLogin}: Props) => {
const dispatch = useDispatch<DispatchFunc>();
const history = useHistory();
const {search} = useLocation();
const query = new URLSearchParams(search);
const serverToken = query.get('server_token');
const receivedClientToken = query.get('client_token');
const storedClientToken = sessionStorage.getItem(DESKTOP_AUTH_PREFIX);
const [status, setStatus] = useState(serverToken ? DesktopAuthStatus.LoggedIn : DesktopAuthStatus.None);
const [showBottomMessage, setShowBottomMessage] = useState<boolean>();
const tryDesktopLogin = async () => {
if (!(serverToken && receivedClientToken === storedClientToken)) {
setStatus(DesktopAuthStatus.Error);
return;
}
sessionStorage.removeItem(DESKTOP_AUTH_PREFIX);
const {data: userProfile, error: loginError} = await dispatch(loginWithDesktopToken(serverToken));
if (loginError && loginError.server_error_id && loginError.server_error_id.length !== 0) {
setStatus(DesktopAuthStatus.Error);
return;
}
setStatus(DesktopAuthStatus.LoggedIn);
await onLogin(userProfile as UserProfile);
};
const openExternalLoginURL = async () => {
const isDev = await window.desktopAPI?.isDev?.();
const desktopToken = `${isDev ? 'dev-' : ''}${crypto.randomBytes(32).toString('hex')}`.slice(0, 64);
sessionStorage.setItem(DESKTOP_AUTH_PREFIX, desktopToken);
const parsedURL = new URL(href);
const params = new URLSearchParams(parsedURL.searchParams);
params.set('desktop_token', desktopToken);
window.open(`${parsedURL.origin}${parsedURL.pathname}?${params.toString()}`);
setStatus(DesktopAuthStatus.WaitingForBrowser);
};
const forwardToDesktopApp = () => {
const url = new URL(window.location.href);
if (url.searchParams.get('isDesktopDev')) {
url.protocol = 'mattermost-dev';
} else {
url.protocol = 'mattermost';
}
window.location.href = url.toString();
};
useEffect(() => {
setShowBottomMessage(false);
const timeout = setTimeout(() => {
setShowBottomMessage(true);
}, BOTTOM_MESSAGE_TIMEOUT) as unknown as number;
return () => {
clearTimeout(timeout);
};
}, [status]);
useEffect(() => {
if (serverToken) {
if (storedClientToken) {
tryDesktopLogin();
} else {
forwardToDesktopApp();
}
return;
}
openExternalLoginURL();
}, [serverToken]);
let mainMessage;
let subMessage;
let bottomMessage;
if (status === DesktopAuthStatus.WaitingForBrowser) {
mainMessage = (
<FormattedMessage
id='desktop_auth_token.polling.redirectingToBrowser'
defaultMessage='Redirecting to browser...'
/>
);
subMessage = (
<FormattedMessage
id='desktop_auth_token.polling.awaitingToken'
defaultMessage='Authenticating in the browser, awaiting valid token.'
/>
);
bottomMessage = null;
}
if (status === DesktopAuthStatus.LoggedIn) {
mainMessage = (
<FormattedMessage
id='desktop_auth_token.complete.youAreNowLoggedIn'
defaultMessage='You are now logged in'
/>
);
subMessage = (
<FormattedMessage
id='desktop_auth_token.complete.openMattermost'
defaultMessage='Click on <b>Open Mattermost</b> in the browser prompt to <a>launch the desktop app</a>'
values={{
a: (chunks: React.ReactNode) => {
return (
<a onClick={forwardToDesktopApp}>
{chunks}
</a>
);
},
b: (chunks: React.ReactNode) => (<b>{chunks}</b>),
}}
/>
);
bottomMessage = (
<FormattedMessage
id='desktop_auth_token.complete.havingTrouble'
defaultMessage='Having trouble logging in? <a>Open Mattermost in your browser</a>'
values={{
a: (chunks: React.ReactNode) => {
return (
<a onClick={() => history.push('/')}>
{chunks}
</a>
);
},
}}
/>
);
}
if (status === DesktopAuthStatus.Error) {
mainMessage = (
<FormattedMessage
id='desktop_auth_token.error.somethingWentWrong'
defaultMessage='Something went wrong'
/>
);
subMessage = (
<FormattedMessage
id='desktop_auth_token.error.restartFlow'
defaultMessage={'Click <a>here</a> to try again.'}
values={{
a: (chunks: React.ReactNode) => {
return (
<a onClick={() => history.push('/')}>
{chunks}
</a>
);
},
}}
/>
);
bottomMessage = null;
}
return (
<div className='DesktopAuthToken'>
<h1 className='DesktopAuthToken__main'>
{mainMessage}
</h1>
<p className={classNames('DesktopAuthToken__sub', {complete: status === DesktopAuthStatus.LoggedIn})}>
{subMessage}
</p>
<div className='DesktopAuthToken__bottom'>
{showBottomMessage ? bottomMessage : null}
</div>
</div>
);
};
export default DesktopAuthToken;

Просмотреть файл

@@ -13,6 +13,7 @@ export type ExternalLoginButtonType = {
label: string;
style?: React.CSSProperties;
direction?: 'row' | 'column';
onClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
};
const ExternalLoginButton = ({
@@ -22,12 +23,14 @@ const ExternalLoginButton = ({
label,
style,
direction = 'row',
onClick,
}: ExternalLoginButtonType) => (
<a
id={id}
className={classNames('external-login-button', {'direction-column': direction === 'column'}, id)}
href={url}
style={style}
onClick={onClick}
>
{icon}
<span className='external-login-button-label'>

Просмотреть файл

@@ -3,7 +3,7 @@
import React, {useState, useEffect, useRef, useCallback, FormEvent} from 'react';
import {useIntl} from 'react-intl';
import {Link, useLocation, useHistory} from 'react-router-dom';
import {Link, useLocation, useHistory, Route} from 'react-router-dom';
import {useSelector, useDispatch} from 'react-redux';
import classNames from 'classnames';
import throttle from 'lodash/throttle';
@@ -32,7 +32,9 @@ import {setNeedsLoggedInLimitReachedCheck} from 'actions/views/admin';
import {trackEvent} from 'actions/telemetry_actions';
import AlertBanner, {ModeType, AlertBannerProps} from 'components/alert_banner';
import DesktopAuthToken from 'components/desktop_auth_token';
import ExternalLoginButton, {ExternalLoginButtonType} from 'components/external_login_button/external_login_button';
import ExternalLink from 'components/external_link';
import AlternateLinkLayout from 'components/header_footer_route/content_layouts/alternate_link';
import ColumnLayout from 'components/header_footer_route/content_layouts/column';
import {CustomizeHeaderType} from 'components/header_footer_route/header_footer_route';
@@ -54,12 +56,12 @@ import {GlobalState} from 'types/store';
import Constants from 'utils/constants';
import {showNotification} from 'utils/notifications';
import {t} from 'utils/i18n';
import {isDesktopApp} from 'utils/user_agent';
import {setCSRFFromCookie} from 'utils/utils';
import LoginMfa from './login_mfa';
import './login.scss';
import ExternalLink from 'components/external_link';
const MOBILE_SCREEN_WIDTH = 1200;
@@ -148,6 +150,8 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
const query = new URLSearchParams(search);
const redirectTo = query.get('redirect_to');
const [desktopLoginLink, setDesktopLoginLink] = useState('');
const getExternalLoginOptions = () => {
const externalLoginOptions: ExternalLoginButtonType[] = [];
@@ -156,55 +160,76 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
}
if (enableSignUpWithGitLab) {
const url = `${Client4.getOAuthRoute()}/gitlab/login${search}`;
externalLoginOptions.push({
id: 'gitlab',
url: `${Client4.getOAuthRoute()}/gitlab/login${search}`,
url,
icon: <LoginGitlabIcon/>,
label: GitLabButtonText || formatMessage({id: 'login.gitlab', defaultMessage: 'GitLab'}),
style: {color: GitLabButtonColor, borderColor: GitLabButtonColor},
onClick: desktopExternalAuth(url),
});
}
if (enableSignUpWithGoogle) {
const url = `${Client4.getOAuthRoute()}/google/login${search}`;
externalLoginOptions.push({
id: 'google',
url: `${Client4.getOAuthRoute()}/google/login${search}`,
url,
icon: <LoginGoogleIcon/>,
label: formatMessage({id: 'login.google', defaultMessage: 'Google'}),
onClick: desktopExternalAuth(url),
});
}
if (enableSignUpWithOffice365) {
const url = `${Client4.getOAuthRoute()}/office365/login${search}`;
externalLoginOptions.push({
id: 'office365',
url: `${Client4.getOAuthRoute()}/office365/login${search}`,
url,
icon: <LoginOffice365Icon/>,
label: formatMessage({id: 'login.office365', defaultMessage: 'Office 365'}),
onClick: desktopExternalAuth(url),
});
}
if (enableSignUpWithOpenId) {
const url = `${Client4.getOAuthRoute()}/openid/login${search}`;
externalLoginOptions.push({
id: 'openid',
url: `${Client4.getOAuthRoute()}/openid/login${search}`,
url,
icon: <LoginOpenIDIcon/>,
label: OpenIdButtonText || formatMessage({id: 'login.openid', defaultMessage: 'Open ID'}),
style: {color: OpenIdButtonColor, borderColor: OpenIdButtonColor},
onClick: desktopExternalAuth(url),
});
}
if (enableSignUpWithSaml) {
const url = `${Client4.getUrl()}/login/sso/saml${search}`;
externalLoginOptions.push({
id: 'saml',
url: `${Client4.getUrl()}/login/sso/saml${search}`,
url,
icon: <LockIcon/>,
label: SamlLoginButtonText || formatMessage({id: 'login.saml', defaultMessage: 'SAML'}),
onClick: desktopExternalAuth(url),
});
}
return externalLoginOptions;
};
const desktopExternalAuth = (href: string) => {
return (event: React.MouseEvent) => {
if (isDesktopApp()) {
event.preventDefault();
setDesktopLoginLink(href);
history.push(`/login/desktop${search}`);
}
};
};
const dismissAlert = () => {
setAlertBanner(null);
setHasError(false);
@@ -376,6 +401,11 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
}, [onCustomizeHeader, search, showMfa, isMobileView, getAlternateLink]);
useEffect(() => {
// We don't want to redirect outside of this route if we're doing Desktop App auth
if (query.get('server_token')) {
return;
}
if (currentUser) {
if (redirectTo && redirectTo.match(/^\/([^/]|$)/)) {
history.push(redirectTo);
@@ -594,6 +624,10 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
return;
}
await postSubmit(userProfile);
};
const postSubmit = async (userProfile: UserProfile) => {
if (graphQLEnabled) {
await dispatch(loadMe());
} else {
@@ -752,6 +786,20 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
);
}
if (desktopLoginLink || query.get('server_token')) {
return (
<Route
path={'/login/desktop'}
render={() => (
<DesktopAuthToken
href={desktopLoginLink}
onLogin={postSubmit}
/>
)}
/>
);
}
return (
<>
<div

Просмотреть файл

@@ -142,6 +142,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
id="gitlab"
key="gitlab"
label="GitLab"
onClick={[Function]}
style={
Object {
"borderColor": "",
@@ -315,6 +316,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
id="gitlab"
key="gitlab"
label="GitLab"
onClick={[Function]}
style={
Object {
"borderColor": "",
@@ -328,6 +330,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
id="google"
key="google"
label="Google"
onClick={[Function]}
url="/oauth/google/signup"
/>
<ExternalLoginButton
@@ -335,6 +338,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
id="office365"
key="office365"
label="Office 365"
onClick={[Function]}
url="/oauth/office365/signup"
/>
<ExternalLoginButton
@@ -342,6 +346,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
id="openid"
key="openid"
label="Open ID"
onClick={[Function]}
style={
Object {
"borderColor": "",
@@ -355,6 +360,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
id="ldap"
key="ldap"
label="AD/LDAP Credentials"
onClick={[Function]}
url="/login?extra=create_ldap"
/>
<ExternalLoginButton
@@ -362,6 +368,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
id="saml"
key="saml"
label="SAML"
onClick={[Function]}
url="/login/sso/saml?action=signup"
/>
</div>

Просмотреть файл

@@ -4,7 +4,7 @@
import React, {useState, useEffect, useRef, useCallback, FocusEvent} from 'react';
import {useIntl} from 'react-intl';
import {useLocation, useHistory} from 'react-router-dom';
import {useLocation, useHistory, Route} from 'react-router-dom';
import {useSelector, useDispatch} from 'react-redux';
import classNames from 'classnames';
import throttle from 'lodash/throttle';
@@ -54,9 +54,11 @@ import useCWSAvailabilityCheck from 'components/common/hooks/useCWSAvailabilityC
import ExternalLink from 'components/external_link';
import {Constants, HostedCustomerLinks, ItemStatus, ValidationErrors} from 'utils/constants';
import {isDesktopApp} from 'utils/user_agent';
import {isValidUsername, isValidPassword, getPasswordConfig, getRoleFromTrackFlow, getMediumFromTrackFlow} from 'utils/utils';
import './signup.scss';
import DesktopAuthToken from 'components/desktop_auth_token';
const MOBILE_SCREEN_WIDTH = 1200;
@@ -148,6 +150,8 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
const canSubmit = Boolean(email && name && password) && !hasError && !loading;
const {error: passwordInfo} = isValidPassword('', getPasswordConfig(config), intl);
const [desktopLoginLink, setDesktopLoginLink] = useState('');
const subscribeToSecurityNewsletterFunc = () => {
try {
Client4.subscribeToNewsletter({email, subscribed_content: 'security_newsletter'});
@@ -165,40 +169,48 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
}
if (enableSignUpWithGitLab) {
const url = `${Client4.getOAuthRoute()}/gitlab/signup${search}`;
externalLoginOptions.push({
id: 'gitlab',
url: `${Client4.getOAuthRoute()}/gitlab/signup${search}`,
url,
icon: <LoginGitlabIcon/>,
label: GitLabButtonText || formatMessage({id: 'login.gitlab', defaultMessage: 'GitLab'}),
style: {color: GitLabButtonColor, borderColor: GitLabButtonColor},
onClick: desktopExternalAuth(url),
});
}
if (isLicensed && enableSignUpWithGoogle) {
const url = `${Client4.getOAuthRoute()}/google/signup${search}`;
externalLoginOptions.push({
id: 'google',
url: `${Client4.getOAuthRoute()}/google/signup${search}`,
url,
icon: <LoginGoogleIcon/>,
label: formatMessage({id: 'login.google', defaultMessage: 'Google'}),
onClick: desktopExternalAuth(url),
});
}
if (isLicensed && enableSignUpWithOffice365) {
const url = `${Client4.getOAuthRoute()}/office365/signup${search}`;
externalLoginOptions.push({
id: 'office365',
url: `${Client4.getOAuthRoute()}/office365/signup${search}`,
url,
icon: <LoginOffice365Icon/>,
label: formatMessage({id: 'login.office365', defaultMessage: 'Office 365'}),
onClick: desktopExternalAuth(url),
});
}
if (isLicensed && enableSignUpWithOpenId) {
const url = `${Client4.getOAuthRoute()}/openid/signup${search}`;
externalLoginOptions.push({
id: 'openid',
url: `${Client4.getOAuthRoute()}/openid/signup${search}`,
url,
icon: <LoginOpenIDIcon/>,
label: OpenIdButtonText || formatMessage({id: 'login.openid', defaultMessage: 'Open ID'}),
style: {color: OpenIdButtonColor, borderColor: OpenIdButtonColor},
onClick: desktopExternalAuth(url),
});
}
@@ -211,6 +223,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
url: `${Client4.getUrl()}/login?${newSearchParam.toString()}`,
icon: <LockIcon/>,
label: LdapLoginFieldName || formatMessage({id: 'signup.ldap', defaultMessage: 'AD/LDAP Credentials'}),
onClick: () => {},
});
}
@@ -218,11 +231,13 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
const newSearchParam = new URLSearchParams(search);
newSearchParam.set('action', 'signup');
const url = `${Client4.getUrl()}/login/sso/saml?${newSearchParam.toString()}`;
externalLoginOptions.push({
id: 'saml',
url: `${Client4.getUrl()}/login/sso/saml?${newSearchParam.toString()}`,
url,
icon: <LockIcon/>,
label: SamlLoginButtonText || formatMessage({id: 'login.saml', defaultMessage: 'SAML'}),
onClick: desktopExternalAuth(url),
});
}
@@ -295,6 +310,17 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
setIsMobileView(window.innerWidth < MOBILE_SCREEN_WIDTH);
}, 100);
const desktopExternalAuth = (href: string) => {
return (event: React.MouseEvent) => {
if (isDesktopApp()) {
event.preventDefault();
setDesktopLoginLink(href);
history.push(`/signup_user_complete/desktop${search}`);
}
};
};
useEffect(() => {
dispatch(removeGlobalItem('team'));
trackEvent('signup', 'signup_user_01_welcome', {...getRoleFromTrackFlow(), ...getMediumFromTrackFlow()});
@@ -448,6 +474,12 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
return;
}
await postSignupSuccess();
};
const postSignupSuccess = async () => {
const redirectTo = (new URLSearchParams(search)).get('redirect_to');
if (graphQLEnabled) {
await dispatch(loadMe());
} else {
@@ -696,6 +728,20 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
);
}
if (desktopLoginLink) {
return (
<Route
path={'/signup_user_complete/desktop'}
render={() => (
<DesktopAuthToken
href={desktopLoginLink}
onLogin={postSignupSuccess}
/>
)}
/>
);
}
let emailCustomLabelForInput: CustomMessageInputType = parsedEmail ? {
type: ItemStatus.INFO,
value: formatMessage(

Просмотреть файл

@@ -3277,6 +3277,13 @@
"demote_to_user_modal.demote": "Demote",
"demote_to_user_modal.desc": "This action demotes the user {username} to a guest. It will restrict the user's ability to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to demote user {username} to guest?",
"demote_to_user_modal.title": "Demote User {username} to Guest",
"desktop_auth_token.complete.havingTrouble": "Having trouble logging in? <a>Open Mattermost in your browser</a>",
"desktop_auth_token.complete.openMattermost": "Click on <b>Open Mattermost</b> in the browser prompt to <a>launch the desktop app</a>",
"desktop_auth_token.complete.youAreNowLoggedIn": "You are now logged in",
"desktop_auth_token.error.restartFlow": "Click <a>here</a> to try again.",
"desktop_auth_token.error.somethingWentWrong": "Something went wrong",
"desktop_auth_token.polling.awaitingToken": "Authenticating in the browser, awaiting valid token.",
"desktop_auth_token.polling.redirectingToBrowser": "Redirecting to browser...",
"device_icons.android": "Android Icon",
"device_icons.apple": "Apple Icon",
"device_icons.linux": "Linux Icon",

Просмотреть файл

@@ -755,6 +755,18 @@ export default class Client4 {
return profile;
};
loginWithDesktopToken = async (token: string) => {
const body: any = {
token,
deviceId: '',
};
return await this.doFetch<UserProfile>(
`${this.getUsersRoute()}/login/desktop_token`,
{method: 'post', body: JSON.stringify(body)},
);
};
loginById = (id: string, password: string, token = '') => {
this.trackEvent('api', 'api_users_login');
const body: any = {