feat(1227): integrate OIDC auth (#393)
parent
a0219f8212
commit
022ecb0365
@ -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 = 'https://api.instat.tv' |
||||||
|
|
||||||
export const API_ROOT = isProduction ? 'https://api.instat.tv' : 'https://api-staging.instat.tv' |
|
||||||
export const DATA_URL = `${API_ROOT}/data` |
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 |
||||||
|
} |
||||||
@ -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) |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,71 +1,19 @@ |
|||||||
import type { FormEvent, FocusEvent } from 'react' |
import type { FormEvent } from 'react' |
||||||
|
|
||||||
import trim from 'lodash/trim' |
|
||||||
import isEmpty from 'lodash/isEmpty' |
|
||||||
|
|
||||||
import { formIds } from 'config/form' |
|
||||||
|
|
||||||
import { isValidEmail } from 'helpers/isValidEmail' |
|
||||||
|
|
||||||
import { useAuthStore } from 'features/AuthStore' |
import { useAuthStore } from 'features/AuthStore' |
||||||
import { useForm } from 'features/FormStore' |
import { useForm } from 'features/FormStore' |
||||||
|
|
||||||
export const useLoginForm = () => { |
export const useLoginForm = () => { |
||||||
const { |
const { readFormError } = useForm() |
||||||
readFormError, |
|
||||||
readFormValue, |
|
||||||
updateFormError, |
|
||||||
updateFormValue, |
|
||||||
} = useForm() |
|
||||||
const { login } = useAuthStore() |
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>) => { |
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { |
||||||
event.preventDefault() |
event.preventDefault() |
||||||
|
login() |
||||||
if (validateForm()) { |
|
||||||
const email = readTrimmedValue(formIds.email) |
|
||||||
const password = readTrimmedValue(formIds.password) |
|
||||||
|
|
||||||
login({ email, password }).catch(onError) |
|
||||||
} |
|
||||||
} |
} |
||||||
|
|
||||||
return { |
return { |
||||||
handleSubmit, |
handleSubmit, |
||||||
onEmailBlur, |
|
||||||
readFormError, |
readFormError, |
||||||
readFormValue, |
|
||||||
updateFormValue, |
|
||||||
} |
} |
||||||
} |
} |
||||||
|
|||||||
@ -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…
Reference in new issue