Single Sign-On (SSO) — це механізм автентифікації, який дозволяє користувачам увійти один раз і отримати доступ до кількох підключених додатків або систем без необхідності повторно автентифікуватися для кожного з них. SSO централізує автентифікацію користувачів в одному надійному системі (зазвичай це постачальник ідентифікації, або IdP), яка керує обліковими даними та видає токени або сеансові дані для перевірки особистості користувача через інші сервіси (які називаються постачальниками послуг, або SP).
Single Sign-On (SSO): Повний посібник з використанням React та ExpressJS
У цьому посібнику ми розглянемо, як працює SSO, його переваги та недоліки, поширені варіанти використання та приклади впровадження SSO в API (Node.js з Express), основному додатку (React) та зовнішньому додатку (React). Розуміння принципів і практик SSO допоможе організаціям підвищити досвід користувачів, безпеку та ефективність роботи їхніх додатків і систем.
Зміст
- Single Sign-On (SSO)
- Як працює SSO?
- Переваги SSO
- Недоліки SSO
- Варіанти використання SSO
- Приклади впровадження SSO
- 1. API (Node.js з Express)
- 2. Основний додаток (React)
- 3. Зовнішній додаток (React)
- Висновок
Посилання
Демонстраційне відео
Single Sign-On (SSO)
Single Sign-On (SSO) — це механізм автентифікації, який дозволяє користувачам увійти один раз і отримати доступ до кількох підключених додатків або систем без необхідності повторно автентифікуватися для кожного з них.
SSO централізує автентифікацію користувачів в одному надійному системі (зазвичай це постачальник ідентифікації, або IdP), яка керує обліковими даними та видає токени або сеансові дані для перевірки особистості користувача через інші сервіси (які називаються постачальниками послуг, або SP).
Як працює SSO?
SSO працює через захищені механізми на основі токенів, такі як OAuth 2.0, OpenID Connect (OIDC) або Security Assertion Markup Language (SAML).
Ось спрощена схема:
Увійти користувачу
: Користувач вводить свої облікові дані в постачальника ідентифікації (IdP).Видача токена
: IdP перевіряє облікові дані та видає токен автентифікації (наприклад, JWT або SAML-assertion).Доступ до сервісу
: Токен передається постачальникам послуг (Service Providers, SP), які перевіряють його і надають доступ без необхідності повторної автентифікації.
Переваги SSO
- Покращений досвід користувачів: Користувачі можуть отримати доступ до кількох сервісів за допомогою одного входу, що зменшує тривалість і покращує зручність використання.
- Покращена безпека:
- Зменшує втому від паролів, що може призвести до небезпечних практик, таких як повторне використання паролів.
- Централізована автентифікація дозволяє впроваджувати суворіші політики паролів і вимагати використання багатофакторної автентифікації (MFA).
- Спрощене управління користувачами:
- Легше адміністраторам керувати доступом користувачів до підключених додатків.
- Відкликання доступу користувача з IdP позбавляє його доступу до всіх інтегрованих систем.
- Ефективність по часу та витратах:
- Економить час для користувачів і служб підтримки, зменшуючи кількість запитів до служби підтримки, пов'язаних із входом.
- Зменшує час розробки та витрати, використовуючи існуючі механізми автентифікації.
- Відповідність стандартам та аудит:
- Централізована автентифікація та контроль доступу полегшують виконання політик безпеки та відстеження діяльності користувачів.
Недоліки SSO
- Єдина точка відмови:
- Якщо IdP недоступний або скомпрометований, користувачі не можуть отримати доступ до жодної з підключених систем.
- Зниження ризиків: Використовувати резервні IdP та забезпечити високу доступність.
- Складність впровадження:
- Інтеграція SSO вимагає значного планування та експертизи, особливо в середовищах з різноманітними додатками та протоколами.
- Зниження ризиків: Використовувати усталені протоколи, такі як OAuth 2.0 або SAML, і надійні бібліотеки SSO.
- Ризики безпеки:
- Якщо зловмисник отримує доступ до облікових даних користувача для SSO, він може отримати доступ до всіх підключених систем.
- Зниження ризиків: Впроваджувати сильну багатофакторну автентифікацію та моніторити підозрілу активність при вході.
- Залежність від постачальника:
- Організації можуть сильно залежати від певного постачальника IdP, що ускладнює міграцію.
- Зниження ризиків: Вибирати відкриті стандарти і уникати власницьких рішень.
- Проблеми з управлінням токенами:
- Протерміновані або вкрадені токени можуть призвести до проблем з доступом або створити вразливості.
- Зниження ризиків: Впроваджувати механізми закінчення терміну дії токенів, оновлення та безпечне зберігання токенів.
Варіанти використання SSO
- Корпоративні додатки:
- Співробітники можуть отримати доступ до різних внутрішніх інструментів і сервісів за допомогою одного входу.
- Спрощує процеси введення в систему та виведення з неї співробітників.
- Хмарні сервіси:
- Користувачі можуть безперешкодно перемикатися між хмарними додатками без повторних входів.
- Підвищує продуктивність і покращує досвід користувачів.
- Клієнтські портали:
- Забезпечує єдиний вхід для клієнтів у різних сервісах.
- Дозволяє персоналізувати досвід і проводити таргетовану маркетингову діяльність.
- Інтеграція з партнерами:
- Спрощує безпечний доступ до спільних ресурсів між партнерськими організаціями.
- Полегшує співпрацю та обмін даними.
Приклади впровадження SSO
1. API (Node.js з Express)
API діє як постачальник ідентифікації (IdP). Він автентифікує користувачів і видає JWT токени для доступу.
Нижче наведено структурований опис наданого коду, що пояснює мету кожної секції для ваших підписників.
Це є надійним прикладом того, як впровадити функціональність SSO на рівні API.
Налаштування та залежності
У цьому налаштуванні використовуються такі пакети:
- express: Для обробки HTTP-запитів і маршрутизації.
- jsonwebtoken: Для генерації та перевірки JWT.
- cors: Для обробки крос-доменних запитів з різних клієнтських додатків.
- @faker-js/faker: Для генерації тестових даних користувачів і задач.
- cookie-parser: Для розбору файлів cookie, що надсилаються в запитах.
- dotenv: Для безпечного завантаження змінних середовища.
Налаштування
dotenv
використовується для безпечного управління секретним ключем.- Для середовищ розробки надається резервний секретний ключ.
dotenv.config();
const SECRET_KEY = process.env.SECRET_KEY || "secret";
Проміжне програмне забезпечення (Middleware)
- CORS забезпечує, щоб запити з певних фронтенд-джерел (
main
таexternal-app
) були дозволені. - cookieParser розбирає cookies, що надсилаються клієнтами.
- express.json дозволяє розбирати JSON-тіла запитів.
app.use(
cors({
origin: ["http://localhost:5173", "http://localhost:5174"],
credentials: true,
})
);
app.use(express.json());
app.use(cookieParser());
Автентифікація користувачів і генерація токенів
Тестові дані імітують користувачів і їхні пов’язані задачі.
Користувачі мають ролі (адміністратор або користувач) і базову інформацію про профіль. Задачі пов’язані з ID користувача для персоналізованого доступу.
/login
: Автентифікує користувачів за допомогою електронної пошти та пароля.
Користувачі отримують cookie (sso_token), що містить JWT після успішного входу. Цей токен є безпечним, доступним тільки через HTTP і має обмежений термін дії для запобігання маніпуляціям.
app.post("/login", (req, res) => {
const { email, password } = req.body;
const user = users.find(
(user) => user.email === email && user.password === password
);
if (user) {
const token = jwt.sign({ user }, SECRET_KEY, { expiresIn: "1h" });
res.cookie("sso_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 3600000,
sameSite: "strict",
});
res.json({ message: "Login successful" });
} else {
res.status(400).json({ error: "Invalid credentials" });
}
});
/verify
: Перевіряє ідентичність користувача, декодуючи токен.
Невірні токени призводять до відповіді "неавторизовано".
app.get("/verify", (req, res) => {
const token = req.cookies.sso_token;
if (!token) {
return res.status(401).json({ authenticated: false });
}
try {
const decoded = jwt.verify(token, SECRET_KEY);
res.json({ authenticated: true, user: decoded });
} catch {
res.status(401).json({ authenticated: false, error: "Invalid token" });
}
});
/logout
: Очищає cookie, що містить JWT токен.
Це гарантує, що користувачі можуть вийти з системи безпечно, очищаючи свій токен.
app.post("/logout", (req, res) => {
res.clearCookie("sso_token");
res.json({ message: "Logout successful" });
});
/todos
: Отримує задачі, пов’язані з автентифікованим користувачем.
app.get("/todos/:userId", (req, res) => {
const ssoToken = req.cookies.sso_token;
const user = getUser(ssoToken);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userTodos = todos.filter((todo) => todo.userId === user.id);
res.json(userTodos);
});
/todos
: Додає нову задачу для автентифікованого користувача.
app.post("/todos", (req, res) => {
const ssoToken = req.cookies.sso_token;
const user = getUser(ssoToken);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const { title, description } = req.body;
const newTodo = {
id: faker.string.uuid(),
userId: user.id,
title,
description,
};
todos.push(newTodo);
res.status(201).json({ message: "Todo added successfully", data: newTodo });
});
/todos/:id
: Оновлює задачу на основі наданого ID.
// Оновити задачу
app.put("/todos/:id", (req, res) => {
const ssotoken = req.cookies.sso_token;
const user = getUser(ssotoken);
if (!user) {
return res.status(401).json({ message: "Unauthorized" });
}
const { id } = req.params;
const { title, description } = req.body;
const index = todos.findIndex((todo) => todo.id === id);
if (index !== -1) {
todos[index] = {
...todos[index],
title,
description,
};
res.json({
message: "Todo updated successfully",
data: todos[index],
});
} else {
res.status(404).json({ message: "Todo not found" });
}
});
/todos/:id
: Видаляє задачу на основі наданого ID.
// Видалити задачу
app.delete("/todos/:id", (req, res) => {
const ssoToken = req.cookies.sso_token;
const user = getUser(ssoToken);
if (!user) {
return res.status(401).json({ message: "Unauthorized" });
}
const { id } = req.params;
const index = todos.findIndex((todo) => todo.id === id);
if (index !== -1) {
todos = todos.filter((todo) => todo.id !== id);
res.json({ message: "Todo deleted successfully" });
} else {
res.status(404).json({ message: "Todo not found" });
}
});
2. Головний додаток (React)
Головний додаток виступає в ролі Провайдера Послуг (Service Provider, SP), який споживає API та керує взаємодією з користувачем.
Нижче наведено структурований опис наданого коду, що пояснює призначення кожного розділу для ваших підписників.
Це є надійним прикладом того, як реалізувати функціональність SSO в основному шарі додатку.
Компонент App
Компонент App керує автентифікацією користувачів та перенаправляє їх на основі статусу входу.
import { useState, useEffect } from "react";
import {
Navigate,
Route,
Routes,
useNavigate,
useSearchParams,
} from "react-router-dom";
import Todos from "./components/Todos";
import Login from "./components/Login";
import { toast } from "react-toastify";
import api from "./api";
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
useEffect(() => {
const verifyLogin = async () => {
const returnUrl = searchParams.get("returnUrl");
try {
const response = await api.get("/verify", {
withCredentials: true,
});
if (response.data.authenticated) {
setIsLoggedIn(true);
toast.success("You are logged in.");
navigate("/todos");
} else {
setIsLoggedIn(false);
if (!returnUrl) {
toast.error("You are not logged in.");
}
}
} catch (error) {
setIsLoggedIn(false);
console.error("Verification failed:", error);
}
};
verifyLogin();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
verifyLogin();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [navigate, searchParams]);
return (
} /> : } />
); } export default App; ```
## Компонент Login
Компонент Login обробляє вхід користувача та перенаправляє на сторінку "Todos" після успішної автентифікації.
import React, { useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { ToastContainer, toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import api from "../api";
function Login() {
const [email, setEmail] = useState("[email protected]");
const [password, setPassword] = useState("admin");
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const returnUrl = searchParams.get("returnUrl");
setLoading(true);
try {
await api.post("/login", { email, password }, { withCredentials: true });
toast.success("Login successful!");
if (returnUrl) {
window.location.href = returnUrl;
} else {
navigate("/todos");
}
} catch (error) {
toast.error("Login failed.");
Будь ласка, перевірте ваші облікові дані.");
console.error("Login failed:", error);
} finally {
setLoading(false);
}
};
return (
Login
setEmail(e.target.value)} className="w-full p-2 border border-gray-300 rounded" /> setPassword(e.target.value)} className="w-full p-2 border border-gray-300 rounded" /> {loading ? "Logging in..." : "Login"}
); } export default Login; ```
Компонент Todos
Компонент Todos відображає списки завдань (todos), що належать користувачу, та дозволяє додавати й видаляти завдання.
import { useState, useEffect } from "react";
import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom";
import api from "../api";
interface Todo {
id: string;
userId: string;
title: string;
description: string;
}
interface UserInfo {
username: string;
email: string;
iat: number;
exp: number;
}
function Todos() {
const [todos, setTodos] = useState([]);
const [userInfo, setUserInfo] = useState(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
useEffect(() => {
fetchUserInfo();
fetchTodos();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
fetchTodos();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
const fetchUserInfo = async () => {
try {
const response = await api.get("/verify", {
withCredentials: true,
});
setUserInfo({
username: response.data.user.user.username,
email: response.data.user.user.email,
iat: response.data.user.iat,
exp: response.data.user.exp,
});
} catch (error) {
toast.error("Error fetching user information.");
console.error("Error fetching user information:", error);
}
};
const fetchTodos = async () => {
try {
const response = await api.get("/todos", {
withCredentials: true,
});
setTodos(response.data);
} catch (error) {
toast.error("Error fetching todos.");
console.error("Error fetching todos:", error);
}
};
const addTodo = async () => {
if (!title || !description) {
toast.error("Title and description are required.");
return;
}
setLoading(true);
try {
const response = await api.post(
"/todos",
{ title, description },
{ withCredentials: true }
);
setTodos([...todos, response.data.data]);
setTitle("");
setDescription("");
toast.success("Todo added successfully!");
} catch (error) {
toast.error("Error adding todo.");
console.error("Error adding todo:", error);
} finally {
setLoading(false);
}
};
const deleteTodo = async (id: string) => {
setLoading(true);
try {
await api.delete(`/todos/${id}`, {
withCredentials: true,
});
setTodos(todos.filter((todo) => todo.id !== id));
toast.success("Todo deleted successfully!");
} catch (error) {
toast.error("Error deleting todo.");
console.error("Error deleting todo:", error);
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
try {
await api.post("/logout", {}, { withCredentials: true });
toast.success("Logged out successfully!");
navigate("/");
} catch (error) {
toast.error("Error logging out.");
console.error("Error logging out:", error);
}
};
return (
Todos
Logout
{userInfo && (
Username: {userInfo.username}
Email: {userInfo.email}
)} setTitle(e.target.value)} className="w-full p-2 mb-2 border border-gray-300 rounded" /> setDescription(e.target.value)} className="w-full p-2 mb-2 border border-gray-300 rounded" /> {loading ? "Adding..." : "Add Todo"}
{todos.map((todo) => (
{todo.title}
{todo.description}
deleteTodo(todo.id)} className="p-1 text-white bg-red-500 rounded" disabled={loading} > Delete
))}
); } export default Todos; ```
## 3.
External Application (React)
Зовнішній застосунок виконує роль ще одного Постачальника послуг (Service Provider, SP), який споживає API та керує взаємодією з користувачем.
Нижче подано структуроване пояснення наданого коду, що роз'яснює призначення кожної частини для ваших підписників.
This serves as a robust example of how to implement SSO functionality in the external application layer.
- App Component
Компонент App управляє автентифікацією користувачів і здійснює перенаправлення залежно від статусу входу.
import { useState, useEffect } from "react";
import Todos from "./components/Todos";
import api from "./api";
const MAINAPPURL = import.meta.env.VITEMAINAPP_URL;
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
useEffect(() => {
const verifyLogin = async () => {
try {
const response = await api.get("/verify", {
withCredentials: true,
});
if (response.data.authenticated) {
setIsLoggedIn(true);
} else {
setIsLoggedIn(false);
}
} catch (error) {
console.error("Verification failed:", error);
setIsLoggedIn(false);
}
};
verifyLogin();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
verifyLogin();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
return (
{isLoggedIn ? ( ) : (
You are not logged in
Go to Main App to Login
)}
); } export default App; - Todos Component Компонент Todos відображає todos, які належать конкретному користувачеві.
import { useState, useEffect } from "react"; import { toast } from "react-toastify"; import api from "../api"; interface Todo { id: string; userId: string; title: string; description: string; } function Todos() { const [todos, setTodos] = useState([]); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [loading, setLoading] = useState(false); useEffect(() => { fetchTodos(); const handleVisibilityChange = () => { if (document.visibilityState === "visible") { fetchTodos(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, []); const fetchTodos = async () => { try { const response = await api.get("/todos", { withCredentials: true, }); setTodos(response.data); } catch (error) { toast.error("Error fetching todos."); console.error("Error fetching todos:", error); } }; const addTodo = async () => { if (!title || !description) { toast.error("Title and description are required."); return; } setLoading(true); try { const response = await api.post( "/todos", { title, description }, { withCredentials: true } ); setTodos([...todos, response.data.data]); setTitle(""); setDescription(""); toast.success("Todo added successfully!"); } catch (error) { toast.error("Error adding todo."); console.error("Error adding todo:", error); } finally { setLoading(false); } }; const deleteTodo = async (id: string) => { setLoading(true); try { await api.delete(/todos/${id}
, { withCredentials: true, }); setTodos(todos.filter((todo) => todo.id !== id)); toast.success("Todo deleted successfully!"); } catch (error) { toast.error("Error deleting todo."); console.error("Error deleting todo:", error); } finally { setLoading(false); } }; return (
External App - Todos
setTitle(e.target.value)}
className="w-full p-2 mb-2 border border-gray-300 rounded"
/>
setDescription(e.target.value)}
className="w-full p-2 mb-2 border border-gray-300 rounded"
/>
{loading ? "Adding..." : "Add Todo"}
{todos.map((todo) => (
{todo.title}
{todo.description}
deleteTodo(todo.id)} className="p-1 text-white bg-red-500 rounded" disabled={loading} > Delete
))}
); } export default Todos; ``` ## Висновок
Єдина автентифікація (SSO) спрощує процес автентифікації користувачів та керування доступом у різних додатках, покращуючи досвід користувача, безпеку та ефективність роботи.
By centralizing authentication and leveraging secure token-based mechanisms, organizations can streamline user access, reduce password-related risks, and improve compliance and auditing capabilities.
While SSO offers numerous benefits, it also presents challenges such as single points of failure, complex implementation requirements, security risks, and potential vendor lock-in. Organizations must carefully plan and implement SSO solutions to mitigate these risks and maximize the benefits of centralized authentication.
By following best practices, leveraging established protocols, and choosing open standards, organizations can successfully implement SSO to enhance user experience, security, and operational efficiency across their applications and systems.
Централізуючи автентифікацію та використовуючи механізми на основі токенів для забезпечення безпеки, організації можуть спростити доступ користувачів, знизити ризики, пов'язані з паролями, і покращити можливості для забезпечення відповідності та аудиту.
Хоча SSO має численні переваги, він також має свої виклики, такі як єдині точки відмови, складні вимоги до реалізації, ризики безпеки та можливий "vendor lock-in". Організації повинні ретельно планувати та впроваджувати рішення SSO, щоб мінімізувати ці ризики та максимізувати переваги централізованої автентифікації.
Дотримуючись найкращих практик, використовуючи усталені протоколи та вибираючи відкриті стандарти, організації можуть успішно впровадити SSO для покращення досвіду користувача, безпеки та ефективності роботи своїх додатків та систем.
Перекладено з: Single Sign-On (SSO): A Comprehensive Guide with React and ExpressJS