From 022ecb036528c6d9aaf6529e363434682f90a699 Mon Sep 17 00:00:00 2001 From: Mirlan Date: Fri, 9 Jul 2021 18:26:41 +0600 Subject: [PATCH] feat(1227): integrate OIDC auth (#393) --- package.json | 2 + public/silent-refresh.html | 15 ++++ src/config/authKeys.tsx | 2 +- src/config/routes.tsx | 4 +- src/features/App/RedirectCallback.tsx | 16 ++++ src/features/App/UnauthenticatedApp.tsx | 8 +- src/features/App/index.tsx | 13 ++- src/features/AuthStore/helpers.tsx | 19 ++++ src/features/AuthStore/hooks/useAuth.tsx | 95 ++++++++++++++++++++ src/features/AuthStore/hooks/useLogin.tsx | 25 ------ src/features/AuthStore/hooks/useLogout.tsx | 20 ----- src/features/AuthStore/index.tsx | 26 +----- src/features/Login/hooks.tsx | 58 +----------- src/features/Login/index.tsx | 25 +----- src/helpers/callApi/getRequestConfig.tsx | 2 +- src/helpers/callApi/logoutIfUnauthorized.tsx | 3 +- src/requests/index.tsx | 2 - src/requests/login.tsx | 64 ------------- src/requests/logout.tsx | 18 ---- 19 files changed, 174 insertions(+), 243 deletions(-) create mode 100644 public/silent-refresh.html create mode 100644 src/features/App/RedirectCallback.tsx create mode 100644 src/features/AuthStore/helpers.tsx create mode 100644 src/features/AuthStore/hooks/useAuth.tsx delete mode 100644 src/features/AuthStore/hooks/useLogin.tsx delete mode 100644 src/features/AuthStore/hooks/useLogout.tsx delete mode 100644 src/requests/login.tsx delete mode 100644 src/requests/logout.tsx diff --git a/package.json b/package.json index f78ae89f..574a224e 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,12 @@ "dependencies": { "@stripe/react-stripe-js": "^1.4.0", "@stripe/stripe-js": "^1.13.2", + "babel-polyfill": "^6.26.0", "date-fns": "^2.14.0", "history": "^4.10.1", "hls.js": "^0.14.15", "lodash": "^4.17.15", + "oidc-client": "^1.11.5", "react": "^17.0.1", "react-datepicker": "^3.1.3", "react-dom": "^17.0.1", diff --git a/public/silent-refresh.html b/public/silent-refresh.html new file mode 100644 index 00000000..852cc138 --- /dev/null +++ b/public/silent-refresh.html @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/src/config/authKeys.tsx b/src/config/authKeys.tsx index 74db72ec..2ababeaf 100644 --- a/src/config/authKeys.tsx +++ b/src/config/authKeys.tsx @@ -1 +1 @@ -export const TOKEN_KEY = 'x-auth-token' +export const TOKEN_KEY = 'Authorization' diff --git a/src/config/routes.tsx b/src/config/routes.tsx index ea2e231e..897c6015 100644 --- a/src/config/routes.tsx +++ b/src/config/routes.tsx @@ -1,4 +1,2 @@ -import { isProduction } from './env' - -export const API_ROOT = isProduction ? 'https://api.instat.tv' : 'https://api-staging.instat.tv' +export const API_ROOT = 'https://api.instat.tv' export const DATA_URL = `${API_ROOT}/data` diff --git a/src/features/App/RedirectCallback.tsx b/src/features/App/RedirectCallback.tsx new file mode 100644 index 00000000..e7a62103 --- /dev/null +++ b/src/features/App/RedirectCallback.tsx @@ -0,0 +1,16 @@ +import { useEffect } from 'react' + +import { useAuthStore } from 'features/AuthStore' +import { queryParamStorage } from 'features/QueryParamsStorage' + +export const RedirectCallback = () => { + const { signinRedirectCallback } = useAuthStore() + + useEffect(() => { + signinRedirectCallback().then(() => { + queryParamStorage.clear() + }) + }, [signinRedirectCallback]) + + return null +} diff --git a/src/features/App/UnauthenticatedApp.tsx b/src/features/App/UnauthenticatedApp.tsx index e8d30f04..7fd733cc 100644 --- a/src/features/App/UnauthenticatedApp.tsx +++ b/src/features/App/UnauthenticatedApp.tsx @@ -1,8 +1,8 @@ import { lazy } from 'react' import { - Route, Redirect, + Route, Switch, } from 'react-router-dom' @@ -15,6 +15,8 @@ import { useLexicsConfig } from 'features/LexicsStore' import { LanguageSelect } from 'features/LanguageSelect' import { HeaderStyled, HeaderGroup } from 'features/ProfileHeader/styled' +import { RedirectCallback } from './RedirectCallback' + const Login = lazy(() => import('features/Login')) const Register = lazy(() => import('features/Register')) @@ -42,6 +44,10 @@ export const UnauthenticatedApp = () => { + + + + diff --git a/src/features/App/index.tsx b/src/features/App/index.tsx index 7cbf2d91..0b107907 100644 --- a/src/features/App/index.tsx +++ b/src/features/App/index.tsx @@ -13,11 +13,16 @@ import { AuthenticatedApp } from './AuthenticatedApp' import { UnauthenticatedApp } from './UnauthenticatedApp' const Main = () => { - const { token } = useAuthStore() + const { loadingUser, user } = useAuthStore() - return token - ? - : + // юзер считывается из localstorage или + // access_token токен истек и запрашивается новый + if (loadingUser || user?.expired) return null + + if (!user) return + + // имеется действующий токен + return } export const App = () => ( diff --git a/src/features/AuthStore/helpers.tsx b/src/features/AuthStore/helpers.tsx new file mode 100644 index 00000000..aa9958ca --- /dev/null +++ b/src/features/AuthStore/helpers.tsx @@ -0,0 +1,19 @@ +import type { UserManagerSettings } from 'oidc-client' +import { WebStorageStateStore } from 'oidc-client' + +export const getClientSettings = (): UserManagerSettings => ({ + authority: 'https://api.instat.tv/', + automaticSilentRenew: true, + client_id: 'ott-web', + filterProtocolClaims: false, + loadUserInfo: false, + metadataUrl: 'https://api.instat.tv/auth/.well-known/openid-configuration', + post_logout_redirect_uri: `${window.origin}/login`, + redirect_uri: `${window.origin}/redirect`, + response_mode: 'query', + response_type: 'id_token token', + scope: 'openid', + silent_redirect_uri: `${window.origin}/silent-refresh.html`, + silentRequestTimeout: 5000, + userStore: new WebStorageStateStore({ store: window.localStorage }), +}) diff --git a/src/features/AuthStore/hooks/useAuth.tsx b/src/features/AuthStore/hooks/useAuth.tsx new file mode 100644 index 00000000..f7f2881e --- /dev/null +++ b/src/features/AuthStore/hooks/useAuth.tsx @@ -0,0 +1,95 @@ +import { + useCallback, + useState, + useMemo, + useEffect, +} from 'react' + +import type { User } from 'oidc-client' +import { UserManager } from 'oidc-client' + +import { writeToken, removeToken } from 'helpers/token' + +import { useToggle } from 'hooks' + +import { getClientSettings } from '../helpers' +import { useRegister } from './useRegister' + +export const useAuth = () => { + const { + close: markUserLoaded, + isOpen: loadingUser, + } = useToggle(true) + const [user, setUser] = useState() + const userManager = useMemo(() => new UserManager(getClientSettings()), []) + + const login = useCallback(async () => ( + userManager.signinRedirect() + ), [userManager]) + + const logout = useCallback(async () => { + userManager.signoutRedirect() + userManager.clearStaleState() + removeToken() + }, [userManager]) + + const register = useRegister() + + const storeUser = useCallback(async (loadedUser: User) => { + setUser(loadedUser) + writeToken(loadedUser.access_token) + }, []) + + const checkUser = useCallback(async () => { + const loadedUser = await userManager.getUser() + if (loadedUser) { + storeUser(loadedUser) + } + markUserLoaded() + }, [userManager, storeUser, markUserLoaded]) + + useEffect(() => { + // при первой загрузке считываем пользователя из localstorage + checkUser() + }, [checkUser]) + + useEffect(() => { + // попытаемся обновить токен используя refresh_token + const tryRenewToken = () => { + userManager.signinSilent().catch(logout) + } + // если запросы вернули 401 | 403 + window.addEventListener('FORBIDDEN_REQUEST', tryRenewToken) + + // и если токен истек + userManager.events.addAccessTokenExpired(tryRenewToken) + return () => { + window.removeEventListener('FORBIDDEN_REQUEST', tryRenewToken) + userManager.events.removeAccessTokenExpired(tryRenewToken) + } + }, [userManager, logout]) + + useEffect(() => { + // событие срабатывает после получения токена(первый логин и обновление токена) + userManager.events.addUserLoaded(storeUser) + return () => userManager.events.removeUserLoaded(storeUser) + }, [userManager, storeUser]) + + const auth = useMemo(() => ({ + loadingUser, + login, + logout, + register, + signinRedirectCallback: () => userManager.signinRedirectCallback(), + user, + }), [ + login, + logout, + register, + user, + loadingUser, + userManager, + ]) + + return auth +} diff --git a/src/features/AuthStore/hooks/useLogin.tsx b/src/features/AuthStore/hooks/useLogin.tsx deleted file mode 100644 index 68ec11c3..00000000 --- a/src/features/AuthStore/hooks/useLogin.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useHistory } from 'react-router-dom' - -import { PAGES } from 'config' -import { login } from 'requests' -import { writeToken } from 'helpers' - -type LoginArgs = Parameters[0] - -type Args = { - setToken: (token: string | null) => void, -} - -export const useLogin = ({ setToken }: Args) => { - const history = useHistory() - - const onSuccess = (token: string) => { - writeToken(token) - setToken(token) - history.replace(PAGES.home) - } - - return async ({ email, password }: LoginArgs) => ( - login({ email, password }).then(onSuccess) - ) -} diff --git a/src/features/AuthStore/hooks/useLogout.tsx b/src/features/AuthStore/hooks/useLogout.tsx deleted file mode 100644 index 1658e169..00000000 --- a/src/features/AuthStore/hooks/useLogout.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useHistory } from 'react-router-dom' - -import { PAGES } from 'config' -import { removeToken } from 'helpers' -import { logout } from 'requests' - -type Args = { - setToken: (token: string | null) => void, -} - -export const useLogout = ({ setToken }: Args) => { - const history = useHistory() - - return () => { - logout() - removeToken() - setToken(null) - history.replace(PAGES.login) - } -} diff --git a/src/features/AuthStore/index.tsx b/src/features/AuthStore/index.tsx index e51504a5..78262074 100644 --- a/src/features/AuthStore/index.tsx +++ b/src/features/AuthStore/index.tsx @@ -2,21 +2,11 @@ import type { ReactNode } from 'react' import { createContext, useContext, - useState, } from 'react' -import { readToken } from 'helpers' +import { useAuth } from './hooks/useAuth' -import { useLogin } from './hooks/useLogin' -import { useLogout } from './hooks/useLogout' -import { useRegister } from './hooks/useRegister' - -type Auth = { - login: ReturnType, - logout: ReturnType, - register: ReturnType, - token: string | null, -} +type Auth = ReturnType const AuthContext = createContext({} as Auth) @@ -25,17 +15,7 @@ type Props = { } export const AuthStore = ({ children }: Props) => { - const [token, setToken] = useState(readToken()) - const login = useLogin({ setToken }) - const logout = useLogout({ setToken }) - const register = useRegister() - - const auth = { - login, - logout, - register, - token, - } + const auth = useAuth() return {children} } diff --git a/src/features/Login/hooks.tsx b/src/features/Login/hooks.tsx index 064532b7..35ac2541 100644 --- a/src/features/Login/hooks.tsx +++ b/src/features/Login/hooks.tsx @@ -1,71 +1,19 @@ -import type { FormEvent, FocusEvent } from 'react' - -import trim from 'lodash/trim' -import isEmpty from 'lodash/isEmpty' - -import { formIds } from 'config/form' - -import { isValidEmail } from 'helpers/isValidEmail' +import type { FormEvent } from 'react' import { useAuthStore } from 'features/AuthStore' import { useForm } from 'features/FormStore' export const useLoginForm = () => { - const { - readFormError, - readFormValue, - updateFormError, - updateFormValue, - } = useForm() + const { readFormError } = useForm() const { login } = useAuthStore() - const onEmailBlur = ({ target }: FocusEvent) => { - const email = target.value - if (email && !isValidEmail(email)) { - updateFormError(formIds.email, 'error_invalid_email_format') - } - } - - const readTrimmedValue = (fieldName: string) => trim(readFormValue(fieldName)) - - const validateForm = () => { - let hasError = false - const email = readTrimmedValue(formIds.email) - const password = readTrimmedValue(formIds.password) - if (isEmpty(email)) { - updateFormError(formIds.email, 'error_empty_email') - hasError = true - } else if (!isValidEmail(email)) { - updateFormError(formIds.email, 'error_invalid_email_format') - hasError = true - } - if (isEmpty(password)) { - updateFormError(formIds.password, 'error_empty_password') - hasError = true - } - return !hasError - } - - const onError = (message: string) => { - updateFormError(formIds.formError, message) - } - const handleSubmit = async (event: FormEvent) => { event.preventDefault() - - if (validateForm()) { - const email = readTrimmedValue(formIds.email) - const password = readTrimmedValue(formIds.password) - - login({ email, password }).catch(onError) - } + login() } return { handleSubmit, - onEmailBlur, readFormError, - readFormValue, - updateFormValue, } } diff --git a/src/features/Login/index.tsx b/src/features/Login/index.tsx index e1ddcb0b..9f85e47b 100644 --- a/src/features/Login/index.tsx +++ b/src/features/Login/index.tsx @@ -3,7 +3,7 @@ import { formIds } from 'config/form' import { T9n } from 'features/T9n' import { Logo } from 'features/Logo' -import { Input, ButtonSolid } from 'features/Common' +import { ButtonSolid } from 'features/Common' import { Error } from 'features/Common/Input/styled' import { FormStore } from 'features/FormStore' @@ -16,15 +16,10 @@ import { RegisterButton, } from './styled' -const labelWidth = 75 - const LoginForm = () => { const { handleSubmit, - onEmailBlur, readFormError, - readFormValue, - updateFormValue, } = useLoginForm() const requestError = readFormError(formIds.formError) @@ -35,24 +30,6 @@ const LoginForm = () => {
- - - diff --git a/src/helpers/callApi/getRequestConfig.tsx b/src/helpers/callApi/getRequestConfig.tsx index d182fa66..8f567a7c 100644 --- a/src/helpers/callApi/getRequestConfig.tsx +++ b/src/helpers/callApi/getRequestConfig.tsx @@ -22,7 +22,7 @@ export const getRequestConfig = ( const token = readToken() if (token) { - requestConfig.headers.set(TOKEN_KEY, token) + requestConfig.headers.set(TOKEN_KEY, `Bearer ${token}`) } return requestConfig diff --git a/src/helpers/callApi/logoutIfUnauthorized.tsx b/src/helpers/callApi/logoutIfUnauthorized.tsx index ceb23e50..d7cf61c8 100644 --- a/src/helpers/callApi/logoutIfUnauthorized.tsx +++ b/src/helpers/callApi/logoutIfUnauthorized.tsx @@ -1,10 +1,9 @@ -import { PAGES } from 'config' import { removeToken } from 'helpers' export const logoutIfUnauthorized = async (response: Response) => { if (response.status === 401 || response.status === 403) { removeToken() - window.location.pathname = PAGES.login + window.dispatchEvent(new Event('FORBIDDEN_REQUEST')) } const error = new Error(response.statusText) diff --git a/src/requests/index.tsx b/src/requests/index.tsx index dff38a8f..e3469058 100644 --- a/src/requests/index.tsx +++ b/src/requests/index.tsx @@ -1,6 +1,4 @@ export * from './register' -export * from './login' -export * from './logout' export * from './getCountries' export * from './getCountryCities' export * from './getLexics' diff --git a/src/requests/login.tsx b/src/requests/login.tsx deleted file mode 100644 index ebf04803..00000000 --- a/src/requests/login.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { - DATA_URL, - PROCEDURES, - TOKEN_KEY, -} from 'config' -import { callApiBase } from 'helpers/callApi' -import { parseJSON } from 'helpers/callApi/parseJSON' - -const proc = PROCEDURES.auth_user - -const statusCodes = { - ACCOUNT_EXPIRED: 5, - EMAIL_NOT_FOUND: 2, - INVALID_CREDENTIALS: 3, - SUCCESS: 1, - USER_REMOVED_OR_BLOCKED: 4, -} - -const errorMessages = { - [statusCodes.EMAIL_NOT_FOUND]: 'error_invalid_email_or_password', - [statusCodes.INVALID_CREDENTIALS]: 'error_invalid_email_or_password', - [statusCodes.USER_REMOVED_OR_BLOCKED]: 'error_account_blocked', -} - -type Response = { - _p_status: number, -} - -type Args = { - email: string, - password: string, -} - -export const login = async ({ - email, - password, -}: Args) => { - const config = { - body: { - params: { - _p_email: email, - _p_password: password, - }, - proc, - }, - } - - try { - const response = await callApiBase({ - config, - url: DATA_URL, - }) - - const token = response.headers.get(TOKEN_KEY) - const { _p_status }: Response = await parseJSON(response) - - if (token && _p_status === statusCodes.SUCCESS) { - return Promise.resolve(token) - } - return Promise.reject(errorMessages[_p_status]) - } catch (error) { - return Promise.reject() - } -} diff --git a/src/requests/logout.tsx b/src/requests/logout.tsx deleted file mode 100644 index 5488b4d5..00000000 --- a/src/requests/logout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { DATA_URL, PROCEDURES } from 'config' -import { callApi } from 'helpers' - -const proc = PROCEDURES.logout_user - -export const logout = () => { - const config = { - body: { - params: {}, - proc, - }, - } - - callApi({ - config, - url: DATA_URL, - }) -}