单点登录方案(SSO)

1. 一句话概括主题

单点登录(SSO)是一种让用户只需登录一次,就能访问多个相关应用系统的认证方案。


2. 它是什么

想象一下,你每天要登录公司的邮箱、OA系统、项目管理工具、财务系统等十多个系统。如果每个系统都要单独登录,每次都要输入用户名密码,是不是很烦?

单点登录就像是一把”万能钥匙”:你只需要在第一个系统登录一次,之后访问其他系统时,系统会自动识别你已经登录过了,直接放行,不用再输入密码。

打个比方

  • 传统方式:就像进一栋大楼,每层楼都要刷卡,很麻烦
  • SSO方式:就像进一栋大楼,在大厅刷一次卡,之后所有楼层都能直接进

举个例子

  • 你登录了 Google 账号,然后访问 Gmail、YouTube、Google Drive 时都不需要再登录
  • 企业内网中,登录了统一认证系统后,访问各个业务系统都自动登录

3. 能解决什么问题 + 为什么重要

解决的痛点

  1. 用户体验差:用户需要记住多套账号密码,频繁登录很麻烦
  2. 管理成本高:IT部门需要为每个系统单独管理用户账号
  3. 安全风险大:用户可能因为密码太多而使用弱密码,或者写在纸上
  4. 数据孤岛:各系统用户信息不统一,难以做统一的数据分析

为什么重要

  • 提升用户体验:一次登录,处处通行,用户满意度大幅提升
  • 降低管理成本:统一管理用户账号,减少运维工作量
  • 提高安全性:集中管理认证,更容易实施安全策略(如密码复杂度、双因素认证)
  • 便于扩展:新增系统时,只需接入SSO,无需重新开发认证模块

4. 核心知识点拆解

基本概念

单点登录(SSO):Single Sign-On,用户在一个系统登录后,可以访问其他信任的系统而无需再次登录。

认证中心(IdP):Identity Provider,负责用户身份认证的中心系统,比如统一登录页面。

服务提供方(SP):Service Provider,需要认证的各个业务系统,比如OA系统、财务系统。

信任关系:SP信任IdP的认证结果,IdP信任SP是合法的。

必会概念与关键字

1. Token(令牌)

认证中心颁发的凭证,证明用户已登录。

// Token 通常包含用户信息、过期时间等
{
  userId: "12345",
  username: "zhangsan",
  expireTime: "2025-01-20 12:00:00",
  signature: "加密签名"
}

1.1. AccessToken(访问令牌)

用于访问受保护资源的短期令牌,通常有效期较短(如 2 小时)。

特点

  • 有效期短,提高安全性
  • 每次 API 请求都需要携带
  • 过期后需要刷新
// 登录成功后返回的 AccessToken
{
  "accessToken": "a641a16b76e44501ba9ab2bfcea3d469",
  "expiresIn": 7200,  // 2小时后过期(单位:秒)
  "tokenType": "Bearer"
}
 
// 使用 AccessToken 访问 API
fetch('https://api.example.com/user/profile', {
  headers: {
    'Authorization': `Bearer ${accessToken}`
  }
});

1.2. RefreshToken(刷新令牌)

用于刷新 AccessToken 的长期令牌,通常有效期较长(如 7 天或 30 天)。

特点

  • 有效期长,用于获取新的 AccessToken
  • 不直接用于访问资源,只用于刷新
  • 需要安全存储(如 HttpOnly Cookie)
// 登录成功后返回的 RefreshToken
{
  "accessToken": "a641a16b76e44501ba9ab2bfcea3d469",
  "refreshToken": "21ee0ad85b2946fa91c16f893c97345c",
  "expiresIn": 7200,
  "refreshExpiresIn": 604800  // 7天后过期
}
 
// 使用 RefreshToken 刷新 AccessToken
async function refreshAccessToken() {
  const response = await fetch('https://sso.example.com/api/refresh', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      refreshToken: localStorage.getItem('refreshToken')
    })
  });
  
  const data = await response.json();
  // 更新 AccessToken
  localStorage.setItem('accessToken', data.accessToken);
  // RefreshToken 可能会更新,也可能不变
  if (data.refreshToken) {
    localStorage.setItem('refreshToken', data.refreshToken);
  }
  
  return data.accessToken;
}

AccessToken 和 RefreshToken 配合使用

