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