Commit 82619cf0 authored by Matija Obreza's avatar Matija Obreza
Browse files

Merge branch '257-expressjs-and-ssr' into 'master'

Resolve "ExpressJS and SSR"

Closes #257

See merge request genesys-pgr/genesys-ui!274
parents d56a8824 556a73e9
......@@ -47,7 +47,7 @@ if (__PRELOADED_STATE__ === undefined) {
document.getElementsByTagName('html')[0].setAttribute('lang', detectedLang);
document.getElementsByTagName('html')[0].setAttribute('dir', direction);
checkAccessTokens(store.dispatch)
checkAccessTokens(store.dispatch, store.getState)
.then(() => {
// no SSR
ReactDOM.render(
......
This diff is collapsed.
......@@ -20,6 +20,7 @@ const config = {
clientId: argv['client-id'] || 'defaultclient@localhost',
clientSecret: argv['client-secret'] || 'changeme',
googleClientId: argv['google-client-id'] || '',
access_token: null,
};
if (config.frontendPath.endsWith('/')) {
......
import * as Loadable from 'react-loadable';
import { configureBackendApi } from 'utilities/requestUtils';
import { LoginService } from 'service/LoginService';
import config from './config';
import { refreshServerStorage } from './middleware/serverStorage';
/**
* Start the frontend server
......@@ -11,9 +14,26 @@ const PORT = process.env.PORT || 3000;
// Configure axios for server
configureBackendApi({ apiUrl: `http://localhost:${PORT}/proxy` });
// start server on PORT
Loadable.preloadAll().then(() => {
server.listen(PORT, () => {
console.log('HTTP server listening on: ' + PORT);
const refreshAppToken = () => {
return LoginService.loginApp(config.clientId, config.clientSecret, config.apiUrl).then(async (token) => {
console.log('Received token: \n', token);
configureBackendApi({ accessToken: token.access_token });
const refreshIn = (token.expires_in - 30) * 1000;
console.log(`Scheduling app token refresh in ${refreshIn}s`);
config.access_token = token;
setTimeout(refreshAppToken, refreshIn);
await refreshServerStorage();
}).catch((axiosError) => {
console.log(`Error refreshing app token: ${axiosError.response.statusText}`, axiosError.response);
});
});
};
refreshAppToken()
.then(() => {
// start server on PORT
Loadable.preloadAll().then(() => {
server.listen(PORT, () => {
console.log('HTTP server listening on: ' + PORT);
});
});
});
import * as jwt from 'jsonwebtoken';
import { loginAppRequest } from 'actions/login';
import { checkTokenRequest, loginUser } from 'user/actions/public';
import {loginUser} from 'user/actions/public';
import ApiError from 'model/ApiError';
import config from '../config';
function checkAuthTokenRequest(req, dispatch) {
const token = req.cookies.access_token;
const token = req.cookies.access_token || config.access_token.access_token;
if (token) {
console.log('Checking cookie token', token);
return dispatch(checkTokenRequest(token))
.then((checkedToken) => {
console.log(`User token ${token} is valid, response`, checkedToken);
return dispatch(loginUser(checkedToken));
})
.catch(() => {
console.log('Server: check failed dispatching loginAppRequest');
return dispatch(loginAppRequest());
});
try {
const parsedToken = jwt.decode(token);
console.log('Parsed token data', parsedToken);
dispatch(loginUser({access_token: token, ...parsedToken}));
return Promise.resolve({access_token: token, ...parsedToken});
} catch (e) {
console.log('Error while parsing token:', e.message);
return dispatch(loginAppRequest());
}
} else {
console.log('Server: No token in cookie, dispatching loginAppRequest');
return dispatch(loginAppRequest());
......@@ -28,7 +31,8 @@ export default function checkAuthToken(req, res, dispatch) {
console.log(`Setting cookie to expire in ${(data.exp || data.expires_in) / 60}min from`, data);
res.cookie('access_token', data.access_token, { path: '/', expires: new Date(data.exp * 1000 || new Date().getTime() + ((data.expires_in * 1000) || (/* 1hr */ 1000 * 60 * 60))) });
if (data.authorities) {
res.cookie('authorities', JSON.stringify(data.authorities), { path: '/', expires: new Date(data.exp * 1000 || new Date().getTime() + ((data.expires_in * 1000) || (/* 1hr */ 1000 * 60 * 60))) });
console.log(`Setting authorities cookies: `, data.authorities);
res.cookie('authorities', JSON.stringify(data.authorities), { path: '/', expires: new Date(data.exp * 1000 || new Date().getTime() + ((data.expires_in * 1000) || (/* 1hr */ 1000 * 60 * 60))) });
} else {
res.clearCookie('authorities');
}
......
......@@ -37,6 +37,8 @@ import ApiError from 'model/ApiError';
// react-loadable webpack stats
import * as path from 'path';
import {readFileSync} from 'fs';
import { serverStorage } from './serverStorage';
import { initFromServerStorage } from 'actions/serverStorage';
const reactLoadableStats = JSON.parse(readFileSync(path.join('./', 'react-loadable.json'), {encoding: 'utf8'}));
// console.log(`react-loadable stats: `, reactLoadableStats);
......@@ -48,7 +50,8 @@ const prerenderer = (html, errHtml) => (req, res) => {
res.header('Access-Control-Allow-Headers', 'X-Requested-With');
const store = createStore(rootReducer, {} as any, applyMiddleware(thunk, routerMiddleware(createMemoryHistory)));
// Only send public data to client
store.dispatch(configure({ frontendPath: config.frontendPath, apiUrl: config.apiUrl, googleClientId: config.googleClientId, clientId: config.clientId }));
store.dispatch(configure({ frontendPath: config.frontendPath, apiUrl: config.apiUrl, googleClientId: config.googleClientId, clientId: config.clientId, anonToken: config.access_token }));
initFromServerStorage(serverStorage, store.dispatch);
console.log(`Processing request`, req._parsedOriginalUrl);
const pathname = req._parsedOriginalUrl.pathname;
......
// constants
import 'babel-polyfill';
import { RECEIVE_CROPS } from 'crop/constants';
import { GET_SERVER_INFO } from 'constants/serverInfo';
// model
import ApiCall from 'model/ApiCall';
// service
import { CropService } from 'service/CropService';
import ApiInfoService from 'service/genesys/ApiInfoService';
// server
import config from '../config';
const REFRESH_TIME = 15 * 60 * 1000;
export const serverStorage = {
crops: {
actionType: RECEIVE_CROPS,
data: null,
},
serverInfo: {
actionType: GET_SERVER_INFO,
data: null,
},
};
const directXhrConfig = {
baseURL: config.apiUrl,
};
const refreshCrops = () => {
const start = Date.now();
return CropService.listCrops(directXhrConfig)
.then((crops) => {
console.log(`Crops fetched in ${Date.now() - start}ms`);
serverStorage.crops.data = {apiCall: ApiCall.success(crops)};
});
};
const refreshServerInfo = () => {
const start = Date.now();
return ApiInfoService.apiInfo(directXhrConfig)
.then((serverInfo) => {
console.log(`Server info fetched in ${ Date.now() - start }ms`);
serverStorage.serverInfo.data = {info: serverInfo};
});
};
export const refreshServerStorage = async () => {
await refreshCrops();
await refreshServerInfo();
console.log('Server storage refresh done');
setTimeout(refreshServerStorage, REFRESH_TIME);
};
......@@ -7,7 +7,7 @@ import ApiCall from 'model/ApiCall';
// utility
import { log } from 'utilities/debug';
export const createApiCaller = (method, payloadType: string) => {
export const createApiCaller = (method, payloadType: string, config: {direct?: boolean, withoutTimeout?: boolean} = {direct: false, withoutTimeout: false}) => {
let cancelToken;
......@@ -24,7 +24,27 @@ export const createApiCaller = (method, payloadType: string) => {
dispatch({ type: payloadType, payload: { apiCall: ApiCall.start() } }); // Loading
return method(...ensureParamsAmount(method, params), {cancelToken: cancelToken && cancelToken.token})
const accessToken = getState().applicationConfig.anonToken.access_token;
const xhrConfig: any = {
cancelToken: cancelToken && cancelToken.token,
headers: {
common: {
Authorization: `Bearer ${accessToken}`,
},
},
};
if (config.direct) {
xhrConfig.baseURL = getState().applicationConfig.apiUrl;
}
if (config.withoutTimeout) {
xhrConfig.timeout = 0;
}
return method(...ensureParamsAmount(method, params), xhrConfig)
.then((data) => { // Success
// log('Data received: ', data);
dispatch({ type: payloadType, payload: { apiCall: ApiCall.success(data) } });
......@@ -45,11 +65,11 @@ export const createApiCaller = (method, payloadType: string) => {
};
export const createPureApiCaller = (method) => {
export const createPureApiCaller = (method, config: {direct?: boolean, withoutTimeout?: boolean} = {direct: false, withoutTimeout: false}) => {
let cancelToken;
return (...params) => (dispatch) => {
return (...params) => (dispatch, getState) => {
// log('Pure Api call started');
......@@ -60,7 +80,27 @@ export const createPureApiCaller = (method) => {
cancelToken = axios.CancelToken.source();
return method(...ensureParamsAmount(method, params), {cancelToken: cancelToken && cancelToken.token})
const accessToken = getState().applicationConfig.anonToken.access_token;
const xhrConfig: any = {
cancelToken: cancelToken && cancelToken.token,
headers: {
common: {
Authorization: `Bearer ${accessToken}`,
},
},
};
if (config.direct) {
xhrConfig.baseURL = getState().applicationConfig.apiUrl;
}
if (config.withoutTimeout) {
xhrConfig.timeout = 0;
}
return method(...ensureParamsAmount(method, params), xhrConfig)
.then((data) => { // Success
// log('Data received: ', data);
return Promise.resolve(data);
......
import { LoginService } from 'service/LoginService';
import * as Constants from 'constants/login';
import * as cookies from 'es-cookie';
import * as jwt from 'jsonwebtoken';
import { clearCookies, saveCookies } from 'utilities';
import { ROLE_CLIENT } from 'constants/userRoles';
import {log} from 'utilities/debug';
import {loginUser} from 'user/actions/public';
import { configureBackendApi } from 'utilities/requestUtils';
export function checkAccessTokens(dispatch) {
export function checkAccessTokens(dispatch, getState) {
const cookieToken: string = typeof window !== 'undefined' && cookies.get('access_token');
console.log('Application config: ', getState().applicationConfig);
const applicationLogin = () =>
LoginService.loginApp()
LoginService.loginApp(getState().applicationConfig.clientId)
.then((data) => {
// console.log('loginApp token', data);
saveCookies({access_token: data.access_token, authorities: [ROLE_CLIENT]}, data.exp * 1000 || new Date().getTime() + data.expires_in * 1000);
......@@ -22,15 +24,15 @@ export function checkAccessTokens(dispatch) {
});
if (cookieToken) {
return LoginService.checkToken(cookieToken)
.then((data) => {
dispatch(loginUser({ access_token: cookieToken, ...data }));
return true;
})
.catch((error) => {
clearCookies();
return applicationLogin();
});
try {
const parsedTokenData = jwt.decode(cookieToken);
dispatch(loginUser({ access_token: cookieToken, ...parsedTokenData }));
return Promise.resolve();
} catch (e) {
console.log('Error while parsing token: ', e.message);
clearCookies();
return applicationLogin();
}
} else {
clearCookies();
return applicationLogin();
......@@ -38,8 +40,8 @@ export function checkAccessTokens(dispatch) {
}
function loginAppRequest() {
return (dispatch) => {
return LoginService.loginApp()
return (dispatch, getState) => {
return LoginService.loginApp(getState().applicationConfig.clientId)
.then((data) => {
return dispatch(loginApp(data));
});
......@@ -49,10 +51,13 @@ function loginAppRequest() {
function loginApp(d) {
// console.log('Login app', d);
configureBackendApi({ accessToken: d.access_token });
return {
return (dispatch, getState) => {
return dispatch({
type: Constants.LOGIN_APP,
authorities: [ROLE_CLIENT],
...d,
access_token: d.access_token,
...jwt.decode(d.access_token),
});
};
}
......
export const initFromServerStorage = (serverStorage, dispatch) => {
// console.log('ServerStorage: ', serverStorage);
Object.keys(serverStorage).forEach((key) => dispatch({type: serverStorage[key].actionType, payload: serverStorage[key].data }));
};
import update from 'immutability-helper';
import {CONFIGURE_APPLICATION, SET_LANG} from 'constants/applicationConfig';
import { LOGIN_APP, LOGIN_USER } from 'constants/login';
const INITIAL_STATE: {
frontendUrl: string;
frontendPath: string;
apiUrl: string;
clientId: string;
googleClientId: string;
apiUrl: string;
frontendPath: string;
lang: string,
anonToken: string,
} = {
clientId: process.env.CLIENT_ID || '',
googleClientId: process.env.GOOGLE_CLIENT_ID || '',
apiUrl: process.env.CATALOG_API_URL || 'http://localhost:8080',
frontendUrl: 'http://localhost:3000',
frontendPath: '',
apiUrl: process.env.CATALOG_API_URL || 'http://localhost:8080',
clientId: process.env.CLIENT_ID || 'defaultclient@localhost', // TODO check if '' is required here
googleClientId: process.env.GOOGLE_CLIENT_ID || '',
lang: 'en',
anonToken: null,
};
export default function configure(state = INITIAL_STATE, action: { type?: string, payload?: any } = {type: '', payload: {}}) {
......@@ -29,6 +34,20 @@ export default function configure(state = INITIAL_STATE, action: { type?: string
lang: {$set: action.payload},
});
}
case LOGIN_USER: {
const access_token = {...action};
delete access_token.type;
return update(state, {
anonToken: {$set: access_token},
});
}
case LOGIN_APP: {
const access_token = {...action};
delete access_token.type;
return update(state, {
anonToken: {$set: access_token},
});
}
default:
return state;
......
......@@ -10,15 +10,17 @@ const URL_RESET_PASSWORD = `/api/v1/me/password/reset`;
const URL_CANCEL_VALIDATION = UrlTemplate.parse(`/api/v1/me/{tokenUuid}/cancel`);
const URL_UPDATE_PASSWORD = UrlTemplate.parse(`/api/v1/me/{tokenUuid}/pwdreset`);
// const LOGOUT_URL = `${API_BASE_URL}/me/logout`;
export const CHECK_TOKEN_URL = `/oauth/check_token`;
export const VERIFY_GOOGLE_TOKEN_URL = `/api/google/verify-token`;
export class LoginService {
public static loginApp() {
public static loginApp(clientId: string, clientSecret?: string, baseURL?: string) {
return axiosBackend.post(LOGIN_URL, null, {
baseURL: baseURL || '/proxy',
params: {
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret || undefined,
},
})
.then(({ data }) => data);
......@@ -59,17 +61,6 @@ export class LoginService {
}).then(({ data }) => data as any);
}
public static checkToken(token: string) {
return axiosBackend.get(CHECK_TOKEN_URL, {
params: {
token,
},
})
.then(({ data }) => {
return data;
});
}
/**
* @param clientId our OAuth Client ID
* @param tokenId Token returned by Google Auth
......
......@@ -34,11 +34,6 @@ interface IAppProps extends React.ClassAttributes<any> {
class App extends React.Component<IAppProps, any> {
public static needs = [
() => loadCrops(),
() => serverInfoRequest(),
];
public constructor(props: any) {
super(props);
}
......
import * as jwt from 'jsonwebtoken';
// Actions
import {loginApp} from 'actions/login';
import {createPureApiCaller} from 'actions/ApiCall';
import navigateTo from 'actions/navigation';
import {loginAppRequest} from 'actions/login';
// Constants
import * as Constants from 'constants/login';
......@@ -10,13 +11,10 @@ import {LoginService} from 'service/LoginService';
import {UserService} from 'service/UserService';
// Util
import {clearCookies, saveCookies} from 'utilities';
import {log} from 'utilities/debug';
import { configureBackendApi } from 'utilities/requestUtils';
// Wrapped API Calls
const apiRegister = createPureApiCaller(UserService.register);
const apiLogin = createPureApiCaller(LoginService.login);
const apiCheckToken = createPureApiCaller(LoginService.checkToken);
const apiVerifyGoogleToken = createPureApiCaller(LoginService.verifyGoogleToken);
const apiLogout = createPureApiCaller(LoginService.logout);
const apiResetPassword = createPureApiCaller(LoginService.resetPassword);
......@@ -32,53 +30,32 @@ export const registrationRequest = (email, pass, fullName, captcha) => (dispatch
// Login
export const loginUser = (payload) => {
// console.log('loginUser', payload);
configureBackendApi({ accessToken: payload.access_token });
// configureBackendApi({ accessToken: payload.access_token }); // TODO do not configure backend depending on users token
return {
type: Constants.LOGIN_USER,
...payload,
};
};
const checkToken = (payload) => {
return {
type: Constants.CHECK_TOKEN,
...payload,
};
};
export const loginRequest = (username, password) => (dispatch) => {
export const loginRequest = (username, password) => (dispatch, getState) => {
return dispatch(apiLogin(username, password))
.then((data) => {
saveCookies(data, data.exp * 1000 || new Date().getTime() + data.expires_in * 1000);
return dispatch(loginUser(data));
});
};
const tokenData = jwt.decode(data.access_token);
saveCookies({access_token: data.access_token, ...tokenData}, data.exp * 1000 || new Date().getTime() + data.expires_in * 1000);
export const checkTokenRequest = (token) => (dispatch) => {
log('checkTokenRequest verifying ', token);
return dispatch(apiCheckToken(token))
.then((data) => {
log('checkTokenRequest got', data);
return dispatch(checkToken({access_token: token, ...data}));
return dispatch(loginUser({...data, ...tokenData}));
});
};
// Google login
// TODO find usage
export const verifyGoogleToken = (payload) => {
return {
type: Constants.VERIFY_GOOGLE_TOKEN,
...payload,
};
};
export const verifyGoogleTokenRequest = (tokenId) => (dispatch, getState) => {
const clientId = getState().applicationConfig.clientId;
return dispatch(apiVerifyGoogleToken(clientId, tokenId))
.then((data) => {
saveCookies(data, data.exp * 1000 || new Date().getTime() + data.expires_in * 1000);
return dispatch(loginApp(data));
const tokenData = jwt.decode(data.access_token);
saveCookies({access_token: data.access_token, ...tokenData}, data.exp * 1000 || new Date().getTime() + data.expires_in * 1000);
return dispatch(loginUser({...data, ...tokenData}));
});
};
......@@ -92,8 +69,10 @@ const logout = () => {
export const logoutRequest = () => (dispatch, getState) => {
const token = getState().login.access_token;
return dispatch(apiLogout(token))
.then(() => {
.then((token) => {
clearCookies();
dispatch(loginAppRequest())
.then((data) => saveCookies(data, data.exp * 1000 || new Date().getTime() + data.expires_in * 1000));
return dispatch(logout());
});
};
......@@ -113,10 +92,6 @@ export const cancelValidation = (tokenUuid: string, gRecaptchaResponse?: string)
export const loginAction = (username, password, needRedirect = false): any => (dispatch) => {
// log('Trying login', username);
return dispatch(loginRequest(username, password))
.then(({access_token}) => {
// log('Access token', access_token);
return dispatch(checkTokenRequest(access_token));
})
.then((data) => {
if (needRedirect) {
dispatch(navigateTo('/'));
......@@ -130,10 +105,6 @@ export const loginAction = (username, password, needRedirect = false): any => (d
export const googleLogin = (response, needRedirect): any => (dispatch) => {
// log('Trying google login');
return dispatch(verifyGoogleTokenRequest(response.tokenId))
.then(({access_token}) => {
// log('Access token', access_token);
return dispatch(checkTokenRequest(access_token));
})
.then((data) => {
if (needRedirect) {
dispatch(navigateTo('/'));
......
......@@ -35,15 +35,20 @@ axiosBackend.interceptors.request.use((config) => {
* @param apiUrl baseURL to the backend API (http://localhost:3000/proxy)
* @param authToken OAuth authorization token
*/
export const configureBackendApi = ({apiUrl, accessToken, timeout = 0}: {apiUrl?: string, accessToken?: string, timeout?: number}) => {
export const configureBackendApi = ({apiUrl, accessToken, origin, timeout = 20000}: {apiUrl?: string, accessToken?: string, origin?: string, timeout?: number}) => {
if (apiUrl) {
console.log(`Using backend API baseURL ${apiUrl}`);
axiosBackend.defaults.baseURL = apiUrl;
console.log(`axiosBackend.defaults.headers`, axiosBackend.defaults.headers);
}
if (accessToken) {
console.log(`Using backend API accessToken ..snip..`);
axiosBackend.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
}
if (origin) {
console.log(`Using origin ${origin}`);