mirror of
https://github.com/ansible/awx.git
synced 2026-03-09 13:39:27 -02:30
Adds support for GitHub, Azure AD, Google and SAML auth to the UI
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
10
awx/ui_next/src/api/models/Auth.js
Normal file
10
awx/ui_next/src/api/models/Auth.js
Normal 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;
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user