From 96b571d09e8fa185cf503068fcd9c4da2b1d7a08 Mon Sep 17 00:00:00 2001 From: Nikolay Vigdorov Date: Tue, 27 Jul 2021 12:09:07 +0300 Subject: [PATCH] add authorization --- public/index.html | 2 +- .../components/login-layout/LoginLayout.tsx | 99 +++++++++++++++++++ src/app/components/login-layout/index.ts | 1 + src/app/components/main-layout/MainLayout.tsx | 26 ++++- src/app/components/page/Page.tsx | 31 +++++- src/core/infrastructure/AuthServiceAPI.ts | 50 ++++++++++ src/core/infrastructure/Http.ts | 5 +- src/core/infrastructure/HttpAuthAPI.ts | 92 +++++++++++++++++ src/core/infrastructure/StorageAPI.ts | 35 +++++++ src/core/infrastructure/TokenAPI.ts | 32 ++++++ src/core/infrastructure/atom/authAtom.ts | 14 +++ src/core/infrastructure/atom/exampleAtom.ts | 7 -- src/core/services/LocalStorageService.ts | 21 ---- src/core/utils/__test__/jsonParse.test.ts | 2 +- src/core/utils/jsonParse.ts | 6 +- 15 files changed, 382 insertions(+), 41 deletions(-) create mode 100644 src/app/components/login-layout/LoginLayout.tsx create mode 100644 src/app/components/login-layout/index.ts create mode 100644 src/core/infrastructure/AuthServiceAPI.ts create mode 100644 src/core/infrastructure/HttpAuthAPI.ts create mode 100644 src/core/infrastructure/StorageAPI.ts create mode 100644 src/core/infrastructure/TokenAPI.ts create mode 100644 src/core/infrastructure/atom/authAtom.ts delete mode 100644 src/core/infrastructure/atom/exampleAtom.ts delete mode 100644 src/core/services/LocalStorageService.ts diff --git a/public/index.html b/public/index.html index d1c1bb9..cbdc28c 100644 --- a/public/index.html +++ b/public/index.html @@ -7,7 +7,7 @@ - Crypto bot: dev + Crypto bot: Admin diff --git a/src/app/components/login-layout/LoginLayout.tsx b/src/app/components/login-layout/LoginLayout.tsx new file mode 100644 index 0000000..42a8be9 --- /dev/null +++ b/src/app/components/login-layout/LoginLayout.tsx @@ -0,0 +1,99 @@ +import {Button, Card, Form, Input, Layout} from 'antd'; +import {useForm} from 'antd/lib/form/Form'; +import {AxiosError} from 'axios'; +import React, {FC, memo, useCallback, useState} from 'react'; +import {createUseStyles} from 'react-jss'; +import authServiceApi from '../../../core/infrastructure/AuthServiceAPI'; + +type LoginModel = { + login: string; + password: string; +}; + +const useStyles = createUseStyles({ + input: { + marginBottom: '16px' + }, + layout: { + height: '100%' + }, + content: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center' + }, +}); + +const formItemLayout = { + labelCol: { + xs: {span: 24}, + sm: {span: 8} + }, + wrapperCol: { + xs: {span: 24}, + sm: {span: 16} + } +}; + +const LoginLayout: FC = () => { + const classes = useStyles(); + + const [disabled, setDisabled] = useState(false); + + const [form] = useForm(); + + const handleSubmit = useCallback(() => { + const {login, password} = form.getFieldsValue(); + setDisabled(true); + authServiceApi.auth(login, password).catch((error: AxiosError) => { + const message = error.response?.data.message; + const errors = message ? [message] : ['Some error']; + const validationParams = { + validating: false, + errors + }; + form.setFields([ + {...validationParams, name: 'login'}, + {...validationParams, name: 'password'} + ]); + setDisabled(false); + }); + }, [form]); + + return ( + + + +
+ + + + + + + + + +
+
+
+
+ ); +}; + +export default memo(LoginLayout); diff --git a/src/app/components/login-layout/index.ts b/src/app/components/login-layout/index.ts new file mode 100644 index 0000000..c0082af --- /dev/null +++ b/src/app/components/login-layout/index.ts @@ -0,0 +1 @@ +export {default} from './LoginLayout'; diff --git a/src/app/components/main-layout/MainLayout.tsx b/src/app/components/main-layout/MainLayout.tsx index 67c4172..97b71bf 100644 --- a/src/app/components/main-layout/MainLayout.tsx +++ b/src/app/components/main-layout/MainLayout.tsx @@ -1,20 +1,42 @@ -import {Layout} from 'antd'; +import {Button, Layout} from 'antd'; import React, {FC, memo, PropsWithChildren} from 'react'; import {createUseStyles} from 'react-jss'; +import authServiceApi from '../../../core/infrastructure/AuthServiceAPI'; import Menu from '../menu'; const useStyles = createUseStyles({ layout: { height: '100%', }, + container: { + height: '100%', + display: 'flex', + flexDirection: 'column', + }, + buttonContainer: { + margin: 'auto 16px 16px 16px', + }, + button: { + width: '100%', + } }); +const handleSignOut = () => { + authServiceApi.signOut(); +}; + const MainLayout: FC> = ({children}) => { const classes = useStyles(); + return ( - +
+ +
+ +
+
diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 35937e5..3c3b656 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -1,6 +1,6 @@ -import React, {memo} from 'react'; +import React, {FC, Fragment, memo, useEffect} from 'react'; import {Route, Switch} from 'react-router-dom'; -import {context} from '@reatom/react'; +import {context, useAtom} from '@reatom/react'; import mainPageRouter from '_pages/main/routing'; import usersPageRouter from '_pages/users/routing'; import actionsPageRouter from '_pages/actions/routing'; @@ -13,6 +13,9 @@ import jss from 'jss'; import preset from 'jss-preset-default'; import {store} from '../../../core/infrastructure/atom/store'; import ConnectedRouter from '../../../core/blocks/connected-router/ConnectedRouter'; +import {authAtom} from '../../../core/infrastructure/atom/authAtom'; +import LoginLayout from '../login-layout'; +import authServiceApi from '../../../core/infrastructure/AuthServiceAPI'; jss.setup(preset()); @@ -33,10 +36,11 @@ const styles = { jss.createStyleSheet(styles).attach(); -const Page: React.FC = () => { +const SecurityRoutes: FC = memo(() => { + const isAuth = useAtom(authAtom); return ( - - + + {isAuth && ( {mainPageRouter} @@ -50,6 +54,23 @@ const Page: React.FC = () => { + )} + {!isAuth && ( + + )} + + ); +}); + +const Page: FC = () => { + useEffect(() => { + authServiceApi.refresh(); + }); + + return ( + + + ); diff --git a/src/core/infrastructure/AuthServiceAPI.ts b/src/core/infrastructure/AuthServiceAPI.ts new file mode 100644 index 0000000..5689140 --- /dev/null +++ b/src/core/infrastructure/AuthServiceAPI.ts @@ -0,0 +1,50 @@ +import axios from 'axios'; +import {bindedActions as authActions} from './atom/authAtom'; + +import {tokenAPI} from './TokenAPI'; + +type AuthResponse = { + access_token: string; + refresh_token: string; +}; + +const ENDPOINT = 'https://api.crypto-bot.vigdorov.ru/auth'; + +class AuthServiceApi { + auth = (login: string, password: string) => { + return axios.post(`${ENDPOINT}/auth-user`, { + login, + password, + }).then(({data: {access_token, refresh_token}}) => { + tokenAPI.setTokens(access_token, refresh_token); + authActions.changeAuth(true); + }); + } + + refresh = () => { + const refreshToken = tokenAPI.getRefreshToken(); + if (refreshToken) { + return axios.post(`${ENDPOINT}/refresh-tokens`, {refresh_token: refreshToken}) + .then(({data: {access_token, refresh_token}}) => { + tokenAPI.setTokens(access_token, refresh_token); + authActions.changeAuth(true); + }) + .catch(e => { + tokenAPI.clearTokents(); + authActions.changeAuth(false); + throw e; + }); + } + + return Promise.reject(new Error('Не авторизован')); + } + + signOut = () => { + tokenAPI.clearTokents(); + authActions.changeAuth(false); + } +} + +const authServiceApi = new AuthServiceApi(); + +export default authServiceApi; diff --git a/src/core/infrastructure/Http.ts b/src/core/infrastructure/Http.ts index 6887d6c..2c20ae7 100644 --- a/src/core/infrastructure/Http.ts +++ b/src/core/infrastructure/Http.ts @@ -1,4 +1,5 @@ -import axios, {AxiosRequestConfig} from 'axios'; +import {AxiosRequestConfig} from 'axios'; +import httpAuthApi from './HttpAuthAPI'; enum Method { Get = 'get', @@ -16,7 +17,7 @@ type RequestConfig = Omit & { }; const requestMiddleware = async (config: RequestConfig): Promise => { - const axiosResponse = await axios.request(config); + const axiosResponse = await httpAuthApi.request(config); // Добавить обработку ошибок return axiosResponse.data; }; diff --git a/src/core/infrastructure/HttpAuthAPI.ts b/src/core/infrastructure/HttpAuthAPI.ts new file mode 100644 index 0000000..b9f57cb --- /dev/null +++ b/src/core/infrastructure/HttpAuthAPI.ts @@ -0,0 +1,92 @@ +import axios, {AxiosError, AxiosRequestConfig, AxiosResponse} from 'axios'; +import authServiceApi from './AuthServiceAPI'; +import {tokenAPI} from './TokenAPI'; + +type HandlerArgs = { + resolve: (v: AxiosResponse) => void, + reject: (reason?: any) => void, + requestConfig: AxiosRequestConfig + error?: AxiosError, +} + +type Handler = (args: HandlerArgs) => void; + +class HttpAuthApi { + pendingRequests: HandlerArgs[]; + + isPendingRefresh: boolean; + + constructor() { + this.pendingRequests = []; + this.isPendingRefresh = false; + } + + processPendingRequests = (handler: Handler) => { + this.isPendingRefresh = false; + while (this.pendingRequests.length) { + const pendingRequest = this.pendingRequests.shift()!; + handler(pendingRequest); + } + } + + repeatRequests = () => { + this.processPendingRequests(({resolve, reject, requestConfig}) => { + this.request(requestConfig) + .then(resolve) + .catch(reject); + }); + } + + rejectRequests = (error: AxiosError) => { + this.processPendingRequests(({reject}) => { + reject(error); + }); + } + + errorHandling = ( + error: AxiosError, + resolve: (v: AxiosResponse) => void, + reject: (reason?: any) => void, + requestConfig: AxiosRequestConfig + ) => { + if (error.response?.status === 401) { + this.isPendingRefresh = true; + this.pendingRequests.push({resolve, reject, requestConfig}); + authServiceApi.refresh() + .then(() => { + this.repeatRequests(); + }) + .catch((refreshError: AxiosError) => { + this.rejectRequests(refreshError); + tokenAPI.clearTokents(); + location.reload(); + }); + } else { + reject(error); + } + } + + addTokenToRequest = (requestConfig: AxiosRequestConfig): AxiosRequestConfig => ({ + ...requestConfig, + headers: { + ...(requestConfig.headers ?? {}), + 'Authorization': tokenAPI.getAccessToken(), + }, + }) + + request = (requestConfig: AxiosRequestConfig): Promise> => { + return new Promise((resolve, reject) => { + if (this.isPendingRefresh) { + this.pendingRequests.push({resolve, reject, requestConfig}); + } else { + axios(this.addTokenToRequest(requestConfig)) + .then(response => resolve(response)) + .catch(error => this.errorHandling(error, resolve, reject, requestConfig)); + } + }); + } +} + +const httpAuthApi = new HttpAuthApi(); + +export default httpAuthApi; diff --git a/src/core/infrastructure/StorageAPI.ts b/src/core/infrastructure/StorageAPI.ts new file mode 100644 index 0000000..971e552 --- /dev/null +++ b/src/core/infrastructure/StorageAPI.ts @@ -0,0 +1,35 @@ +export class StorageAPI { + private _init: T; + + private _storage: Storage; + + private _stateName: string; + + constructor(init: T, stateName: string, storageType: 'local' | 'session' = 'local') { + this._init = init; + this._stateName = stateName; + this._storage = storageType === 'local' ? localStorage : sessionStorage; + + if (!this._storage.getItem(stateName)) { + this.set(this._init); + } + } + + set(updatedState: T) { + this._storage.setItem(this._stateName, JSON.stringify(updatedState)); + } + + get(): T { + const stringValue = this._storage.getItem(this._stateName) || ''; + + try { + return JSON.parse(stringValue); + } catch (e) { + return this._init; + } + } + + clear() { + this._storage.removeItem(this._stateName); + } +} diff --git a/src/core/infrastructure/TokenAPI.ts b/src/core/infrastructure/TokenAPI.ts new file mode 100644 index 0000000..2898841 --- /dev/null +++ b/src/core/infrastructure/TokenAPI.ts @@ -0,0 +1,32 @@ +import {StorageAPI} from './StorageAPI'; + +class TokenAPI { + private accessTokenAPI: StorageAPI; + + private refreshTokenAPI: StorageAPI; + + constructor() { + this.accessTokenAPI = new StorageAPI('', 'access', 'session'); + this.refreshTokenAPI = new StorageAPI('', 'refresh', 'local'); + } + + setTokens(accessToken: string, refreshToken: string) { + this.accessTokenAPI.set(accessToken); + this.refreshTokenAPI.set(refreshToken); + } + + getAccessToken() { + return this.accessTokenAPI.get(); + } + + getRefreshToken() { + return this.refreshTokenAPI.get(); + } + + clearTokents() { + this.accessTokenAPI.clear(); + this.refreshTokenAPI.clear(); + } +} + +export const tokenAPI = new TokenAPI(); diff --git a/src/core/infrastructure/atom/authAtom.ts b/src/core/infrastructure/atom/authAtom.ts new file mode 100644 index 0000000..906cc8a --- /dev/null +++ b/src/core/infrastructure/atom/authAtom.ts @@ -0,0 +1,14 @@ +import {declareAction, declareAtom} from '@reatom/core'; +import {store} from './store'; + +const authAction = declareAction(); + +export const authAtom = declareAtom(false, on => [ + on(authAction, (_state, payload) => payload), +]); + +export const bindedActions = { + changeAuth: (isAuth: boolean) => { + store.dispatch(authAction(isAuth)); + }, +}; diff --git a/src/core/infrastructure/atom/exampleAtom.ts b/src/core/infrastructure/atom/exampleAtom.ts deleted file mode 100644 index 56ab85b..0000000 --- a/src/core/infrastructure/atom/exampleAtom.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {declareAction, declareAtom} from '@reatom/core'; - -export const changeNameAction = declareAction(); - -export const nameAtom = declareAtom('', on => [ - on(changeNameAction, (_state, payload) => payload), -]); diff --git a/src/core/services/LocalStorageService.ts b/src/core/services/LocalStorageService.ts deleted file mode 100644 index ddc19b1..0000000 --- a/src/core/services/LocalStorageService.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const makeLocalStorageService = (init: T, stateName: string) => { - if (!localStorage.getItem(stateName)) { - localStorage.setItem(stateName, JSON.stringify(init)); - } - - return { - set: (updatedState: T) => { - localStorage.setItem(stateName, JSON.stringify(updatedState)); - return updatedState; - }, - get: (): T => { - const stringValue = localStorage.getItem(stateName) || ''; - - try { - return JSON.parse(stringValue); - } catch (e) { - return init; - } - }, - }; -}; diff --git a/src/core/utils/__test__/jsonParse.test.ts b/src/core/utils/__test__/jsonParse.test.ts index f75f886..f5b47c3 100644 --- a/src/core/utils/__test__/jsonParse.test.ts +++ b/src/core/utils/__test__/jsonParse.test.ts @@ -16,7 +16,7 @@ describe('jsonParse', () => { }); it('Должен вернуть undefined для не корректных значений', () => { - expect(jsonParse()).toBeUndefined(); + expect(jsonParse(undefined)).toBeUndefined(); expect(jsonParse('')).toBeUndefined(); expect(jsonParse(' ')).toBeUndefined(); expect(jsonParse('{"9')).toBeUndefined(); diff --git a/src/core/utils/jsonParse.ts b/src/core/utils/jsonParse.ts index 73f2176..c9915a6 100644 --- a/src/core/utils/jsonParse.ts +++ b/src/core/utils/jsonParse.ts @@ -1,4 +1,6 @@ -export const jsonParse = (str?: string, defaultValue?: T): Undefinable => { +export function jsonParse(str: Undefinable, defaultValue: T): T; +export function jsonParse(str: Undefinable, defaultValue?: T): Undefinable; +export function jsonParse(str: Undefinable, defaultValue?: T) { const trimStr = str?.trim(); try { const parsedValue = JSON.parse(trimStr ?? ''); @@ -7,4 +9,4 @@ export const jsonParse = (str?: string, defaultValue?: T): Undefinable => } catch (e) { return defaultValue; } -}; +}