// 完整的 Token 管理示例
class TokenManager {
  constructor() {
    this.accessToken = localStorage.getItem('accessToken');
    this.refreshToken = localStorage.getItem('refreshToken');
  }
  
  // 检查 AccessToken 是否过期
  isAccessTokenExpired() {
    if (!this.accessToken) return true;
    // 实际应该解析 JWT 或检查服务器返回的过期时间
    const tokenData = JSON.parse(atob(this.accessToken.split('.')[1]));
    return Date.now() >= tokenData.exp * 1000;
  }
  
  // 获取有效的 AccessToken(自动刷新)
  async getValidAccessToken() {
    // 如果 AccessToken 未过期,直接返回
    if (!this.isAccessTokenExpired()) {
      return this.accessToken;
    }
    
    // AccessToken 过期,使用 RefreshToken 刷新
    if (this.refreshToken) {
      try {
        const newToken = await this.refreshAccessToken();
        this.accessToken = newToken;
        localStorage.setItem('accessToken', newToken);
        return newToken;
      } catch (error) {
        // RefreshToken 也过期了,需要重新登录
        this.clearTokens();
        window.location.href = '/login';
        throw error;
      }
    } else {
      // 没有 RefreshToken,需要重新登录
      window.location.href = '/login';
      throw new Error('No refresh token available');
    }
  }
  
  // 刷新 AccessToken
  async refreshAccessToken() {
    const response = await fetch('https://sso.example.com/api/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken: this.refreshToken })
    });
    
    if (!response.ok) {
      throw new Error('Failed to refresh token');
    }
    
    const data = await response.json();
    return data.accessToken;
  }
  
  // 清除所有 Token
  clearTokens() {
    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');
    this.accessToken = null;
    this.refreshToken = null;
  }
}
 
// 使用示例
const tokenManager = new TokenManager();
 
// 在 API 请求中使用
async function fetchUserData() {
  const accessToken = await tokenManager.getValidAccessToken();
  
  const response = await fetch('https://api.example.com/user/profile', {
    headers: {
      'Authorization': `Bearer ${accessToken}`
    }
  });
  
  return response.json();
}

2. Session(会话)

服务器端保存的用户登录状态。

// 服务器端 Session 存储
sessionStorage.setItem('sso_token', token);
sessionStorage.setItem('user_info', userInfo);

同一域名下的 Cookie 可以共享,这是实现 SSO 的基础。详见 Cookie-API - Domain(域名)

// 设置 Cookie 在父域名下,子域名可以共享
document.cookie = "sso_token=xxx; domain=.example.com; path=/";

关键点

4. 重定向(Redirect)

用户访问系统时,如果未登录,重定向到认证中心。

// 未登录时重定向到认证中心
if (!isLoggedIn) {
  window.location.href = 'https://sso.example.com/login?redirect=' + encodeURIComponent(currentUrl);
}

关联知识

常见误解说明与纠正

误解1:SSO 就是共享 Cookie

  • 纠正:Cookie 共享只是实现方式之一,还有 Token、CAS 协议等多种方式

误解2:所有系统必须用同一个域名

  • 纠正:跨域 SSO 可以通过 Token、OAuth 2.0 等方式实现

误解3:SSO 不安全,一个账号被破解所有系统都危险

  • 纠正:SSO 集中管理反而更容易实施安全策略,如双因素认证、异常检测等

5. 示例代码(可运行 + 逐行注释)

💡 相关知识点:详细原理请参考 Cookie-API - 如何在控制台正确设置跨子域 Cookie?

// SSO 认证中心登录成功后,设置 Cookie
// 在认证中心(sso.example.com)
function loginSuccess(userInfo) {
  // 生成 Token
  const token = generateToken(userInfo);
  
  // 设置 Cookie 到父域名,所有子域名都能访问
  // domain=.example.com 表示 example.com 及其所有子域名都能访问
  // 注意:domain 前面必须加 .(点),详见 [Cookie-API - Domain(域名)](../../02-编程语言/01-JavaScript/08-浏览器环境与DOM/13-Cookie-API.md#domain域名)
  document.cookie = `sso_token=${token}; domain=.example.com; path=/; max-age=3600`;
  
  // 跳转回原系统
  const redirectUrl = getUrlParam('redirect') || 'https://oa.example.com';
  window.location.href = redirectUrl;
}
 
