1
0
Fork 0
mirror of https://git.rwth-aachen.de/acs/public/villas/web/ synced 2025-03-09 00:00:01 +01:00

Merge branch 'feature-external-auth' into 'master'

Feature external authentication

See merge request acs/public/villas/web!75
This commit is contained in:
Sonja Happ 2021-02-26 13:37:11 +00:00
commit 7ae9679b5f
13 changed files with 350 additions and 25 deletions

View file

@ -46,6 +46,11 @@ class App extends React.Component {
constructor(props) {
super(props);
AppDispatcher.dispatch({
type: 'config/load',
});
this.state = {
showSidebarMenu: false,
}

View file

@ -17,9 +17,73 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { Container } from 'flux/utils';
import LoginStore from '../user/login-store';
import AppDispatcher from './app-dispatcher';
class SidebarMenu extends React.Component {
constructor(props) {
super(props);
this.state = {
externalAuth: false,
logoutLink: "",
}
}
static getStores() {
return [LoginStore]
}
static calculateState(prevState, props) {
let config = LoginStore.getState().config;
let logout_url = _.get(config, ['authentication', 'logout_url']);
if (logout_url) {
return {
externalAuth: true,
logoutLink: logout_url,
}
}
return {
externalAuth: false,
logoutLink: "/logout",
}
}
logout() {
AppDispatcher.dispatch({
type: 'users/logout'
});
// The Login Store is deleted automatically
// discard login token and current User
localStorage.setItem('token', '');
localStorage.setItem('currentUser', '');
}
render() {
if (this.state.externalAuth) {
return (
<div className="menu-sidebar">
<h2>Menu</h2>
<ul>
<li><NavLink to="/home" activeClassName="active" title="Home">Home</NavLink></li>
<li><NavLink to="/scenarios" activeClassName="active" title="Scenarios">Scenarios</NavLink></li>
<li><NavLink to="/infrastructure" activeClassName="active" title="Infrastructure Components">Infrastructure Components</NavLink></li>
{ this.props.currentRole === 'Admin' ?
<li><NavLink to="/users" activeClassName="active" title="User Management">User Management</NavLink></li> : ''
}
<li><NavLink to="/account" title="Account">Account</NavLink></li>
<a onClick={this.logout.bind(this)} href={this.state.logoutLink}>Logout</a>
<li><NavLink to="/api" title="API Browser">API Browser</NavLink></li>
</ul>
</div>
);
}
return (
<div className="menu-sidebar">
<h2>Menu</h2>
@ -32,7 +96,7 @@ class SidebarMenu extends React.Component {
<li><NavLink to="/users" activeClassName="active" title="User Management">User Management</NavLink></li> : ''
}
<li><NavLink to="/account" title="Account">Account</NavLink></li>
<li><NavLink to="/logout" title="Logout">Logout</NavLink></li>
<li><NavLink to={this.state.logoutLink} title="Logout">Logout</NavLink></li>
<li><NavLink to="/api" title="API Browser">API Browser</NavLink></li>
</ul>
</div>
@ -40,4 +104,5 @@ class SidebarMenu extends React.Component {
}
}
export default SidebarMenu;
let fluxContainerConverter = require('../common/FluxContainerConverter');
export default Container.create(fluxContainerConverter.convert(SidebarMenu));

43
src/config-reader.js Normal file
View file

@ -0,0 +1,43 @@
/**
* This file is part of VILLASweb.
*
* VILLASweb is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* VILLASweb is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with VILLASweb. If not, see <http://www.gnu.org/licenses/>.
******************************************************************************/
import RestDataManager from './common/data-managers/rest-data-manager';
import RestAPI from './common/api/rest-api';
import AppDispatcher from './common/app-dispatcher';
class ConfigReader extends RestDataManager {
constructor() {
super('config', '/config');
}
loadConfig() {
RestAPI.get(this.makeURL('/config'), null).then(response => {
AppDispatcher.dispatch({
type: 'config/loaded',
data: response,
});
}).catch(error => {
AppDispatcher.dispatch({
type: 'config/load-error',
error: error,
});
});
}
};
export default new ConfigReader();

View file

@ -22,7 +22,11 @@ const config = {
admin: {
name: 'Institute for Automation of Complex Power Systems (ACS), RWTH Aachen University, Germany',
mail: 'stvogel@eonerc.rwth-aachen.de'
}
},
externalAuth: true,
loginURL: '/oauth2/start?rd=/login/complete',
provider: 'Jupyter',
disableVillasLogin: false,
};
export default config

BIN
src/img/dog-waiting-bw.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View file

@ -28,12 +28,15 @@ import Dashboard from './dashboard/dashboard'
import InfrastructureComponents from './ic/ics';
import Users from './user/users';
import User from "./user/user";
import LoginComplete from './user/login-complete'
class Root extends React.Component {
render() {
return (
<BrowserRouter>
<Switch>
<Route path='/login/complete' component={LoginComplete} />
<Route path='/login' component={Login} />
<Route path='/logout' component={Logout} />
<Route path='/' component={App} />

View file

@ -112,6 +112,13 @@ body {
}
}
.verticalhorizontal {
display: flex;
justify-content: center;
align-items: center;
height: 800px;
}
/**
* Menus
*/
@ -219,6 +226,26 @@ body {
background-color: white;
}
/**
* Login select
*/
.login-select {
position: sticky;
width: 300px;
height: 150px;
top: 50%;
left: 50%;
margin-top: 50px;
margin-bottom: 100px;
transform: translate(-50%);
padding: 20px 20px;
background-color: #a8c7cf;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
0 9px 18px 0 rgba(0, 0, 0, 0.1);
}
/**
* Login form
@ -234,6 +261,13 @@ body {
0 9px 18px 0 rgba(0, 0, 0, 0.1);
}
hr {
margin-top: 1rem;
margin-bottom: 1rem;
border:0;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
/**
* Tables
*/

107
src/user/login-complete.js Normal file
View file

@ -0,0 +1,107 @@
/**
* This file is part of VILLASweb.
*
* VILLASweb is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* VILLASweb is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with VILLASweb. If not, see <http://www.gnu.org/licenses/>.
******************************************************************************/
import React from 'react';
import { Redirect } from 'react-router-dom';
import AppDispatcher from '../common/app-dispatcher';
import LoginStore from './login-store'
import { Container } from 'flux/utils';
class LoginComplete extends React.Component {
constructor(props) {
console.log("LoginComplete constructor");
super(props);
AppDispatcher.dispatch({
type: 'users/extlogin',
});
this.state = {
loginMessage: '',
token: '',
currentUser: '',
secondsToWait: 99,
}
this.timer = 0;
this.startTimer = this.startTimer.bind(this);
this.countDown = this.countDown.bind(this);
this.stopTimer = this.stopTimer.bind(this);
}
componentDidMount() {
this.startTimer();
this.setState({secondsToWait: 5});
}
static getStores(){
return [LoginStore]
}
static calculateState(prevState, props) {
// We need to work with the login store here to trigger the re-render upon state change after login
// Upon successful login, the token and currentUser are stored in the local storage as strings
return {
loginMessage: LoginStore.getState().loginMessage,
token: LoginStore.getState().token,
currentUser: LoginStore.getState().currentUser,
}
}
stopTimer() {
console.log("stop timer");
clearInterval(this.timer);
}
startTimer() {
if (this.timer == 0 && this.state.secondsToWait > 0) {
// call function 'countDown' every 1000ms
this.timer = setInterval(this.countDown, 1000);
}
}
countDown() {
let seconds = this.state.secondsToWait - 1;
this.setState({secondsToWait: seconds});
// waiting time over, stop counting down
if (seconds == 0) {
clearInterval(this.timer);
}
}
render() {
if (this.state.currentUser && this.state.currentUser !== "") {
this.stopTimer();
return (<Redirect to="/home" />);
}
else if (this.state.secondsToWait == 0) {
this.stopTimer();
return (<Redirect to="/login" />);
} else {
return (<div class="verticalhorizontal">
<img style={{height: 300}}src={require('../img/dog-waiting-bw.jpg').default} alt="Waiting Dog" /></div>);
}
}
}
let fluxContainerConverter = require('../common/FluxContainerConverter');
export default Container.create(fluxContainerConverter.convert(LoginComplete));

View file

@ -19,6 +19,8 @@ import React, { Component } from 'react';
import { Form, Button, FormGroup, FormControl, FormLabel, Col } from 'react-bootstrap';
import RecoverPassword from './recover-password'
import AppDispatcher from '../common/app-dispatcher';
import _ from 'lodash';
class LoginForm extends Component {
constructor(props) {
@ -56,17 +58,17 @@ class LoginForm extends Component {
this.setState({ [event.target.id]: event.target.value, disableLogin });
}
openRecoverPassword(){
this.setState({forgottenPassword: true});
openRecoverPassword() {
this.setState({ forgottenPassword: true });
}
closeRecoverPassword(){
this.setState({forgottenPassword: false});
closeRecoverPassword() {
this.setState({ forgottenPassword: false });
}
render() {
villaslogin() {
return (
<Form>
<Form key="login_a">
<FormGroup controlId="username">
<FormLabel column={true}>Username</FormLabel>
<Col>
@ -89,7 +91,7 @@ class LoginForm extends Component {
</div>
}
<FormGroup style={{paddingTop: 15, paddingBottom: 5}}>
<FormGroup style={{ paddingTop: 15, paddingBottom: 5 }}>
<Col>
<span className='solid-button'>
<Button variant='secondary' style={{width: 90}} type="submit" disabled={this.state.disableLogin} onClick={e => this.login(e)}>Login</Button>
@ -101,9 +103,28 @@ class LoginForm extends Component {
<RecoverPassword show={this.state.forgottenPassword} onClose={() => this.closeRecoverPassword()} sessionToken={this.props.sessionToken} />
</Form>
);
}
render() {
let villasLogin = this.villaslogin();
if (this.props.config) {
let externalLogin = _.get(this.props.config, ['authentication', 'external', 'enabled'])
let provider = _.get(this.props.config, ['authentication', 'external', 'provider_name'])
let url = _.get(this.props.config, ['authentication', 'external', 'authorize_url']) + "?rd=/login/complete"
if (externalLogin && provider && url) {
return [
villasLogin,
<hr key="login_b"/>,
<Button key="login_c" onClick={e => window.location = url } block>Sign in with {provider}</Button>
];
}
}
return villasLogin;
}
}
export default LoginForm;

View file

@ -20,6 +20,7 @@ import { ReduceStore } from 'flux/utils';
import AppDispatcher from '../common/app-dispatcher';
import UsersDataManager from './users-data-manager';
import ICDataDataManager from '../ic/ic-data-data-manager';
import ConfigReader from '../config-reader';
class LoginStore extends ReduceStore {
constructor() {
@ -31,15 +32,30 @@ class LoginStore extends ReduceStore {
currentUser: null,
token: null,
loginMessage: null,
config: null,
};
}
reduce(state, action) {
switch (action.type) {
case 'config/load':
ConfigReader.loadConfig();
return state;
case 'config/loaded':
return Object.assign({}, state, { config: action.data });
case 'config/load-error':
return Object.assign({}, state, { config: null});
case 'users/login':
UsersDataManager.login(action.username, action.password);
return Object.assign({}, state, { loginMessage: null });
case 'users/extlogin':
UsersDataManager.login();
return Object.assign({}, state, { loginMessage: null });
case 'users/logout':
// disconnect from all infrastructure components
ICDataDataManager.closeAll();

View file

@ -26,10 +26,20 @@ import Header from '../common/header';
import Footer from '../common/footer';
import NotificationsDataManager from '../common/data-managers/notifications-data-manager';
import LoginStore from './login-store'
import AppDispatcher from '../common/app-dispatcher';
class Login extends Component {
constructor(props) {
super(props);
static getStores(){
// load config in case the user goes directly to /login
// otherwise it will be loaded in app constructor
AppDispatcher.dispatch({
type: 'config/load',
});
}
static getStores() {
return [LoginStore]
}
@ -40,6 +50,7 @@ class Login extends Component {
loginMessage: LoginStore.getState().loginMessage,
token: LoginStore.getState().token,
currentUser: LoginStore.getState().currentUser,
config: LoginStore.getState().config,
}
}
@ -62,7 +73,7 @@ class Login extends Component {
<div className="login-container">
<NavbarBrand>Login</NavbarBrand>
<LoginForm loginMessage={this.state.loginMessage} />
<LoginForm loginMessage={this.state.loginMessage} config={this.state.config}/>
</div>
<Footer />

View file

@ -17,10 +17,10 @@
import React from 'react';
import { Redirect } from 'react-router-dom';
import AppDispatcher from '../common/app-dispatcher';
class Logout extends React.Component {
componentDidMount() {
AppDispatcher.dispatch({
type: 'users/logout'
@ -40,4 +40,4 @@ class Logout extends React.Component {
}
}
export default Logout;
export default Logout;

View file

@ -25,18 +25,34 @@ class UsersDataManager extends RestDataManager {
}
login(username, password) {
RestAPI.post(this.makeURL('/authenticate'), { username: username, password: password }).then(response => {
AppDispatcher.dispatch({
type: 'users/logged-in',
token: response.token,
currentUser: response.user,
if (username && password) {
RestAPI.post(this.makeURL('/authenticate/internal'), { username: username, password: password }).then(response => {
AppDispatcher.dispatch({
type: 'users/logged-in',
token: response.token,
currentUser: response.user,
});
}).catch(error => {
AppDispatcher.dispatch({
type: 'users/login-error',
error: error
});
});
}).catch(error => {
AppDispatcher.dispatch({
type: 'users/login-error',
error: error
} else { // external authentication
RestAPI.post(this.makeURL('/authenticate/external'),).then(response => {
console.log(response);
AppDispatcher.dispatch({
type: 'users/logged-in',
token: response.token,
currentUser: response.user,
});
}).catch(error => {
AppDispatcher.dispatch({
type: 'users/login-error',
error: error
});
});
});
}
}
}