一、为什么需要跨应用单点登录

1.1 多应用登录的日常痛点

相信大家都有过这样的经历:公司有OA系统、客户管理系统(CRM)和报销系统三个工具,每次换系统都要重新输一次账号密码,有时候甚至同一个系统的网页版和APP也要分开登录,打开一个新的应用就要走一遍登录流程,不仅麻烦,还容易忘密码。从开发者的角度看,每个应用单独做登录模块,要维护多套用户数据,不仅工作量大,还存在安全风险,比如某一个系统的密码泄露,会影响其他系统的安全。

1.2 JWT与OAuth的适配思路

要解决这个问题,我们可以把两个技术结合起来:一个是JWT,你可以把它理解成一个加密的电子会员卡,里面只装必要的身份信息(比如用户ID),不需要每次请求都去查数据库,服务器校验速度快,安全性高;另一个是OAuth授权码流程,就像你用微信登录某个APP的过程,点微信登录后跳转到微信页,微信给APP发一个临时授权码,APP用这个码去微信服务器换你真正的身份凭证,这样就能在不同应用之间安全传递身份,不让用户密码直接给第三方。

二、架构落地的核心步骤

2.1 准备统一的技术环境

本次示例统一使用Node.js + Express技术栈,原因是它轻量,不需要复杂的环境配置,适合不同基础的开发者快速上手,我们会搭建两个核心服务:一个是统一授权中心(Auth Server),负责用户登录、生成授权码和签发JWT;另一个是客户端应用(Client App),负责接收授权码、换取JWT并实现登录校验。

2.2 核心代码实现(授权中心)

// 技术栈:Node.js + Express + jsonwebtoken
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.urlencoded({ extended: true })); // 解析表单数据
const PORT = 3000;

// 密钥:生产环境必须存在环境变量,不能硬编码!
const SECRET_KEY = 'test_secret_2024';
// 模拟用户数据库,实际要存在数据库
const userDB = { 'zhangsan': { password: '123456', userId: 'u001' } };
// 允许跳转的客户端地址,防止恶意请求
const allowedClients = ['http://localhost:3001'];

// 1. 统一登录页:用户输入账号密码的地方
app.get('/login', (req, res) => {
  const redirectUri = req.query.redirect_uri;
  // 校验跳转地址是否合法
  if (!allowedClients.includes(redirectUri)) {
    return res.send('非法请求,跳转地址未被授权');
  }
  // 渲染登录表单,带跳转地址,登录后返回
  res.send(`
    <h1>统一身份登录</h1>
    <form method="post" action="/do-login">
      <input type="hidden" name="redirect_uri" value="${redirectUri}">
      用户名:<input name="username" required><br><br>
      密码:<input type="password" name="password" required><br><br>
      <button type="submit" style="padding:5px 15px;">登录</button>
    </form>
  `);
});

// 2. 处理登录:校验账号密码,返回授权码
app.post('/do-login', (req, res) => {
  const { username, password, redirect_uri } = req.body;
  // 校验用户是否存在,密码是否正确
  const user = userDB[username];
  if (!user || user.password !== password) {
    return res.send('用户名或密码错误,请重试');
  }
  // 生成授权码:绑定用户ID和跳转地址,有效期5分钟,防止重放
  const authCode = jwt.sign(
    { userId: user.userId, redirectUri: redirect_uri },
    SECRET_KEY,
    { expiresIn: '5m' }
  );
  // 跳回客户端,带上授权码
  res.redirect(`${redirect_uri}?code=${authCode}`);
});

// 3. 客户端用授权码换JWT的接口
app.get('/exchange-token', (req, res) => {
  const authCode = req.query.code;
  if (!authCode) {
    return res.status(400).json({ error: '缺少授权码' });
  }
  try {
    // 校验授权码的合法性和过期时间
    const decoded = jwt.verify(authCode, SECRET_KEY);
    // 签发JWT:有效期1小时,用于后续请求的身份校验
    const accessToken = jwt.sign(
      { userId: decoded.userId },
      SECRET_KEY,
      { expiresIn: '1h' }
    );
    // 返回JWT给客户端
    res.json({
      access_token: accessToken,
      token_type: 'Bearer',
      expires_in: 3600
    });
  } catch (err) {
    return res.status(400).json({ error: '授权码无效或已过期' });
  }
});

// 启动授权中心服务
app.listen(PORT, () => {
  console.log(`统一授权中心已启动,地址:http://localhost:${PORT}`);
});

2.3 客户端应用代码实现

// 技术栈:Node.js + Express + axios
const express = require('express');
const jwt = require('jsonwebtoken');
const axios = require('axios');
const app = express();
const PORT = 3001;

// 授权中心地址,必须和授权中心一致
const AUTH_SERVER_URL = 'http://localhost:3000';
// 密钥必须和授权中心完全一致,否则JWT校验失败
const SECRET_KEY = 'test_secret_2024';

