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

@ -6,5 +6,6 @@ Admin How-to Guides
installation
zuul-from-scratch
openid-connect-examples
troubleshooting
zookeeper

View File

@ -0,0 +1,21 @@
OpenID Connect Integration Examples
===================================
This document lists simple How-Tos to help administrators enable OpenID
Connect authentication in Zuul and Zuul's Web UI.
.. toctree::
:maxdepth: 1
openid-with-google
openid-with-keycloak
Debugging
---------
If problems appear:
* Make sure your configuration is correct, especially callback URIs.
* More information can be found in Zuul's web service logs.
* From the user's side, activating the web console in the browser can be helpful
to debug API calls.

View File

@ -0,0 +1,65 @@
Configuring Google Authentication
=================================
This document explains how to configure Zuul in order to enable authentication
with Google.
Prerequisites
-------------
* The Zuul instance must be able to query Google's OAUTH API servers. This
simply generally means that the Zuul instance must be able to send and
receive HTTPS data to and from the Internet.
* You must set up a project in `Google's developers console <https://console.developers.google.com/>`_.
Setting up credentials with Google
----------------------------------
In the developers console, choose your project and click `APIs & Services`.
Choose `Credentials` in the menu on the left, then click `Create Credentials`.
Choose `Create OAuth client ID`. You might need to configure a consent screen first.
Create OAuth client ID
......................
Choose `Web application` as Application Type.
In `Authorized JavaScript Origins`, add the base URL of Zuul's Web UI. For example,
if you are running a yarn development server on your computer, it would be
`http://localhost:3000` .
In `Authorized redirect URIs`, write down the base URL of Zuul's Web UI followed
by "/t/<tenant>/auth_callback", for each tenant on which you want to enable
authentication. For example, if you are running a yarn development server on
your computer and want to set up authentication for tenant "local",
write `http://localhost:3000/t/local/auth_callback` .
Click Save. Google will generate a Client ID and a Client secret for your new
credentials; we will only need the Client ID for the rest of this How-To.
Configure Zuul
..............
Edit the ``/etc/zuul/zuul.conf`` to add the google authenticator:
.. code-block:: ini
[auth google_auth]
default=true
driver=OpenIDConnect
realm=my_realm
issuer_id=https://accounts.google.com
client_id=<your Google Client ID>
Restart Zuul services (scheduler, web).
Head to your tenant's status page. If all went well, you should see a "Sign in"
button in the upper right corner of the page. Congratulations!
Further Reading
---------------
This How-To is based on `Google's documentation on their implementation of OpenID Connect <https://developers.google.com/identity/protocols/oauth2/openid-connect>`_.

View File

@ -0,0 +1,110 @@
Configuring Keycloak Authentication
===================================
This document explains how to configure Zuul and Keycloak in order to enable
authentication in Zuul with Keycloak.
Prerequisites
-------------
* The Zuul instance must be able to query Keycloak over HTTPS.
* Authenticating users must be able to reach Keycloak's web UI.
* Have a realm set up in Keycloak.
`Instructions on how to do so can be found here <https://www.keycloak.org/docs/latest/getting_started/index.html#creating-a-realm-and-user>`_ .
By convention, we will assume the Keycloak server's FQDN is ``keycloak``, and
Zuul's Web UI's base URL is ``https://zuul/``. We will use the realm ``my_realm``.
Most operations below regarding the configuration of Keycloak can be performed through
Keycloak's admin CLI. The following steps must be performed as an admin on Keycloak's
GUI.
Setting up Keycloak
-------------------
Create a client
...............
Choose the realm ``my_realm``, then click ``clients`` in the Configure panel.
Click ``Create``.
Name your client as you please. We will pick ``zuul`` for this example. Make sure
to fill the following fields:
* Client Protocol: ``openid-connect``
* Access Type: ``public``
* Implicit Flow Enabled: ``ON``
* Valid Redirect URIs: ``https://zuul/*``
* Web Origins: ``https://zuul/``
Click "Save" when done.
Create a client scope
......................
Keycloak maps the client ID to a specific claim, instead of the usual `aud` claim.
We need to configure Keycloak to add our client ID to the `aud` claim by creating
a custom client scope for our client.
Choose the realm ``my_realm``, then click ``client scopes`` in the Configure panel.
Click ``Create``.
Name your scope as you please. We will name it ``zuul_aud`` for this example.
Make sure you fill the following fields:
* Protocol: ``openid-connect``
* Include in Token Scope: ``ON``
Click "Save" when done.
On the Client Scopes page, click on ``zuul_aud`` to configure it; click on
``Mappers`` then ``create``.
Make sure to fill the following:
* Mapper Type: ``Audience``
* Included Client Audience: ``zuul``
* Add to ID token: ``ON``
* Add to access token: ``ON``
Then save.
Finally, go back to the clients list and pick the ``zuul`` client again. Click
on ``Client Scopes``, and add the ``zuul_aud`` scope to the ``Assigned Default
Client Scopes``.
(Optional) Set up a social identity provider
............................................
Keycloak can delegate authentication to predefined social networks. Follow
`these steps to find out how. <https://www.keycloak.org/docs/latest/server_admin/index.html#social-identity-providers>`_
If you don't set up authentication delegation, make sure to create at least one
user in your realm, or allow self-registration. See Keycloak's documentation section
on `user management <https://www.keycloak.org/docs/latest/server_admin/index.html#user-management>`_
for more details on how to do so.
Setting up Zuul
---------------
Edit the ``/etc/zuul/zuul.conf`` to add the keycloak authenticator:
.. code-block:: ini
[auth keycloak]
default=true
driver=OpenIDConnect
realm=my_realm
issuer_id=https://keycloak/auth/realms/my_realm
client_id=zuul
Restart Zuul services (scheduler, web).
Head to your tenant's status page. If all went well, you should see a "Sign in"
button in the upper right corner of the page. Congratulations!
Further Reading
---------------
This How-To is based on `Keycloak's documentation <https://www.keycloak.org/documentation.html>`_,
specifically `the documentation about clients <https://www.keycloak.org/docs/latest/server_admin/#_clients>`_.

View File

@ -0,0 +1,5 @@
---
features:
- |
Add authentication in the web UI. Zuul's web UI can be configured to authenticate
users against an Identity Provider supporting the OpenID Connect protocol.

View File

@ -17,6 +17,8 @@
"moment": "^2.22.2",
"moment-duration-format": "2.3.2",
"moment-timezone": "^0.5.28",
"oidc-client": "^1.10.1",
"oidc-react": "^1.5.1",
"patternfly-react": "^2.39.16",
"prop-types": "^15.6.2",
"react": "^16.13.1",

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'