// 业务系统(oa.example.com)检查登录状态
function checkLogin() {
  // 读取 Cookie 中的 Token
  const token = getCookie('sso_token');
  
  if (!token) {
    // 没有 Token,跳转到认证中心登录
    const currentUrl = window.location.href;
    window.location.href = `https://sso.example.com/login?redirect=${encodeURIComponent(currentUrl)}`;
    return;
  }
  
  // 验证 Token 是否有效
  validateToken(token).then(valid => {
    if (!valid) {
      // Token 无效,清除 Cookie 并跳转登录
      document.cookie = 'sso_token=; domain=.example.com; path=/; max-age=0';
      window.location.href = 'https://sso.example.com/login?redirect=' + encodeURIComponent(window.location.href);
    } else {
      // Token 有效,获取用户信息
      getUserInfo(token).then(userInfo => {
        console.log('用户已登录:', userInfo);
      });
    }
  });
}
 
// 辅助函数:获取 Cookie
function getCookie(name) {
  const value = `; ${document.cookie}`;
  const parts = value.split(`; ${name}=`);
  if (parts.length === 2) return parts.pop().split(';').shift();
  return null;
}
 
// 辅助函数:验证 Token(实际应该调用后端接口)
function validateToken(token) {
  return fetch('https://sso.example.com/api/validate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token })
  }).then(res => res.json()).then(data => data.valid);
}
 
// 页面加载时检查登录状态
checkLogin();

方案二:跨域 Token 传递(适用于不同域名)

// 认证中心登录成功后,通过 URL 参数传递 Token
// 在认证中心(sso.example.com)
function loginSuccess(userInfo) {
  const token = generateToken(userInfo);
  const redirectUrl = getUrlParam('redirect');
  
  // 将 Token 作为参数传递到业务系统
  window.location.href = `${redirectUrl}?sso_token=${token}`;
}
 
// 业务系统(app.otherdomain.com)接收 Token
function handleSSOToken() {
  // 从 URL 参数中获取 Token
  const urlParams = new URLSearchParams(window.location.search);
  const token = urlParams.get('sso_token');
  
  if (token) {
    // 验证 Token
    validateToken(token).then(valid => {
      if (valid) {
        // Token 有效,保存到本地存储
        localStorage.setItem('sso_token', token);
        
        // 清除 URL 中的 Token 参数(安全考虑)
        window.history.replaceState({}, document.title, window.location.pathname);
        
        // 获取用户信息
        getUserInfo(token).then(userInfo => {
          console.log('用户已登录:', userInfo);
        });
      } else {
        // Token 无效,跳转登录
        redirectToLogin();
      }
    });
  } else {
    // 没有 Token,检查本地存储
    const savedToken = localStorage.getItem('sso_token');
    if (savedToken) {
      validateToken(savedToken).then(valid => {
        if (!valid) {
          localStorage.removeItem('sso_token');
          redirectToLogin();
        }
      });
    } else {
      redirectToLogin();
    }
  }
}
 
// 跳转到认证中心登录
function redirectToLogin() {
  const currentUrl = window.location.href;
  window.location.href = `https://sso.example.com/login?redirect=${encodeURIComponent(currentUrl)}`;
}
 
// 页面加载时处理 SSO Token
handleSSOToken();

方案三:CAS 协议实现(标准方案)

// CAS(Central Authentication Service)是标准的 SSO 协议
// 业务系统检查登录状态
function checkCASLogin() {
  // 检查是否有 CAS Ticket(临时票据)
  const urlParams = new URLSearchParams(window.location.search);
  const ticket = urlParams.get('ticket');
  
  if (ticket) {
    // 有 Ticket,向认证中心验证
    validateCASTicket(ticket).then(result => {
      if (result.valid) {
        // Ticket 有效,保存用户信息
        localStorage.setItem('cas_user', JSON.stringify(result.user));
        localStorage.setItem('cas_token', result.token);
        
        // 清除 URL 中的 Ticket
        window.history.replaceState({}, document.title, window.location.pathname);
      } else {
        redirectToCASLogin();
      }
    });
  } else {
    // 没有 Ticket,检查本地是否有登录信息
    const casUser = localStorage.getItem('cas_user');
    if (!casUser) {
      redirectToCASLogin();
    }
  }
}
 
