一次登录,畅行所有系统 —— 解密现代应用的统一认证之道
前言
你是否曾遇到过这样的情况:早上上班打开电脑,需要先后登录OA系统、邮件系统、项目管理工具、代码仓库……每切换一个系统就要输一次密码,既繁琐又容易忘记。如果这些系统能像Google的服务一样,登录一次就能全部访问,那该多好?
这就是单点登录(Single Sign-On,简称SSO)要解决的问题。
作为一名全栈开发者,我在多个企业级项目中实践过SSO方案,深知其中的坑与技巧。今天,我将从前后端结合的角度,带你完整掌握SSO的实现原理与最佳实践。
一、什么是单点登录?
单点登录(SSO):是一种身份验证机制,允许用户使用一组凭据登录一次,即可访问多个相互信任的独立系统。
简单来说:一次认证,全网通行。
举个例子,当你登录了Google账号后,可以无缝使用Gmail、YouTube、Google Drive、Google Docs等所有Google服务,完全不需要重复输入密码。这就是SSO的典型应用。
核心概念
在深入代码之前,我们需要先理解几个关键概念:
| 概念 | 说明 |
|---|---|
| SSO认证中心 | 负责所有用户的统一认证和会话管理 |
| 业务系统 | 依赖认证中心进行身份验证,不直接管理用户密码 |
| 全局会话 | 用户在认证中心建立的会话(所有系统共享) |
| 局部会话 | 用户在单个业务系统建立的会话(各系统独立) |
┌─────────────────────────────────────────────────────────────┐
│ SSO 架构示意图 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户 ─────────────────────────────────────────────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌───────────┐ │
│ │ 业务系统A │ │ 业务系统B │ │
│ └──────┬──────┘ └─────┬─────┘ │
│ │ │ │
│ │ ┌─────────┐ │ │
│ └──────────────▶│ SSO │◀─────────────┘ │
│ │ 认证中心 │ │
│ └─────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 统一认证 │ 统一授权 │ 统一登出 │ 统一审计 │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
二、为什么需要单点登录?
在传统的多系统架构中,每个系统都有自己独立的认证模块。这种模式带来了诸多问题:
🔴 传统模式的痛点
- 用户体验差:用户需要记住多套用户名密码
- 安全风险高:容易使用弱密码或重复密码,密码泄露风险大
- 管理成本高:离职员工需要在每个系统中逐一删除账号
- 开发重复:每个系统都要重新实现认证逻辑
- 审计困难:用户行为分散在各个系统的日志中
🟢 SSO的核心价值
| 维度 | 价值 |
|---|---|
| 用户体验 | 一次登录,畅行所有系统 |
| 安全性 | 集中管理认证,可实施更强的安全策略(如MFA) |
| 开发效率 | 认证逻辑统一,各业务系统无需重复开发 |
| 运维便利 | 集中审计、集中管控、集中登出 |
三、三种主流SSO方案对比
根据不同的架构场景,SSO有多种实现方案。我将介绍三种最实用的方案。
方案一:基于 Cookie + JWT(同顶级域场景)
适用场景:所有子系统共享同一顶级域名,如 app1.example.com、app2.example.com、sso.example.com
核心原理:利用Cookie的Domain属性设置为 .example.com,使所有子域都能共享同一份身份凭证。
完整代码实现
后端 - SSO认证中心(Node.js + Express)
// sso-server.js
const express = require('express');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const redis = require('redis');
const app = express();
const redisClient = redis.createClient();
const SECRET_KEY = 'your-secret-key-change-in-production';
const TOKEN_EXPIRY = '2h';
app.use(express.json());
app.use(cookieParser());
app.use(cors({
origin: ['https://app1.example.com', 'https://app2.example.com'],
credentials: true
}));
// 模拟用户数据库
const users = new Map([
['admin', { password: '123456', email: 'admin@example.com', name: 'Admin User' }],
['zhangshan', { password: '123456', email: 'zhangshan@example.com', name: '张山' }]
]);
// 登录接口
app.post('/api/sso/login', async (req, res) => {
const { username, password, redirectUrl } = req.body;
const user = users.get(username);
if (!user || user.password !== password) {
return res.status(401).json({
success: false,
error: '用户名或密码错误'
});
}
// 生成JWT token
const sessionToken = jwt.sign(
{
username,
email: user.email,
name: user.name,
loginTime: Date.now()
},
SECRET_KEY,
{ expiresIn: TOKEN_EXPIRY }
);
// 存入Redis,记录活跃会话
await redisClient.setEx(`session:${sessionToken}`, 7200, username);
await redisClient.sAdd(`user:${username}:sessions`, sessionToken);
// 设置跨子域的Cookie
res.cookie('sso_session', sessionToken, {
domain: '.example.com', // 注意开头的点,表示所有子域共享
httpOnly: true, // 防止XSS攻击
secure: true, // 仅HTTPS传输
sameSite: 'lax', // 防止CSRF攻击
maxAge: 2 * 3600 * 1000 // 2小时
});
res.json({
success: true,
username,
name: user.name,
redirectUrl: redirectUrl || '/'
});
});
// 验证Token接口(供业务系统调用)
app.get('/api/sso/verify', async (req, res) => {
const token = req.cookies.sso_session;
if (!token) {
return res.status(401).json({ authenticated: false });
}
try {
const payload = jwt.verify(token, SECRET_KEY);
const exists = await redisClient.exists(`session:${token}`);
if (!exists) {
return res.status(401).json({
authenticated: false,
error: '会话已过期'
});
}
res.json({
authenticated: true,
username: payload.username,
name: payload.name,
email: payload.email
});
} catch (error) {
res.status(401).json({
authenticated: false,
error: 'Token无效或已过期'
});
}
});
// 登出接口
app.post('/api/sso/logout', async (req, res) => {
const token = req.cookies.sso_session;
if (token) {
const username = await redisClient.get(`session:${token}`);
if (username) {
await redisClient.del(`session:${token}`);
await redisClient.sRem(`user:${username}:sessions`, token);
}
}
res.clearCookie('sso_session', { domain: '.example.com' });
res.json({ success: true });
});
app.listen(3000, () => {
console.log('✅ SSO认证中心已启动: http://sso.example.com:3000');
});
后端 - 业务系统(Node.js + Express)
方案二:基于 OAuth 2.0(跨域场景)
适用场景:各系统使用不同的顶级域名,如 app1.com、app2.com、sso.com
核心流程:授权码模式(Authorization Code Flow)4
用户 → 业务系统 → SSO授权页 → 用户登录 → 返回授权码 → 换取Token → 访问资源
前端实现(React)
// OAuthClient.js
class OAuthClient {
constructor(config) {
this.clientId = config.clientId;
this.authServer = config.authServer;
this.redirectUri = config.redirectUri;
}
// 发起登录请求
login() {
const state = this.generateState();
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
client_id: this.clientId,
redirect_uri: this.redirectUri,
response_type: 'code',
state: state,
scope: 'profile email'
});
window.location.href = `${this.authServer}/oauth/authorize?${params}`;
}
// 处理回调
async handleCallback(code, state) {
const savedState = sessionStorage.getItem('oauth_state');
if (state !== savedState) {
throw new Error('State验证失败,可能受到CSRF攻击');
}
// 用授权码换取Token
const response = await fetch('/api/auth/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
const data = await response.json();
if (data.access_token) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('user_info', JSON.stringify(data.user));
return data;
}
throw new Error('Token换取失败');
}
generateState() {
return Math.random().toString(36).substring(2) + Date.now();
}
logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
window.location.href = `${this.authServer}/logout`;
}
}
// React组件使用示例
function LoginButton() {
const oauth = new OAuthClient({
clientId: 'my_business_app',
authServer: 'https://sso.example.com',
redirectUri: 'https://app1.com/auth/callback'
});
return <button onClick={() => oauth.login()}>使用SSO登录</button>;
}
方案三:CAS(中央认证服务)
适用场景:企业内部老系统改造,需要标准化的SSO协议
CAS是耶鲁大学开源的经典SSO方案,协议成熟,被广泛应用。
CAS协议核心流程
// CAS客户端实现
class CASClient {
constructor(serverUrl, serviceUrl) {
this.serverUrl = serverUrl;
this.serviceUrl = serviceUrl;
}
async authenticate() {
const ticket = this.getTicketFromURL();
if (ticket) {
// 有ticket,验证并获取用户信息
return await this.validateTicket(ticket);
} else {
// 无ticket,重定向到CAS登录页
this.redirectToLogin();
}
}
getTicketFromURL() {
const params = new URLSearchParams(window.location.search);
return params.get('ticket');
}
redirectToLogin() {
const loginUrl = `${this.serverUrl}/login?service=${encodeURIComponent(this.serviceUrl)}`;
window.location.href = loginUrl;
}
async validateTicket(ticket) {
const validateUrl = `${this.serverUrl}/validate?ticket=${ticket}&service=${encodeURIComponent(this.serviceUrl)}`;
const response = await fetch(validateUrl);
const text = await response.text();
// CAS验证响应格式: "yes\nusername"
if (text.startsWith('yes')) {
const username = text.split('\n')[1];
// 清除URL中的ticket参数
window.history.replaceState({}, '', window.location.pathname);
return { success: true, username };
}
return { success: false, error: 'Ticket验证失败' };
}
}
四、前端完整实现(React + Context)
一个好的前端实现应该提供流畅的用户体验和完善的安全保障。
创建SSO Context
// contexts/SSOContext.jsx
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import axios from 'axios';
const SSOContext = createContext(null);
export const useSSO = () => {
const context = useContext(SSOContext);
if (!context) {
throw new Error('useSSO必须在SSOProvider内使用');
}
return context;
};
export const SSOProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
// 检查认证状态
const checkAuth = useCallback(async () => {
try {
const response = await axios.get('/api/auth/check', {
withCredentials: true
});
if (response.data.authenticated) {
setUser(response.data.user);
setIsAuthenticated(true);
} else {
setUser(null);
setIsAuthenticated(false);
}
} catch (error) {
setUser(null);
setIsAuthenticated(false);
} finally {
setLoading(false);
}
}, []);
// 监听跨标签页的登出事件
useEffect(() => {
checkAuth();
const handleStorageChange = (event) => {
if (event.key === 'sso_logout_global') {
checkAuth();
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [checkAuth]);
// 登录
const login = async (username, password, redirectUrl = '/') => {
try {
const response = await axios.post('/api/sso/login',
{ username, password, redirectUrl },
{ withCredentials: true }
);
if (response.data.success) {
setUser({
username: response.data.username,
name: response.data.name
});
setIsAuthenticated(true);
return { success: true, redirectUrl: response.data.redirectUrl };
}
} catch (error) {
return {
success: false,
error: error.response?.data?.error || '登录失败,请检查用户名和密码'
};
}
};
// 登出
const logout = async () => {
try {
await axios.post('/api/sso/logout', {}, { withCredentials: true });
setUser(null);
setIsAuthenticated(false);
// 通知其他标签页
localStorage.setItem('sso_logout_global', Date.now().toString());
localStorage.removeItem('sso_logout_global');
// 跳转到登录页
window.location.href = '/login';
} catch (error) {
console.error('登出失败:', error);
}
};
const value = {
user,
loading,
isAuthenticated,
login,
logout,
checkAuth
};
return (
<SSOContext.Provider value={value}>
{children}
</SSOContext.Provider>
);
};
登录页面组件
// pages/LoginPage.jsx
import React, { useState } from 'react';
import { useSSO } from '../contexts/SSOContext';
import { useNavigate, useLocation } from 'react-router-dom';
function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useSSO();
const navigate = useNavigate();
const location = useLocation();
const redirectUrl = new URLSearchParams(location.search).get('redirect') || '/dashboard';
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
const result = await login(username, password, redirectUrl);
if (result.success) {
navigate(result.redirectUrl);
} else {
setError(result.error);
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow-lg">
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900">企业统一认证</h1>
<p className="mt-2 text-gray-600">一次登录,畅享所有服务</p>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
用户名
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
密码
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
<div className="mt-6 text-sm text-gray-500 text-center">
<p>支持的系统:</p>
<div className="flex justify-center space-x-4 mt-2">
<span className="inline-flex items-center">
<span className="w-2 h-2 bg-green-500 rounded-full mr-1"></span>
办公系统
</span>
<span className="inline-flex items-center">
<span className="w-2 h-2 bg-green-500 rounded-full mr-1"></span>
项目管理
</span>
<span className="inline-flex items-center">
<span className="w-2 h-2 bg-green-500 rounded-full mr-1"></span>
代码仓库
</span>
</div>
</div>
</div>
</div>
);
}
export default LoginPage;
应用头部组件
// components/AppHeader.jsx
import React from 'react';
import { useSSO } from '../contexts/SSOContext';
import { Link } from 'react-router-dom';
function AppHeader() {
const { user, isAuthenticated, logout } = useSSO();
return (
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<Link to="/" className="flex items-center">
<span className="text-xl font-bold text-gray-900">企业门户</span>
<span className="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">
SSO
</span>
</Link>
{isAuthenticated && (
<nav className="ml-10 flex space-x-4">
<Link to="/dashboard" className="text-gray-700 hover:text-gray-900">
工作台
</Link>
<Link to="/apps" className="text-gray-700 hover:text-gray-900">
应用中心
</Link>
<Link to="/profile" className="text-gray-700 hover:text-gray-900">
个人中心
</Link>
</nav>
)}
</div>
<div className="flex items-center space-x-4">
{isAuthenticated ? (
<>
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-medium">
{user?.name?.charAt(0) || user?.username?.charAt(0)}
</div>
<span className="text-sm text-gray-700">
{user?.name || user?.username}
</span>
</div>
<button
onClick={logout}
className="text-sm text-gray-600 hover:text-gray-900"
>
退出
</button>
</>
) : (
<Link
to="/login"
className="text-sm bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
>
登录
</Link>
)}
</div>
</div>
</div>
</header>
);
}
export default AppHeader;
受保护的路由组件
// components/ProtectedRoute.jsx
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useSSO } from '../contexts/SSOContext';
function ProtectedRoute({ children }) {
const { isAuthenticated, loading } = useSSO();
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-3 text-gray-600">验证登录状态...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
// 保存当前路径,登录后跳转回来
const redirectUrl = encodeURIComponent(window.location.pathname);
return <Navigate to={`/login?redirect=${redirectUrl}`} replace />;
}
return children;
}
export default ProtectedRoute;
路由配置示例
// App.jsx
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { SSOProvider } from './contexts/SSOContext';
import ProtectedRoute from './components/ProtectedRoute';
import AppHeader from './components/AppHeader';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import AppsPage from './pages/AppsPage';
import ProfilePage from './pages/ProfilePage';
function App() {
return (
<BrowserRouter>
<SSOProvider>
<div className="min-h-screen bg-gray-50">
<AppHeader />
<main>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
} />
<Route path="/dashboard" element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
} />
<Route path="/apps" element={
<ProtectedRoute>
<AppsPage />
</ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
} />
</Routes>
</main>
</div>
</SSOProvider>
</BrowserRouter>
);
}
export default App;
五、安全最佳实践 ⚠️
单点登录把所有鸡蛋放在一个篮子里,安全性至关重要。
1. Token安全存储
// ❌ 错误做法:容易受到XSS攻击
localStorage.setItem('token', token);
sessionStorage.setItem('token', token);
// ✅ 正确做法:使用HttpOnly Cookie
res.cookie('token', token, {
httpOnly: true, // 禁止JavaScript访问
secure: true, // 仅HTTPS传输
sameSite: 'strict', // 禁止跨站请求携带
domain: '.example.com'
});
2. CSRF防护
// 使用CSRF Token
const csrf = require('csurf');
app.use(csrf({ cookie: true }));
app.post('/api/sensitive-action', (req, res) => {
// 自动验证CSRF Token
// 前端需要在请求头中携带 X-CSRF-Token
res.json({ result: 'success' });
});
// 前端携带CSRF Token
axios.post('/api/sensitive-action', data, {
headers: { 'X-CSRF-Token': getCsrfToken() }
});
3. 登录限流
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 5, // 最多5次尝试
skipSuccessfulRequests: true,
keyGenerator: (req) => {
return `${req.body.username}:${req.ip}`;
},
handler: (req, res) => {
res.status(429).json({
error: '登录尝试次数过多,请15分钟后再试'
});
}
});
app.post('/api/sso/login', loginLimiter, handleLogin);
4. 会话过期策略
// 双重过期时间:全局会话长,局部会话短
const sessionConfig = {
globalSession: '2h', // SSO中心会话
localSession: '30m', // 各业务系统局部会话
refreshThreshold: '10m' // 剩余10分钟时自动刷新
};
// 实现Token自动刷新
async function autoRefreshToken(req, res, next) {
const token = req.cookies.local_session;
if (!token) return next();
try {
const payload = jwt.decode(token);
const timeLeft = payload.exp - Math.floor(Date.now() / 1000);
// 剩余时间不足10分钟,刷新Token
if (timeLeft < 600) {
const newToken = await refreshToken(payload);
res.cookie('local_session', newToken, {
httpOnly: true,
maxAge: 30 * 60 * 1000
});
}
} catch (error) {
// Token无效,继续使用原有流程
}
next();
}
5. 敏感操作二次验证
// 对于高危操作,要求重新验证密码或MFA
app.post('/api/user/delete-account', authenticate, async (req, res) => {
const { password } = req.body;
const user = await getUser(req.user.username);
// 即使是登录状态,也要求重新输入密码
if (!verifyPassword(password, user.passwordHash)) {
return res.status(401).json({ error: '密码验证失败' });
}
// 执行删除操作
await deleteAccount(req.user.username);
res.json({ success: true });
});
六、性能优化建议
1. Redis缓存策略
// 合理设置缓存过期时间
const CACHE_CONFIG = {
userInfo: { ttl: 300 }, // 用户信息缓存5分钟
sessionCheck: { ttl: 60 }, // Session验证缓存1分钟
permissions: { ttl: 600 } // 权限缓存10分钟
};
// 批量获取用户信息
async function batchGetUserInfo(userIds) {
const pipeline = redisClient.pipeline();
userIds.forEach(id => {
pipeline.get(`user:${id}`);
});
return await pipeline.exec();
}
2. 前端优化
// 请求拦截器:避免重复验证
let pendingAuthPromise = null;
axios.interceptors.request.use(async (config) => {
if (config.url.includes('/api/')) {
// 如果正在进行认证,等待结果
if (pendingAuthPromise) {
await pendingAuthPromise;
}
}
return config;
});
// 响应拦截器:批量处理401
let isRefreshing = false;
let failedQueue = [];
axios.interceptors.response.use(
response => response,
async error => {
const { config, response } = error;
if (response?.status === 401 && !config._retry) {
if (isRefreshing) {
// 等待token刷新完成
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject, config });
});
}
config._retry = true;
isRefreshing = true;
try {
await refreshToken();
// 重试队列中的请求
failedQueue.forEach(({ resolve, reject, config }) => {
resolve(axios(config));
});
return axios(config);
} catch (err) {
failedQueue.forEach(({ reject }) => reject(err));
window.location.href = '/login';
return Promise.reject(err);
} finally {
isRefreshing = false;
failedQueue = [];
}
}
return Promise.reject(error);
}
);
七、监控与运维
日志审计
// 记录关键操作日志
const auditLog = async (action, username, req, details = {}) => {
const log = {
timestamp: new Date().toISOString(),
action, // 'login', 'logout', 'token_refresh', 'auth_fail'
username,
ip: req.ip,
userAgent: req.get('user-agent'),
details
};
// 写入日志数据库
await AuditLogModel.create(log);
// 异常行为告警
if (action === 'auth_fail' && await countRecentFailures(username) > 5) {
await sendAlert({
type: 'bruteforce',
username,
ip: req.ip
});
}
};
总结
单点登录作为企业级应用的基础设施,其实现方案需要根据具体场景来选择:
| 场景 | 推荐方案 |
|---|---|
| 所有系统同顶级域 | Cookie + JWT |
| 不同域名的独立系统 | OAuth 2.0 / OIDC |
| 企业内部老系统改造 | CAS |
| 需要社交账号登录 | OAuth 2.0 |
| 移动端 + Web端 | JWT + Refresh Token |
核心要点回顾:
- ✅ 统一认证:所有系统依赖同一个认证中心
- ✅ 会话分级:全局会话(长)和局部会话(短)
- ✅ 安全优先:HttpOnly Cookie、CSRF防护、登录限流
- ✅ 用户体验:一次登录、全局登出、自动跳转
单点登录不仅仅是技术方案,更是一种架构思想。它要求我们在设计系统时,始终将"用户体验"和"安全可控"放在同等重要的位置。
希望这篇文章能帮助你理解并实现企业级的SSO方案。如果你在实践中遇到问题,欢迎在评论区交流讨论!
📌 本文示例代码已上传至GitHub,需要完整项目代码的读者请在评论区留言。