JWT 和 Go。如何将它们与安全需求集成
大家好!我是 Alexander Brichak,NIX 的 Golang 开发人员。
使用普遍接受的解决方案和技术时,开发人员很少会考虑某个解决方案如果使用不当会带来什么风险,以及它是否适合他们想要应用的任务。JWT 这种流行的技术也完全符合这一规律。
在本文中,我想讨论在客户端应用程序中使用 JWT 令牌时出现的问题,并考虑一些针对用 Golang 实现的后端服务器的有趣解决方案。
为什么选择 Golang?这门语言的高性能使其更易于处理高负载软件和微服务架构。它的应用范围广泛,语法易于学习。Golang 社区在全球范围内蓬勃发展。因此,NIX 为初学者开发了一个免费的学习平台。
对于已经使用 Go 的人来说,本文在使用 Golang 创建 Web 应用程序时很有用,对于正在寻找现成的解决方案来实现诸如日志记录和用户自动日志记录等非标准 JWT 功能的人来说也很有用。
如何确保 Web 应用服务器(后端或 API)收到的数据确实是由某个用户发送的?这有助于识别JSON Web Token 技术。在使用 Web Token 访问客户端应用程序的 API 时,务必牢记,Token 可能落入攻击者之手。因此,通常情况下,身份验证后用户收到的并非只有一个 Token,而是两个:
-
访问令牌是短期有效的。它可以重复使用,从服务器获取资源。令牌的生命周期显示在有效负载部分,通常限制为数小时,甚至数分钟,具体取决于应用程序。标准 JWT 库在验证令牌时默认检查其是否已过期。收到访问令牌的攻击者只有非常有限的时间代表用户采取行动。
-
refresh-token 具有长期使用期限。它允许您在访问令牌过期后续订一对令牌。
特别是在OAuth 2.0协议中也采用了类似的机制。
在前端应用程序中,使用 JWT 时,工作方案如下:
- 一旦服务器根据用户名和密码返回访问和刷新令牌,系统就会记住这对令牌
- 每次调用 API 时,前端应用程序都会向 HTTP 请求添加一个带有访问令牌的标头。如果令牌未过期,服务器将返回一个响应
- 如果访问令牌已过期,服务器将响应 HTTP 401 未授权错误状态。要获取新的令牌对,应用程序首先需要访问服务器上一个特殊的 API 端点并传递刷新令牌。然后,重复 HTTP 请求,使用已生成的访问令牌获取数据。
例如,在 JavaScript 中,使用拦截器在 axios 库中实现这种机制很方便。
如何使令牌无效以及为什么需要它
JSON Web Token 最初是作为一种无状态授权机制创建的,目的是避免在服务器上存储信息。Token 的有效期会被自动记录。过期后,Token 会失效,服务器不会再接受。这种方案非常出色,因为它不需要额外的服务器资源来记录状态。
假设我们需要实现注销功能——用户退出应用程序。在前端,这可以通过“忘记”一对令牌轻松实现。要继续使用应用程序,用户必须再次输入用户名和密码,并接收一组新的令牌。但是,如果令牌落入攻击者手中怎么办?如果令牌被盗,黑客获得了刷新令牌,他将有足够的时间代表用户执行某些操作。而真正的用户无法撤销令牌并阻止攻击者。唯一能挽救您的方法是在服务器上阻止用户或替换用于签名令牌的秘密字符串。执行此操作后,所有已颁发的令牌都将失效。
因此,描述 OAuth 2.0 协议的 RFC6749要求采取额外措施来识别刷新令牌的非法使用。在这种情况下,可以使用发送此令牌的用户的身份验证。另一种方法是轮换刷新令牌,轮换后,刷新令牌将失效并存储在服务器上。如果将来有人尝试使用它,则表明可能存在黑客攻击。
所有这些考虑,在大多数情况下,都会导致需要将无状态令牌转换为有状态令牌,即在服务器上存储一些信息,以便您可以声明某个用户的令牌无效。然后,对于每个用户请求,服务器首先根据令牌本身的信息(特别是到期日期)检查令牌的有效性,然后再根据服务器上的信息进行检查。
有很多方法可以组织这个过程,例如:
- 在服务器上存储黑名单令牌。该列表在注销或更新令牌对后生成。当用户使用黑名单中的令牌访问服务器时,将收到授权错误;
- 在服务器上存储用户黑名单。黑名单中可以包含用户 ID 和注销时间。任何在用户注销时间之前发放的 token 都将无效;
- 将已发放令牌的信息存储在服务器上,并与用户 ID 关联。如果用户应用程序向服务器发送的请求中传递的令牌信息与已发放给该用户的令牌数据匹配,则该令牌有效;
奇特的方法:
- 为每个用户创建用于签名令牌的秘密行。这将允许您更改该行以使特定用户的令牌无效;
- 如果用户的令牌被盗,则更改用户 ID。此后,旧令牌将不再与任何用户匹配。
为了验证令牌,许多方法需要在用户每次访问服务器时额外查询数据库。为了减轻数据库负载并加快请求处理速度,人们使用了其他存储令牌信息的选项。例如,内存数据库。
自动注销和 JWT
许多用户应用程序都需要实现自动注销功能——当用户一段时间不活动时,系统会自动断开连接。该功能尤其适用于那些提供个人数据和其他“敏感”信息(例如银行账户或病史记录)访问的应用程序。特别是,美国 HIPAA 标准将此类要求应用于那些提供用户安全电子健康信息 (ePHI)访问的应用程序。
当然,用户前端应用程序需要以某种方式跟踪用户的不活动时长,并在超过不活动时长时发出注销请求。但考虑到后端服务器不应依赖于前端应用程序的验证例程,后端显然需要有自己的方法来检测用户不活动状态。
前端应用与外界交互的主要流程是通过后端服务器上的 API 进行的。因此,用户的活动可以视为对 API 的请求执行,而非活动状态则是指同一用户两次请求之间的时间间隔。后端服务器的任务是跟踪请求之间的时间间隔,并在超出最大非活动状态时强制注销。
NIX 团队使用状态令牌的解决方案
我们的方法超越了无状态令牌,涉及将已发行令牌的信息存储在服务器上的 Redis 中。除了用户 ID 之外,我们还为令牌添加了另一个 ID,以便将令牌与服务器上记录的信息进行匹配。本文详细介绍了这种使用令牌的方案。
Redis 数据库的主要优势在于其自动注销功能。得益于 Redis 数据库中数据自动过期(expiration)的机制,我们能够建立一种存储和更新已发放令牌信息的方法。在用户请求间隔的最长允许时间过后,其令牌信息将自动从 Redis 数据库中删除。令牌将失效。
例如,以一个用 Golang Echo Web 框架编写的样板应用程序为例。它已经实现了注册和用户登录功能,并使用刷新令牌更新了一对令牌,并且有一组测试用例。接下来,我们将对其进行持续的修改,以获得所需的结果。这里还有 Swagger 文档,方便我们测试更改。对样板应用程序代码所做的更新可以在代码库的“feature/JWT-logout”分支下找到。
改进模板应用程序
样板应用程序使用dgrijalva / jwt-go库来处理 JWT。除了标准的声明字段集之外,该库还允许您描述其他字段。在应用程序中,这使得能够将令牌颁发给的用户的 ID 写入令牌中。该库支持AuthHandler 应用程序中用于创建和验证令牌的NewWithClaims()和Parse()函数。此外,Echo 框架具有一个 JWT 中间件,它使用指定的库来验证令牌。该中间件连接到声明路由的模板应用程序的configureRoutes()函数中。
样板应用程序的当前实现仅使用无状态令牌。在这种情况下,无法在令牌到期前将其声明为无效。除了无法完全注销之外,这还会导致以下情况:使用一个刷新令牌,您可以多次访问 API 端点/进行刷新。我们后续的改进应该也能解决这个问题。
让我们继续实现我们的想法。在 Redis 数据库中,我们将存储有关为每个用户发放的令牌的某些信息。
我们需要在应用程序代码中添加以下组件:
- 连接到 Redis 数据库
- 生成token对时,在Redis中记录已发行token的信息
- 检查 Redis 中是否存在受授权保护的路由的令牌
- 当用户访问 API 端点/注销时从 Redis 中删除记录。
Redis 连接
由于我们的模板应用程序使用docker-compose,我们可以通过在 docker-compose.yml 中声明它来轻松添加带有 Redis 数据库的容器:
echo_redis:
image: redis
container_name: ${REDIS_HOST}
restart: unless-stopped
ports:
- ${REDIS_EXPOSE_PORT}:${REDIS_PORT}
networks:
- echo-demo-stack
要创建容器,我们需要将 REDIS_HOST、REDIS_PORT、REDIS_EXPOSE_PORT 这几个值输入到 .env 文件中。要连接 Redis 服务器,需要在 config 包中添加RedisConfig结构体:
package config
import "os"
type RedisConfig struct {
Host string
Port string
}
func LoadRedisConfig() RedisConfig {
return RedisConfig{
Host: os.Getenv("REDIS_HOST"),
Port: os.Getenv("REDIS_PORT"),
}
}
Then - the InitRedis () function into the db package. To connect it uses the library [github.com/go-redis/redis](github.com/go-redis/redis)
func InitRedis(cfg *config.Config) *redis.Client {
addr := fmt.Sprintf("%s:%s", cfg.Redis.Host, cfg.Redis.Port)
return redis.NewClient(&redis.Options{
Addr: addr,
})
}
我们在启动应用程序时调用服务器包的NewServer()方法中的InitRedis()函数:
func NewServer(cfg *config.Config) *Server {
return &Server{
Echo: echo.New(),
DB: db.Init(cfg),
Redis: db.InitRedis(cfg),
Config: cfg,
}
}
存储有关代币的信息
现在我们已经连接到 Redis,可以开始保存已颁发令牌的信息了。为此,我们只需更改令牌包中的服务代码即可。我们保存的不是令牌本身,而是某个唯一的 UID。此标识符也会出现在相应令牌的声明中。解析用户请求中的令牌,并将 UID 与服务器上存储的 UID 进行比对后,我们就能始终知道该令牌是否处于活动状态。
将 UID 字段添加到JwtCustomClaims和createToken()方法中:
type JwtCustomClaims struct {
ID uint `json:"id"`
UID string `json:"uid"`
jwtGo.StandardClaims
}
我们将使用github.com/google/uuid库创建 UID 。同时,我们将生成的 UID 添加到createToken ()方法的输出参数列表中:
func (tokenService *Service) createToken(userID uint, expireMinutes int, secret string) (
token string,
uid string,
exp int64,
err error,
) {
exp = time.Now().Add(time.Minute * time.Duration(expireMinutes)).Unix()
uid = uuid.New().String()
claims := &JwtCustomClaims{
ID: userID,
UID: uid,
StandardClaims: jwtGo.StandardClaims{
ExpiresAt: exp,
},
}
现在让我们声明一个结构,每当生成一对令牌时,该结构就会保存在服务器上:
type CachedTokens struct {
AccessUID string `json:"access"`
RefreshUID string `json:"refresh"`
}
由于 token 包中的服务需要连接到 Redis,因此让我们更改服务声明和NewTokenService ()方法,如下所示:
type Service struct {
server *s.Server
}
func NewTokenService(server *s.Server) *Service {
return &Service{
server: server,
}
}
最后一点修改涉及GenerateTokenPair ()方法。在接收每个创建的令牌的 UID 并将这些 UID 写入 CachedTokens 结构后,使用“token-{ID}”键将此结构的 JSON 保存在 Redis 中,其中登录用户的 ID 将替换为:
func (tokenService *Service) GenerateTokenPair(user *models.User) (
accessToken string,
refreshToken string,
exp int64,
err error,
) {
var accessUID, refreshUID string
if accessToken, accessUID, exp, err = tokenService.createToken(user.ID, ExpireAccessMinutes,
tokenService.server.Config.Auth.AccessSecret); err != nil {
return
}
if refreshToken, refreshUID, _, err = tokenService.createToken(user.ID, ExpireRefreshMinutes,
tokenService.server.Config.Auth.RefreshSecret); err != nil {
return
}
cacheJSON, err := json.Marshal(CachedTokens{
AccessUID: accessUID,
RefreshUID: refreshUID,
})
tokenService.server.Redis.Set(fmt.Sprintf("token-%d", user.ID), string(cacheJSON), 0)
return
}
现在,我们真正地免受攻击者的攻击。如果有人窃取了我们的令牌,每次用户使用用户名和密码登录系统时,新令牌都会擦除旧令牌的信息,使旧令牌失效。需要注意的是,在此实现中,用户只能在一台设备上同时使用系统。当从另一台设备登录时,为第一个设备颁发的令牌将失效。
剩下的任务是添加代码来检查用户发送的令牌是否存在。
检查 Redis 中令牌的存在
在 token 包中,将ValidateToken ()方法添加到服务中。此方法从 Redis 中检索令牌数据,该数据以“token- {ID}”键存储。该 ID 将被请求中发送的声明令牌中的用户 ID 替换。接下来,将请求中令牌的 UID 与 Redis 中的 UID 进行比较。如果匹配,则表示用户发送了有效的令牌。
func (tokenService *Service) ValidateToken(claims *JwtCustomClaims, isRefresh bool) error {
cacheJSON, _ := tokenService.server.Redis.Get(fmt.Sprintf("token-%d", claims.ID)).Result()
cachedTokens := new(CachedTokens)
err := json.Unmarshal([]byte(cacheJSON), cachedTokens)
var tokenUID string
if isRefresh {
tokenUID = cachedTokens.RefreshUID
} else {
tokenUID = cachedTokens.AccessUID
}
if err != nil || tokenUID != claims.UID {
return errors.New("token not found")
}
return nil
}
我们将在AuthHandler中的RefreshToken()方法中调用它:
func (authHandler *AuthHandler) RefreshToken(c echo.Context) error {
refreshRequest := new(requests.RefreshRequest)
if err := c.Bind(refreshRequest); err != nil {
return err
}
claims, err := authHandler.tokenService.ParseToken(refreshRequest.Token,
authHandler.server.Config.Auth.RefreshSecret)
if err != nil {
return responses.ErrorResponse(c, http.StatusUnauthorized, "Not authorized")
}
if authHandler.tokenService.ValidateToken(claims, true) != nil {
return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
}
user := new(models.User)
为此,需要稍微重做ParseToken ()方法,以便它不返回标准的 JWT 声明集,而是返回指向JwtCustomClaims的链接,我们可以从中提取令牌标识符:
func (tokenService *Service) ParseToken(tokenString, secret string) (
claims *JwtCustomClaims,
err error,
) {
token, err := jwtGo.ParseWithClaims(tokenString, &JwtCustomClaims{},
func(token *jwtGo.Token) (interface{}, error) {
if _, ok := token.Method.(*jwtGo.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return
}
if claims, ok := token.Claims.(*JwtCustomClaims); ok && token.Valid {
return claims, nil
当然,所有受 token 保护的路由都必须调用 ValidateToken () 方法进行验证。为此,我们在 auth.go 文件中添加一个中间件:
func ValidateJWT(server *s.Server) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token := c.Get("user").(*jwtGo.Token)
claims := token.Claims.(*tokenService.JwtCustomClaims)
if tokenService.NewTokenService(server).ValidateToken(claims, false) != nil {
return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
}
return next(c)
}
}
}
然后我们在嵌入的JWT中间件之后,在ConfigureRoutes()函数中声明路由时使用它:
authMW := middleware.JWT(server.Config.Auth.AccessSecret)
validateTokenMW := middleware.ValidateJWT(server)
apiProtected := server.Echo.Group("")
apiProtected.Use(authMW, validateTokenMW)
由于内置的 JWT 中间件在验证令牌后,将其添加到具有键“user”的请求上下文中,因此我们用于令牌验证的附加中间件可以从上下文中提取令牌并使用它 - 运行令牌包中服务的ValidateToken()方法以验证其在 Redis 中的数据。
注销时删除有关令牌的信息
要实现注销,还剩下添加从 Redis 中删除用户令牌条目的代码。让我们将Logout()方法添加到 AuthHandler:
func (authHandler *AuthHandler) Logout(c echo.Context) error {
user := c.Get("user").(*jwtGo.Token)
claims := user.Claims.(*tokenservice.JwtCustomClaims)
authHandler.server.Redis.Del(fmt.Sprintf("token-%d", claims.ID))
return responses.MessageResponse(c, http.StatusOK, "User logged out")
}
We use simplified token validation (no additional validation in Redis). Let's add the “/ logout” route to the *ConfigureRoutes ()* function:
authMW := middleware.JWT(server.Config.Auth.AccessSecret)
server.Echo.POST("/logout", authHandler.Logout, authMW)
validateTokenMW := middleware.ValidateJWT(server)
自动注销
假设我们面临用户 10 分钟不活动就自动注销的情况。设置访问令牌的有效期并不能解决这个问题。如果用户收到了几个令牌,并在 11 分钟后再次访问 API,我们将返回 401 未授权状态。但是,用户可以申请/刷新令牌,由于刷新令牌的有效期较长,他将收到一对新的令牌。我们不能允许这种情况发生。
另一方面,将刷新令牌的周期设置为 10 分钟也是不可行的。如果用户在收到一对令牌后 9 分钟内访问 API,则从此时起,我们必须启动新的自动注销倒计时,并允许用户在收到第一对令牌后 19 分钟内访问 API(使用访问令牌或 /refresh 的刷新令牌)。
正如我之前提到的,Redis 的 TTL 机制对于解决这个问题非常方便。
需要提醒的是,当我们在GenerateTokenPair()方法中创建令牌后将数据写入 Redis 时,Redis.Set()方法中的第三个参数指定了记录的过期日期。过期后,Redis 会自动删除该条目。如果我们传递 0 作为此参数,则该记录将具有无限的 TTL:
tokenService.server.Redis.Set(fmt.Sprintf("token-%d", user.ID), string(cacheJSON), 0)
通过控制 Redis 中记录的 TTL,我们将实现令牌在指定时间后自动失效。在这种情况下,自动注销的时间可以设置为任意值,而不受令牌有效期的限制。
应该做什么:
- 在GenerateTokenPair ()方法中设置 TTL,使其在 10 分钟后写入 Redis 。此步骤将在用户首次登录时以及后续通过 /refresh 刷新令牌对时生效。
- 每次用户成功发出 API 请求时,将此条目的 TTL 再延长 10 分钟。
让我们创建一个常量 const AutoLogoffMinutes = 10 并更改GenerateTokenPair ()中的“expiration”参数:
tokenService.server.Redis.Set(fmt.Sprintf("token-%d", user.ID), string(cacheJSON),
time.Minute*AutoLogoffMinutes)
使用 Redis Expire 命令,在 auth.go 文件中的 ValidateJWT 中间件中成功检查记录的存在后,添加带有令牌的记录的 TTL 扩展:
func ValidateJWT(server *s.Server) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token := c.Get("user").(*jwtGo.Token)
claims := token.Claims.(*tokenService.JwtCustomClaims)
if tokenService.NewTokenService(server).ValidateToken(claims, false) != nil {
return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
}
server.Redis.Expire(fmt.Sprintf("token-%d", claims.ID),
time.Minute*tokenService.AutoLogoffMinutes)
return next(c)
}
}
}
假设我们设置了当用户 10 分钟不活动时自动注销。访问令牌的有效期为 20 分钟,刷新令牌的有效期为 60 分钟。自动注销机制可以从下图中完美理解:
在第一阶段,前端应用程序发送用户名和密码,并接收来自 API 的响应,其中包含访问和刷新令牌。令牌 UID 条目存储在 Redis 中,TTL 为 10 分钟。
在第二阶段和第三阶段,应用程序会发送各种 API 请求。每个请求与前一个请求的时间间隔不超过 10 分钟。每次发送时,Redis 中带有 token UID 的记录的 TTL 都会移动 10 分钟。同时,token 有效期本身保持不变。
在第四阶段,前端应用程序在令牌生成 20 分钟后向 API 发送请求,并收到 401 Not Authorized 响应,因为访问令牌已过期。通过使用刷新令牌联系端点/刷新,前端将收到一组新的令牌。Redis 会将新令牌的信息写入,新的 TTL 为 10 分钟。旧令牌不再有效。
在第五阶段,应用程序在上一阶段结束 12 分钟后向 API 发送请求。尽管令牌尚未过期,但 Redis 条目在 10 分钟的 TTL 后被删除。前端将无法接收新的令牌,直到用户再次登录。至此,自动注销操作完成。
用户信息
我们的令牌验证代码存在一个问题。假设用户已登录,其令牌信息存储在 Redis 中。登录后,该令牌信息立即被停用(例如,系统管理员从数据库中删除了一条用户记录,或将其设置为“停用”状态)。我们需要确保用户的应用程序无法再使用已颁发的令牌集来操作 API。当管理员停用用户时,该用户的令牌信息应该自动从 Redis 中删除。但是,如果您忘记了执行此操作怎么办?
为了避免此类问题,在验证令牌时,我们不仅可以检查 Redis 中条目的存在,还可以检查数据库中用户条目的存在/活动。这需要对数据库进行额外的查询。
另一方面,在处理请求的过程中,经常会出现在数据库中搜索用户记录的情况:
-
服务器需要当前用户的信息。这将有助于确定执行某些操作的权限;
-
当进行更改数据库中数据的查询时,后端应用程序代码必须检查用户记录是否存在于数据库中,以及用户是否未被停用。
为了实现这个想法,在令牌服务的ValidateToken()方法中添加代码,用于在数据库中查找用户记录。我们还将找到的用户记录添加到指定方法的返回参数列表中:
func (tokenService *Service) ValidateToken(claims *JwtCustomClaims, isRefresh bool) (
user *models.User,
err error,
) {
cacheJSON, _ := tokenService.server.Redis.Get(fmt.Sprintf("token-%d", claims.ID)).Result()
cachedTokens := new(CachedTokens)
err = json.Unmarshal([]byte(cacheJSON), cachedTokens)
var tokenUID string
if isRefresh {
tokenUID = cachedTokens.RefreshUID
} else {
tokenUID = cachedTokens.AccessUID
}
if err != nil || tokenUID != claims.UID {
return nil, errors.New("token not found")
}
user = new(models.User)
userRepository := repositories.NewUserRepository(tokenService.server.DB)
userRepository.GetUser(user, int(claims.ID))
if user.ID == 0 {
return nil, errors.New("user not found")
}
return user, nil
}
存储库的GetUser ()方法不仅可以从 users 表中检索用户记录,还可以通过一次 JOIN 请求从 user_details、user_roles 和其他表中获取个人数据和用户角色(如果数据库中存在此类表,并且这些信息对处理请求有用)。这些更改将允许我们从RefreshToken ()方法中删除用于检查用户记录的代码:
func (authHandler *AuthHandler) RefreshToken(c echo.Context) error {
refreshRequest := new(requests.RefreshRequest)
if err := c.Bind(refreshRequest); err != nil {
return err
}
claims, err := authHandler.tokenService.ParseToken(refreshRequest.Token,
authHandler.server.Config.Auth.RefreshSecret)
if err != nil {
return responses.ErrorResponse(c, http.StatusUnauthorized, "Not authorized")
}
user, err := authHandler.tokenService.ValidateToken(claims, true)
if err != nil {
return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
}
accessToken, refreshToken, exp, err := authHandler.tokenService.GenerateTokenPair(user)
if err != nil {
return err
}
res := responses.NewLoginResponse(accessToken, refreshToken, exp)
return responses.Response(c, http.StatusOK, res)
}
There will be a more significant change in the middleware ValidateJWT code. Let's add the found user record to the request context with the "currentUser" key, making it possible to access this information at all subsequent stages of request processing:
// Middleware for additional steps:
// 1. Check the user exists in DB
// 2. Check the token info exists in Redis
// 3. Add the user DB data to Context
// 4. Prolong the Redis TTL of the current token pair
func ValidateJWT(server *s.Server) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token := c.Get("user").(*jwtGo.Token)
claims := token.Claims.(*tokenService.JwtCustomClaims)
user, err := tokenService.NewTokenService(server).ValidateToken(claims, false)
if err != nil {
return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
}
c.Set("currentUser", user)
server.Redis.Expire(fmt.Sprintf("token-%d", claims.ID),
time.Minute*tokenService.AutoLogoffMinutes)
return next(c)
}
}
}
优化 ValidateToken() 代码
请注意,在token 包的ValidateToken ()方法中发生了两个连续的操作:
- 从 Redis 中检索包含有关令牌信息的记录;
- 从数据库中检索有关用户的信息。
Golang 允许我们并行执行这些请求。这样可以节省一些请求的处理时间(实际上,只是检索 Redis 记录并将其解析为 Golang 结构所需的时间)。但是,当你可以优化代码时,何乐而不为呢?
我们使用golang.org/x/sync/errgroup包。它允许你运行多个 goroutine 并等待它们成功完成。但是,如果其中一个 goroutine 发生错误,整个 goroutine 组的执行将被取消。ValidateToken() 方法代码如下所示:
func (tokenService *Service) ValidateToken(claims *JwtCustomClaims, isRefresh bool) (
user *models.User,
err error,
) {
var g errgroup.Group
g.Go(func() error {
cacheJSON, _ := tokenService.server.Redis.Get(fmt.Sprintf("token-%d", claims.ID)).Result()
cachedTokens := new(CachedTokens)
err = json.Unmarshal([]byte(cacheJSON), cachedTokens)
var tokenUID string
if isRefresh {
tokenUID = cachedTokens.RefreshUID
} else {
tokenUID = cachedTokens.AccessUID
}
if err != nil || tokenUID != claims.UID {
return errors.New("token not found")
}
return nil
})
g.Go(func() error {
user = new(models.User)
userRepository := repositories.NewUserRepository(tokenService.server.DB)
userRepository.GetUser(user, int(claims.ID))
if user.ID == 0 {
return errors.New("user not found")
}
return nil
})
err = g.Wait()
return user, err
}
中间件 ValidateJWT 中还有另一个小优化等待着我们。使用 Redis 中令牌的信息扩展 TTL 记录也可以在 goroutine 中完成。因此,在等待此操作结束期间,请求的进一步处理不会被阻塞:
c.Set("currentUser", user)
go func() {
server.Redis.Expire(fmt.Sprintf("token-%d", claims.ID),
time.Minute*tokenService.AutoLogoffMinutes)
}()
return next(c)
我们做的每件事都正确吗?
如果你查看生成的代码,你会发现在检查用户是否存在时,我们仍然会查询主数据库。这意味着我们可以将用户的令牌及其上次使用日期的信息存储在这个数据库中,并且无需使用 Redis 数据库即可实现注销和自动注销。为什么选择 Redis?
-
这使得您可以卸载主数据库,避免存储不寻常的数据和不必要的请求(有关令牌和用户上次访问 API 的时间信息是相当短暂的);
-
自动删除TTL过期记录的机制,可以让你更优雅地实现自动注销,并且不占用数据库服务器的空间来存储过期信息;
-
数据库中的其他数据也可以存储在 Redis 中。例如,有关用户角色和权限的信息。
在服务器上存储有关已颁发令牌的信息的位置应根据每个特定应用程序的具体情况来决定。
如何在客户端存储令牌
在登录过程中,将令牌包含在响应主体中通常会导致前端开发人员决定将收到的令牌存储在浏览器的本地存储中。这样可以避免用户强制刷新页面或打开新标签页时重新登录的麻烦。这种解决方案很容易受到 XSS 攻击,攻击者的代码可以访问本地存储。
通常使用另一种方案,将访问令牌传递到响应主体中,并进一步存储在前端应用程序的内存中,将刷新令牌放置在 HttpOnly Cookie 中。这种方法有助于更好地防御 XSS 攻击,但同时也容易受到 CSRF 攻击。
将刷新令牌放入 Cookie 中,理想情况下也意味着后端应用程序架构的改变,授权服务将位于单独的域中。因此,带有刷新令牌的 Cookie 仅在与授权服务交互时才会传输。
但是会议怎么样?
人们认为,除了确认用户身份之外,以任何其他方式使用令牌不再包含在 JWT 功能中,应该以不同的方式实现。
会话机制是解决注销和自动注销问题的方法之一。最简单的方法是在服务器响应中添加一个包含特定字符串的 Cookie。该字符串可以是服务器响应时间,并用密钥签名。前端应用程序下次发出请求时,服务器会将 Cookie 中包含的上一次请求的时间与当前时间进行比较。如果自上一次请求以来已超过指定的分钟数,用户将收到 HTTP 401 未授权错误状态。因此,访问令牌只有与包含用户会话信息的 Cookie 配对时才有效。
但这并不能消除遭受攻击时的安全问题。因此,为了改进会话机制,您应该使用其他方法来存储会话信息(在主数据库中、在附加的内存数据库中、在服务器文件系统中等)。
我们使用 JWT 进行用户身份验证的方法虽然并非没有缺点,但确实有效。使用会话来存储刷新令牌或其他有关用户状态的信息的方法也很有前景。
维护应用程序安全始终是一个复杂的过程,需要复杂的解决方案。没有理想的解决方案,因为每个特定的应用程序都有其自身的需求。
文章来源:https://dev.to/abrichak/jwt-and-go-how-to-integrate-them-with-security-requirements-eh5