Adds support for GitHub, Azure AD, Google and SAML auth to the UI

This commit is contained in:
mabashian
2020-11-30 14:00:33 -05:00
parent 8e46166313
commit 889eb2331c
6 changed files with 249 additions and 32 deletions

View File

@@ -25,7 +25,7 @@ class BaseRedirectView(RedirectView):
def get_redirect_url(self, *args, **kwargs): def get_redirect_url(self, *args, **kwargs):
last_path = self.request.COOKIES.get('lastPath', '') last_path = self.request.COOKIES.get('lastPath', '')
last_path = urllib.parse.quote(urllib.parse.unquote(last_path).strip('"')) last_path = urllib.parse.quote(urllib.parse.unquote(last_path).strip('"'))
url = reverse('ui:index') url = reverse('ui_next:index')
if last_path: if last_path:
return '%s#%s' % (url, last_path) return '%s#%s' % (url, last_path)
else: else:

View File

@@ -1,5 +1,6 @@
import AdHocCommands from './models/AdHocCommands'; import AdHocCommands from './models/AdHocCommands';
import Applications from './models/Applications'; import Applications from './models/Applications';
import Auth from './models/Auth';
import Config from './models/Config'; import Config from './models/Config';
import CredentialInputSources from './models/CredentialInputSources'; import CredentialInputSources from './models/CredentialInputSources';
import CredentialTypes from './models/CredentialTypes'; import CredentialTypes from './models/CredentialTypes';
@@ -40,6 +41,7 @@ import WorkflowJobs from './models/WorkflowJobs';
const AdHocCommandsAPI = new AdHocCommands(); const AdHocCommandsAPI = new AdHocCommands();
const ApplicationsAPI = new Applications(); const ApplicationsAPI = new Applications();
const AuthAPI = new Auth();
const ConfigAPI = new Config(); const ConfigAPI = new Config();
const CredentialInputSourcesAPI = new CredentialInputSources(); const CredentialInputSourcesAPI = new CredentialInputSources();
const CredentialTypesAPI = new CredentialTypes(); const CredentialTypesAPI = new CredentialTypes();
@@ -81,6 +83,7 @@ const WorkflowJobsAPI = new WorkflowJobs();
export { export {
AdHocCommandsAPI, AdHocCommandsAPI,
ApplicationsAPI, ApplicationsAPI,
AuthAPI,
ConfigAPI, ConfigAPI,
CredentialInputSourcesAPI, CredentialInputSourcesAPI,
CredentialTypesAPI, CredentialTypesAPI,

View File

@@ -0,0 +1,10 @@
import Base from '../Base';
class Auth extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/auth/';
}
}
export default Auth;

View File

