JWT

在设计Web应用程序时,安全认证是其中的关键部分。使用令牌进行认证是这方面的一个突破,而刷新令牌的出现,则是对其进行了补充并使其可用。

认证

身份认证系统根据如何验证用户可划分为:

  • 基于已知的东西(密码)
  • 基于拥有的东西(身份证、U盘、令牌)
  • 基于体貌特征(声音、指纹、眼睛)

基于令牌的身份认证

令牌是通过现代认证和授权引入Web应用程序的。 我们可以说,由于OAuth协议,它的使用得到了扩展。这些都集中在授权上,而不是人们通常认为的身份验证上。当我们谈论到使用令牌进行身份验证时,我们可以将其分为两种类型:基于令牌的有状态认证和基于令牌的无状态认证。

基于令牌的有状态认证

这是最常见的认证模式。当用户登录时,服务器会返回一个令牌,这个令牌通常存储在客户端浏览器的Cookie中,服务器会将会话信息保存在内存或数据库中(Redis、MongoDB…)。

因此,每次用户用该令牌发起请求时,服务器都会搜索存储的会话信息以识别出是哪个用户在尝试访问,如果用户信息有效,就会执行所请求的方法。

这种认证方式有几个问题,比如超载(存储所有被认证用户的信息会造成超载)、可扩展性(如果启动了多个服务器实例,为避免再次登录,将不得不以某种方式共享会话信息),除此之外,这种架构还存在着一些漏洞(CORS - 跨域资源共享、CSRF - 跨站请求伪造)。

基于令牌的无状态认证

为了解决上述的这些问题,无状态认证出现了。这意味着服务器不会存储任何信息,也不会存储会话。

当用户使用凭证或其他方式进行身份验证时,在响应中将接收到一个访问令牌。从那一刻起,所有发起的API请求都会在HTTP头中携带这个令牌,这样服务器就可以识别出是哪个用户发起的请求,而不需要搜索数据库或其他存储系统。

使用这种认证方式,应用程序变得可扩展,因为是客户端本身存储其认证信息而不是服务器,这样一来,请求可以在没有同步的情况下到达任意的服务器实例;此外,不同的平台可以使用相同的API;同时,这也提高了安全性,避免了CSRF漏洞(因为没有会话),并且,如果我们在令牌中加入过期时间,安全性会更高。

JWT认证

JWT是基于JSON的开放标准,用于创建允许应用程序或API资源使用的访问令牌。这个令牌将包含服务器识别所需要的用户信息以及其他可能有用的附加信息(如角色、权限等)。

访问令牌也可以设置一个有效期,一旦有效期过了,服务器将不再允许用户使用这个令牌访问资源。此时,用户必须通过重新认证或一些额外的方法(刷新令牌)来获得一个新的访问令牌。

JWT将JSON作为令牌中存储的信息要使用的内部格式。此外,如果与JWS和JWE结合使用,它会变得非常有用。将JWT与JWS和JWE结合,我们不仅可以对用户进行身份验证,还可以将加密后的信息发送出去,这样只有服务器才能提取加密后的信息出来,同时还可以对内容进行校验以确保没有被篡改。

JWT令牌由三个部分组成:头信息、消息体和签名,中间使用点号分割:Header.Payload.Signature

  • 头信息:Header 部分是一个 JSON 对象,描述 JWT 的元数据,如alg属性表示签名的算法,typ属性表示这个令牌的类型
  • 消息体:Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据
  • 签名:Signature 部分是对前两部分内容的签名,以防止数据被篡改

令牌类型

令牌有很多类型,不过在JWT认证中,最典型的是访问令牌和刷新令牌:

  • 访问令牌:它包含了服务器需要知道用户是否可以访问所请求资源的所有信息。访问令牌通常是过期的令牌,其有效期很短。
  • 刷新令牌:刷新令牌用于生成一个新的访问令牌。通常情况下,如果访问令牌设置了有效期,一旦过期,用户就必须重新认证才能获得新的访问令牌。使用刷新令牌则可以跳过这一步,只需携带刷新令牌发起请求,就可以获得一个新的访问令牌,以允许用户继续访问应用资源。

