Merge pull request #9519 from mabashian/7603-custom-login

Adds support for html in custom login text

SUMMARY
link #7603
I couldn't come up with a way to do this without breaking up the component and discontinuing use of the LoginPage PF component.  This is because LoginPage expects the textContent component (what we use to display the custom login text) to be a string.  By using the underlying LoginPage components I reconstructed the login page and got more control over that prop.
The custom message in the old UI supported both strings and HTML:

So we need to support rendering HTML but we need to do it in a safe way.  Our solution to that was https://docs.angularjs.org/api/ngSanitize.  React doesn't seem to have anything like this built in so I went looking for outside help.  html-entities is already included in our project but as best as I can tell that lib is mainly focused on swapping special characters out for html entities.  I wanted something that was going to strip the HTML of bits that could be exploited by a malicious actor.
I settled on https://www.npmjs.com/package/sanitize-html because it was a) small and b) actively maintained.  The API was simple and let me sanitize the HTML before setting it using dangerouslySetInnerHTML.  If we need to tweak the configuration away from the default values then we can certainly do that.


ISSUE TYPE

Feature Pull Request

COMPONENT NAME

UI

Reviewed-by: Jake McDermott <yo@jakemcdermott.me>
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-03-19 22:45:42 +00:00 committed by GitHub
commit cfff30f024
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 357 additions and 168 deletions

View File

@ -46,6 +46,7 @@ To learn more about Ansible Builder and Execution Environments, see: https://www
- Added toast message to show notification template test result to notification templates list https://github.com/ansible/awx/pull/9318
- Replaced CodeMirror with AceEditor for editing template variables and notification templates https://github.com/ansible/awx/pull/9281
- Added support for filtering and pagination on job output https://github.com/ansible/awx/pull/9208
- Added support for html in custom login text https://github.com/ansible/awx/pull/9519
# 17.1.0 (March 9th, 2021)
- Addressed a security issue in AWX (CVE-2021-20253)

View File

@ -11103,6 +11103,11 @@
"integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
"dev": true
},
"klona": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.4.tgz",
"integrity": "sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA=="
},
"language-subtag-registry": {
"version": "0.3.21",
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz",
@ -11827,6 +11832,11 @@
"dev": true,
"optional": true
},
"nanoid": {
"version": "3.1.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz",
"integrity": "sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw=="
},
"nanomatch": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
@ -12560,6 +12570,11 @@
"json-parse-better-errors": "^1.0.1"
}
},
"parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha1-8r0iH2zJcKk42IVWq8WJyqqiveE="
},
"parse5": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-3.0.3.tgz",
@ -15623,6 +15638,106 @@
}
}
},
"sanitize-html": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.3.2.tgz",
"integrity": "sha512-p7neuskvC8pSurUjdVmbWPXmc9A4+QpOXIL+4gwFC+av5h+lYCXFT8uEneqsFQg/wEA1IH+cKQA60AaQI6p3cg==",
"requires": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^6.0.0",
"is-plain-object": "^5.0.0",
"klona": "^2.0.3",
"parse-srcset": "^1.0.2",
"postcss": "^8.0.2"
},
"dependencies": {
"colorette": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz",
"integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w=="
},
"deepmerge": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg=="
},
"dom-serializer": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.2.0.tgz",
"integrity": "sha512-n6kZFH/KlCrqs/1GHMOd5i2fd/beQHuehKdWvNNffbGHTr/almdhuVvTVFb3V7fglz+nC50fFusu3lY33h12pA==",
"requires": {
"domelementtype": "^2.0.1",
"domhandler": "^4.0.0",
"entities": "^2.0.0"
}
},
"domelementtype": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.1.0.tgz",
"integrity": "sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w=="
},
"domhandler": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.0.0.tgz",
"integrity": "sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA==",
"requires": {
"domelementtype": "^2.1.0"
}
},
"domutils": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.4.4.tgz",
"integrity": "sha512-jBC0vOsECI4OMdD0GC9mGn7NXPLb+Qt6KW1YDQzeQYRUFKmNG8lh7mO5HiELfr+lLQE7loDVI4QcAxV80HS+RA==",
"requires": {
"dom-serializer": "^1.0.1",
"domelementtype": "^2.0.1",
"domhandler": "^4.0.0"
}
},
"entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="
},
"escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
},
"htmlparser2": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.0.0.tgz",
"integrity": "sha512-numTQtDZMoh78zJpaNdJ9MXb2cv5G3jwUoe3dMQODubZvLoGvTE/Ofp6sHvH8OGKcN/8A47pGLi/k58xHP/Tfw==",
"requires": {
"domelementtype": "^2.0.1",
"domhandler": "^4.0.0",
"domutils": "^2.4.4",
"entities": "^2.0.0"
}
},
"is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="
},
"postcss": {
"version": "8.2.7",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.7.tgz",
"integrity": "sha512-DsVLH3xJzut+VT+rYr0mtvOtpTjSyqDwPf5EZWXcb0uAKfitGpTY9Ec+afi2+TgdN8rWS9Cs88UDYehKo/RvOw==",
"requires": {
"colorette": "^1.2.2",
"nanoid": "^3.1.20",
"source-map": "^0.6.1"
}
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
}
}
},
"sanitize.css": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-10.0.0.tgz",

