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):
last_path = self.request.COOKIES.get('lastPath', '')
last_path = urllib.parse.quote(urllib.parse.unquote(last_path).strip('"'))
url = reverse('ui:index')
url = reverse('ui_next:index')
if last_path:
return '%s#%s' % (url, last_path)
else:

View File

@ -1,5 +1,6 @@
import AdHocCommands from './models/AdHocCommands';
import Applications from './models/Applications';
import Auth from './models/Auth';
import Config from './models/Config';
import CredentialInputSources from './models/CredentialInputSources';
import CredentialTypes from './models/CredentialTypes';
@ -40,6 +41,7 @@ import WorkflowJobs from './models/WorkflowJobs';
const AdHocCommandsAPI = new AdHocCommands();
const ApplicationsAPI = new Applications();
const AuthAPI = new Auth();
const ConfigAPI = new Config();
const CredentialInputSourcesAPI = new CredentialInputSources();
const CredentialTypesAPI = new CredentialTypes();
@ -81,6 +83,7 @@ const WorkflowJobsAPI = new WorkflowJobs();
export {
AdHocCommandsAPI,
ApplicationsAPI,
AuthAPI,
ConfigAPI,
CredentialInputSourcesAPI,
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 { Formik } from 'formik';
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 { RootAPI } from '../../api';
import { AuthAPI, RootAPI } from '../../api';
import AlertModal from '../../components/AlertModal';
import ErrorDetail from '../../components/ErrorDetail';
@ -23,7 +34,7 @@ function AWXLogin({ alt, i18n, isAuthenticated }) {
isLoading: isCustomLoginInfoLoading,
error: customLoginInfoError,
request: fetchCustomLoginInfo,
result: { brandName, logo, loginInfo },
result: { brandName, logo, loginInfo, socialAuthOptions },
} = useRequest(
useCallback(async () => {
const [
@ -33,7 +44,12 @@ function AWXLogin({ alt, i18n, isAuthenticated }) {
{
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
? `data:image/jpeg;${custom_logo}`
: loginLogoSrc;
@ -41,9 +57,15 @@ function AWXLogin({ alt, i18n, isAuthenticated }) {
brandName: BRAND_NAME,
logo: logoSrc,
loginInfo: custom_login_info,
socialAuthOptions: authData,
};
}, []),
{ brandName: null, logo: loginLogoSrc, loginInfo: null }
{
brandName: null,
logo: loginLogoSrc,
loginInfo: null,
socialAuthOptions: {},
}
);
const {
@ -100,6 +122,79 @@ function AWXLogin({ alt, i18n, isAuthenticated }) {
: ''
}
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
initialValues={{
@ -109,30 +204,28 @@ function AWXLogin({ alt, i18n, isAuthenticated }) {
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}
/>
</>
<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 && (

View File

@ -1,6 +1,6 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { RootAPI } from '../../api';
import { AuthAPI, RootAPI } from '../../api';
import {
mountWithContexts,
waitForElement,
@ -16,6 +16,10 @@ RootAPI.readAssetVariables.mockResolvedValue({
},
});
AuthAPI.read.mockResolvedValue({
data: {},
});
describe('<Login />', () => {
async function findChildren(wrapper) {
const [
@ -268,4 +272,111 @@ describe('<Login />', () => {
await waitForElement(wrapper, 'Redirect', el => el.props().to === '/');
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 => {
app.use(
createProxyMiddleware(['/api', '/websocket'], {
createProxyMiddleware(['/api', '/websocket', '/sso'], {
target: TARGET,
secure: false,
ws: true,