// 1. 客户端首页:检查是否已登录,未登录则跳转授权中心
app.get('/', (req, res) => {
  // 从请求头中获取JWT(Bearer开头)
  const authHeader = req.headers.authorization;
  const token = authHeader?.split(' ')[1];
  if (!token) {
    // 未登录,跳去授权中心登录,带上当前客户端地址
    return res.redirect(`${AUTH_SERVER_URL}/login?redirect_uri=http://localhost:3001`);
  }
  try {
    // 校验JWT的合法性,通过则显示登录信息
    const decoded = jwt.verify(token, SECRET_KEY);
    res.send(`
      <h1>客户端应用首页</h1>
      <p>✅ 已通过单点登录,当前用户ID:${decoded.userId}</p>
      <p>不需要重复登录,直接访问所有信任该授权中心的应用</p>
    `);
  } catch (err) {
    // JWT无效,跳转到授权码交换接口
    return res.send('请先登录:<a href="/get-token">点击此处获取登录凭证</a>');
  }
});

// 2. 接收授权码,换取JWT并存储
app.get('/get-token', async (req, res) => {
  const authCode = req.query.code;
  if (!authCode) {
    // 没有授权码,跳转回授权中心登录
    return res.redirect(`${AUTH_SERVER_URL}/login?redirect_uri=http://localhost:3001`);
  }
  try {
    // 用授权码调用授权中心的换token接口,获取JWT
    const tokenResp = await axios.get(`${AUTH_SERVER_URL}/exchange-token?code=${authCode}`);
    const accessToken = tokenResp.data.access_token;
    // 把JWT放到请求头,后续所有页面请求都会携带,实现自动登录
    res.setHeader('Authorization', `Bearer ${accessToken}`);
    // 跳回客户端首页,此时已登录
    res.redirect('/');
  } catch (err) {
    res.send('获取登录凭证失败,请重试:<a href="/">返回首页</a>');
  }
});

// 启动客户端应用
app.listen(PORT, () => {
  console.log(`客户端应用已启动,地址:http://localhost:${PORT}`);
});

2.4 完整的授权流程演示

把上面两个代码分别保存为auth-server.js和client-app.js,在两个终端分别运行node auth-server.jsnode client-app.js,然后打开浏览器访问http://localhost:3001,就会跳转到统一登录页,输入用户名zhangsan,密码123456,登录后就会进入客户端首页,显示已登录状态。这个流程就是整个架构的核心:用户只在授权中心登录一次,所有接入的客户端都能识别身份,不用重复登录。

三、应用场景、优缺点及落地注意事项

3.1 常见适用场景

这个架构最适合的场景有三类:一是公司内部系统,比如OA、CRM、报销系统,员工登录一次OA,打开其他系统免登录,管理员不用维护多套用户数据;二是SaaS多子产品,比如笔记、表格、待办,用户注册一次就能用所有子产品,提升用户留存;三是跨端产品,比如电商的网页版、APP、小程序,打通身份,用户在一个端登录,其他端自动登录。

3.2 技术优缺点分析

优点主要有三个:第一是安全,用户密码只存储在授权中心,其他应用完全接触不到密码,从源头降低密码泄露风险;第二是高效,JWT是轻量的加密字符串,校验不需要查询数据库,适合高并发的场景,比如用户量百万级的SaaS平台;第三是灵活,只要是信任授权中心的客户端,都可以接入,不用修改每个应用的登录逻辑。 缺点也需要重视:第一是JWT主动注销困难,因为它是客户端存储的,不像服务端的session可以直接删除,需要额外的Redis黑名单机制;第二是密钥安全要求高,一旦密钥泄露,所有应用的登录都会被攻破,所以生产环境必须严格保密密钥;第三是客户端存储风险,JWT如果存在localStorage,容易被XSS攻击窃取,建议用httpOnly的Cookie存储,防止恶意脚本获取。

3.3 落地的核心注意事项

落地时一定要注意这些细节:第一是密钥管理,绝对不能硬编码在代码里,生产环境用环境变量或者专业的密钥管理服务,就像银行的保险柜,不能随便放在外面;第二是授权码的时效性,必须设置短有效期(比如5分钟),且只能用一次,防止被恶意拦截复用;第三是JWT过期时间,设置1-2小时最合适,太长不安全,太短会影响用户体验,还可以加refresh token(刷新凭证)机制,让用户不用频繁登录;第四是跨域配置,授权中心和客户端不在同一个域名下,必须配置CORS,否则客户端收不到授权码;第五是权限最小化,OAuth流程里要限制客户端能获取的用户信息,只给必要的身份数据,比如只传用户ID,不传手机号、邮箱等敏感信息,降低泄露风险。

四、总结

基于JWT结合OAuth授权码流程的单点登录架构,本质是把身份认证的核心能力统一到一个中心,其他应用只需要接入这个中心,就能实现用户的统一身份识别,解决了多应用重复登录的痛点,提升了用户体验和管理员的管理效率。只要落地时注意安全细节,比如密钥存储、过期时间、防重放等,就能搭建一个稳定、高效的跨应用身份穿透系统,适合大多数有多个应用的技术场景。