web UI: user login with OpenID Connect

Under the hood, this uses AuthProvider as supplied by oidc-react.
Most of the theory is explained in the comment in ZuulAuthProvider.jsx

The benefit of doing this is that we allow the AuthProvider and
userManager to handle the callback logic, so we don't need to
handle the callback logic ourselves.  A callback page is still required
though in order to deal with the parameters passed in a successful
redirection from the Identity Provider.

The challenge in using these classes as-is is that our authority
endpoints (eg, the IDP itself) may change from one tenant to
the next; these classes aren't set up for that.  So we need to be
careful about how and when we change those authority URLs.

In terms of functionalities: if the default realm's authentication driver
is set to "OpenIDConnect", display a "Sign in" button. If the the user
is logged in, redirect to the last page visited prior to logging in;
fetch user authorizations and add them to the redux store; display the
user's preferred username in the upper right corner. Clicking on the
user icon in the right corner displays a modal with user information
such as the user's zuul-client configuration, and a sign out button.

Clicking on the sign out button removes user information from the
store (note that it does not log the user out from the Identity Provider).

Add some basic documentation explaining how to configure Zuul with
Google's authentication, and with a Keycloak server.

(This squashes https://review.opendev.org/c/zuul/zuul/+/816208 into
https://review.opendev.org/c/zuul/zuul/+/734082 )

Co-authored-by: James E. Blair <jim@acmegating.com>

Change-Id: I31e71f2795f3f7c4253d0d5b8ed309bfd7d4f98e
This commit is contained in:
Matthieu Huin
2021-11-09 15:27:38 +01:00
parent 59af3e2c17
commit b13ff51dda
25 changed files with 1045 additions and 46 deletions

View File

@@ -58,6 +58,7 @@ import {
UsersIcon,
} from '@patternfly/react-icons'
import AuthContainer from './containers/auth/Auth'
import ErrorBoundary from './containers/ErrorBoundary'
import { Fetching } from './containers/Fetching'
import SelectTz from './containers/timezone/SelectTz'
@@ -67,6 +68,7 @@ import { clearError } from './actions/errors'
import { fetchConfigErrorsAction } from './actions/configErrors'
import { routes } from './routes'
import { setTenantAction } from './actions/tenant'
import { configureAuthFromTenant, configureAuthFromInfo } from './actions/auth'
class App extends React.Component {
static propTypes = {
@@ -79,6 +81,7 @@ class App extends React.Component {
history: PropTypes.object,
dispatch: PropTypes.func,
isKebabDropdownOpen: PropTypes.bool,
user: PropTypes.object,
}
state = {
@@ -106,7 +109,7 @@ class App extends React.Component {
)
} else {
// Return an empty navigation bar in case we don't have an active tenant
return <Nav aria-label="Nav" variant="horizontal"/>
return <Nav aria-label="Nav" variant="horizontal" />
}
}
@@ -165,7 +168,7 @@ class App extends React.Component {
whiteLabel = false
const match = matchPath(
this.props.location.pathname, {path: '/t/:tenant'})
this.props.location.pathname, { path: '/t/:tenant' })
if (match) {
tenantName = match.params.tenant
@@ -177,6 +180,14 @@ class App extends React.Component {
this.props.dispatch(tenantAction)
if (tenantName) {
this.props.dispatch(fetchConfigErrorsAction(tenantAction.tenant))
if (whiteLabel) {
// The app info endpoint was already a tenant info
// endpoint, so auth info was already provided.
this.props.dispatch(configureAuthFromInfo(info))
} else {
// Query the tenant info endpoint for auth info.
this.props.dispatch(configureAuthFromTenant(tenantName))
}
}
}
}
@@ -231,7 +242,7 @@ class App extends React.Component {
<TimedToastNotification
key={error.id}
type='error'
onDismiss={() => {this.props.dispatch(clearError(error.id))}}
onDismiss={() => { this.props.dispatch(clearError(error.id)) }}
>
<span title={moment.utc(error.date).tz(this.props.timezone).format()}>
<strong>{error.text}</strong> ({error.status})&nbsp;
@@ -263,14 +274,14 @@ class App extends React.Component {
variant="danger"
onClick={() => {
history.push(this.props.tenant.linkPrefix + '/config-errors')
this.setState({showErrors: false})
this.setState({ showErrors: false })
}}
>
<NotificationDrawerListItemHeader
title={item.source_context.project + ' | ' + ctxPath}
variant="danger" />
<NotificationDrawerListItemBody>
<pre style={{whiteSpace: 'pre-wrap'}}>
<pre style={{ whiteSpace: 'pre-wrap' }}>
{error}
</pre>
</NotificationDrawerListItemBody>
@@ -402,14 +413,16 @@ class App extends React.Component {
aria-label="Notifications"
onClick={(e) => {
e.preventDefault()
this.setState({showErrors: !this.state.showErrors})
this.setState({ showErrors: !this.state.showErrors })
}}
>
<BellIcon />
</NotificationBadge>
}
<SelectTz/>
<ConfigModal/>
<SelectTz />
<ConfigModal />
{tenant.name && (<AuthContainer />)}
</PageHeaderTools>
)
@@ -418,7 +431,7 @@ class App extends React.Component {
const pageHeader = (
<PageHeader
logo={<Brand src={logo} alt='Zuul logo' className="zuul-brand" />}
logoProps={{to: logoUrl}}
logoProps={{ to: logoUrl }}
logoComponent={Link}
headerTools={pageHeaderTools}
topNav={nav}
@@ -446,6 +459,7 @@ export default withRouter(connect(
configErrors: state.configErrors,
info: state.info,
tenant: state.tenant,
timezone: state.timezone
timezone: state.timezone,
user: state.user
})
)(App))

View File

@@ -23,6 +23,7 @@ import configureStore from './store'
import App from './App'
import TenantsPage from './pages/Tenants'
import StatusPage from './pages/Status'
import ZuulAuthProvider from './ZuulAuthProvider'
import * as api from './api'
api.fetchInfo = jest.fn()
@@ -35,7 +36,10 @@ api.fetchConfigErrors.mockImplementation(() => Promise.resolve({data: []}))
it('renders without crashing', () => {
const store = configureStore()
const div = document.createElement('div')
ReactDOM.render(<Provider store={store}><Router><App /></Router></Provider>,
ReactDOM.render(
<Provider store={store}>
<ZuulAuthProvider><Router><App /></Router></ZuulAuthProvider>
</Provider>,
div)
ReactDOM.unmountComponentAtNode(div)
})
@@ -52,7 +56,9 @@ it('renders multi tenant', async () => {
)
const application = create(
<Provider store={store}><Router><App /></Router></Provider>
<Provider store={store}>
<ZuulAuthProvider><Router><App /></Router></ZuulAuthProvider>
</Provider>
)
await act(async () => {
@@ -88,7 +94,9 @@ it('renders single tenant', async () => {
)
const application = create(
<Provider store={store}><Router><App /></Router></Provider>
<Provider store={store}>
<ZuulAuthProvider><Router><App /></Router></ZuulAuthProvider>
</Provider>
)
await act(async () => {

View File

@@ -0,0 +1,76 @@
// Copyright 2020 Red Hat, Inc
// Copyright 2021 Acme Gating, LLC
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import * as React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { AuthProvider } from 'oidc-react'
import { userLoggedIn, userLoggedOut } from './actions/user'
class ZuulAuthProvider extends React.Component {
/*
This wraps the oidc-react AuthProvider and supplies the necessary
information as props.
The oidc-react AuthProvider is not really meant to be reconstructed
frequently. Calling render multiple times (even if nothing actually
changes) during a login can cause multiple AuthProviders to be created
which can interfere with the login process.
We connect this class to state.auth.auth_params, so make sure that isn't
updated unless the OIDC parameters are actually changed.
If they are changed, then we will create a new AuthProvider with the
new parameters. Save those parameters in local storage so that when
we return from the IDP redirect, an AuthProvider with the same
configuration is created.
*/
static propTypes = {
auth_params: PropTypes.object,
dispatch: PropTypes.func,
children: PropTypes.any,
}
render() {
const { auth_params } = this.props
console.debug('ZuulAuthProvider rendering with params', auth_params)
const oidcConfig = {
onSignIn: async (user) => {
this.props.dispatch(userLoggedIn(user))
},
onSignOut: async () => {
this.props.dispatch(userLoggedOut())
},
responseType: 'token id_token',
autoSignIn: false,
...auth_params,
}
return (
<React.Fragment>
<AuthProvider {...oidcConfig} key={JSON.stringify(auth_params)}>
{this.props.children}
</AuthProvider>
</React.Fragment>
)
}
}
export default connect(state => ({
auth_params: state.auth.auth_params,
}))(ZuulAuthProvider)

85
web/src/actions/auth.js Normal file
View File

@@ -0,0 +1,85 @@
// Copyright 2020 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import * as API from '../api'
export const AUTH_CONFIG_REQUEST = 'AUTH_CONFIG_REQUEST'
export const AUTH_CONFIG_SUCCESS = 'AUTH_CONFIG_SUCCESS'
export const AUTH_CONFIG_FAIL = 'AUTH_CONFIG_FAIL'
export const USER_ACL_REQUEST = 'USER_ACL_REQUEST'
export const USER_ACL_SUCCESS = 'USER_ACL_SUCCESS'
export const USER_ACL_FAIL = 'USER_ACL_FAIL'
export const AUTH_START = 'AUTH_START'
const authConfigRequest = () => ({
type: AUTH_CONFIG_REQUEST
})
function createAuthParamsFromJson(json) {
let auth_info = json.info.capabilities.auth
let auth_params = {
authority: '',
clientId: '',
scope: '',
}
if (!auth_info) {
console.log('No auth config')
return auth_params
}
const realm = auth_info.default_realm
const client_config = auth_info.realms[realm]
if (client_config.driver === 'OpenIDConnect') {
auth_params.clientId = client_config.client_id
auth_params.scope = client_config.scope
auth_params.authority = client_config.authority
return auth_params
} else {
console.log('No OpenIDConnect provider found')
return auth_params
}
}
const authConfigSuccess = (json, auth_params) => ({
type: AUTH_CONFIG_SUCCESS,
info: json.info,
auth_params: auth_params,
})
const authConfigFail = error => ({
type: AUTH_CONFIG_FAIL,
error
})
export const configureAuthFromTenant = (tenantName) => (dispatch) => {
dispatch(authConfigRequest())
return API.fetchTenantInfo('tenant/' + tenantName + '/')
.then(response => {
dispatch(authConfigSuccess(
response.data,
createAuthParamsFromJson(response.data)))
})
.catch(error => {
dispatch(authConfigFail(error))
})
}
export const configureAuthFromInfo = (info) => (dispatch) => {
dispatch(authConfigSuccess(
{info: info},
createAuthParamsFromJson({info: info})))
}

View File

@@ -25,6 +25,7 @@ export const fetchInfoRequest = () => ({
export const fetchInfoSuccess = json => ({
type: INFO_FETCH_SUCCESS,
tenant: json.info.tenant,
capabilities: json.info.capabilities,
})
const fetchInfoFail = error => ({
@@ -35,7 +36,9 @@ const fetchInfoFail = error => ({
const fetchInfo = () => dispatch => {
dispatch(fetchInfoRequest())
return API.fetchInfo()
.then(response => dispatch(fetchInfoSuccess(response.data)))
.then(response => {
dispatch(fetchInfoSuccess(response.data))
})
.catch(error => {
dispatch(fetchInfoFail(error))
setTimeout(() => {dispatch(fetchInfo())}, 5000)

72
web/src/actions/user.js Normal file
View File

@@ -0,0 +1,72 @@
// Copyright 2020 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import * as API from '../api'
import { USER_ACL_FAIL, USER_ACL_REQUEST, USER_ACL_SUCCESS } from './auth'
export const USER_LOGGED_IN = 'USER_LOGGED_IN'
export const USER_LOGGED_OUT = 'USER_LOGGED_OUT'
// Access tokens are not necessary JWTs (Google OAUTH uses a custom format)
// check the access token, if it isn't a JWT, use the ID token
export function getToken(user) {
try {
JSON.parse(atob(user.access_token.split('.')[1]))
return user.access_token
} catch (e) {
return user.id_token
}
}
export const fetchUserACLRequest = (tenant) => ({
type: USER_ACL_REQUEST,
tenant: tenant,
})
export const userLoggedIn = (user) => (dispatch) => {
dispatch({
type: USER_LOGGED_IN,
user: user,
token: getToken(user),
})
}
export const userLoggedOut = () => (dispatch) => {
dispatch({
type: USER_LOGGED_OUT,
})
}
const fetchUserACLSuccess = (json) => ({
type: USER_ACL_SUCCESS,
isAdmin: json.zuul.admin,
scope: json.zuul.scope,
})
const fetchUserACLFail = error => ({
type: USER_ACL_FAIL,
error
})
export const fetchUserACL = (tenant, user) => (dispatch) => {
dispatch(fetchUserACLRequest(tenant))
let apiPrefix = 'tenant/' + tenant + '/'
return API.fetchUserAuthorizations(apiPrefix, user.token)
.then(response => dispatch(fetchUserACLSuccess(response.data)))
.catch(error => {
dispatch(fetchUserACLFail(error))
})
}

View File

@@ -14,7 +14,7 @@
import Axios from 'axios'
function getHomepageUrl (url) {
function getHomepageUrl(url) {
//
// Discover serving location from href.
//
@@ -64,14 +64,14 @@ function getHomepageUrl (url) {
if (baseUrl.includes('/t/')) {
baseUrl = baseUrl.slice(0, baseUrl.lastIndexOf('/t/') + 1)
}
if (! baseUrl.endsWith('/')) {
if (!baseUrl.endsWith('/')) {
baseUrl = baseUrl + '/'
}
// console.log('Homepage url is ', baseUrl)
return baseUrl
}
function getZuulUrl () {
function getZuulUrl() {
// Return the zuul root api absolute url
const ZUUL_API = process.env.REACT_APP_ZUUL_API
let apiUrl
@@ -81,12 +81,12 @@ function getZuulUrl () {
apiUrl = ZUUL_API
} else {
// Api url is relative to homepage path
apiUrl = getHomepageUrl () + 'api/'
apiUrl = getHomepageUrl() + 'api/'
}
if (! apiUrl.endsWith('/')) {
if (!apiUrl.endsWith('/')) {
apiUrl = apiUrl + '/'
}
if (! apiUrl.endsWith('/api/')) {
if (!apiUrl.endsWith('/api/')) {
apiUrl = apiUrl + 'api/'
}
// console.log('Api url is ', apiUrl)
@@ -95,7 +95,7 @@ function getZuulUrl () {
const apiUrl = getZuulUrl()
function getStreamUrl (apiPrefix) {
function getStreamUrl(apiPrefix) {
const streamUrl = (apiUrl + apiPrefix)
.replace(/(http)(s)?:\/\//, 'ws$2://') + 'console-stream'
// console.log('Stream url is ', streamUrl)
@@ -103,7 +103,7 @@ function getStreamUrl (apiPrefix) {
}
// Direct APIs
function fetchInfo () {
function fetchInfo() {
return Axios.get(apiUrl + 'info')
}
@@ -111,60 +111,76 @@ function fetchComponents() {
return Axios.get(apiUrl + 'components')
}
function fetchOpenApi () {
return Axios.get(getHomepageUrl () + 'openapi.yaml')
function fetchTenantInfo(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'info')
}
function fetchTenants () {
function fetchOpenApi() {
return Axios.get(getHomepageUrl() + 'openapi.yaml')
}
function fetchTenants() {
return Axios.get(apiUrl + 'tenants')
}
function fetchConfigErrors (apiPrefix) {
function fetchConfigErrors(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'config-errors')
}
function fetchStatus (apiPrefix) {
function fetchStatus(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'status')
}
function fetchChangeStatus (apiPrefix, changeId) {
function fetchChangeStatus(apiPrefix, changeId) {
return Axios.get(apiUrl + apiPrefix + 'status/change/' + changeId)
}
function fetchBuild (apiPrefix, buildId) {
function fetchBuild(apiPrefix, buildId) {
return Axios.get(apiUrl + apiPrefix + 'build/' + buildId)
}
function fetchBuilds (apiPrefix, queryString) {
function fetchBuilds(apiPrefix, queryString) {
let path = 'builds'
if (queryString) {
path += '?' + queryString.slice(1)
}
return Axios.get(apiUrl + apiPrefix + path)
}
function fetchBuildset (apiPrefix, buildsetId) {
function fetchBuildset(apiPrefix, buildsetId) {
return Axios.get(apiUrl + apiPrefix + 'buildset/' + buildsetId)
}
function fetchBuildsets (apiPrefix, queryString) {
function fetchBuildsets(apiPrefix, queryString) {
let path = 'buildsets'
if (queryString) {
path += '?' + queryString.slice(1)
}
return Axios.get(apiUrl + apiPrefix + path)
}
function fetchProject (apiPrefix, projectName) {
function fetchProject(apiPrefix, projectName) {
return Axios.get(apiUrl + apiPrefix + 'project/' + projectName)
}
function fetchProjects (apiPrefix) {
function fetchProjects(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'projects')
}
function fetchJob (apiPrefix, jobName) {
function fetchJob(apiPrefix, jobName) {
return Axios.get(apiUrl + apiPrefix + 'job/' + jobName)
}
function fetchJobs (apiPrefix) {
function fetchJobs(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'jobs')
}
function fetchLabels (apiPrefix) {
function fetchLabels(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'labels')
}
function fetchNodes (apiPrefix) {
function fetchNodes(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'nodes')
}
// token-protected API
function fetchUserAuthorizations(apiPrefix, token) {
// Axios.defaults.headers.common['Authorization'] = 'Bearer ' + token
const instance = Axios.create({
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.get(apiPrefix + 'authorizations')
.catch(err => { console.log('An error occurred', err) })
// Axios.defaults.headers.common['Authorization'] = ''
return res
}
export {
apiUrl,
getHomepageUrl,
@@ -186,4 +202,6 @@ export {
fetchTenants,
fetchInfo,
fetchComponents,
fetchTenantInfo,
fetchUserAuthorizations,
}

View File

@@ -0,0 +1,242 @@
// Copyright 2020 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import {
Accordion,
AccordionItem,
AccordionToggle,
AccordionContent,
Button,
ButtonVariant,
ClipboardCopy,
ClipboardCopyVariant,
Modal,
ModalVariant
} from '@patternfly/react-core'
import {
UserIcon,
SignInAltIcon,
SignOutAltIcon,
HatWizardIcon
} from '@patternfly/react-icons'
import * as moment from 'moment'
import { apiUrl } from '../../api'
import { fetchUserACL } from '../../actions/user'
import { withAuth } from 'oidc-react'
import { getHomepageUrl } from '../../api'
import { userLoggedIn, userLoggedOut } from '../../actions/user'
class AuthContainer extends React.Component {
static propTypes = {
user: PropTypes.object,
tenant: PropTypes.object,
dispatch: PropTypes.func.isRequired,
timezone: PropTypes.string.isRequired,
info: PropTypes.object,
auth: PropTypes.object,
// Props coming from withAuth
signIn: PropTypes.func,
signOut: PropTypes.func,
userData: PropTypes.object,
}
constructor(props) {
super(props)
this.state = {
isModalOpen: false,
showZuulClientConfig: false,
}
this.handleModalToggle = () => {
this.setState(({ isModalOpen }) => ({
isModalOpen: !isModalOpen
}))
}
this.handleConfigToggle = () => {
this.setState(({ showZuulClientConfig }) => ({
showZuulClientConfig: !showZuulClientConfig
}))
}
}
componentDidMount() {
const { user, userData } = this.props
// Make sure redux is synced with the userManager
const now = Date.now() / 1000
if (userData && userData.expires_at < now) {
console.log('Token expired, logging out')
this.props.signOut()
this.props.dispatch(userLoggedOut())
} else if (user.data !== userData) {
console.log('Restoring login from userManager')
this.props.dispatch(userLoggedIn(userData))
}
}
componentDidUpdate() {
const { user, tenant } = this.props
// Make sure the token is current and the tenant is up to date.
const now = Date.now() / 1000
if (user.data && user.data.expires_at < now) {
console.log('Token expired, logging out')
this.props.signOut()
} else if (user.data && user.tenant !== tenant.name) {
console.log('Refreshing ACL', user.tenant, tenant.name)
this.props.dispatch(fetchUserACL(tenant.name, user))
}
}
ZuulClientConfig() {
const { user, tenant } = this.props
let ZCconfig
ZCconfig = '[' + tenant.name + ']\n'
ZCconfig = ZCconfig + 'url=' + apiUrl.slice(0, -4) + '\n'
ZCconfig = ZCconfig + 'tenant=' + tenant.name + '\n'
ZCconfig = ZCconfig + 'auth_token=' + user.token + '\n'
return ZCconfig
}
renderModal() {
const { user, tenant, timezone } = this.props
const { isModalOpen, showZuulClientConfig } = this.state
let config = this.ZuulClientConfig(tenant, user.data)
let valid_until = moment.unix(user.data.expires_at).tz(timezone).format('YYYY-MM-DD HH:mm:ss')
return (
<React.Fragment>
<Modal
variant={ModalVariant.small}
title="User Info"
isOpen={isModalOpen}
onClose={this.handleModalToggle}
actions={[
<Button
key="SignOut"
variant="primary"
onClick={() => {
this.props.signOut()
}}
title="Note that you will be logged out of Zuul, but not out of your identity provider.">
Sign Out &nbsp;
<SignOutAltIcon title='Sign Out' />
</Button>
]}
>
<div>
<p key="user">Name: <strong>{user.data.profile.name}</strong></p>
<p key="preferred_username">Logged in as: <strong>{user.data.profile.preferred_username}</strong>&nbsp;
{(user.isAdmin && user.scope.indexOf(tenant.name) !== -1) && (
<HatWizardIcon title='This user can perform admin tasks' />
)}</p>
<Accordion asDefinitionList>
<AccordionItem>
<AccordionToggle
onClick={this.handleConfigToggle}
isExpanded={showZuulClientConfig}
title='Configuration parameters that can be used to perform tasks with the CLI'
id="ZCConfig">
Show Zuul Client Config
</AccordionToggle>
<AccordionContent
isHidden={!showZuulClientConfig}>
<ClipboardCopy isCode isReadOnly variant={ClipboardCopyVariant.expansion}>{config}</ClipboardCopy>
</AccordionContent>
</AccordionItem>
</Accordion>
<p key="valid_until">Token expiry date: <strong>{valid_until}</strong></p>
<p key="footer">
Zuul stores and uses information such as your username
and your email to provide some features. This data is
stored <strong>in your browser only</strong> and is
discarded once you log out.
</p>
</div>
</Modal>
</React.Fragment>
)
}
renderButton(containerStyles) {
const { user } = this.props
if (!user.data) {
return (
<div style={containerStyles}>
<Button
key="SignIn"
variant={ButtonVariant.plain}
onClick={() => {
const redirect_target = window.location.href.slice(getHomepageUrl().length)
localStorage.setItem('zuul_auth_redirect', redirect_target)
this.props.signIn({ redirect_uri: getHomepageUrl() + 'auth_callback' })
}}>
Sign in &nbsp;
<SignInAltIcon title='Sign In' />
</Button>
</div>
)
} else {
return (user.data.isFetching ? <div style={containerStyles}>Loading...</div> :
<div style={containerStyles}>
{this.renderModal()}
<Button
variant={ButtonVariant.plain}
key="userinfo"
onClick={this.handleModalToggle}>
<UserIcon title='User details' />
&nbsp;{user.data.profile.preferred_username}&nbsp;
</Button>
</div>
)
}
}
render() {
const { info, auth } = this.props
const textColor = '#d1d1d1'
const containerStyles = {
color: textColor,
border: 'solid #2b2b2b',
borderWidth: '0 0 0 1px',
display: 'initial',
padding: '6px'
}
if (info.isFetching) {
return (<><div style={containerStyles}>Fetching auth info ...</div></>)
}
if (auth.info) {
return this.renderButton(containerStyles)
} else {
return (<div style={containerStyles} title="Authentication disabled">-</div>)
}
}
}
export default connect(state => ({
auth: state.auth,
user: state.user,
tenant: state.tenant,
timezone: state.timezone,
info: state.info,
}))(withAuth(AuthContainer))

View File

@@ -90,7 +90,7 @@ class ConfigModal extends React.Component {
]}
>
<div>
<p key="info">User configurable settings are saved in browser local storage only.</p>
<p key="info">Application settings are saved in browser local storage only. They are applied whether authenticated or not.</p>
<Switch
key="autoreload"
id="autoreload"

View File

@@ -46,6 +46,7 @@ import App from './App'
// style attributes of PF4 component (as their CSS is loaded when the component
// is imported within the App).
import './index.css'
import ZuulAuthProvider from './ZuulAuthProvider'
const store = configureStore()
@@ -54,6 +55,8 @@ store.dispatch(fetchInfoIfNeeded())
ReactDOM.render(
<Provider store={store}>
<Router basename={new URL(getHomepageUrl()).pathname}><App /></Router>
<ZuulAuthProvider>
<Router basename={new URL(getHomepageUrl()).pathname}><App /></Router>
</ZuulAuthProvider>
</Provider>, document.getElementById('root'))
registerServiceWorker()

View File

@@ -0,0 +1,41 @@
// Copyright 2020 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import React, { useEffect } from 'react'
import { useHistory } from 'react-router-dom'
import { Fetching } from '../containers/Fetching'
// Several pages use the location hash in a way that would be
// difficult to disentangle from the OIDC callback parameters. This
// dedicated callback page accepts the OIDC params and then internally
// redirects to the page we saved before redirecting to the IDP.
function AuthCallbackPage() {
let history = useHistory()
useEffect(() => {
const redirect = localStorage.getItem('zuul_auth_redirect')
history.push(redirect)
}, [history])
return (
<>
<div>Login successful. You will be redirected shortly...</div>
<Fetching />
</>
)
}
export default AuthCallbackPage

76
web/src/reducers/auth.js Normal file
View File

@@ -0,0 +1,76 @@
// Copyright 2020 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import {
AUTH_CONFIG_REQUEST,
AUTH_CONFIG_SUCCESS,
AUTH_CONFIG_FAIL,
} from '../actions/auth'
// Load the defaults from local storage if it exists so that we
// construct the same AuthProvider we had before we navigated to the
// IDP redirect.
const stored_params = localStorage.getItem('zuul_auth_params')
let auth_params = {
authority: '',
clientId: '',
scope: '',
}
if (stored_params !== null) {
auth_params = JSON.parse(stored_params)
}
export default (state = {
isFetching: false,
info: null,
auth_params: auth_params,
}, action) => {
const json_params = JSON.stringify(action.auth_params)
switch (action.type) {
case AUTH_CONFIG_REQUEST:
return {
...state,
isFetching: true,
info: null,
}
case AUTH_CONFIG_SUCCESS:
// Make sure we only update the auth_params object if something actually
// changes. Otherwise, it will re-create the AuthProvider which
// may cause errors with auth state if it happens concurrently with
// a login.
if (json_params === JSON.stringify(state.auth_params)) {
return {
...state,
isFetching: false,
info: action.info,
}
} else {
localStorage.setItem('zuul_auth_params', json_params)
return {
...state,
isFetching: false,
info: action.info,
auth_params: action.auth_params,
}
}
case AUTH_CONFIG_FAIL:
return {
...state,
isFetching: false,
info: null,
}
default:
return state
}
}

View File

@@ -14,6 +14,7 @@
import { combineReducers } from 'redux'
import auth from './auth'
import configErrors from './configErrors'
import change from './change'
import component from './component'
@@ -33,8 +34,10 @@ import status from './status'
import tenant from './tenant'
import tenants from './tenants'
import timezone from './timezone'
import user from './user'
const reducers = {
auth,
build,
change,
component,
@@ -54,6 +57,7 @@ const reducers = {
tenants,
timezone,
preferences,
user,
}
export default combineReducers(reducers)

View File

@@ -21,6 +21,7 @@ import {
export default (state = {
isFetching: false,
tenant: null,
capabilities: null,
}, action) => {
switch (action.type) {
case INFO_FETCH_REQUEST:
@@ -33,6 +34,7 @@ export default (state = {
return {
isFetching: false,
tenant: action.tenant,
capabilities: action.capabilities,
ready: true
}
default:

View File

@@ -26,4 +26,6 @@ export default {
isFetching: false,
url: null,
},
auth: {},
user: {},
}

74
web/src/reducers/user.js Normal file
View File

@@ -0,0 +1,74 @@
// Copyright 2020 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import {
USER_LOGGED_IN,
USER_LOGGED_OUT,
} from '../actions/user'
import {
USER_ACL_REQUEST,
USER_ACL_SUCCESS,
USER_ACL_FAIL,
} from '../actions/auth'
export default (state = {
isFetching: false,
data: null,
scope: [],
isAdmin: false,
tenant: null,
}, action) => {
switch (action.type) {
case USER_LOGGED_IN: {
return {
isFetching: false,
data: action.user,
token: action.token,
scope: [],
isAdmin: false
}
}
case USER_LOGGED_OUT:
return {
isFetching: false,
data: null,
token: null,
scope: [],
isAdmin: false
}
case USER_ACL_REQUEST:
return {
...state,
tenant: action.tenant,
isFetching: true
}
case USER_ACL_FAIL:
return {
...state,
isFetching: false,
scope: [],
isAdmin: false
}
case USER_ACL_SUCCESS:
return {
...state,
isFetching: false,
scope: action.scope,
isAdmin: action.isAdmin
}
default:
return state
}
}

View File

@@ -29,6 +29,7 @@ import ConfigErrorsPage from './pages/ConfigErrors'
import TenantsPage from './pages/Tenants'
import StreamPage from './pages/Stream'
import OpenApiPage from './pages/OpenApi'
import AuthCallbackPage from './pages/AuthCallback'
// The Route object are created in the App component.
// Object with a title are created in the menu.
@@ -89,27 +90,27 @@ const routes = () => [
{
to: '/build/:buildId',
component: BuildPage,
props: {'activeTab': 'results'},
props: { 'activeTab': 'results' },
},
{
to: '/build/:buildId/artifacts',
component: BuildPage,
props: {'activeTab': 'artifacts'},
props: { 'activeTab': 'artifacts' },
},
{
to: '/build/:buildId/logs',
component: BuildPage,
props: {'activeTab': 'logs'},
props: { 'activeTab': 'logs' },
},
{
to: '/build/:buildId/console',
component: BuildPage,
props: {'activeTab': 'console'},
props: { 'activeTab': 'console' },
},
{
to: '/build/:buildId/log/:file*',
component: BuildPage,
props: {'activeTab': 'logs', 'logfile': true},
props: { 'activeTab': 'logs', 'logfile': true },
},
{
to: '/buildset/:buildsetId',
@@ -134,6 +135,11 @@ const routes = () => [
component: ComponentsPage,
noTenantPrefix: true,
},
{
to: '/auth_callback',
component: AuthCallbackPage,
noTenantPrefix: true,
},
]
export { routes }

View File

@@ -32,7 +32,11 @@ export default function configureStore(initialState) {
// TODO (felix): Re-enable the status.status path once we know how to
// solve the weird state mutations that are done somewhere deep within
// the logic of the status page (or its child components).
reduxImmutableStateInvariant({ ignore: ['status.status'] })
reduxImmutableStateInvariant({
ignore: [
'status.status',
]
})
)
)
)