查看: 220|回复: 3

Node.js实现SSO单点登录:Session与JWT及CORS

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
单点登录(SSO)适合多套业务系统共用一套认证入口的场景。原文示例把登录能力集中在Node.js认证服务:业务系统A和B本身不保存账号体系,访问时携带appId跳到认证服务;认证服务用Session记录登录状态,用JWT生成业务系统令牌,再带token重定向回业务系统,前端把token写入localStorage。这样用户一次登录后可访问相互信任的应用。原文将其好处归纳为方便用户一次登录、管理员维护统一账号、新系统直接复用认证服务,减少重复开发用户认证程序。

认证服务依赖Express、cors、express-session和jsonwebtoken,安装命令如下:
  1. npm install express
  2. npm install express-session
  3. npm install jsonwebtoken
  4. npm install cors
复制代码
原文项目结构为:server/index.js负责认证服务,login.html为登录页,vueA和vueB由Vite创建,分别运行在5173和5174,认证服务监听3000端口。这个结构把业务应用和认证中心分开,appId作为业务系统标识。

服务端核心代码按原文逻辑整理如下:
  1. import express from 'express'
  2. import session from 'express-session'
  3. import fs from 'node:fs'
  4. import cors from 'cors'
  5. import jwt from 'jsonwebtoken'
  6. const appToMapUrl = {
  7.   'fd8xIoDC': { url: 'http://localhost:5173', name: 'appA', secret: '123456', token: '' },
  8.   'DDkq0YYh': { url: 'http://localhost:5174', name: 'appB', secret: '789102', token: '' }
  9. }
  10. const app = express()
  11. app.use(express.json())
  12. app.use(cors())
  13. app.use(session({
  14.   secret: '123456',
  15.   resave: false,
  16.   saveUninitialized: true,
  17.   cookie: { maxAge: 30 * 60 * 1000 }
  18. }))
  19. const getToken = (appId) => {
  20.   const appInfo = appToMapUrl[appId]
  21.   if (!appInfo) return null
  22.   return jwt.sign({ id: appId, name: appInfo.name, secret: appInfo.secret }, '123456', { expiresIn: 60 * 60 })
  23. }
  24. app.get('/login', (req, res) => {
  25.   const { appId } = req.query
  26.   if (!appId) return res.send('请输入appId')
  27.   if (req.session.userName) {
  28.     let token = appToMapUrl[appId].token
  29.     if (!token) {
  30.       token = getToken(appId)
  31.       appToMapUrl[appId].token = token
  32.     }
  33.     res.redirect(`${appToMapUrl[appId].url}?token=${token}`)
  34.     return
  35.   }
  36.   const html = fs.readFileSync('./login.html', 'utf-8')
  37.   res.send(html)
  38. })
  39. app.use(express.urlencoded({ extended: true }))
  40. app.post('/protected', (req, res) => {
  41.   const { username, password, appId } = req.body
  42.   if (username === 'admin' && password === '123456') {
  43.     const token = getToken(appId)
  44.     appToMapUrl[appId].token = token
  45.     req.session.userName = username
  46.     res.redirect(`${appToMapUrl[appId].url}?token=${token}`)
  47.   } else {
  48.     res.send('用户名或密码错误')
  49.   }
  50. })
  51. app.listen(3000, () => console.log('http://localhost:3000'))
复制代码

这段代码的关键点:appToMapUrl保存appId、业务系统地址、名称、secret和token;express.json解析JSON请求体,cors启用跨域,express-session的cookie maxAge为30分钟,secret为示例值123456;getToken按appId查配置,用jwt.sign生成token,payload包含id、name、secret,expiresIn为60*60秒;GET /login检查appId,有session.userName时复用或生成token并重定向到业务系统url?token=...,无session时读取login.html返回;POST /protected接收username、password、appId,原文校验admin/123456,通过后生成token、写入appToMapUrl[appId].token、设置req.session.userName并重定向,失败返回用户名或密码错误。

业务系统接入时,原文Vue A在script setup中从location.search取token;没有token就fetch认证服务/login?appId=fd8xIoDC,并把响应URL赋给location.href;有token则写入localStorage。Vue B逻辑相同,只把appId换成DDkq0YYh。示例片段如下:
  1. <script setup>
  2. const token = location.search.split('token=')[1]
  3. if (!token) {
  4.   fetch('http://localhost:3000/login?appId=fd8xIoDC').then(res => {
  5.     location.href = res.url
  6.   })
  7. } else {
  8.   localStorage.setItem('token', token)
  9. }
  10. </script>
复制代码
登录页通过表单POST到/protected,隐藏域appId由页面脚本从URL查询串取出并填充:
  1. <form action='/protected' method='post'>
  2. <input type='text' name='username'>
  3. <input type='password' name='password'>
  4. <input type='hidden' name='appId'>
  5. <input type='submit' value='登录'>
  6. </form>
  7. <script>
  8. const appId = location.search.split('?')[1].split('=')[1]
  9. document.querySelector('input[name=appId]').value = appId
  10. </script>
复制代码

从访问链路看,用户首次访问业务系统A或B,因本地无token而请求认证服务;认证服务没有session时返回登录页;用户提交账号密码后,认证服务校验并把用户标识写入Session,同时发放JWT;浏览器重定向回业务系统并携带token,业务系统保存token后进入。用户再访问另一个系统时,业务系统再次请求认证服务;认证服务发现Session中已有userName,就直接复用或生成对应appId的token并重定向,实现一次登录多处使用。原文的Session负责认证中心会话状态,JWT负责业务系统间的令牌传递,CORS负责前后端分离和不同端口之间的跨域请求。

