diff --git a/README.md b/README.md index 283015c..bebab73 100644 --- a/README.md +++ b/README.md @@ -161,3 +161,17 @@ fetch(`${Config.API_URL}/api`, { Речь идет об вызываемом эндпоинте ${Config.API_URL}/api, Обязательно используйте конструкцию данного вида т.к. при сборке приложения статический сайт обращается относительно api и вместо запросов вида http://localhost:8000/api должно быть так: /api +# Авторизация фронтенда + +По умолчанию фронтенд использует существующую локальную форму входа. Keycloak включается только при точном значении `true` у production-флага: + +```dotenv +REACT_APP_IS_PRODUCTION=true +REACT_APP_KEYCLOAK_URL=https://keycloak.example.ru +REACT_APP_REALM=example-realm +REACT_APP_CLIENT_ID=example-client +REACT_APP_NAMESPACE=example-namespace +REACT_APP_APP_NAME=example-app +``` + +При `REACT_APP_IS_PRODUCTION=false` или если переменная отсутствует, Keycloak не инициализируется. diff --git a/web/package-lock.json b/web/package-lock.json index 1a7ae02..5debc9c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,6 +14,7 @@ "@mui/x-tree-view": "^9.1.0", "axios": "^1.9.0", "date-fns": "^4.1.0", + "keycloak-js": "^26.2.4", "material-react-table": "^3.2.1", "react": "^18.3.1", "react-daisyui": "^5.0.5", @@ -2362,6 +2363,15 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/keycloak-js": { + "version": "26.2.4", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz", + "integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==", + "license": "Apache-2.0", + "workspaces": [ + "test" + ] + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", diff --git a/web/package.json b/web/package.json index f2eee39..5a3bde3 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "@mui/x-tree-view": "^9.1.0", "axios": "^1.9.0", "date-fns": "^4.1.0", + "keycloak-js": "^26.2.4", "material-react-table": "^3.2.1", "react": "^18.3.1", "react-daisyui": "^5.0.5", diff --git a/web/src/app/Routes.jsx b/web/src/app/Routes.jsx index 57b4861..e011d85 100644 --- a/web/src/app/Routes.jsx +++ b/web/src/app/Routes.jsx @@ -25,7 +25,7 @@ export const PrivateRoute = () => { const { isAuthenticated, loading } = useAuth(); if (loading) { - ; + return ; } return isAuthenticated ? : ; diff --git a/web/src/app/context/AuthProvider.jsx b/web/src/app/context/AuthProvider.jsx index 964ee0f..fcc73de 100644 --- a/web/src/app/context/AuthProvider.jsx +++ b/web/src/app/context/AuthProvider.jsx @@ -1,12 +1,20 @@ import { CircularProgress } from '@mui/material'; +import Keycloak from 'keycloak-js'; import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { AuthApi } from '../../api/auth'; import { UsersApi } from '../../api/users'; import { getAuthToken, isTokenExpired, saveAuthTokens } from '../../api/utils/token'; +import { Config } from '../../conf/config'; const AuthContext = createContext(null); -export const AuthProvider = ({ children }) => { +const LoadingScreen = () => ( +
+ +
+); + +const LocalAuthProvider = ({ children }) => { const [loading, setLoading] = useState(true); const [user, setUser] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(() => { @@ -99,18 +107,123 @@ export const AuthProvider = ({ children }) => { }, []); return ( - - {loading ? ( -
- -
- ) : ( - children - )} + + {loading ? : children} ); }; +const KeycloakAuthProvider = ({ children }) => { + const [keycloak] = useState( + () => + new Keycloak({ + url: Config.KEYCLOAK_URL, + realm: Config.REALM, + clientId: Config.CLIENT_ID, + }), + ); + const [loading, setLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [user, setUser] = useState(null); + + const saveKeycloakToken = useCallback(() => { + if (!keycloak.token) return; + + localStorage.setItem('access_token', keycloak.token); + localStorage.removeItem('refresh_token'); + window.dispatchEvent(new CustomEvent('auth:changed', { detail: { loggedIn: true } })); + }, [keycloak]); + + const loadUser = useCallback(async () => { + try { + const userData = await UsersApi.me(); + setUser(userData); + } catch (error) { + console.error('Ошибка загрузки пользователя:', error); + setUser(null); + } + }, []); + + const login = useCallback(() => keycloak.login(), [keycloak]); + + const logout = useCallback(async () => { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + setIsAuthenticated(false); + setUser(null); + await keycloak.logout({ redirectUri: window.location.origin }); + }, [keycloak]); + + useEffect(() => { + let active = true; + + const initialize = async () => { + try { + const authenticated = await keycloak.init({ + onLoad: 'login-required', + checkLoginIframe: false, + pkceMethod: 'S256', + }); + + if (!active) return; + setIsAuthenticated(authenticated); + + if (authenticated) { + saveKeycloakToken(); + await loadUser(); + } + } catch (error) { + console.error('Ошибка инициализации Keycloak:', error); + if (active) { + setIsAuthenticated(false); + setUser(null); + } + } finally { + if (active) setLoading(false); + } + }; + + keycloak.onAuthRefreshSuccess = saveKeycloakToken; + keycloak.onTokenExpired = async () => { + try { + await keycloak.updateToken(30); + saveKeycloakToken(); + } catch (error) { + console.error('Не удалось обновить токен Keycloak:', error); + await login(); + } + }; + keycloak.onAuthLogout = () => { + localStorage.removeItem('access_token'); + setIsAuthenticated(false); + setUser(null); + }; + + initialize(); + + return () => { + active = false; + keycloak.onAuthRefreshSuccess = undefined; + keycloak.onTokenExpired = undefined; + keycloak.onAuthLogout = undefined; + }; + }, [keycloak, loadUser, login, saveKeycloakToken]); + + return ( + + {loading ? : children} + + ); +}; + +export const AuthProvider = ({ children }) => { + if (Config.IS_PRODUCTION) { + return {children}; + } + + return {children}; +}; + export const useAuth = () => { const context = useContext(AuthContext); if (!context) { diff --git a/web/src/conf/config.ts b/web/src/conf/config.ts index 90f6c29..e12d670 100644 --- a/web/src/conf/config.ts +++ b/web/src/conf/config.ts @@ -1,9 +1,11 @@ //@ts-ignore const environment = process.env; +const isTrue = (value: string | undefined): boolean => value?.trim().toLowerCase() === 'true'; + let env_var: string | undefined; -if (environment.APP_ENV == 'test') { +if (environment.APP_ENV === 'test') { env_var = environment.OPENBAO__SETTINGS_TEST__REACT_APP_API_URL; } else { env_var = environment.OPENBAO__SETTINGS__REACT_APP_API_URL; @@ -11,6 +13,7 @@ if (environment.APP_ENV == 'test') { export class Config { static API_URL: string = env_var ?? environment.REACT_APP_API_URL ?? environment.REACT_APP_ROOT_PATH ?? ''; + static IS_PRODUCTION: boolean = isTrue(environment.REACT_APP_IS_PRODUCTION); static KEYCLOAK_URL: string = environment.REACT_APP_KEYCLOAK_URL ?? ''; static REALM: string = environment.REACT_APP_REALM ?? ''; static CLIENT_ID: string = environment.REACT_APP_CLIENT_ID ?? ''; diff --git a/web/src/env.d.ts b/web/src/env.d.ts index b0ac762..5cd9f50 100644 --- a/web/src/env.d.ts +++ b/web/src/env.d.ts @@ -1 +1,12 @@ /// + +declare namespace NodeJS { + interface ProcessEnv { + readonly REACT_APP_IS_PRODUCTION?: string; + readonly REACT_APP_KEYCLOAK_URL?: string; + readonly REACT_APP_REALM?: string; + readonly REACT_APP_CLIENT_ID?: string; + readonly REACT_APP_NAMESPACE?: string; + readonly REACT_APP_APP_NAME?: string; + } +}