View File

@ -27,6 +27,7 @@
"react-router-dom": "^5.1.2",
"react-virtualized": "^9.21.1",
"rrule": "^2.6.4",
"sanitize-html": "^2.3.2",
"styled-components": "^4.2.0"
},
"devDependencies": {

View File

@ -4,12 +4,20 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Formik } from 'formik';
import styled from 'styled-components';
import sanitizeHtml from 'sanitize-html';
import {
Brand,
LoginMainFooterLinksItem,
LoginForm,
LoginPage as PFLoginPage,
Login as PFLogin,
LoginHeader,
LoginFooter,
LoginMainHeader,
LoginMainBody,
LoginMainFooter,
Tooltip,
} from '@patternfly/react-core';
import {
AzureIcon,
GoogleIcon,
@ -23,7 +31,7 @@ import ErrorDetail from '../../components/ErrorDetail';
const loginLogoSrc = '/static/media/logo-login.svg';
const LoginPage = styled(PFLoginPage)`
const Login = styled(PFLogin)`
& .pf-c-brand {
max-height: 285px;
}
@ -112,171 +120,227 @@ function AWXLogin({ alt, i18n, isAuthenticated }) {
helperText = i18n._(t`There was a problem signing in. Please try again.`);
}
return (
<LoginPage
brandImgSrc={logo}
brandImgAlt={alt || brandName}
loginTitle={
brandName
? i18n._(t`Welcome to Ansible ${brandName}! Please Sign In.`)
: ''
}
textContent={loginInfo}
socialMediaLoginContent={
<>
{socialAuthOptions &&
Object.keys(socialAuthOptions).map(authKey => {
const loginUrl = socialAuthOptions[authKey].login_url;
if (authKey === 'azuread-oauth2') {
return (
<LoginMainFooterLinksItem href={loginUrl} key={authKey}>
<Tooltip content={i18n._(t`Sign in with Azure AD`)}>
<AzureIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'github') {
return (
<LoginMainFooterLinksItem href={loginUrl} key={authKey}>
<Tooltip content={i18n._(t`Sign in with GitHub`)}>
<GithubIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'github-org') {
return (
<LoginMainFooterLinksItem href={loginUrl} key={authKey}>
<Tooltip
content={i18n._(t`Sign in with GitHub Organizations`)}
>
<GithubIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'github-team') {
return (
<LoginMainFooterLinksItem href={loginUrl} key={authKey}>
<Tooltip content={i18n._(t`Sign in with GitHub Teams`)}>
<GithubIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'github-enterprise') {
return (
<LoginMainFooterLinksItem href={loginUrl} key={authKey}>
<Tooltip
content={i18n._(t`Sign in with GitHub Enterprise`)}
>
<GithubIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'github-enterprise-org') {
return (
<LoginMainFooterLinksItem href={loginUrl} key={authKey}>
<Tooltip
content={i18n._(
t`Sign in with GitHub Enterprise Organizations`
)}
>
<GithubIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'github-enterprise-team') {
return (
<LoginMainFooterLinksItem href={loginUrl} key={authKey}>
<Tooltip
content={i18n._(t`Sign in with GitHub Enterprise Teams`)}
>
<GithubIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'google-oauth2') {
return (
<LoginMainFooterLinksItem href={loginUrl} key={authKey}>
<Tooltip content={i18n._(t`Sign in with Google`)}>
<GoogleIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey.startsWith('saml')) {
const samlIDP = authKey.split(':')[1] || null;
return (
<LoginMainFooterLinksItem href={loginUrl} key={authKey}>
<Tooltip
content={
samlIDP
? i18n._(t`Sign in with SAML ${samlIDP}`)
: i18n._(t`Sign in with SAML`)
}
>
<UserCircleIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
const HeaderBrand = (
<Brand dataCy="brand-logo" src={logo} alt={alt || brandName} />
);
const Header = <LoginHeader headerBrand={HeaderBrand} />;
const Footer = (
<LoginFooter
dataCy="login-footer"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(loginInfo),
}}
/>
);
return null;
})}
</>
}
>
<Formik
initialValues={{
password: '',
username: '',
}}
onSubmit={handleSubmit}
>
{formik => (
<LoginForm
className={authError ? 'pf-m-error' : ''}
helperText={helperText}
isLoginButtonDisabled={isAuthenticating}
isValidPassword={!authError}
isValidUsername={!authError}
loginButtonLabel={i18n._(t`Log In`)}
onChangePassword={val => {
formik.setFieldValue('password', val);
dismissAuthError();
}}
onChangeUsername={val => {
formik.setFieldValue('username', val);
dismissAuthError();
}}
onLoginButtonClick={formik.handleSubmit}
passwordLabel={i18n._(t`Password`)}
passwordValue={formik.values.password}
showHelperText={authError}
usernameLabel={i18n._(t`Username`)}
usernameValue={formik.values.username}
/>
)}
</Formik>
{loginInfoError && (
<AlertModal
isOpen={loginInfoError}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissLoginInfoError}
return (
<Login header={Header} footer={Footer}>
<LoginMainHeader
dataCy="login-header"
title={
brandName
? i18n._(t`Welcome to Ansible ${brandName}! Please Sign In.`)
: ''
}
/>
<LoginMainBody>
<Formik
initialValues={{
password: '',
username: '',
}}
onSubmit={handleSubmit}
>
{i18n._(
t`Failed to fetch custom login configuration settings. System defaults will be shown instead.`
{formik => (
<LoginForm
dataCy="login-form"
className={authError ? 'pf-m-error' : ''}
helperText={helperText}
isLoginButtonDisabled={isAuthenticating}
isValidPassword={!authError}
isValidUsername={!authError}
loginButtonLabel={i18n._(t`Log In`)}
onChangePassword={val => {
formik.setFieldValue('password', val);
dismissAuthError();
}}
onChangeUsername={val => {
formik.setFieldValue('username', val);
dismissAuthError();
}}
onLoginButtonClick={formik.handleSubmit}
passwordLabel={i18n._(t`Password`)}
passwordValue={formik.values.password}
showHelperText={authError}
usernameLabel={i18n._(t`Username`)}
usernameValue={formik.values.username}
/>
)}
<ErrorDetail error={loginInfoError} />
</AlertModal>
)}
</LoginPage>
</Formik>
{loginInfoError && (
<AlertModal
isOpen={loginInfoError}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissLoginInfoError}
dataCy="login-info-error"
>
{i18n._(
t`Failed to fetch custom login configuration settings. System defaults will be shown instead.`
)}
<ErrorDetail error={loginInfoError} />
</AlertModal>
)}
</LoginMainBody>
<LoginMainFooter
socialMediaLoginContent={
<>
{socialAuthOptions &&
Object.keys(socialAuthOptions).map(authKey => {
const loginUrl = socialAuthOptions[authKey].login_url;
if (authKey === 'azuread-oauth2') {
return (
<LoginMainFooterLinksItem
dataCy="social-auth-azure"
href={loginUrl}
key={authKey}
>
<Tooltip content={i18n._(t`Sign in with Azure AD`)}>
<AzureIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'github') {
return (
<LoginMainFooterLinksItem
dataCy="social-auth-github"
href={loginUrl}
key={authKey}
>
<Tooltip content={i18n._(t`Sign in with GitHub`)}>
<GithubIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'github-org') {
return (
<LoginMainFooterLinksItem
dataCy="social-auth-github-org"
href={loginUrl}
key={authKey}
>
<Tooltip
content={i18n._(t`Sign in with GitHub Organizations`)}
>
<GithubIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'github-team') {
return (
<LoginMainFooterLinksItem
dataCy="social-auth-github-team"
href={loginUrl}
key={authKey}
>
<Tooltip content={i18n._(t`Sign in with GitHub Teams`)}>
<GithubIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'github-enterprise') {
return (
<LoginMainFooterLinksItem
dataCy="social-auth-github-enterprise"
href={loginUrl}
key={authKey}
>
<Tooltip
content={i18n._(t`Sign in with GitHub Enterprise`)}
>
<GithubIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'github-enterprise-org') {
return (
<LoginMainFooterLinksItem
dataCy="social-auth-github-enterprise-org"
href={loginUrl}
key={authKey}
>
<Tooltip
content={i18n._(
t`Sign in with GitHub Enterprise Organizations`
)}
>
<GithubIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'github-enterprise-team') {
return (
<LoginMainFooterLinksItem
dataCy="social-auth-github-enterprise-team"
href={loginUrl}
key={authKey}
>
<Tooltip
content={i18n._(
t`Sign in with GitHub Enterprise Teams`
)}
>
<GithubIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey === 'google-oauth2') {
return (
<LoginMainFooterLinksItem
dataCy="social-auth-google"
href={loginUrl}
key={authKey}
>
<Tooltip content={i18n._(t`Sign in with Google`)}>
<GoogleIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
if (authKey.startsWith('saml')) {
const samlIDP = authKey.split(':')[1] || null;
return (
<LoginMainFooterLinksItem
dataCy="social-auth-saml"
href={loginUrl}
key={authKey}
>
<Tooltip
content={
samlIDP
? i18n._(t`Sign in with SAML ${samlIDP}`)
: i18n._(t`Sign in with SAML`)
}
>
<UserCircleIcon />
</Tooltip>
</LoginMainFooterLinksItem>
);
}
return null;
})}
</>
}
/>
</Login>
);
}

View File

@ -24,7 +24,6 @@ describe('<Login />', () => {
async function findChildren(wrapper) {
const [
awxLogin,
loginPage,
loginForm,
usernameInput,
passwordInput,
@ -32,7 +31,6 @@ describe('<Login />', () => {
loginHeaderLogo,
] = await Promise.all([
waitForElement(wrapper, 'AWXLogin', el => el.length === 1),
waitForElement(wrapper, 'LoginPage', el => el.length === 1),
waitForElement(wrapper, 'LoginForm', el => el.length === 1),
waitForElement(
wrapper,
@ -49,7 +47,6 @@ describe('<Login />', () => {
]);
return {
awxLogin,
loginPage,
loginForm,
usernameInput,
passwordInput,
@ -61,7 +58,8 @@ describe('<Login />', () => {
beforeEach(() => {
RootAPI.read.mockResolvedValue({
data: {
custom_login_info: '',
custom_login_info:
'<div id="custom-button" onmouseover="alert()">TEST</div>',
custom_logo: 'images/foo.jpg',
},
});
@ -114,6 +112,16 @@ describe('<Login />', () => {
done();
});
test('custom login info handled correctly', async done => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => false} />);
});
await findChildren(wrapper);
expect(wrapper.find('footer').html()).toContain('<div>TEST</div>');
done();
});
test('data initialization error is properly handled', async done => {
RootAPI.read.mockRejectedValueOnce(
new Error({