OIDC授权码模式实战全流程
OIDC授权码模式实战全流程
你一定遇到过这些登录的坑
真实场景:公司有18个内部系统(OA、HR、代码仓库、测试平台、监控系统……),每个都要独立注册账号,每个都有不同的密码规则(有的要特殊字符,有的不让用特殊字符),每3个月强制改一次密码。
你的心声:"我到底设了几个密码?哪个系统用哪个密码?算了,点'忘记密码'吧……"
后果:每天浪费20分钟找密码、重置密码,运维天天被轰炸"帮我重置下密码"。
真实场景:员工离职了,人走了,但他的账号还活着——OA里有、钉钉里有、VPN里有、代码仓库里也有。更棘手的是,没人知道他到底在哪些系统里有账号,更没人记得删。
你的心声:"这些离职员工的账号会不会哪天突然登录进来搞破坏?"
后果:安全审计时被抓包,公司被罚款,技术负责人背锅。
真实场景:公司请了10家外包供应商,每家5-10个人,有的做开发、有的做运维、有的做客服。权限管理乱成一锅粥:谁该有什么权限?谁的合同到期了?外包张伟是不是和离职员工张伟的账号合并了?
你的心声:"每次外包人员入职/离职,都要在18个系统里手动加人/删人,这特么是人干的活?"
后果:外包人员意外获得财务系统权限,差点把公司账户清空。
真实场景:公司要接入供应商的SaaS系统,供应商说"我们支持OIDC、SAML、CAS、OAuth2.0",你一脸懵逼。技术选型讨论1个月,开发联调2个月,生产环境出Bug又调1个月,总共3个月过去了。
你的心声:"为什么一个登录功能要搞这么复杂?我就想让用户能登进去啊!"
后果:项目延期,老板天天追进度,技术团队加班到崩溃。
真实场景:用户填了一个超长表单(2000字的业务审批单),填到一半去上厕所,回来发现登录超时了,刷新页面后表单数据全没了。
用户心声:"草!老子白填了半小时!这破系统垃圾!"
后果:用户投诉暴增,产品经理被骂,开发被拉去改需求。
所有痛点本质上都是没有统一身份认证导致的:
- 每个系统独立管理用户,导致账号密码爆炸
- 没有中心化的权限控制,导致账号删不干净
- 各系统登录机制不统一,导致对接困难
- 会话管理各自为政,导致用户体验差
解决方案:用一套企业级SSO(单点登录)系统统一管理所有应用的登录和权限,而OIDC授权码模式就是目前最成熟、最安全、生态最好的实现方案。
如果你正被上面这些问题折磨,接下来我会用大白话讲清楚OIDC怎么解决这些坑,并给出30分钟就能跑起来的实战方案。
一句话总结
用户访问应用 → 后端中间件检测无Token返回401 → 跳转SSO登录 → 认证服务器验证用户+检查应用权限 → 带code回调 → 后端用code换Token → 中间件解析用户信息 → 登录完成
OIDC到底是个啥?用故事讲给你听
别被"OpenID Connect授权码模式"这种学术名词吓到,我用两个生活中的故事讲清楚核心原理。
故事1:银行贷款的流程(理解Authorization Code Flow)
假设你要去银行贷款买房,银行不会直接把钱打给你,而是这样操作:
- 你去银行申请:你带着身份证去银行,说"我要贷款买房"(对应:用户点击登录按钮)
- 银行验证你的身份:银行核对你的身份证、征信记录、收入证明(对应:SSO服务器验证用户名密码)
- 银行给你一张预批函:验证通过后,银行给你一张"预批函",上面写着"此人通过审核,可以贷款"(对应:SSO服务器给你一个
authorization code) - 你拿着预批函去开发商那:你把预批函交给房地产开发商(对应:前端把code发给后端)
- 开发商去银行验证并取钱:开发商拿着你的预批函 + 他们的公章(client_secret)去银行,银行核实无误后把钱打给开发商(对应:后端用code + client_secret去SSO服务器换Token)
- 你住进新房:钱到位,你正式入住(对应:后端用Token验证用户身份,允许访问系统)
为什么不直接给钱(Token)?
- 安全:预批函(code)泄露了也没事,因为开发商去银行取钱时还要验证公章(client_secret)。但如果直接给你现金(Token),钱丢了就是真的丢了
- 可追溯:银行有记录谁取了钱、什么时候取的,出问题可以查
- 权限控制:银行可以在取钱时再次检查你的资质,防止预批函期间你征信出问题
- 你(用户):浏览器里的真人
- 银行(SSO服务器):统一身份认证中心(如企业微信、钉钉、Keycloak)
- 开发商(应用后端):你要访问的业务系统后端
- 预批函(Authorization Code):一次性临时凭证,5分钟过期
- 公章(Client Secret):后端持有的密钥,证明"我是真的应用后端"
- 钱(Token):真正的通行证,用户身份的数字化证明
故事2:餐厅会员卡(理解Token机制)
你去一家连锁餐厅吃饭,第一次需要办会员卡:
- 办卡验证身份:你提供手机号、身份证,餐厅给你开卡(对应:登录验证)
- 卡上印着你的信息:会员卡上有你的姓名、会员等级、余额(对应:Token里包含用户信息)
- 每次进店出示卡:以后每次来吃饭,你出示会员卡,服务员扫一下就知道你是谁、有什么权益(对应:每次请求带上Token,后端中间件解析Token获取用户信息)
- 卡有有效期:会员卡有效期1年,过期需要续费(对应:Token有效期,过期需要刷新或重新登录)
- 卡可以挂失:卡丢了可以挂失重办(对应:Token泄露可以撤销)
为什么不每次都验证密码?
- 性能:每次进店都让你输密码、查数据库,太慢了。直接扫卡,服务员看一眼就知道你的身份
- 体验:你不想每次吃饭都输一遍密码吧?一卡通行多方便
- 安全:密码传输次数越多,泄露风险越大。办一次卡,以后都用卡,密码只在办卡时传一次
- 自包含(Self-contained):Token本身就包含用户信息(姓名、ID、权限),后端不用每次查数据库
- 防篡改(Tamper-proof):Token有数字签名,任何修改都会被检测到(就像会员卡有防伪标识)
- 有时效性(Expiration):Token有有效期,过期自动失效,降低泄露风险
看懂这两个故事,你就理解了OIDC的核心逻辑:
- Authorization Code Flow = 银行贷款的流程(先拿预批函,再去银行取钱)
- Token = 餐厅会员卡(一次验证,到处通行)
完整时序图(带人话注释)
现在用技术图示再走一遍流程。别被箭头吓到,每个步骤我都标注了人话解释,对照着上面的银行贷款故事看。
(无Token) Note over SP后端: 后端中间件拦截请求
检测Header无Authorization
或Token无效/过期 SP后端-->>前端页面: 3. 返回401
{code:401, loginUrl:"..."} 前端页面-->>用户: 4. 跳转SSO登录页 Note over 用户,SSO服务器: 【阶段二:用户登录】 用户->>SSO服务器: 5. 跳转到登录页
https://sso.com/auth/authorize?
client_id=lowcode&
redirect_uri=https://lowcode.com/callback 用户->>SSO服务器: 6. 输入账号密码,提交登录 Note over SSO服务器: 7. 验证用户身份
├─ 本地用户:查数据库验证
└─ 外部用户:调用身份源API验证 Note over SSO服务器: 8. 检查用户是否有权访问该应用
(检查用户是否有该Client的access角色) alt 验证失败或无权限 SSO服务器-->>用户: 返回错误:用户名密码错误/无权访问 else 验证通过且有权限 SSO服务器-->>用户: 9. 重定向到回调地址
https://lowcode.com/callback?code=abc123 end Note over 用户,SSO服务器: 【阶段三:用code换Token】 用户->>前端页面: 10. 访问回调页面
/callback?code=abc123 前端页面->>SP后端: 11. POST /api/auth/login
{code: "abc123"} SP后端->>SSO服务器: 12. POST /token
grant_type=authorization_code
code=abc123
client_id=lowcode
client_secret=xxxxxx
redirect_uri=https://lowcode.com/callback SSO服务器-->>SP后端: 13. 返回Token
{access_token, refresh_token, id_token} Note over SP后端: 14. 后端中间件解析Token
提取用户信息 SP后端-->>前端页面: 15. 返回登录结果
{token:"xxx", user:{name:"张三",...}} 前端页面->>前端页面: 16. 存储Token到localStorage或者sessionStorage 前端页面-->>用户: 17. 跳转应用首页 Note over 用户,SSO服务器: 【阶段四:正常使用】 用户->>前端页面: 18. 访问业务页面 前端页面->>SP后端: 19. GET /api/data
Header: Authorization: Bearer xxx Note over SP后端: 中间件验证Token
├─ 验证签名
├─ 检查是否过期
└─ 解析用户信息注入请求 alt Token无效或过期 SP后端-->>前端页面: 返回401 前端页面-->>用户: 跳转重新登录 else Token有效 SP后端-->>前端页面: 20. 返回业务数据 前端页面-->>用户: 21. 展示页面内容 end
各步骤详解(对照银行贷款故事理解)
接下来把时序图的每个步骤展开讲,每个步骤都对应到银行贷款故事中的某个环节,让你彻底理解。
步骤1-4:首次访问,检测未登录(你走进银行大门)
🏦 生活类比:你走进银行想办贷款,但还没出示身份证,保安拦住你说"先去1号窗口登记"。
用户访问应用,前端请求后端接口时,后端中间件会自动拦截:
请求:GET /api/data
Header:无Authorization(你手里没有通行证)
中间件检测结果:无Token → 返回401(保安说"你还没登记")
返回数据结构:
{
"code": 401,
"message": "未登录或Token已过期",
"loginUrl": "https://sso.com/auth/authorize?client_id=lowcode&redirect_uri=https://lowcode.com/callback&response_type=code&scope=openid"
}
前端收到401后,自动跳转到loginUrl(保安把你带到1号窗口)。
步骤5-9:用户在SSO服务器登录(银行验证你的身份,给你预批函)
🏦 生活类比:银行柜员验证你的身份证、征信记录,确认你有资格贷款,给你一张"预批函"。
登录页URL结构:
https://sso.com/auth/authorize
?client_id=lowcode # 应用ID(相当于"你要贷款买哪个楼盘")
&redirect_uri=https://lowcode.com/callback # 授权回调地址(验证通过后把你送到哪里)
&response_type=code # 使用授权码模式(告诉银行"给我预批函,不是直接给现金")
&scope=openid profile email # 请求的权限范围(你要贷多少钱)
认证服务器内部处理(银行的验证流程):
- 验证用户身份(核对你的身份证)
- 本地用户:查数据库验证账号密码(银行自己的客户)
- 外部用户:调用配置的身份源API验证(通过其他机构验证,比如公积金中心)
- 检查应用访问权限(检查你有没有资格贷款)
- 查看该用户是否被分配了
lowcode这个应用的access角色(你的征信分够不够) - 有角色 → 允许登录(征信通过,给预批函)
- 无角色 → 拒绝,提示"无权访问该应用"(征信不行,拒贷)
- 查看该用户是否被分配了
登录成功后重定向(银行给你预批函,让你去找开发商):
https://lowcode.com/callback?code=abc123&session_state=xxx
# code=abc123 就是那张"预批函",5分钟过期
步骤10-17:用code换Token(开发商拿着预批函去银行取钱)
🏦 生活类比:你把预批函交给开发商,开发商拿着预批函 + 他们的公章去银行,银行核实后把钱打给开发商。
前端回调页面处理(你把预批函交给开发商):
// 从URL获取code(拿到预批函)
const code = new URLSearchParams(location.search).get('code')
// 发给后端(把预批函交给开发商)
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
})
后端处理流程(开发商拿着预批函去银行取钱):
// POST /api/auth/login 接口实现
async function handleLogin(request) {
// 1. 获取前端传来的code(开发商收到预批函)
const { code } = request.body;
// 2. 用code + client_secret换Token(拿预批函 + 公章去银行取钱)
const tokenResponse = await fetch('https://sso.com/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code, // 预批函
client_id: 'lowcode', // 开发商ID
client_secret: 'xxxxxx', // 开发商公章(密钥)
redirect_uri: 'https://lowcode.com/callback'
})
});
const tokenData = await tokenResponse.json(); // 银行返回钱(Token)
// 3. 后端中间件解析Token,获取用户信息(清点钱的面额)
const userInfo = parseToken(tokenData.access_token);
// 4. 返回给前端(告诉你"钱到账了,你可以住新房了")
return {
token: tokenData.access_token,
refreshToken: tokenData.refresh_token,
user: userInfo
};
}
认证服务器返回的Token数据(银行打款清单):
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", // 贷款(主Token)
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", // 续贷凭证
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", // 用户身份证明
"expires_in": 300, // access_token有效期(5分钟)
"refresh_expires_in": 1800, // refresh_token有效期(30分钟)
"token_type": "Bearer" // Token类型
}
返回给前端的数据结构(告诉你可以入住了):
{
"code": 200,
"data": {
"token": "eyJhbGciOiJSUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"username": "zhangsan",
"email": "zhangsan@company.com",
"name": "张三",
"isExternalUser": false,
"roles": ["user", "admin"]
}
}
}
步骤18-21:正常访问业务
前端每次请求都带Token:
fetch('/api/data', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
后端中间件验证流程:
验证失败返回:
{
"code": 401,
"message": "Token已过期,请重新登录"
}
验证成功后,业务代码获取用户信息(以Node.js Express为例):
// GET /api/data 接口实现
app.get('/api/data', (req, res) => {
// 中间件已将用户信息注入request
const user = req.user;
// 使用用户信息处理业务
const username = user.username;
const isAdmin = user.roles.includes('admin');
// 判断是否外部用户
if (user.isExternalUser) {
const source = user.externalSource; // "b-system"
const extId = user.externalId; // "ext123"
}
res.json({ code: 200, data: yourBusinessData });
});
Token数据结构
本地用户Token(解码后)
{
"sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"preferred_username": "zhangsan",
"email": "zhangsan@company.com",
"name": "张三",
"given_name": "三",
"family_name": "张",
"realm_access": {
"roles": ["user", "admin"]
},
"resource_access": {
"lowcode": {
"roles": ["access"]
}
},
"iss": "https://sso.com/realms/master",
"aud": "lowcode",
"exp": 1732550400,
"iat": 1732550100
}
外部系统用户Token(解码后)
{
"sub": "f_b-system_ext123",
"preferred_username": "ext123",
"email": "ext123@b-system.com",
"name": "B系统用户123",
"external_id": "ext123",
"external_source": "b-system",
"realm_access": {
"roles": ["user"]
},
"resource_access": {
"lowcode": {
"roles": ["access"]
}
},
"iss": "https://sso.com/realms/master",
"aud": "lowcode",
"exp": 1732550400,
"iat": 1732550100
}
字段说明:
| 字段 | 说明 |
|---|---|
sub |
用户唯一ID |
preferred_username |
用户名 |
email |
邮箱 |
name |
显示名称 |
external_id |
外部系统用户ID(仅外部用户) |
external_source |
外部系统标识(仅外部用户) |
realm_access.roles |
全局角色列表 |
resource_access.{clientId}.roles |
应用级角色列表 |
iss |
Token签发者(认证服务器地址) |
aud |
Token接收者(应用ID) |
exp |
过期时间(Unix时间戳) |
iat |
签发时间(Unix时间戳) |
多身份源冲突场景处理
企业SSO系统往往需要对接多个身份源(本地数据库、AD域、外部系统等),当相同用户名出现在不同身份源时,就会产生身份冲突。处理不当会导致权限混乱、用户无法登录等严重问题。
典型场景一览
让我们用真实案例来理解这3种常见冲突场景:
| 场景 | 账号 | 密码 | 权限关系 | 典型问题 |
|---|---|---|---|---|
| 场景1 | 相同 | 不同 | 有交叉/覆盖 | 用户记不住多个密码,频繁输错 |
| 场景2 | 相同 | 相同 | 有交叉/覆盖 | 认证服务器不知道该用哪个身份 |
| 场景3 | 相同 | 相同 | 完全隔离 | 明明能认证通过,却因身份错误无权访问 |
场景1:相同账号 + 不同密码 + 权限交叉
真实案例:员工张三工号为 zhangsan
身份1(SSO本地账号)
├─ 用户名:zhangsan
├─ 密码:password123
└─ 权限:可访问【ERP系统】【OA系统】【CRM系统】
身份2(财务系统身份源)
├─ 用户名:zhangsan
├─ 密码:finance@2024
└─ 权限:可访问【ERP系统】【财务系统】
问题分析:
- 张三访问ERP系统时,输入
zhangsan/password123能登录 - 张三访问财务系统时,输入
zhangsan/password123被拒绝 - 换成
zhangsan/finance@2024才能访问财务系统 - 但此时张三发现OA系统又登不上了...
核心问题:密码不一致导致身份切换频繁,用户体验极差。
场景2:相同账号 + 相同密码 + 权限交叉
真实案例:员工李四工号为 lisi,公司统一了密码策略
身份1(SSO本地账号)
├─ 用户名:lisi
├─ 密码:Abc@123456
└─ 权限:可访问【系统A】【系统B】【系统C】
身份2(HR系统身份源)
├─ 用户名:lisi
├─ 密码:Abc@123456
└─ 权限:可访问【系统B】【系统D】【系统E】
问题分析:
李四输入lisi/Abc@123456时,两个身份源都能验证通过!认证服务器必须做出选择:
- 优先本地 → 李四只能访问A、B、C,访问不了D、E
- 优先外部 → 李四只能访问B、D、E,访问不了A、C
- 合并权限 → 李四能访问A、B、C、D、E(权限叠加)
核心问题:权限合并策略不明确,导致权限时多时少,不可预测。
场景3:相同账号 + 相同密码 + 权限完全隔离
真实案例:员工王五工号为 wangwu
身份1(SSO本地账号)
├─ 用户名:wangwu
├─ 密码:Pass@2024
└─ 权限:可访问【研发系统】
身份2(供应商系统身份源)
├─ 用户名:wangwu
├─ 密码:Pass@2024
└─ 权限:可访问【采购系统】
注意:研发系统和采购系统的权限完全不重叠
问题分析:
真实场景还原:
- 王五用
wangwu/Pass@2024登录成功(走的是本地身份) - Token生成,
isExternalUser: false - 王五访问研发系统 → 成功
- 王五访问采购系统 → 403无权限
- 王五打电话投诉:"我明明能登录,为什么说我没权限?"
根本原因:虽然两个身份源都能验证密码,但认证服务器选择了本地身份,而本地身份没有采购系统权限。王五需要的是供应商身份,但SSO无法自动识别。
多身份源认证决策流程
认证服务器在处理多身份源时的完整决策流程:
生成Token] G -->|否| J[返回401认证失败] H --> K[调用身份源API验证] K -->|某个通过| L{已有验证通过的身份?} K -->|全部失败| M{本地验证通过?} L -->|否| N[记录该身份源] L -->|是| O{策略判断} O -->|优先级策略| P[比较优先级
保留高优先级] O -->|合并策略| Q[合并所有通过的身份] O -->|用户选择策略| R[展示身份选择页面] M -->|是| S{策略判断} M -->|否| J S -->|优先本地| I S -->|优先外部| T[使用外部身份
生成Token] S -->|合并权限| U[合并本地+外部权限
生成Token] N --> V[继续检查下一个身份源] V --> K P --> W[使用选定身份
生成Token] Q --> U R --> X[用户选择后
生成Token] I --> Y[登录成功] T --> Y U --> Y W --> Y X --> Y J --> Z[登录失败] style A fill:#e3f2fd style Y fill:#c8e6c9 style Z fill:#ffcdd2 style O fill:#fff9c4 style S fill:#fff9c4 style R fill:#f8bbd0
解决方案对比
针对多身份源冲突,业界主流的4种解决方案:
| 解决方案 | 工作原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 方案1: 身份源优先级 |
为每个身份源设置优先级(1-100),优先级高的先验证,验证通过就不再查其他源 | 逻辑清晰,性能好 | 低优先级身份永远用不上 | 有明确的身份源主次关系 |
| 方案2: 权限合并 |
所有验证通过的身份源权限全部合并,生成一个超级Token | 用户能访问所有授权系统 | 权限过大,安全风险高,审计困难 | 内部系统,安全要求不高 |
| 方案3: 用户显式选择 |
登录页展示:"您要用哪个身份登录?"让用户自己选 | 权限清晰,用户可控 | 用户体验差,需要理解身份概念 | 专业用户,权限隔离严格 |
| 方案4: 用户名加前缀 |
不同身份源的用户名加前缀区分,如local_zhangsan vs finance_zhangsan |
完全避免冲突 | 用户名变长,体验差 | 系统改造成本低的场景 |
最佳实践建议
策略矩阵(根据应用敏感度选择):
| 应用类型 | 推荐策略 | 理由 |
|---|---|---|
| 核心业务系统(财务/HR) | 用户显式选择 | 权限必须清晰,不能混淆 |
| 一般办公系统(OA/邮箱) | 身份源优先级(本地优先) | 平衡体验和安全 |
| 内部工具系统 | 权限合并 | 方便员工使用,安全要求不高 |
| 外部供应商系统 | 用户名加前缀 | 明确区分内外部身份 |
方案1:身份源优先级配置(以Keycloak为例)
# 身份源配置
identity-providers:
- name: local-database
priority: 100 # 最高优先级
type: local
- name: ad-domain
priority: 80 # 次优先级
type: ldap
url: ldap://ad.company.com
- name: finance-system
priority: 60 # 较低优先级
type: oidc
issuer: https://finance.company.com
方案2:权限合并配置
# 权限合并策略
authorization:
merge-strategy: union # union=合并 intersection=取交集
conflict-resolution:
- type: role-level # 角色级别冲突
action: use-higher # 使用更高权限
- type: scope-level # 范围级别冲突
action: merge-all # 全部合并
方案3:用户选择界面(前端实现)
// 检测到多个可用身份时,展示选择页面
if (availableIdentities.length > 1) {
showIdentitySelector({
identities: [
{ id: 'local', name: '公司账号', icon: 'building' },
{ id: 'finance', name: '财务系统账号', icon: 'wallet' }
],
onSelect: (identityId) => {
// 在Token请求中带上身份标识
loginWithIdentity(username, password, identityId);
}
});
}
- 不要默认合并权限:除非你明确知道风险,否则不要启用权限合并
- 记录身份来源:Token中必须包含
identitySource字段,便于审计追溯 - 定期审查权限:多身份源容易导致权限膨胀,每季度审查一次
- 用户培训:如果采用"用户选择"策略,必须培训用户理解不同身份的区别
- 监控异常登录:设置告警,当同一用户频繁切换身份时触发安全审查
示例场景分析:身份源合并的潜在风险
说明:以下是假设性示例场景,用于说明身份源合并策略可能带来的安全风险。
假设背景:某快速发展的电商公司(员工500人),配置了3个身份源:
- 企业微信 - 公司全员使用,日常办公通讯
- 钉钉系统 - 财务部门使用,包含薪资、报销、审批等敏感功能
- 外包供应商VPN账号系统 - 管理技术外包团队的远程接入
初始配置:公司为了方便员工登录,配置了"身份源自动合并策略",系统会将姓名相同的账号自动视为同一人(这是一个非常危险的配置)。
潜在风险演示(讲个真实故事):
- 外包开发"张伟"入职:公司为他开通了企业微信账号和VPN账号,方便远程工作
- 系统自动合并账号:财务部有个离职员工也叫"张伟",钉钉账号一直没删除。系统检测到两个身份源中都有"张伟"这个名字,就自动把外包张伟的企业微信账号和离职张伟的钉钉账号合并了
- 权限失控的隐患:外包张伟用企业微信扫码登录时,意外继承了离职员工在钉钉系统中的所有权限:
- 能查看全公司500人的工资明细
- 能发起金额不限的费用报销申请
- 能代替财务总监审批百万级采购订单
- 能查看公司所有银行账户的余额和流水
- 发现时已晚:公司财务审计时才发现外包人员拥有财务系统最高权限,此时已经过去了n个月
改进方案:
- 身份源严格隔离:不同业务系统的身份源不自动合并,特别是涉及敏感数据的系统
- 离职账号及时清理:建立员工离职时的账号清理流程,确保所有系统账号同步停用
- 最小权限原则:新员工账号默认只给最基础权限,按需申请额外权限
- 实时权限监控告警:当账号权限发生变化时(特别是权限增加),立即发送告警通知给管理员
经验总结:身份源合并就像打开了权限失控的潘多拉魔盒,一个简单的同名账号,就可能让外包商变成"超级管理员",掌握公司最核心的财务数据。千万不要为了方便而牺牲安全,特别是涉及薪资、财务等敏感系统时。
认证服务器选型
本文所述流程基于OIDC标准协议,适用于所有OIDC兼容的认证服务器。配置参数名称可能略有差异,但核心流程完全一致。
市面上主流的OIDC认证服务器解决方案包括:
| 类别 | 产品/方案 | 特点 | 适用场景 |
|---|---|---|---|
| 开源方案 | Keycloak、Ory Hydra、IdentityServer | 免费、可自部署、高度可定制 | 有技术团队,需要完全控制 |
| 商业SaaS | Auth0、Okta、Azure AD、AWS Cognito | 开箱即用、高可用、技术支持 | 快速上线,预算充足 |
| 自建方案 | 基于OIDC库(如node-oidc-provider) | 完全自主、灵活性最高 | 有特殊需求,技术能力强 |
- 中小企业:优先选择商业SaaS(如Auth0),省时省力
- 大型企业:可选开源方案(如Keycloak)自建私有化部署
- 创业团队:快速验证阶段用SaaS,成熟后可迁移到自建方案
- 政府/金融:优先私有化部署,满足合规要求
后端中间件配置说明
无论使用何种后端语言(Java/Node.js/Python/Go等),都需要实现一个Token验证中间件,核心配置参数如下:
| 参数 | 说明 | 示例 |
|---|---|---|
server-url |
认证服务器地址 | https://sso.company.com |
client-id |
应用ID(appid) | lowcode |
client-secret |
应用密钥(appsecret) | xxxxxx |
可选配置:
exclude-paths:不需要验证Token的路径(如登录接口)exclude-path-prefixes:不需要验证的路径前缀(如静态资源)
各语言配置示例
Java (Spring Boot)
# application.yml
sso:
auth:
enabled: true
server-url: https://sso.company.com
client-id: lowcode
client-secret: your-secret-here
exclude-paths:
- /health
- /api/auth/login
exclude-path-prefixes:
- /public/
Node.js (Express)
// config.js
module.exports = {
ssoAuth: {
serverUrl: 'https://sso.company.com',
clientId: 'lowcode',
clientSecret: 'your-secret-here',
excludePaths: ['/health', '/api/auth/login'],
excludePathPrefixes: ['/public/']
}
};
Python (Flask/FastAPI)
# config.py
SSO_AUTH_CONFIG = {
'server_url': 'https://sso.company.com',
'client_id': 'lowcode',
'client_secret': 'your-secret-here',
'exclude_paths': ['/health', '/api/auth/login'],
'exclude_path_prefixes': ['/public/']
}
实际项目中,开发、测试、生产环境的认证服务器地址通常不同,建议通过环境变量或配置文件进行管理:
| 环境 | 认证服务器地址 |
|---|---|
| 开发环境 | http://localhost:8080 |
| 测试环境 | https://sso-test.company.com |
| 生产环境 | https://sso.company.com |
核心概念对照表
| 通用术语 | OIDC标准术语 | 说明 |
|---|---|---|
| 应用ID | Client ID | 标识SP应用 |
| 应用密钥 | Client Secret | 验证SP应用身份 |
| 应用网址 | Root URL | 应用首页地址 |
| 授权回调地址 | Redirect URIs | 登录成功后跳转地址 |
| 身份源 | Identity Provider / User Federation | 外部用户来源配置 |
| access角色 | Client Role / Scope | 控制用户能否访问该应用 |
常见问题
A: 安全考虑。code通过URL传递(用户可见),但换Token需要client_secret(存在后端,用户看不到)。这确保了即使code被截获,攻击者也无法获取Token。
A: 有两种方案:
- 使用refresh_token换取新的access_token(推荐)
- 让用户重新登录
A: 通过"身份源"(Identity Provider / User Federation)配置。认证服务器登录时会调用身份源API验证用户,验证通过后生成Token。
A: 通过用户组关联应用。用户组绑定了哪些应用的access角色,组内用户就能访问哪些应用。
- client_secret必须妥善保管,绝不能暴露在前端代码中
- Token应该存储在localStorage或sessionStorage中,避免使用Cookie(除非有CSRF保护)
- 生产环境必须使用HTTPS协议
- 建议设置合理的Token过期时间(如5-30分钟)
典型风险场景:不重视SSO可能遇到的问题
📌 声明:以下是基于行业经验总结的假设场景,用于说明缺乏统一身份认证可能带来的风险。虽然具体细节经过演绎,但相信在对应的企业场景下,类似的问题是真实存在的。
如果你还在犹豫要不要上SSO系统,看看下面这3个典型场景,或许能帮你做出决策。
假设背景:一家电商公司,一名销售总监因业绩问题离职。
可能的演进:
- Day 1:销售总监离职,HR办理离职手续,但只删除了OA系统账号
- Day 30:前员工发现自己的CRM系统账号还能登录,可以访问客户数据
- Day 90:前员工加入竞争对手公司,可能带着客户数据投奔
- Day 120:公司发现大量客户被竞争对手挖走,营收下降
- Day 180:技术团队审计日志,发现离职员工账号仍在活跃
- 后续风险:一旦客户投诉数据泄露,监管部门介入,公司可能面临巨额罚款和声誉损失
根本原因:
- 公司有18个内部系统,每个系统独立管理用户账号
- 离职流程只包含OA、邮箱,没有覆盖CRM、ERP、代码仓库等核心系统
- 没有统一身份管理中心,无法"一键注销所有账号"
- 没有审计日志,无法及时发现异常登录行为
如果有SSO系统:只需在SSO服务器删除该用户,所有应用的访问权限瞬间失效。整个过程不超过30秒。
假设背景:一家互联网金融公司,雇佣了多家外包供应商协助开发。
可能的演进:
- 公司为了方便外包人员登录,给每个人创建了企业微信账号
- 财务部有个离职员工也叫"张伟",钉钉系统账号一直没删
- SSO服务器配置了"姓名相同自动合并账号"策略(为了省事)
- 外包张伟登录时,系统自动把他和离职张伟的账号合并了
- 严重后果:外包张伟意外获得了财务系统所有权限——工资审批、转账授权、账户余额查询
- 外包张伟无意中打开财务系统,发现自己拥有不应有的权限,立刻报告给项目经理
- 如果外包人员职业道德不佳,可能造成资金损失或敏感数据泄露
根本原因:
- 多个身份源(企业微信、钉钉、VPN系统)没有统一管理
- 配置了危险的"姓名自动合并"策略
- 离职员工账号没有及时删除
- 没有最小权限原则,外包人员能继承离职员工的所有权限
如果有规范的SSO系统:
- 统一身份源管理,账号唯一标识用工号而非姓名
- 离职员工一键删除,不会留下"沉睡账号"
- 外包人员和正式员工账号明确区分,权限隔离
- 财务系统权限单独审批,不会被意外继承
假设背景:一家SaaS公司,自研了一套简单的登录系统。
可能的演进:
- 为了省事,Token有效期设置为30天,且Token直接包含用户密码MD5
- Token使用简单对称加密(Base64),没有数字签名验证
- 某天,攻击者通过XSS攻击获取了某企业管理员的Token
- 攻击者反编译Token,破解了加密逻辑,可能伪造其他管理员的Token
- 攻击者用伪造的Token可能批量删除或窃取客户的业务数据
- 公司紧急回滚数据库,但仍可能有部分数据无法恢复
- 大量客户投诉,公司声誉受损,客户流失率上升
根本原因:
- Token设计不规范,有效期过长,加密强度不足
- 没有使用成熟的JWT标准,自己发明加密算法
- 没有Token撤销机制,泄露后无法及时失效
- 没有操作审计日志,黑客攻击持续了3小时才被发现
如果使用标准OIDC方案:
- Token使用RS256非对称加密签名,伪造几乎不可能
- Token有效期5-15分钟,泄露窗口期极短
- 支持Token主动撤销,发现异常可立即失效所有Token
- 认证服务器记录所有登录行为,异常登录实时告警
很多公司觉得"我们公司小,不需要SSO",但你算过重复造轮子的成本吗?
| 成本项 | 计算依据(假设公司有5个系统) | 年成本 |
|---|---|---|
| 重复开发登录功能 | 每个新系统开发登录注册(前端表单+后端接口+用户表+密码加密+Token管理+权限控制) 工作量:3天/系统 × 每年新增2个系统 × 假设800元/天 |
4.8万元 |
| 维护升级成本 | 假设每年有1次密码策略升级(如MD5→bcrypt)或安全补丁 需要在5个系统分别修改:5系统 × 0.5天/系统 × 假设800元/天 |
2万元 |
| 员工离职账号清理 | 每月2人离职 × 5个系统 × 10分钟/系统 × 假设400元/小时(IT+HR) 容易遗漏,还有安全隐患 |
0.8万元 |
| 用户密码重置处理 | 100人 × 每人每年忘记2次密码 × 5分钟 × 假设200元/小时 (员工自己的时间成本) |
3.3万元 |
| 合计(可量化成本) | 仅计算开发、维护、管理成本 | 10.9万元/年 |
⚠️ 更大的隐性成本:
- 用户体验差:员工要记住5个密码,要么都用弱密码,要么经常忘记
- 安全风险高:离职员工账号容易遗漏,各系统密码强度不一致
- 无法统一管控:想加双因素认证?5个系统都要改一遍
而搭建一套SSO系统的成本?
- 首次投入:1个后端工程师 × 3天集成Keycloak = 2400元
- 服务器成本:2核2G云服务器 × 8元/月 = 99元/年
- 总计首年成本:2499元
投入产出比:首年节省10.4万元,次年起每年节省10.7万元。
更重要的是:以后每个新系统不用再写登录功能,直接接入SSO,节省3天开发时间。
立即行动:30分钟跑起来你的第一个SSO系统
看懂了原理,别被案例吓到了,现在就开始动手吧。我给你准备了一份超详细的可执行清单,跟着做,30分钟内你就能看到效果。
第一步:10分钟启动Keycloak服务器(Docker方式)
前提:你的电脑已经安装了Docker(没装的话去 https://www.docker.com/ 下载)
1. 启动Keycloak容器(1分钟)
# 拉取Keycloak镜像并启动(使用最新稳定版)
docker run -d \
--name keycloak \
-p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin123 \
quay.io/keycloak/keycloak:latest \
start-dev
# 等待30秒,让Keycloak启动完成
sleep 30
# 检查容器是否正常运行
docker ps | grep keycloak
2. 访问管理后台(1分钟)
- 浏览器打开:
http://localhost:8080 - 点击右上角【Administration Console】
- 用户名:
admin,密码:admin123
3. 创建应用Client(5分钟)
- 点击左侧菜单【Clients】→ 点击【Create client】
- 填写Client ID:
my-app→ 点击【Next】 - 开启【Client authentication】和【Authorization】 → 点击【Next】
- 填写Valid redirect URIs:
http://localhost:3000/*→ 点击【Save】 - 点击【Credentials】标签页,复制Client secret(后面要用)
4. 创建测试用户(3分钟)
- 点击左侧菜单【Users】→ 点击【Add user】
- 填写Username:
testuser,Email:test@example.com→ 点击【Create】 - 点击【Credentials】标签页 → 点击【Set password】
- 输入密码:
123456,关闭【Temporary】开关 → 点击【Save】
✅ 第一步完成!你的SSO服务器已经跑起来了。
第二步:10分钟集成后端代码
1. 安装依赖(1分钟)
npm install express keycloak-connect express-session
2. 创建后端服务(5分钟)
// server.js
const express = require('express');
const session = require('express-session');
const Keycloak = require('keycloak-connect');
const app = express();
const memoryStore = new session.MemoryStore();
// Session配置
app.use(session({
secret: 'my-secret',
resave: false,
saveUninitialized: true,
store: memoryStore
}));
// Keycloak配置
const keycloak = new Keycloak({ store: memoryStore }, {
realm: 'master', // Realm名称
'auth-server-url': 'http://localhost:8080/',
'ssl-required': 'none',
resource: 'my-app', // Client ID
'credentials': {
'secret': '你的Client Secret' // 替换成你刚才复制的secret
}
});
app.use(keycloak.middleware());
// 公开接口(不需要登录)
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// 受保护接口(需要登录)
app.get('/api/data', keycloak.protect(), (req, res) => {
res.json({
message: '恭喜你!SSO认证成功!',
user: req.kauth.grant.access_token.content
});
});
app.listen(3001, () => {
console.log('后端服务启动成功:http://localhost:3001');
});
3. 启动后端服务(1分钟)
node server.js
4. 测试受保护接口(3分钟)
# 直接访问(应该返回401或重定向到登录页)
curl http://localhost:3001/api/data
# 先登录获取Token,再访问(会返回用户信息)
# 具体流程见前端集成部分
✅ 第二步完成!后端中间件已经集成。
第三步:10分钟集成前端代码
1. 创建前端HTML(5分钟)
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>SSO测试页面</title>
</head>
<body>
<h1>SSO登录测试</h1>
<div id="status">未登录</div>
<button onclick="login()">点击登录</button>
<button onclick="getData()">获取数据(需要登录)</button>
<pre id="result"></pre>
<script>
const KEYCLOAK_URL = 'http://localhost:8080';
const CLIENT_ID = 'my-app';
const REDIRECT_URI = 'http://localhost:3000';
// 登录函数
function login() {
const authUrl = `${KEYCLOAK_URL}/realms/master/protocol/openid-connect/auth?` +
`client_id=${CLIENT_ID}&` +
`redirect_uri=${encodeURIComponent(REDIRECT_URI)}&` +
`response_type=code&` +
`scope=openid`;
window.location.href = authUrl;
}
// 获取URL中的code参数
function getCodeFromUrl() {
const params = new URLSearchParams(window.location.search);
return params.get('code');
}
// 用code换Token
async function exchangeToken(code) {
const response = await fetch('http://localhost:3001/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
const data = await response.json();
localStorage.setItem('access_token', data.access_token);
return data.access_token;
}
// 调用受保护接口
async function getData() {
const token = localStorage.getItem('access_token');
if (!token) {
alert('请先登录!');
return;
}
const response = await fetch('http://localhost:3001/api/data', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
document.getElementById('result').textContent = JSON.stringify(data, null, 2);
}
// 页面加载时检查是否有code参数
window.onload = async () => {
const code = getCodeFromUrl();
if (code) {
document.getElementById('status').textContent = '正在登录...';
try {
await exchangeToken(code);
document.getElementById('status').textContent = '登录成功!';
// 清除URL中的code参数
window.history.replaceState({}, document.title, "/");
} catch (error) {
document.getElementById('status').textContent = '登录失败:' + error.message;
}
} else if (localStorage.getItem('access_token')) {
document.getElementById('status').textContent = '已登录';
}
};
</script>
</body>
</html>
2. 启动前端服务(1分钟)
# 使用Python快速启动HTTP服务器
python3 -m http.server 3000
# 或使用Node.js的http-server
npx http-server -p 3000
3. 测试完整登录流程(4分钟)
- 浏览器打开:
http://localhost:3000 - 点击【点击登录】按钮 → 跳转到Keycloak登录页
- 输入用户名:
testuser,密码:123456→ 点击【Sign In】 - 自动跳回前端页面,显示"登录成功!"
- 点击【获取数据】按钮 → 看到返回的用户信息
✅ 第三步完成!完整的SSO登录流程跑通了!
第四步:生产环境部署检查清单
| 检查项 | 说明 | 状态 |
|---|---|---|
| ✅ 使用HTTPS协议 | 生产环境必须用HTTPS,防止Token被中间人截获 | □ |
| ✅ Client Secret保护 | 绝不能泄露到前端代码、Git仓库、日志文件中 | □ |
| ✅ Token有效期设置 | 建议access_token 5-15分钟,refresh_token 30分钟 | □ |
| ✅ Redirect URI白名单 | 只允许可信域名的回调地址 | □ |
| ✅ 启用审计日志 | 记录所有登录行为,便于安全审计 | □ |
| ✅ 配置CORS策略 | 只允许可信域名的跨域请求 | □ |
| ✅ 定期备份数据 | Keycloak数据库每天自动备份 | □ |
| ✅ 异常登录告警 | 设置监控告警,发现可疑登录立即通知 | □ |
写在最后
如果你看到这里,说明你是真的想解决登录认证的问题。
我知道你在担心什么:
- "OIDC听起来好复杂,我能学会吗?" → 你已经看懂了银行贷款和餐厅会员卡的故事,原理已经掌握了
- "公司现在就我一个后端,没时间搞SSO" → 30分钟就能跑起来,1天就能上线,比你每天重置密码省事多了
- "万一搞砸了怎么办?" → 用Docker启动Keycloak,出问题一条命令就能回滚,风险可控
- "老板会不会觉得我在瞎折腾?" → 拿本文的3个典型场景给他看,再算算那笔账(投入合理,长期省成本),他会理解价值
不要等到出事了才后悔。离职员工盗取数据、外包人员权限失控、Token泄露导致数据丢失……这些事故每天都在发生,下一个受害者会是你的公司吗?
今天就动手,跟着上面的30分钟清单操作一遍,看到登录成功的那一刻,你会发现:原来SSO没那么难,而我离解决那些登录的坑,只差这一步。
加油,开发者!💪