企业级前后端单点登录(SSO)完全指南

64 阅读8分钟

一次登录,畅行所有系统 —— 解密现代应用的统一认证之道


前言

你是否曾遇到过这样的情况:早上上班打开电脑,需要先后登录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.comapp2.example.comsso.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.comapp2.comsso.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

核心要点回顾

  1. ✅ 统一认证:所有系统依赖同一个认证中心
  2. ✅ 会话分级:全局会话(长)和局部会话(短)
  3. ✅ 安全优先:HttpOnly Cookie、CSRF防护、登录限流
  4. ✅ 用户体验:一次登录、全局登出、自动跳转

单点登录不仅仅是技术方案,更是一种架构思想。它要求我们在设计系统时,始终将"用户体验"和"安全可控"放在同等重要的位置。

希望这篇文章能帮助你理解并实现企业级的SSO方案。如果你在实践中遇到问题,欢迎在评论区交流讨论!

📌 本文示例代码已上传至GitHub,需要完整项目代码的读者请在评论区留言。