一、先搞懂:两个常用认证工具到底是啥
很多开发者做登录权限功能时,会碰到JWT和OpenID Connect(后面简称OIDC)这两个东西,经常搞混,要么直接把JWT当万能令牌用,要么把OIDC当成复杂的认证框架不敢碰。其实这俩的定位完全不一样,就像快递里的面单和取件码——一个管身份,一个管权限,分开用才不会乱。
1.1 JWT:就是个带信息的“电子信封”
JWT本质就是一串用特殊格式拼起来的字符串,你可以把它想象成一个封好的信封,信封上印着发件人的身份(比如你的用户ID、登录时间),还有发件人盖的防伪章(数字签名),收件人拿到后,只要拆开来核对防伪章,就能确定里面的信息没被改,也知道是谁发的。它的好处是不用服务器存东西,发出去后服务器不用记,自己就能验证,省了不少数据库查询的麻烦。
举个简单的JWT例子,你可以把它分成三段,用点隔开,比如eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsIm5hbWUiOiJhZG1pbiIsImV4cCI6MTYzMDMxNDAwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c,第一段是防伪算法说明,第二段是里面的内容(叫Payload),第三段是签名。我们可以用工具把第二段转成JSON,就能看到里面的用户信息:
{
"userId": 1,
"name": "admin",
"exp": 1630314000 // 过期时间,是个时间戳
}
这里的exp是JWT自带的标准字段,用来控制有效期,避免别人拿到令牌后一直用。
1.2 OIDC:就是个管“认人”的“认证规则”
OIDC其实是在OAuth2.0的基础上,专门加了一套认人的规则——OAuth2.0管的是“你能不能拿别人的资源”,比如微信授权你登录某个网站,网站能拿你的头像,这是权限;而OIDC管的是“你到底是谁”,比如网站要确认登录的是不是你本人,而不是别人用了你的微信授权。OIDC的核心就是返回一个专门的“身份令牌”,用来证明用户的身份,而不是给权限。
二、为啥要把俩结合起来用?职责分离才是核心
很多人一开始做登录,只会用JWT当万能令牌,登录成功后发一个JWT,里面既写了用户是谁,又写了能访问哪些接口,结果碰到很多麻烦:比如用户改了权限,JWT已经发出去了,没法实时更新;或者JWT泄露了,别人既能冒充身份,又能拿资源,风险特别大。
把JWT和OIDC结合起来,核心就是把“认人”和“拿资源”的职责分开:用OIDC管认人,返回身份令牌(用来证明“你是谁”);用JWT管拿资源,返回访问令牌(用来证明“你能拿什么”)。就像你去酒店,前台先给你一张房卡(身份令牌,证明你是住客),再给你一张消费卡(访问令牌,证明你能消费的范围),房卡丢了别人不能随便拿你的消费权限,消费卡丢了也不会泄露你的身份。
2.1 职责分离的具体好处
第一,安全系数更高。身份令牌和访问令牌分开,就算访问令牌泄露,别人也没法冒充你的身份;就算身份令牌泄露,别人也拿不到你的资源权限,相当于两道锁。第二,权限更新更灵活。比如用户从普通用户改成管理员,只要更新访问令牌的权限就行,不用重新发身份令牌,也不用等旧令牌过期。第三,维护更方便。身份令牌只需要包含必要的身份信息(比如用户ID、手机号),访问令牌只需要包含权限信息(比如能访问哪些接口、有效期),内容更简洁,验证速度更快。
三、实际操作:完整的融合实践步骤
下面我们用一个完整的示例来演示,整个流程分三步:用户登录、认证发令牌、用令牌访问资源,全程用Node.js做技术栈,所有代码都有注释,方便理解。
3.1 准备工作:搭建基础环境
首先我们需要安装两个核心包:一个是jsonwebtoken,用来生成和验证JWT;另一个是oidc-provider,用来实现OIDC的认证服务,还有express用来做接口服务。先在终端执行安装命令:
# 安装核心依赖
npm install express jsonwebtoken oidc-provider
3.2 第一步:实现OIDC认证服务,发身份令牌
OIDC服务的核心是接收用户的登录请求,验证用户名密码后,返回身份令牌,这个身份令牌我们可以用JWT格式来做,方便验证。下面是OIDC服务的核心代码:
// 引入依赖
const express = require('express');
const { Provider } = require('oidc-provider');
const jwt = require('jsonwebtoken');
// 配置OIDC服务的基础参数
const provider = new Provider('http://localhost:3000', {
clients: [
{
client_id: 'my-app-client', // 客户端ID,用来标识是哪个应用来请求认证
client_secret: 'my-secret-123', // 客户端密钥,用来验证客户端身份
redirect_uris: ['http://localhost:3001/callback'], // 认证成功后的回调地址
response_types: ['code'], // 用授权码模式,安全系数更高
grant_types: ['authorization_code'],
},
],
// 自定义用户验证逻辑,这里用模拟用户,实际项目可以连数据库
findAccount: async (ctx, id) => {
// 假设我们有一个用户表,这里模拟查询用户
const user = { id: 1, username: 'test', email: 'test@example.com' };
return {
accountId: user.id,
claims: async () => ({
sub: user.id, // 标准字段,用户唯一标识
username: user.username,
email: user.email,
}),
};
},
// 自定义身份令牌的格式,用JWT
extraClaims: async (ctx, token, claims) => {
// 这里的claims是用户的身份信息,用来生成JWT格式的身份令牌
const identityToken = jwt.sign(claims, 'identity-secret-key', { expiresIn: '1h' });
return { identity_token: identityToken };
},
});
// 启动OIDC服务
const app = express();
app.use(express.json());
app.use(provider.callback);
app.listen(3000, () => console.log('OIDC服务启动在3000端口'));
这段代码里,我们配置了一个OIDC服务,当用户登录后,会生成一个JWT格式的身份令牌,里面包含用户的ID、用户名、邮箱等身份信息,有效期1小时,只能用来证明用户身份。
3.3 第二步:用身份令牌换访问令牌
用户拿到身份令牌后,不能直接去访问资源,需要用身份令牌去换访问令牌,访问令牌才是用来拿资源的凭证。下面是换访问令牌的接口代码:
// 换访问令牌的接口
app.post('/get-access-token', async (req, res) => {
// 接收前端传过来的身份令牌
const { identity_token } = req.body;
try {
// 验证身份令牌的合法性
const identityClaims = jwt.verify(identity_token, 'identity-secret-key');
// 根据用户的身份,查询对应的权限,这里模拟权限
const userPermissions = {
userId: identityClaims.sub,
permissions: ['read:article', 'write:article'], // 用户的权限列表
expiresIn: '2h', // 访问令牌有效期2小时
};
// 生成访问令牌,用JWT格式
const accessToken = jwt.sign(userPermissions, 'access-secret-key', { expiresIn: '2h' });
// 返回访问令牌
res.json({ access_token: accessToken });
} catch (err) {
// 身份令牌不合法,返回错误
res.status(401).json({ error: '身份令牌无效' });
}
});
这段代码里,我们先验证身份令牌是不是合法的,有没有过期,有没有被篡改;然后根据用户的身份,查询他能访问哪些资源,生成访问令牌,访问令牌里只包含权限信息,不包含身份信息,有效期2小时。
3.4 第三步:用访问令牌访问资源
前端拿到访问令牌后,每次访问资源接口,都要把访问令牌放在请求头里,后端验证访问令牌的合法性和权限,再返回资源。下面是资源接口的代码:
// 资源接口,比如获取文章列表
app.get('/api/articles', async (req, res) => {
// 从请求头里拿访问令牌,格式是Authorization: Bearer 令牌内容
const accessToken = req.headers.authorization?.split(' ')[1];
if (!accessToken) {
return res.status(401).json({ error: '缺少访问令牌' });
}
try {
// 验证访问令牌的合法性
const accessClaims = jwt.verify(accessToken, 'access-secret-key');
// 验证用户有没有访问这个接口的权限
if (!accessClaims.permissions.includes('read:article')) {
return res.status(403).json({ error: '没有权限访问该接口' });
}
// 权限合法,返回文章列表,这里模拟数据
const articles = [
{ id: 1, title: '第一篇文章', content: '内容1' },
{ id: 2, title: '第二篇文章', content: '内容2' },
];
res.json(articles);
} catch (err) {
// 访问令牌不合法,返回错误
res.status(401).json({ error: '访问令牌无效' });
}
});
这段代码里,我们先拿访问令牌,验证合法后,再检查用户有没有访问这个接口的权限,只有权限合法,才返回资源。
四、应用场景、优缺点和注意事项
4.1 适合的应用场景
这种融合实践特别适合中大型应用,比如电商平台、企业内部管理系统、多应用统一登录平台。比如电商平台,用户登录后,身份令牌用来证明是本人,访问令牌用来控制能访问个人中心、下单、查看订单等权限;企业内部管理系统,不同部门的用户有不同的权限,访问令牌可以灵活控制;多应用统一登录平台,用OIDC统一认人,不同应用用不同的访问令牌控制权限,不用每个应用都做登录功能。
4.2 技术的优缺点
优点很明显:第一,安全系数高,职责分离,两道锁,就算一个令牌泄露,另一个也能挡住风险;第二,权限更新灵活,用户权限变了,只要重新生成访问令牌就行,不用重新登录;第三,维护方便,令牌内容简洁,验证速度快,不用服务器存大量的令牌信息。
缺点也有:第一,流程比单JWT复杂,要多一次换访问令牌的步骤,前端要处理两个令牌的存储和传递;第二,需要管理两个密钥,身份令牌和访问令牌的密钥要分开,不能混用,不然就失去了分离的意义;第三,令牌过期要处理,两个令牌的有效期不一样,前端要定时刷新,不然会出现身份令牌有效但访问令牌过期的情况。
4.3 注意事项
第一,密钥要严格保密,不能写在前端代码里,也不能随便泄露,最好用环境变量来管理;第二,令牌的有效期要合理,身份令牌的有效期不要太长,比如1小时,访问令牌的有效期可以根据场景调整,比如2小时,过期时间越短,安全系数越高;第三,令牌要加密存储,前端存令牌的时候,不要存在localStorage里,容易被XSS攻击,最好存在httpOnly的cookie里;第四,权限要最小化,访问令牌里的权限不要写太多,够用就行,比如用户只能访问自己的订单,就不要给查看所有订单的权限;第五,要处理令牌刷新,比如访问令牌过期了,前端可以用身份令牌重新换一个访问令牌,不用重新登录,提升用户体验。
五、总结
把JWT和OIDC结合起来,核心就是把“认人”和“拿资源”的职责分开,用OIDC的身份令牌管认人,用JWT的访问令牌管权限,这样既安全又灵活,适合中大型应用的认证体系设计。整个流程虽然比单JWT复杂一点,但只要理解了职责分离的核心,就能很容易实现。实际项目中,还可以根据需求调整令牌的有效期、权限的粒度,甚至可以用加密的JWT,进一步提升安全系数。
Comments