abstract tokenstore to support different storage methods of tokenstore

This commit is contained in:
lukas 2021-05-07 17:31:35 +02:00
parent ab02a49b8f
commit 0797632c44
7 changed files with 186 additions and 159 deletions

View File

@ -9,7 +9,7 @@ import style from './App.module.css';
import SettingsPage from './pages/SettingsPage/SettingsPage'; import SettingsPage from './pages/SettingsPage/SettingsPage';
import CategoryPage from './pages/CategoryPage/CategoryPage'; import CategoryPage from './pages/CategoryPage/CategoryPage';
import {APINode, apiTokenValid, callAPI, refreshAPIToken} from './utils/Api'; import {APINode, callAPI, token} from './utils/Api';
import {BrowserRouter as Router, NavLink, Route, Switch} from 'react-router-dom'; import {BrowserRouter as Router, NavLink, Route, Switch} from 'react-router-dom';
import Player from './pages/Player/Player'; import Player from './pages/Player/Player';
@ -19,6 +19,7 @@ import {SettingsTypes} from './types/ApiTypes';
import AuthenticationPage from './pages/AuthenticationPage/AuthenticationPage'; import AuthenticationPage from './pages/AuthenticationPage/AuthenticationPage';
import TVShowPage from './pages/TVShowPage/TVShowPage'; import TVShowPage from './pages/TVShowPage/TVShowPage';
import TVPlayer from './pages/TVShowPage/TVPlayer'; import TVPlayer from './pages/TVShowPage/TVPlayer';
import {CookieTokenStore} from './utils/TokenStore/CookieTokenStore';
interface state { interface state {
password: boolean | null; // null if uninitialized - true if pwd needed false if not needed password: boolean | null; // null if uninitialized - true if pwd needed false if not needed
@ -32,12 +33,14 @@ class App extends React.Component<{}, state> {
constructor(props: {}) { constructor(props: {}) {
super(props); super(props);
token.setTokenStore(new CookieTokenStore());
let pwdneeded: boolean | null = null; let pwdneeded: boolean | null = null;
if (apiTokenValid()) { if (token.apiTokenValid()) {
pwdneeded = false; pwdneeded = false;
} else { } else {
refreshAPIToken((err) => { token.refreshAPIToken((err) => {
if (err === 'invalid_client') { if (err === 'invalid_client') {
this.setState({password: true}); this.setState({password: true});
} else if (err === '') { } else if (err === '') {
@ -61,7 +64,7 @@ class App extends React.Component<{}, state> {
// set the hook to load passwordfield on global func call // set the hook to load passwordfield on global func call
GlobalInfos.loadPasswordPage = (callback?: () => void): void => { GlobalInfos.loadPasswordPage = (callback?: () => void): void => {
// try refreshing the token // try refreshing the token
refreshAPIToken((err) => { token.refreshAPIToken((err) => {
if (err !== '') { if (err !== '') {
this.setState({password: true}); this.setState({password: true});
} else { } else {

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import AuthenticationPage from './AuthenticationPage'; import AuthenticationPage from './AuthenticationPage';
import {shallow} from 'enzyme'; import {shallow} from 'enzyme';
import {token} from "../../utils/Api";
describe('<AuthenticationPage/>', function () { describe('<AuthenticationPage/>', function () {
it('renders without crashing ', function () { it('renders without crashing ', function () {
@ -25,8 +26,7 @@ describe('<AuthenticationPage/>', function () {
it('test fail authenticate', function () { it('test fail authenticate', function () {
const events = mockKeyPress(); const events = mockKeyPress();
const helpers = require('../../utils/Api'); token.refreshAPIToken = jest.fn().mockImplementation((callback, force, pwd) => {
helpers.refreshAPIToken = jest.fn().mockImplementation((callback, force, pwd) => {
callback('there was an error') callback('there was an error')
}); });
@ -41,8 +41,7 @@ describe('<AuthenticationPage/>', function () {
const events = mockKeyPress(); const events = mockKeyPress();
const func = jest.fn() const func = jest.fn()
const helpers = require('../../utils/Api'); token.refreshAPIToken = jest.fn().mockImplementation((callback, force, pwd) => {
helpers.refreshAPIToken = jest.fn().mockImplementation((callback, force, pwd) => {
callback('') callback('')
}); });

View File

@ -2,7 +2,7 @@ import React from 'react';
import {Button} from '../../elements/GPElements/Button'; import {Button} from '../../elements/GPElements/Button';
import style from './AuthenticationPage.module.css'; import style from './AuthenticationPage.module.css';
import {addKeyHandler, removeKeyHandler} from '../../utils/ShortkeyHandler'; import {addKeyHandler, removeKeyHandler} from '../../utils/ShortkeyHandler';
import {refreshAPIToken} from '../../utils/Api'; import {token} from '../../utils/Api';
import {faTimes} from '@fortawesome/free-solid-svg-icons'; import {faTimes} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
@ -76,7 +76,7 @@ class AuthenticationPage extends React.Component<Props, state> {
* request a new token and check if pwd was valid * request a new token and check if pwd was valid
*/ */
authenticate(): void { authenticate(): void {
refreshAPIToken( token.refreshAPIToken(
(error) => { (error) => {
if (error !== '') { if (error !== '') {
this.setState({wrongPWDInfo: true}); this.setState({wrongPWDInfo: true});

View File

@ -7,6 +7,8 @@ import '@testing-library/jest-dom/extend-expect';
import {configure} from 'enzyme'; import {configure} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16'; import Adapter from 'enzyme-adapter-react-16';
import GlobalInfos from './utils/GlobalInfos'; import GlobalInfos from './utils/GlobalInfos';
import {CookieTokenStore} from "./utils/TokenStore/CookieTokenStore";
import {token} from "./utils/Api";
configure({adapter: new Adapter()}); configure({adapter: new Adapter()});
@ -44,6 +46,7 @@ global.callAPIMock = (resonse) => {
global.beforeEach(() => { global.beforeEach(() => {
// empty fetch response implementation for each test // empty fetch response implementation for each test
global.fetch = prepareFetchApi({}); global.fetch = prepareFetchApi({});
token.setTokenStore(new CookieTokenStore());
// todo with callAPIMock // todo with callAPIMock
}); });

View File

@ -1,4 +1,5 @@
import GlobalInfos from './GlobalInfos'; import GlobalInfos from './GlobalInfos';
import {TokenStore} from './TokenStore/TokenStore';
const APIPREFIX: string = '/api/'; const APIPREFIX: string = '/api/';
@ -11,165 +12,127 @@ interface ApiBaseRequest {
[_: string]: string | number | boolean | object; [_: string]: string | number | boolean | object;
} }
// store api token - empty if not set export namespace token {
let apiToken = ''; // store api token - empty if not set
let apiToken = '';
// a callback que to be called after api token refresh // a callback que to be called after api token refresh
let callQue: ((error: string) => void)[] = []; let callQue: ((error: string) => void)[] = [];
// flag to check wheter a api refresh is currently pending // flag to check wheter a api refresh is currently pending
let refreshInProcess = false; let refreshInProcess = false;
// store the expire seconds of token // store the expire seconds of token
let expireSeconds = -1; let expireSeconds = -1;
/** let tokenStore: TokenStore;
* refresh the api token or use that one in cookie if still valid
* @param callback to be called after successful refresh
* @param password
* @param force
*/
export function refreshAPIToken(callback: (error: string) => void, force?: boolean, password?: string): void {
callQue.push(callback);
// check if already is a token refresh is in process export function setTokenStore(ts: TokenStore): void {
if (refreshInProcess) { tokenStore = ts;
// if yes return
return;
} else {
// if not set flat
refreshInProcess = true;
} }
if (apiTokenValid() && !force) { /**
console.log('token still valid...'); * refresh the api token or use that one in cookie if still valid
callFuncQue(''); * @param callback to be called after successful refresh
return; * @param password
} * @param force
*/
export function refreshAPIToken(callback: (error: string) => void, force?: boolean, password?: string): void {
callQue.push(callback);
const formData = new FormData(); // check if already is a token refresh is in process
formData.append('grant_type', 'client_credentials'); if (refreshInProcess) {
formData.append('client_id', 'openmediacenter'); // if yes return
formData.append('client_secret', password ? password : 'openmediacenter'); return;
formData.append('scope', 'all'); } else {
// if not set flat
refreshInProcess = true;
}
interface APIToken { if (apiTokenValid() && !force) {
error?: string; console.log('token still valid...');
// eslint-disable-next-line camelcase
access_token: string; // no camel case allowed because of backendlib
// eslint-disable-next-line camelcase
expires_in: number; // no camel case allowed because of backendlib
scope: string;
// eslint-disable-next-line camelcase
token_type: string; // no camel case allowed because of backendlib
}
fetch('/token', {method: 'POST', body: formData}).then((response) =>
response.json().then((result: APIToken) => {
if (result.error) {
callFuncQue(result.error);
return;
}
// set api token
apiToken = result.access_token;
// set expire time
expireSeconds = new Date().getTime() / 1000 + result.expires_in;
setTokenCookie(apiToken, expireSeconds);
// call all handlers and release flag
callFuncQue(''); callFuncQue('');
}) return;
);
}
export function apiTokenValid(): boolean {
// check if a cookie with token is available
const token = getTokenCookie();
if (token !== null) {
// check if token is at least valid for the next minute
if (token.expire > new Date().getTime() / 1000 + 60) {
apiToken = token.token;
expireSeconds = token.expire;
return true;
} }
}
return false;
}
/** const formData = new FormData();
* call all qued callbacks formData.append('grant_type', 'client_credentials');
*/ formData.append('client_id', 'openmediacenter');
function callFuncQue(error: string): void { formData.append('client_secret', password ? password : 'openmediacenter');
// call all pending handlers formData.append('scope', 'all');
callQue.map((func) => {
return func(error);
});
// reset pending que
callQue = [];
// release flag to be able to start new refresh
refreshInProcess = false;
}
/** interface APIToken {
* set the cookie for the currently gotten token error?: string;
* @param token token string // eslint-disable-next-line camelcase
* @param validSec second time when the token will be invalid access_token: string; // no camel case allowed because of backendlib
*/ // eslint-disable-next-line camelcase
function setTokenCookie(token: string, validSec: number): void { expires_in: number; // no camel case allowed because of backendlib
let d = new Date(); scope: string;
d.setTime(validSec * 1000); // eslint-disable-next-line camelcase
console.log('token set' + d.toUTCString()); token_type: string; // no camel case allowed because of backendlib
let expires = 'expires=' + d.toUTCString();
document.cookie = 'token=' + token + ';' + expires + ';path=/';
document.cookie = 'token_expire=' + validSec + ';' + expires + ';path=/';
}
/**
* get all required cookies for the token
*/
function getTokenCookie(): {token: string; expire: number} | null {
const token = decodeCookie('token');
const expireInString = decodeCookie('token_expire');
const expireIn = parseInt(expireInString, 10);
if (expireIn !== 0 && token !== '') {
return {token: token, expire: expireIn};
} else {
return null;
}
}
/**
* decode a simple cookie with key specified
* @param key cookie key
*/
function decodeCookie(key: string): string {
let name = key + '=';
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') {
c = c.substring(1);
} }
if (c.indexOf(name) === 0) {
return c.substring(name.length, c.length);
}
}
return '';
}
/** fetch('/token', {method: 'POST', body: formData}).then((response) =>
* check if api token is valid -- if not request new one response.json().then((result: APIToken) => {
* when finished call callback if (result.error) {
* @param callback function to be called afterwards callFuncQue(result.error);
*/ return;
function checkAPITokenValid(callback: () => void): void { }
// check if token is valid and set // set api token
if (apiToken === '' || expireSeconds <= new Date().getTime() / 1000) { apiToken = result.access_token;
refreshAPIToken(() => { // set expire time
callback(); expireSeconds = new Date().getTime() / 1000 + result.expires_in;
// setTokenCookie(apiToken, expireSeconds);
tokenStore.setToken({accessToken: apiToken, expireTime: expireSeconds, tokenType: '', scope: ''});
// call all handlers and release flag
callFuncQue('');
})
);
}
export function apiTokenValid(): boolean {
// check if a cookie with token is available
// const token = getTokenCookie();
const tmptoken = tokenStore.loadToken();
if (tmptoken !== null) {
// check if token is at least valid for the next minute
if (tmptoken.expireTime > new Date().getTime() / 1000 + 60) {
apiToken = tmptoken.accessToken;
expireSeconds = tmptoken.expireTime;
return true;
}
}
return false;
}
/**
* call all qued callbacks
*/
function callFuncQue(error: string): void {
// call all pending handlers
callQue.map((func) => {
return func(error);
}); });
} else { // reset pending que
callback(); callQue = [];
// release flag to be able to start new refresh
refreshInProcess = false;
}
/**
* check if api token is valid -- if not request new one
* when finished call callback
* @param callback function to be called afterwards
*/
export function checkAPITokenValid(callback: (mytoken: string) => void): void {
// check if token is valid and set
if (apiToken === '' || expireSeconds <= new Date().getTime() / 1000) {
console.log('token not valid...');
refreshAPIToken(() => {
callback(apiToken);
});
} else {
callback(apiToken);
}
} }
} }
@ -186,13 +149,13 @@ export function callAPI<T>(
callback: (_: T) => void, callback: (_: T) => void,
errorcallback: (_: string) => void = (_: string): void => {} errorcallback: (_: string) => void = (_: string): void => {}
): void { ): void {
checkAPITokenValid(() => { token.checkAPITokenValid((mytoken) => {
fetch(APIPREFIX + apinode, { fetch(APIPREFIX + apinode, {
method: 'POST', method: 'POST',
body: JSON.stringify(fd), body: JSON.stringify(fd),
headers: new Headers({ headers: new Headers({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: 'Bearer ' + apiToken Authorization: 'Bearer ' + mytoken
}) })
}) })
.then((response) => { .then((response) => {
@ -252,13 +215,13 @@ export function callApiUnsafe<T>(
* @param callback the callback with PLAIN text reply from backend * @param callback the callback with PLAIN text reply from backend
*/ */
export function callAPIPlain(apinode: APINode, fd: ApiBaseRequest, callback: (_: string) => void): void { export function callAPIPlain(apinode: APINode, fd: ApiBaseRequest, callback: (_: string) => void): void {
checkAPITokenValid(() => { token.checkAPITokenValid((mytoken) => {
fetch(APIPREFIX + apinode, { fetch(APIPREFIX + apinode, {
method: 'POST', method: 'POST',
body: JSON.stringify(fd), body: JSON.stringify(fd),
headers: new Headers({ headers: new Headers({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: 'Bearer ' + apiToken Authorization: 'Bearer ' + mytoken
}) })
}).then((response) => }).then((response) =>
response.text().then((result) => { response.text().then((result) => {

View File

@ -0,0 +1,48 @@
import {Token, TokenStore} from './TokenStore';
export class CookieTokenStore extends TokenStore {
loadToken(): Token | null {
const token = this.decodeCookie('token');
const expireInString = this.decodeCookie('token_expire');
const expireIn = parseInt(expireInString, 10);
if (expireIn !== 0 && token !== '') {
return {accessToken: token, expireTime: expireIn, scope: '', tokenType: ''};
} else {
return null;
}
}
/**
* set the cookie for the currently gotten token
* @param token the token to set
*/
setToken(token: Token): void {
let d = new Date();
d.setTime(token.expireTime * 1000);
console.log('token set' + d.toUTCString());
let expires = 'expires=' + d.toUTCString();
document.cookie = 'token=' + token.accessToken + ';' + expires + ';path=/';
document.cookie = 'token_expire=' + token.expireTime + ';' + expires + ';path=/';
}
/**
* decode a simple cookie with key specified
* @param key cookie key
*/
decodeCookie(key: string): string {
let name = key + '=';
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') {
c = c.substring(1);
}
if (c.indexOf(name) === 0) {
return c.substring(name.length, c.length);
}
}
return '';
}
}

View File

@ -0,0 +1,11 @@
export interface Token {
accessToken: string;
expireTime: number; // second time when token will be invalidated
scope: string;
tokenType: string;
}
export abstract class TokenStore {
abstract loadToken(): Token | null;
abstract setToken(token: Token): void;
}