当用户访问之前没有访问过的资源时,可能也需要生成一个新的访问令牌,不过这取决于API实现的限制。

与访问令牌相比,刷新令牌在存储时需要更高的安全性,因为如果它被第三方窃取,他们就可以利用它来获得访问令牌以访问受保护的应用资源。为了减少这种情况的发生,除了设置一个明显比访问令牌更长的有效期之外,应用还必须在服务端实现使刷新令牌失效的功能。

刷新令牌的实现

在这个例子中,我将跳过数据库部分,因而,尽管我会对其进行说明,但仍需要进行一些应该做的安全检查。我之所以这样做的原因是为了展示尽可能简单的代码,而并不限制任何永久性系统的实现。

在第一段代码中,我们只是简单地启动一个Node服务,就像其他应用程序一样。

1
2
3
4
var express = require('express')
var app = express()

app.listen(8999)

我们首先要做的第一件事是添加一个验证用户身份的方法,最典型的是使用用户名和密码。为了简化代码,未从数据库中进行校验,且允许我们访问所有用户。

在方法返回值中,我们将同时返回访问令牌和刷新令牌。正如我们在实现中看到的那样,令牌的有效期为300秒。

对于访问令牌,我们使用 jsonwebtoken 模块来加密并生成签名,即我们只需将要加密的对象以及用于加密和解密的密钥传递给它,就可自动生成JWT令牌。

对于刷新令牌,我们将简单地生成一个UID,并将其与相关联的用户名一起储存在内存中。在完整实现中,我们可以将用户的信息、令牌的创建时间和失效时间保存在数据库中。

虽然刷新令牌也可以是自包含令牌,就像我们创建的访问令牌一样,这种实现的好处是不用访问数据库来获取必要的信息。但如果是自包含令牌的话,我们将无法知道刷新令牌是否已被加入黑名单或被管理员覆写,又或者,如果用户被管理员禁用了,我们也无法知晓。这就是为什么我更喜欢通过不带自包含信息来实现这种类型的令牌。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var bodyParser = require('body-parser')
var jwt = require('jsonwebtoken')
var randtoken = require('rand-token')

var refreshTokens = {}
var SECRET = "SECRETO_PARA_ENCRIPTACION"
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))

app.post('/login', function (req, res, next) {
var username = req.body.username
var password = req.body.password
var user = {
'username': username,
'role': 'admin'
}
var token = jwt.sign(user, SECRET, { expiresIn: 300 })
var refreshToken = randtoken.uid(256)
refreshTokens[refreshToken] = username
res.json({token: 'JWT ' + token, refreshToken: refreshToken})
});

为了请求一个新的访问令牌,我们创建了 /token 资源。在该API中,我们接收刷新令牌和拥有该令牌的用户名。在这里,我们要做的是检查刷新令牌列表中是否包含发送给我们的令牌,并且该令牌具有相同的用户名。如果正确的话,我们会生成一个新的令牌,其中包含用户的信息(我们会从数据库中获取)并返回。

在应用中,如果管理员可以暂时禁用用户或刷新令牌,则我们还必须在生成新的访问令牌之前对其进行检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app.post('/token', function (req, res, next) {
var username = req.body.username
var refreshToken = req.body.refreshToken
if((refreshToken in refreshTokens) && (refreshTokens[refreshToken] == username)) {
var user = {
'username': username,
'role': 'admin'
}
var token = jwt.sign(user, SECRET, { expiresIn: 300 })
res.json({token: 'JWT ' + token})
}
else {
res.send(401)
}
})

在这种架构中,有必要有一种方法可来禁用刷新令牌,以避免冒名顶替和滥用。