// 验证 CAS Ticket
function validateCASTicket(ticket) {
  const serviceUrl = window.location.origin + window.location.pathname;
  
  return fetch(`https://sso.example.com/cas/validate?ticket=${ticket}&service=${encodeURIComponent(serviceUrl)}`, {
    method: 'GET',
    credentials: 'include'
  }).then(res => res.json());
}
 
// 跳转到 CAS 登录页面
function redirectToCASLogin() {
  const serviceUrl = window.location.href;
  window.location.href = `https://sso.example.com/cas/login?service=${encodeURIComponent(serviceUrl)}`;
}
 
// 登出
function casLogout() {
  localStorage.removeItem('cas_user');
  localStorage.removeItem('cas_token');
  window.location.href = 'https://sso.example.com/cas/logout';
}
 
// 页面加载时检查
checkCASLogin();

方案四:AccessToken + RefreshToken 实现(推荐方案)

// 使用 AccessToken 和 RefreshToken 实现 SSO
// 这是目前最推荐的方式,兼顾安全性和用户体验
 
// ========== 登录流程 ==========
// 在认证中心(sso.example.com)登录成功后
async function loginSuccess(username, password) {
  // 调用登录接口
  const response = await fetch('https://sso.example.com/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password })
  });
  
  const data = await response.json();
  // 返回的数据格式:
  // {
  //   "accessToken": "a641a16b76e44501ba9ab2bfcea3d469",
  //   "refreshToken": "21ee0ad85b2946fa91c16f893c97345c",
  //   "expiresIn": 7200,  // AccessToken 2小时后过期
  //   "refreshExpiresIn": 604800  // RefreshToken 7天后过期
  // }
  
  // 保存 Token 到本地存储
  localStorage.setItem('accessToken', data.accessToken);
  localStorage.setItem('refreshToken', data.refreshToken);
  localStorage.setItem('tokenExpireTime', Date.now() + data.expiresIn * 1000);
  
  // 跳转回业务系统
  const redirectUrl = getUrlParam('redirect') || 'https://app.example.com';
  window.location.href = redirectUrl;
}
 
// ========== Token 管理器 ==========
class SSOTokenManager {
  constructor() {
    this.accessToken = localStorage.getItem('accessToken');
    this.refreshToken = localStorage.getItem('refreshToken');
    this.tokenExpireTime = parseInt(localStorage.getItem('tokenExpireTime') || '0');
  }
  
  // 检查 AccessToken 是否过期(提前5分钟刷新)
  isAccessTokenExpired() {
    if (!this.accessToken) return true;
    // 提前5分钟刷新,避免在请求过程中过期
    return Date.now() >= (this.tokenExpireTime - 5 * 60 * 1000);
  }
  
  // 获取有效的 AccessToken(自动刷新)
  async getValidAccessToken() {
    // 如果 AccessToken 未过期,直接返回
    if (!this.isAccessTokenExpired()) {
      return this.accessToken;
    }
    
    // AccessToken 过期,使用 RefreshToken 刷新
    if (this.refreshToken) {
      try {
        console.log('AccessToken 已过期,正在刷新...');
        const newTokenData = await this.refreshAccessToken();
        
        // 更新本地存储
        this.accessToken = newTokenData.accessToken;
        this.tokenExpireTime = Date.now() + newTokenData.expiresIn * 1000;
        localStorage.setItem('accessToken', newTokenData.accessToken);
        localStorage.setItem('tokenExpireTime', this.tokenExpireTime.toString());
        
        // RefreshToken 可能会更新
        if (newTokenData.refreshToken) {
          this.refreshToken = newTokenData.refreshToken;
          localStorage.setItem('refreshToken', newTokenData.refreshToken);
        }
        
        return this.accessToken;
      } catch (error) {
        console.error('刷新 Token 失败:', error);
        // RefreshToken 也过期了,需要重新登录
        this.clearTokens();
        this.redirectToLogin();
        throw error;
      }
    } else {
      // 没有 RefreshToken,需要重新登录
      this.redirectToLogin();
      throw new Error('No refresh token available');
    }
  }
  
