feat(1227): integrate OIDC auth (#393)

keep-around/af30b88d367751c9e05a735e4a0467a96238ef47
Mirlan 4 years ago committed by GitHub
parent a0219f8212
commit 022ecb0365
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      package.json
  2. 15
      public/silent-refresh.html
  3. 2
      src/config/authKeys.tsx
  4. 4
      src/config/routes.tsx
  5. 16
      src/features/App/RedirectCallback.tsx
  6. 8
      src/features/App/UnauthenticatedApp.tsx
  7. 13
      src/features/App/index.tsx
  8. 19
      src/features/AuthStore/helpers.tsx
  9. 95
      src/features/AuthStore/hooks/useAuth.tsx
  10. 25
      src/features/AuthStore/hooks/useLogin.tsx
  11. 20
      src/features/AuthStore/hooks/useLogout.tsx
  12. 26
      src/features/AuthStore/index.tsx
  13. 58
      src/features/Login/hooks.tsx
  14. 25
      src/features/Login/index.tsx
  15. 2
      src/helpers/callApi/getRequestConfig.tsx
  16. 3
      src/helpers/callApi/logoutIfUnauthorized.tsx
  17. 2
      src/requests/index.tsx
  18. 64
      src/requests/login.tsx
  19. 18
      src/requests/logout.tsx

@ -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",

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title></title>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/oidc-client/1.11.5/oidc-client.min.js" integrity="sha512-pGtU1n/6GJ8fu6bjYVGIOT9Dphaw5IWPwVlqkpvVgqBxFkvdNbytUh0H8AP15NYF777P4D3XEeA/uDWFCpSQ1g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
new Oidc.UserManager().signinSilentCallback()
.catch((err) => {
console.error('OIDC: silent refresh callback error', err);
});
</script>
</body>
</html>

@ -1 +1 @@
export const TOKEN_KEY = 'x-auth-token'
export const TOKEN_KEY = 'Authorization'

@ -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`

@ -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
}

@ -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 = () => {
<Register />
</Route>
<Route path='/redirect'>
<RedirectCallback />
</Route>
<Redirect to={PAGES.login} />
</Switch>
</Main>

@ -13,11 +13,16 @@ import { AuthenticatedApp } from './AuthenticatedApp'
import { UnauthenticatedApp } from './UnauthenticatedApp'
const Main = () => {
const { token } = useAuthStore()
const { loadingUser, user } = useAuthStore()
return token
? <AuthenticatedApp />
: <UnauthenticatedApp />
// юзер считывается из localstorage или
// access_token токен истек и запрашивается новый
if (loadingUser || user?.expired) return null
if (!user) return <UnauthenticatedApp />
// имеется действующий токен
return <AuthenticatedApp />
}
export const App = () => (

@ -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 }),
})

@ -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<User>()
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
}

@ -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<typeof login>[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)
)
}

@ -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)
}
}

@ -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<typeof useLogin>,
logout: ReturnType<typeof useLogout>,
register: ReturnType<typeof useRegister>,
token: string | null,
}
type Auth = ReturnType<typeof useAuth>
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 <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>
}

@ -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<HTMLInputElement>) => {
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<HTMLFormElement>) => {
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,
}
}

@ -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 = () => {
<Form onSubmit={handleSubmit}>
<BlockTitle t='step_title_login' />
<Input
type='email'
value={readFormValue(formIds.email)}
error={readFormError(formIds.email)}
onChange={updateFormValue(formIds.email)}
onBlur={onEmailBlur}
labelLexic='form_email'
labelWidth={labelWidth}
/>
<Input
type='password'
value={readFormValue(formIds.password)}
error={readFormError(formIds.password)}
onChange={updateFormValue(formIds.password)}
labelLexic='form_password'
labelWidth={labelWidth}
/>
<ButtonsBlock>
<Error t={requestError || ''} marginBottom={27} />
<ButtonSolid type='submit'>

@ -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

@ -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)

@ -1,6 +1,4 @@
export * from './register'
export * from './login'
export * from './logout'
export * from './getCountries'
export * from './getCountryCities'
export * from './getLexics'

@ -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()
}
}

@ -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,
})
}
Loading…
Cancel
Save