fix: ui expired token (#4007)

* fix: login state when token expired
If there token in the localstorage, the user was always displayed as authenticated, regardless of the token expiration

* chore: added changeset for @verdaccio/ui-components

* tests: JSON error for node versions older than 20
This commit is contained in:
Ku3mi41 2023-09-24 15:17:53 +07:00 committed by GitHub
parent d2d3bad0d0
commit 92f1c34ae8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 739 additions and 259 deletions

View File

@ -0,0 +1,5 @@
---
'@verdaccio/ui-components': patch
---
- fixed login state when token is expired (@ku3mi41 in #3980)

View File

@ -0,0 +1,33 @@
/**
* Token Utility
*/
import dayjs from 'dayjs';
function encodeBase64(string: string) {
return Buffer.from(string).toString('base64');
}
export function generateTokenWithTimeRange(amount = 0) {
const payload = {
username: 'verdaccio',
exp: Number.parseInt(String(dayjs(new Date()).add(amount, 'hour').valueOf() / 1000), 10),
};
return `xxxxxx.${encodeBase64(JSON.stringify(payload))}.xxxxxx`;
}
export function generateTokenWithExpirationAsString() {
const payload = { username: 'verdaccio', exp: 'I am not a number' };
return `xxxxxx.${encodeBase64(JSON.stringify(payload))}.xxxxxx`;
}
export function generateInvalidToken() {
const payload = `invalidtoken`;
return `xxxxxx.${encodeBase64(payload)}.xxxxxx`;
}
export function generateTokenWithOutExpiration() {
const payload = {
username: 'verdaccio',
};
return `xxxxxx.${encodeBase64(JSON.stringify(payload))}.xxxxxx`;
}

View File

@ -37,6 +37,7 @@
"highlight.js": "11.7.0",
"history": "4.10.1",
"i18next": "20.6.1",
"js-base64": "3.7.5",
"localstorage-memory": "1.0.3",
"lodash": "4.17.21",
"marked": "4.3.0",

View File

@ -0,0 +1,38 @@
// eslint-disable-next-line jest/no-mocks-import
import { generateTokenWithTimeRange } from '../../../jest/unit/components/__mocks__/token';
describe('getDefaultUserState', (): void => {
const username = 'xyz';
beforeEach(() => {
jest.resetModules();
});
test('should return state with empty user', (): void => {
const token = 'token-xx-xx-xx';
jest.doMock('../storage', () => ({
getItem: (key: string) => (key === 'token' ? token : username),
}));
const { getDefaultUserState } = require('./login');
const result = {
token: null,
username: null,
};
expect(getDefaultUserState()).toEqual(result);
});
test('should return state with user from storage', (): void => {
const token = generateTokenWithTimeRange(24);
jest.doMock('../storage', () => ({
getItem: (key: string) => (key === 'token' ? token : username),
}));
const { getDefaultUserState } = require('./login');
const result = {
token,
username,
};
expect(getDefaultUserState()).toEqual(result);
});
});

View File

@ -2,6 +2,7 @@ import { createModel } from '@rematch/core';
import i18next from 'i18next';
import type { RootModel } from '.';
import { isTokenExpire } from '../../utils';
import API from '../api';
import storage from '../storage';
@ -23,12 +24,17 @@ export type LoginBody = {
error?: LoginError;
} & LoginResponse;
const token = storage.getItem('token');
const username = storage.getItem('username');
const defaultUserState: LoginBody = {
token,
username,
};
export function getDefaultUserState(): LoginBody {
const token = storage.getItem('token');
const username = storage.getItem('username');
const defaultUserState = isTokenExpire(token)
? { token: null, username: null }
: { token, username };
return defaultUserState;
}
const defaultUserState: LoginBody = getDefaultUserState();
/**
*

View File

@ -8,3 +8,4 @@ export {
} from './cli-utils';
export { default as loadable } from './loadable';
export { Route } from './routes';
export * from './token';

View File

@ -0,0 +1,54 @@
// eslint-disable-next-line jest/no-mocks-import
import {
generateInvalidToken,
generateTokenWithExpirationAsString,
generateTokenWithOutExpiration,
generateTokenWithTimeRange,
} from '../../jest/unit/components/__mocks__/token';
import { isTokenExpire } from './token';
/* eslint-disable no-console */
console.error = jest.fn();
describe('isTokenExpire', (): void => {
test('isTokenExpire - null is not a valid payload', (): void => {
expect(isTokenExpire(null)).toBeTruthy();
});
test('isTokenExpire - token is not a valid payload', (): void => {
expect(isTokenExpire('not_a_valid_token')).toBeTruthy();
});
test('isTokenExpire - token should not expire in 24 hrs range', (): void => {
const token = generateTokenWithTimeRange(24);
expect(isTokenExpire(token)).toBeFalsy();
});
test('isTokenExpire - token should expire for current time', (): void => {
const token = generateTokenWithTimeRange();
expect(isTokenExpire(token)).toBeTruthy();
});
test('isTokenExpire - token expiration is not available', (): void => {
const token = generateTokenWithOutExpiration();
expect(isTokenExpire(token)).toBeTruthy();
});
test('isTokenExpire - token is not a valid json token', (): void => {
const NODE_MAJOR_VERSION = +process.versions.node.split('.')[0];
const errorToken = new SyntaxError(
NODE_MAJOR_VERSION >= 20
? 'Unexpected token \'i\', "invalidtoken" is not valid JSON'
: 'Unexpected token i in JSON at position 0'
);
const token = generateInvalidToken();
const result = ['Invalid token:', errorToken, 'xxxxxx.aW52YWxpZHRva2Vu.xxxxxx'];
expect(isTokenExpire(token)).toBeTruthy();
expect(console.error).toHaveBeenCalledWith(...result);
});
test('isTokenExpire - token expiration is not a number', (): void => {
const token = generateTokenWithExpirationAsString();
expect(isTokenExpire(token)).toBeTruthy();
});
});

View File

@ -0,0 +1,33 @@
import { Base64 } from 'js-base64';
import isNumber from 'lodash/isNumber';
import isString from 'lodash/isString';
export function isTokenExpire(token: string | null): boolean {
if (!isString(token)) {
return true;
}
const [, payload] = token.split('.');
if (!payload) {
return true;
}
let exp: number;
try {
exp = JSON.parse(Base64.decode(payload)).exp;
} catch (error: any) {
// eslint-disable-next-line no-console
console.error('Invalid token:', error, token);
return true;
}
if (!exp || !isNumber(exp)) {
return true;
}
// Report as expire before (real expire time - 30s)
const jsTimestamp = exp * 1000 - 30000;
const expired = Date.now() >= jsTimestamp;
return expired;
}

File diff suppressed because it is too large Load Diff