  // 刷新 AccessToken
  async refreshAccessToken() {
    const response = await fetch('https://sso.example.com/api/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        refreshToken: this.refreshToken
      })
    });
    
    if (!response.ok) {
      // 如果返回 401,说明 RefreshToken 也过期了
      if (response.status === 401) {
        throw new Error('RefreshToken expired');
      }
      throw new Error('Failed to refresh token');
    }
    
    const data = await response.json();
    // 返回格式:
    // {
    //   "accessToken": "新的accessToken",
    //   "refreshToken": "新的refreshToken(可选)",
    //   "expiresIn": 7200
    // }
    return data;
  }
  
  // 清除所有 Token
  clearTokens() {
    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');
    localStorage.removeItem('tokenExpireTime');
    this.accessToken = null;
    this.refreshToken = null;
    this.tokenExpireTime = 0;
  }
  
  // 跳转到登录页
  redirectToLogin() {
    const currentUrl = window.location.href;
    window.location.href = `https://sso.example.com/login?redirect=${encodeURIComponent(currentUrl)}`;
  }
}
 
// ========== 业务系统使用 ==========
// 在业务系统(app.example.com)中使用
 
// 创建全局 Token 管理器实例
const tokenManager = new SSOTokenManager();
 
// 封装带自动刷新的 fetch 请求
async function authenticatedFetch(url, options = {}) {
  // 获取有效的 AccessToken(自动刷新)
  const accessToken = await tokenManager.getValidAccessToken();
  
  // 在请求头中添加 Authorization
  const headers = {
    ...options.headers,
    'Authorization': `Bearer ${accessToken}`
  };
  
  // 发起请求
  const response = await fetch(url, {
    ...options,
    headers
  });
  
  // 如果返回 401,可能是 Token 无效,尝试刷新一次
  if (response.status === 401) {
    console.log('收到 401,尝试刷新 Token...');
    try {
      const newToken = await tokenManager.getValidAccessToken();
      // 用新 Token 重试请求
      headers['Authorization'] = `Bearer ${newToken}`;
      return fetch(url, {
        ...options,
        headers
      });
    } catch (error) {
      // 刷新失败,跳转登录
      tokenManager.redirectToLogin();
      throw error;
    }
  }
  
  return response;
}
 
// 使用示例:获取用户信息
async function getUserProfile() {
  try {
    const response = await authenticatedFetch('https://api.example.com/user/profile');
    const userInfo = await response.json();
    console.log('用户信息:', userInfo);
    return userInfo;
  } catch (error) {
    console.error('获取用户信息失败:', error);
  }
}
 
// 使用示例:调用其他 API
async function fetchUserOrders() {
  try {
    const response = await authenticatedFetch('https://api.example.com/orders', {
      method: 'GET'
    });
    const orders = await response.json();
    return orders;
  } catch (error) {
    console.error('获取订单失败:', error);
  }
}
 
// ========== 页面初始化 ==========
// 页面加载时检查登录状态
async function initApp() {
  // 检查是否有 Token
  if (!tokenManager.accessToken) {
    // 没有 Token,跳转到登录页
    tokenManager.redirectToLogin();
    return;
  }
  
  // 验证 Token 是否有效(尝试获取用户信息)
  try {
    const userInfo = await getUserProfile();
    console.log('用户已登录:', userInfo);
    // 继续初始化应用
  } catch (error) {
    console.error('Token 验证失败:', error);
    // Token 无效,清除并跳转登录
    tokenManager.clearTokens();
    tokenManager.redirectToLogin();
  }
}
 
// 页面加载时初始化
initApp();
 