原文还对比了JWT与Session:JWT存储在客户端,服务器不保持会话状态,扩展性好,适合跨域和前后端分离;Token每次请求需要验证和解码,且体积通常大于sessionID;Session存储在服务器,只需查找sessionID,性能较好,但多服务器需要同步会话,跨域配置更复杂。到期时间上JWT可给每个token设置不同过期时间,Session通常统一;存储位置JWT可放Cookie、LocalStorage、SessionStorage,Session通常依赖Cookie。按原文方案,认证中心用Session维持登录态,业务系统拿到JWT,兼顾集中认证和业务侧无状态校验。

调试时可按原文返回值定位:GET /login缺少appId会返回请输入appId;未登录会读取login.html;登录失败返回用户名或密码错误;登录成功发生302重定向,业务系统URL会带token参数。需要注意几个兼容性和安全问题:原文/login在appToMapUrl[appId]未命中时仍访问其token字段,实际应补充appId合法性校验;示例中appToMapUrl把token缓存在内存,服务重启后缓存丢失,生产环境应使用可靠存储;Session secret和JWT密钥在示例中为123456,账号密码也是明文示例值,不能直接用于生产;cors()默认放行所有来源,部署时应按业务域名收窄;原文jsonwebtoken中间件介绍中生成token使用密钥123456,而验证示例使用shhhhh,实际生成与验证必须使用同一密钥,否则jwt.verify会返回err;Session的maxAge是30分钟,JWT的expiresIn是3600秒,两者过期策略要按业务统一。掌握这些参数和调用关系后,即可按原文思路搭建Node.js SSO示例并排查登录回跳、跨域和令牌校验问题。
回复

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Node.js实现SSO单点登录:Session与JWT及CORS

这篇帖子把 SSO 的整体链路讲得挺清楚,appId 区分业务系统、Session 管登录态、JWT 发业务令牌这个思路是通的,适合拿来理解认证中心和业务系统分离的基本模型。不过代码里有几个地方如果照着跑,很容易踩坑,稍微提一下。 第一个是 getToken 里的 `const appInfo = appToMapUrl`,这里应该是 `appToMapUrl`,否则拿到的永远是整个映射对象,后面的 `appInfo.name`、`appInfo.secret` 都是 undefined,签出来的 token 内容不对。同理,读写 token 的地方也要统一成 `appToMapUrl.token`,现在混用了 `appToMapUrl.token` 和 `appToMapUrl.token`,复用逻辑会失效。 第二个是 CORS 和 Session 配合的问题。现在 `cors()` 是默认配置,跨域请求默认不带 cookie,业务系统 5173/5174 跳转到认证服务再回来这套流程里,如果前端用 fetch 带凭证访问认证服务,需要服务端明确指定 origin 并开启 credentials,前端也要加 `credentials: 'include'`,不然 session 可能对不上。示例里靠浏览器整页跳转绕过去了,但一旦改成接口调用就会暴露。 第三个是 token 存 l
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Node.js实现SSO单点登录:Session与JWT及CORS

感谢分享,整体思路挺清晰的,认证服务和业务系统拆开、用 appId 做标识这套设计很实用,尤其适合把已有系统慢慢接入统一登录的场景。 不过看代码里有几个地方还可以再打磨一下。第一个是 getToken 里写的是 const appInfo = appToMapUrl,后面重定向又直接用了 appToMapUrl.token 和 appToMapUrl.url,这样拿到的其实是映射表本身的属性而不是具体应用配置,应该按 appId 去取 appToMapUrl,否则 token 和跳转地址都会是 undefined,示例跑起来会卡在这里。 第二个是 token 缓存在全局对象上这个问题。appToMapUrl.token 是全局共享的,多个用户先后登录同一个业务系统时会互相覆盖,A 用户跳转拿到的可能是 B 用户的 token,这在真实场景里风险挺大的。建议按用户维度存,或者干脆不缓存、每次登录重新签发,反正 JWT 本身成本很低。 第三个是安全相关。cors() 全开在 SSO 场景下最好收一收,用 origin 白名单只放行受信任的业务系统;JWT secret 和 session secret 硬编码在代码里当示例没问题,但上生产一定要走环境变量;token 放 localStorage 有 XSS 风险,如果能换成 HttpOnly cookie 或者配合短时效加刷新机制会更
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Node.js实现SSO单点登录:Session与JWT及CORS

这个思路挺清楚的,把认证中心和业务系统拆开、用 appId 标识业务、Session 管登录态、JWT 发令牌,整条链路对理解 SSO 很有帮助。不过照着这版代码直接跑之前,有几个地方建议先处理一下,不然容易踩坑: 一是 getToken 那段有明显笔误,`const appInfo = appToMapUrl` 少了 ``,后面 `/login` 和 `/protected` 里也写成了 `appToMapUrl.url`、`appToMapUrl.token`,应该用 `appToMapUrl.xxx`,否则拿不到对应业务系统的配置。 二是 token 缓存在 `appToMapUrl.token` 上是全局共享的,多用户场景下后登录的人会把前一个人的 token 覆盖掉,而且缓存 token 过期后还会继续重定向出去。建议每次按需生成,或者按 session 用户维度缓存并校验有效期。 三是前端用 `fetch(' 时默认不带 cookie,认证服务那边的 session 其实用不上,每次都会回到登录页。要带凭据得加 `credentials: 'include'`,同时服务端 cors 也要配 `origin` 和 `credentials: true`,不能简单 `cors()` 全开。 四是 token 直接拼在 query 里重定向,容易被日志、Referer 带出去
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-31 09:48 , Processed in 0.021361 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部