@@ -4,9 +4,20 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Formik } from 'formik'; import { Formik } from 'formik';
import styled from 'styled-components'; import styled from 'styled-components';
import { LoginForm, LoginPage as PFLoginPage } from '@patternfly/react-core'; import {
LoginMainFooterLinksItem,
LoginForm,
LoginPage as PFLoginPage,
Tooltip,
} from '@patternfly/react-core';
import {
AzureIcon,
GoogleIcon,
GithubIcon,
UserCircleIcon,
} from '@patternfly/react-icons';
import useRequest, { useDismissableError } from '../../util/useRequest'; import useRequest, { useDismissableError } from '../../util/useRequest';
import { RootAPI } from '../../api'; import { AuthAPI, RootAPI } from '../../api';
import AlertModal from '../../components/AlertModal'; import AlertModal from '../../components/AlertModal';
import ErrorDetail from '../../components/ErrorDetail'; import ErrorDetail from '../../components/ErrorDetail';
@@ -23,7 +34,7 @@ function AWXLogin({ alt, i18n, isAuthenticated }) {
isLoading: isCustomLoginInfoLoading, isLoading: isCustomLoginInfoLoading,
error: customLoginInfoError, error: customLoginInfoError,
request: fetchCustomLoginInfo, request: fetchCustomLoginInfo,
result: { brandName, logo, loginInfo }, result: { brandName, logo, loginInfo, socialAuthOptions },
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const [ const [
@@ -33,7 +44,12 @@ function AWXLogin({ alt, i18n, isAuthenticated }) {
{ {
data: { BRAND_NAME }, data: { BRAND_NAME },
}, },
] = await Promise.all([RootAPI.read(), RootAPI.readAssetVariables()]); { data: authData },
] = await Promise.all([
RootAPI.read(),
RootAPI.readAssetVariables(),
AuthAPI.read(),
]);
const logoSrc = custom_logo const logoSrc = custom_logo
? `data:image/jpeg;${custom_logo}` ? `data:image/jpeg;${custom_logo}`
: loginLogoSrc; : loginLogoSrc;
@@ -41,9 +57,15 @@ function AWXLogin({ alt, i18n, isAuthenticated }) {
brandName: BRAND_NAME, brandName: BRAND_NAME,
logo: logoSrc, logo: logoSrc,
loginInfo: custom_login_info, loginInfo: custom_login_info,
socialAuthOptions: authData,
}; };
}, []), }, []),
{ brandName: null, logo: loginLogoSrc, loginInfo: null } {
brandName: null,
logo: loginLogoSrc,
loginInfo: null,
socialAuthOptions: {},
}
); );
const { const {
@@ -100,6 +122,79 @@ function AWXLogin({ alt, i18n, isAuthenticated }) {
: '' : ''
} }
textContent={loginInfo} 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 === '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>
);
}
return null;
})}
</>
}
> >
<Formik <Formik
initialValues={{ initialValues={{
@@ -109,30 +204,28 @@ function AWXLogin({ alt, i18n, isAuthenticated }) {
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
{formik => ( {formik => (
<> <LoginForm
<LoginForm className={authError ? 'pf-m-error' : ''}
className={authError ? 'pf-m-error' : ''} helperText={helperText}
helperText={helperText} isLoginButtonDisabled={isAuthenticating}
isLoginButtonDisabled={isAuthenticating} isValidPassword={!authError}
isValidPassword={!authError} isValidUsername={!authError}
isValidUsername={!authError} loginButtonLabel={i18n._(t`Log In`)}
loginButtonLabel={i18n._(t`Log In`)} onChangePassword={val => {
onChangePassword={val => { formik.setFieldValue('password', val);
formik.setFieldValue('password', val); dismissAuthError();
dismissAuthError(); }}
}} onChangeUsername={val => {
onChangeUsername={val => { formik.setFieldValue('username', val);
formik.setFieldValue('username', val); dismissAuthError();
dismissAuthError(); }}
}} onLoginButtonClick={formik.handleSubmit}
onLoginButtonClick={formik.handleSubmit} passwordLabel={i18n._(t`Password`)}
passwordLabel={i18n._(t`Password`)} passwordValue={formik.values.password}
passwordValue={formik.values.password} showHelperText={authError}
showHelperText={authError} usernameLabel={i18n._(t`Username`)}
usernameLabel={i18n._(t`Username`)} usernameValue={formik.values.username}
usernameValue={formik.values.username} />
/>
</>
)} )}
</Formik> </Formik>
{loginInfoError && ( {loginInfoError && (

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { RootAPI } from '../../api'; import { AuthAPI, RootAPI } from '../../api';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -16,6 +16,10 @@ RootAPI.readAssetVariables.mockResolvedValue({
}, },
}); });
AuthAPI.read.mockResolvedValue({
data: {},
});
describe('<Login />', () => { describe('<Login />', () => {
async function findChildren(wrapper) { async function findChildren(wrapper) {
const [ const [
@@ -268,4 +272,111 @@ describe('<Login />', () => {
await waitForElement(wrapper, 'Redirect', el => el.props().to === '/'); await waitForElement(wrapper, 'Redirect', el => el.props().to === '/');
done(); done();
}); });
test('GitHub auth buttons shown', async done => {
AuthAPI.read.mockResolvedValue({
data: {
github: {
login_url: '/sso/login/github/',
complete_url: 'https://localhost:8043/sso/complete/github/',
},
'github-org': {
login_url: '/sso/login/github-org/',
complete_url: 'https://localhost:8043/sso/complete/github-org/',
},
'github-team': {
login_url: '/sso/login/github-team/',
complete_url: 'https://localhost:8043/sso/complete/github-team/',
},
},
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => false} />);
});
wrapper.update();
expect(wrapper.find('GithubIcon').length).toBe(3);
expect(wrapper.find('AzureIcon').length).toBe(0);
expect(wrapper.find('GoogleIcon').length).toBe(0);
expect(wrapper.find('UserCircleIcon').length).toBe(0);
done();
});
test('Google auth button shown', async done => {
AuthAPI.read.mockResolvedValue({
data: {
'google-oauth2': {
login_url: '/sso/login/google-oauth2/',
complete_url: 'https://localhost:8043/sso/complete/google-oauth2/',
},
},
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => false} />);
});
wrapper.update();
expect(wrapper.find('GithubIcon').length).toBe(0);
expect(wrapper.find('AzureIcon').length).toBe(0);
expect(wrapper.find('GoogleIcon').length).toBe(1);
expect(wrapper.find('UserCircleIcon').length).toBe(0);
done();
});
test('Azure AD auth button shown', async done => {
AuthAPI.read.mockResolvedValue({
data: {
'azuread-oauth2': {
login_url: '/sso/login/azuread-oauth2/',
complete_url: 'https://localhost:8043/sso/complete/azuread-oauth2/',
},
},
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => false} />);
});
wrapper.update();
expect(wrapper.find('GithubIcon').length).toBe(0);
expect(wrapper.find('AzureIcon').length).toBe(1);
expect(wrapper.find('GoogleIcon').length).toBe(0);
expect(wrapper.find('UserCircleIcon').length).toBe(0);
done();
});
test('SAML auth buttons shown', async done => {
AuthAPI.read.mockResolvedValue({
data: {
saml: {
login_url: '/sso/login/saml/',
complete_url: 'https://localhost:8043/sso/complete/saml/',
metadata_url: '/sso/metadata/saml/',
},
'saml:onelogin': {
login_url: '/sso/login/saml/?idp=onelogin',
complete_url: 'https://localhost:8043/sso/complete/saml/',
metadata_url: '/sso/metadata/saml/',
},
'saml:someotheridp': {
login_url: '/sso/login/saml/?idp=someotheridp',
complete_url: 'https://localhost:8043/sso/complete/saml/',
metadata_url: '/sso/metadata/saml/',
},
},
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => false} />);
});
wrapper.update();
expect(wrapper.find('GithubIcon').length).toBe(0);
expect(wrapper.find('AzureIcon').length).toBe(0);
expect(wrapper.find('GoogleIcon').length).toBe(0);
expect(wrapper.find('UserCircleIcon').length).toBe(3);
done();
});
}); });

View File

@@ -4,7 +4,7 @@ const TARGET = process.env.TARGET || 'https://localhost:8043';
module.exports = app => { module.exports = app => {
app.use( app.use(
createProxyMiddleware(['/api', '/websocket'], { createProxyMiddleware(['/api', '/websocket', '/sso'], {
target: TARGET, target: TARGET,
secure: false, secure: false,
ws: true, ws: true,