// ========== 登出功能 ==========
async function logout() {
  try {
    // 调用登出接口(可选,用于服务端清除 RefreshToken)
    const accessToken = tokenManager.accessToken;
    if (accessToken) {
      await fetch('https://sso.example.com/api/logout', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${accessToken}`
        }
      });
    }
  } catch (error) {
    console.error('登出请求失败:', error);
  } finally {
    // 清除本地 Token
    tokenManager.clearTokens();
    // 跳转到登录页
    tokenManager.redirectToLogin();
  }
}

6. 常见错误与踩坑

错误1:Cookie 域名设置错误,导致子域名无法共享

为什么错:Cookie 的 domain 属性设置不正确,导致子域名无法读取。详见 Cookie-API - 错误1:Domain 设置错误,导致子域名无法共享

会导致什么:用户在认证中心登录后,业务系统仍然提示未登录。

正确方式

// ❌ 错误:只设置当前域名
document.cookie = "sso_token=xxx; domain=sso.example.com";
 
// ✅ 正确:设置父域名,所有子域名都能访问
// 注意:domain 前面必须加 .(点),详见 [Cookie-API - Domain 的三种情况详解](../../02-编程语言/01-JavaScript/08-浏览器环境与DOM/13-Cookie-API.md#domain-的三种情况详解)
document.cookie = "sso_token=xxx; domain=.example.com; path=/";

更多示例:参考 Cookie-API - 常见错误示例 了解完整的错误场景和解决方案。

错误2:Token 通过 URL 传递但没有及时清除,存在安全风险

为什么容易踩:开发时为了方便,Token 通过 URL 参数传递,但忘记清除,导致 Token 泄露。

正确方式

// ❌ 错误:Token 一直留在 URL 中
window.location.href = `https://app.com?token=${token}`;
// 用户可能分享这个 URL,导致 Token 泄露
 
// ✅ 正确:获取 Token 后立即清除 URL 参数
const token = new URLSearchParams(window.location.search).get('token');
if (token) {
  localStorage.setItem('token', token);
  // 清除 URL 中的 Token
  window.history.replaceState({}, document.title, window.location.pathname);
}

错误3:没有处理 Token 过期,用户突然被登出

真实开发场景:用户正在填写表单,突然 Token 过期,页面跳转到登录页,用户输入的数据丢失。

正确方式

// ✅ 正确:在请求拦截器中处理 Token 过期
axios.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      // Token 过期或无效
      const currentUrl = window.location.href;
      // 保存当前页面状态(如表单数据)
      sessionStorage.setItem('form_data', JSON.stringify(formData));
      // 跳转登录,登录后返回原页面
      window.location.href = `https://sso.example.com/login?redirect=${encodeURIComponent(currentUrl)}`;
    }
    return Promise.reject(error);
  }
);

错误4:将 RefreshToken 存储在 localStorage,存在 XSS 风险

为什么错:RefreshToken 有效期长,如果存储在 localStorage,容易被 XSS 攻击窃取。

会导致什么:攻击者获取 RefreshToken 后,可以长期使用,危害更大。

正确方式

// ❌ 错误:RefreshToken 存储在 localStorage(容易被 XSS 窃取)
localStorage.setItem('refreshToken', refreshToken);
 
// ✅ 正确:RefreshToken 存储在 HttpOnly Cookie 中(服务端设置)
// 前端无法通过 JavaScript 访问,防止 XSS 攻击
// 服务端设置 Cookie:
// Set-Cookie: refreshToken=xxx; HttpOnly; Secure; SameSite=Strict
 
// 或者使用更安全的方式:RefreshToken 也设置较短的过期时间,并配合设备指纹

错误5:AccessToken 过期后没有自动刷新,导致频繁登录

为什么容易踩:每次请求都检查 Token 是否过期,但没有实现自动刷新机制。

正确方式

// ❌ 错误:只检查过期,不自动刷新
function makeRequest() {
  const token = localStorage.getItem('accessToken');
  if (isTokenExpired(token)) {
    // 直接跳转登录,用户体验差
    window.location.href = '/login';
    return;
  }
  // 发起请求...
}
 
// ✅ 正确:自动刷新 Token
async function makeRequest() {
  const tokenManager = new SSOTokenManager();
  // 自动获取有效 Token(过期会自动刷新)
  const validToken = await tokenManager.getValidAccessToken();
  // 使用有效 Token 发起请求
  fetch(url, {
    headers: { 'Authorization': `Bearer ${validToken}` }
  });
}

错误6:多个并发请求同时刷新 Token,导致重复刷新

真实开发场景:页面同时发起多个 API 请求,每个请求都检测到 Token 过期,都去刷新,导致重复刷新。

正确方式

// ✅ 正确:使用 Promise 缓存,确保同时只有一个刷新请求
class SSOTokenManager {
  constructor() {
    this.refreshPromise = null; // 缓存刷新 Promise
  }
  
  async getValidAccessToken() {
    if (!this.isAccessTokenExpired()) {
      return this.accessToken;
    }
    
    // 如果正在刷新,直接返回缓存的 Promise
    if (this.refreshPromise) {
      const result = await this.refreshPromise;
      return result.accessToken;
    }
    
    // 开始刷新,缓存 Promise
    this.refreshPromise = this.refreshAccessToken();
    try {
      const result = await this.refreshPromise;
      this.accessToken = result.accessToken;
      return result.accessToken;
    } finally {
      // 刷新完成后清除缓存
      this.refreshPromise = null;
    }
  }
}

7. 实际应用场景

场景1:企业内部系统统一登录

需求:公司有OA系统、财务系统、人事系统等多个系统,希望员工只需登录一次。

解决方案

  • 搭建统一认证中心(SSO服务器)
  • 各业务系统接入SSO
  • 用户访问任何系统时,未登录则跳转到认证中心
  • 登录成功后,各系统自动识别已登录状态

场景2:第三方登录(OAuth 2.0)

需求:网站允许用户使用微信、QQ、GitHub等账号登录。

解决方案

  • 使用 OAuth 2.0 协议
  • 用户点击”微信登录”,跳转到微信授权页面
  • 用户授权后,微信返回授权码
  • 网站用授权码换取 Access Token
  • 用 Token 获取用户信息,完成登录

场景3:微服务架构中的统一认证

需求:微服务架构中,多个服务需要共享用户认证状态。

解决方案

  • 使用 JWT Token
  • 认证服务生成 Token,包含用户信息和签名
  • 各微服务验证 Token 签名,无需调用认证服务
  • Token 可以设置过期时间,提高安全性

8. 给新手的练习题(可立即实践)

目标:创建两个页面(认证中心和业务系统),实现简单的 SSO 登录。

要求

  1. 认证中心页面:输入用户名密码(可以写死),登录成功后设置 Cookie
  2. 业务系统页面:检查 Cookie,如果没有则跳转到认证中心
  3. 登录成功后显示用户信息

输出结果

  • 在认证中心登录后,访问业务系统自动显示已登录状态
  • 清除 Cookie 后,访问业务系统自动跳转到认证中心

提示代码结构

<!-- sso.html (认证中心) -->
<!DOCTYPE html>
<html>
<head>
  <title>SSO 认证中心</title>
</head>
<body>
  <h1>登录</h1>
  <input type="text" id="username" placeholder="用户名">
  <input type="password" id="password" placeholder="密码">
  <button onclick="login()">登录</button>
  
  <script>
    function login() {
      const username = document.getElementById('username').value;
      // 简单验证(实际应该调用后端接口)
      if (username) {
        // 设置 Cookie
        document.cookie = `sso_user=${username}; domain=.localhost; path=/`;
        // 跳转回业务系统
        const redirect = new URLSearchParams(window.location.search).get('redirect') || 'http://app.localhost:8080';
        window.location.href = redirect;
      }
    }
  </script>
</body>
</html>

进阶题:实现跨域 Token 传递的 SSO

目标:实现两个不同域名的系统之间的 SSO。

要求

  1. 认证中心在 sso.example.com
  2. 业务系统在 app.otherdomain.com
  3. 使用 Token 传递方式实现 SSO
  4. Token 需要验证签名,防止伪造

输出结果

  • 跨域环境下实现 SSO 登录
  • Token 验证机制正常工作
  • 安全处理 Token 传递

9. 用更简单的话再总结一遍(方便复习)

单点登录就是”一次登录,处处通行”

  • 你在认证中心登录一次
  • 之后访问其他系统时,系统自动识别你已经登录了
  • 不用再输入密码,直接就能用

实现方式

  • 同域名:用 Cookie 共享,最简单
  • 跨域名:用 Token 传递,通过 URL 或 PostMessage
  • 标准方案:用 CAS 协议或 OAuth 2.0,更安全可靠

记住三点

  1. 认证中心负责登录,业务系统负责验证
  2. Token 是”通行证”,证明你已经登录了
  3. 跨域时要注意安全,Token 不要泄露

10. 知识体系延伸 & 继续学习方向 & 遵守仓库规范文档

可学习的下一个知识点

继续学习方向

  1. 深入理解认证协议

    • CAS 协议详细实现
    • SAML 协议(企业级 SSO 标准)
    • OpenID Connect(基于 OAuth 2.0 的身份层)
  2. 安全加固

    • Token 加密和签名
    • 防止 CSRF 攻击
    • 防止 XSS 攻击
    • 双因素认证(2FA)
  3. 性能优化

    • Token 缓存策略
    • 分布式 Session 管理
    • Redis 实现 Session 共享
  4. 实际项目实践

    • Spring Security + OAuth 2.0
    • Node.js Passport.js
    • 企业级 SSO 方案选型

遵守仓库规范文档

本文档遵循 语法规范 的格式要求,使用标准的 Markdown 语法和链接格式。


最后更新:2025-01-20
标签:#单点登录 SSO 认证授权 前端开发 后端开发 安全