在应用程序中,用户可以使用同一个身份(相同的用户名)在不同设备上工作,但每个设备上使用不同的令牌,如果其中一个令牌丢失或被盗,这个方法将允许管理员删除或禁用该刷新令牌,而不会影响用户在其他设备上的服务,也不需要重新认证、修改密码等。也就是说,用户可以继续工作而不受任何影响,也没有从被盗设备生成新访问令牌的风险。我们建议访问令牌设置较短的有效期,这样在这种情况下,可以快速恢复到安全状态。

为此,我们创建了一个 /token/reject 资源来禁用刷新令牌。在本示例中,我们只需将其从内存列表中删除即可。在完整的实现中,有必要验证发起请求是否是管理员或其他对此资源具有权限的用户。

1
2
3
4
5
6
7
app.post('/token/reject', function (req, res, next) { 
var refreshToken = req.body.refreshToken
if(refreshToken in refreshTokens) {
delete refreshTokens[refreshToken]
}
res.send(204)
})

最后,我们将公开只能通过发送附带有先前获得的JWT令牌的请求头信息才能访问的资源,该令牌将由我们的应用程序生成并与使用我们的密钥(SECRET)签名。

在本示例中,我们将使用 Passport 模块。Passport是Nodejs中用于身份验证的中间件,它非常灵活且模块化,这体现在大量的模块中,每个模块都实现了不同的身份验证策略(JWT、Twitter、Facebook、Google、Auth0、SAML等多达300种)。我们可以使用其中的任何一种,导入和配置都很简单,把最复杂的认证部分交给Passport来完成即可。

首先,我们要加载中间件和必要的对象。Passport要求我们实现serializeUser方法(根据策略也可以实现deserializeUser),该方法用于将识别所需的用户信息储存在会话中。在我们的例子中,我们用用户名做索引,但理想的情况是使用ID。

事实上,既然是无状态认证,那么会话就没有意义了,如果我们只用JWT,Passport将永远不会使用 deserializeUser 反序列化方法,我把它注释掉,以防引入新的策略时会用到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var passport = require('passport')
var JwtStrategy = require('passport-jwt').Strategy
var ExtractJwt = require('passport-jwt').ExtractJwt

app.use(passport.initialize())
app.use(passport.session())

passport.serializeUser(function (user, done) {
done(null, user.username)
})

/*
passport.deserializeUser(function (username, done) {
done(null, username)
})
*/

最后,我们需要做的是,每当请求到达需要身份认证的资源时,从令牌中提取信息并进行处理(变量jwtPayload将有我们在用户登录时加密的用户对象)。

1
var token = jwt.sign(user, SECRET, { expiresIn: 300 })

Passport身份验证策略配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var opts = {}

// Setup JWT options
opts.jwtFromRequest = ExtractJwt.fromAuthHeader()
opts.secretOrKey = SECRET

passport.use(new JwtStrategy(opts, function (jwtPayload, done) {
//If the token has expiration, raise unauthorized
var expirationDate = new Date(jwtPayload.exp * 1000)
if(expirationDate < new Date()) {
return done(null, false);
}
var user = jwtPayload
done(null, user)
}))

我们将创建用于测试身份验证的 /test_jwt 资源,且只需告诉Passport,通过 jwt 策略,可以访问该路径来对进行身份验证。这样我们就有了一个想法,即通过Passport,我们可以使用不同的策略对每个资源进行身份验证,从而以非常简单的方式给我们提供了极大的灵活性。

1
2
3
app.get('/test_jwt', passport.authenticate('jwt'), function (req, res) {
res.json({success: 'You are authenticated with JWT!', user: req.user})
})

结论

通过使用JWT,我们可以避免多次数据库调用,从而降低延迟,提高应用的效率。此外,通过使用刷新令牌,我们提高了这种架构的安全性和可用性。

在大量的项目中,使用令牌进行认证是非常有用的,但它不是解决所有问题、为所有产品服务的圣杯,但我们在提出任何解决方案时都必须考虑到它。

参考链接