跳到主要内容

2 篇博文 含有标签「jwt」

查看所有标签

· 阅读需 5 分钟

记录一下记账项目后端使用 jwt 方案来鉴权的开发流程

github.com/golang-jwt/jwt/v5

jwt 涉及到加密及解密的过程,将这两个逻辑抽离为两个工具函数

package jwt_helper

import (
"log"
"os"
"time"

"github.com/golang-jwt/jwt/v5"
)

// 辅助函数,从环境变量中获取加密私钥
func getHmacSecret() []byte {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
log.Fatal("JWT_SECRET is not set")
}
return []byte(secret)
}

// 加密,生成 jwt
func GenerateJWT(user_id uint) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": user_id,
// 过期时间
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(),
})
secret := getHmacSecret()

return token.SignedString(secret)
}

// 解密,解析 jwt
func ParseJWT(jwtString string) (*jwt.Token, error) {
secret := getHmacSecret()

return jwt.Parse(jwtString, func(t *jwt.Token) (interface{}, error) {
// 这里直接将结果返回,默认 error 为 nil
// 报错交给中间件即可
return secret, nil
})
}

登录成功后,接口返回 jwt

func (ctrl *SessionController) Create(c *gin.Context) {
// 获取与校验请求体数据..
// 查询验证码是否有效..
// 查询用户(无则创建)..
// 生成并返回 jwt
jwt, err := jwt_helper.GenerateJWT(uint(user.ID))
if err != nil {
log.Print("Generate JWT Error", err)
c.JSON(http.StatusInternalServerError, api.Error{Error: "Failed to generate jwt"})
return
}

res := api.SessionResponse{
Jwt: jwt,
UserID: user.ID,
}
c.JSON(http.StatusOK, res)
}

其他接口的鉴权的过程即读取和解密 jwt 的过程

因为大部分接口都需要鉴权逻辑,可以将鉴权逻辑抽离为中间件

gin 的中间件结构如下:

func Middleware() gin.HandlerFunc {
// 返回一个函数,接收上下文对象的指针
return func(c *gin.Context) {
// ...
// 暴露到上下文中,作为全局变量
c.Set("me", user)
// 中间件是按照注册的顺序执行的
// 移交控制权(下一个中间件或处理函数)
c.Next()
}
}

鉴权逻辑并非单纯返回 true 或 false,我们可以顺便将用户数据直接读取出来,放到 gin 上下文中。这样一来既可以起到鉴权的功能,也减少了 controller 中冗余的用户数据查询逻辑

首先将解析 jwt 及鉴权过程抽离为辅助函数 getMe,接收上下文指针,返回用户指针

package middleware

import (
// ...
)

func getMe(c *gin.Context) (*database.User, error) {
var user database.User

// 获取权限请求头,截取 jwt 字符串
auth := c.GetHeader("Authorization")
if len(auth) < 8 {
return nil, fmt.Errorf("JWT is required")
}
jwtString := auth[7:]

// 解析 jwt,得到 token
t, err := jwt_helper.ParseJWT(jwtString)
if err != nil {
return nil, fmt.Errorf("invalid jwt")
}

// 解析 token 的 claims 部分,将其断言为 MapClaims 类型
claims, ok := t.Claims.(jwt.MapClaims)
if !ok {
return nil, fmt.Errorf("invalid jwt")
}

// 从 claims 中提取用户 ID,并断言其类型为 float64
userID, ok := claims["user_id"].(float64)
if !ok {
return nil, fmt.Errorf("invalid jwt")
}

// 超时校验
exp, ok := claims["exp"].(float64)
if !ok {
return nil, fmt.Errorf("invalid jwt")
}
if float64(time.Now().Unix()) > exp {
return nil, fmt.Errorf("invalid jwt")
}

// 数据库查询用户信息
if tx := database.DB.Find(&user, userID); tx.Error != nil {
return nil, fmt.Errorf("invalid jwt")
}

// 返回 user 地址
return &user, nil
}

中间件接收一个白名单的切片(因为不是全部接口都要鉴权),返回 gin.HandlerFunc 函数

func Me(whiteList []string) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
// 检测白名单
for _, s := range whiteList {
if has := strings.HasPrefix(path, s); has {
c.Next()
return
}
}
// 调用 jwt 解析逻辑,获取用户
user, err := getMe(c)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{
"error": err.Error(),
})
return
}
// 将 me 放到上下文中,作为「全局变量」
c.Set("me", user)
c.Next()
}
}

中间件使用 r.Use 注册

// 创建路由
r := gin.Default()
// 应用中间件
r.Use(middleware.Me([]string{"/api/v1/session", "/api/v1/validation-codes", "/ping"}))
// 注册路由..

· 阅读需 7 分钟

JSON Web Token,既是一种数据标准,也是一种登录认证方案

[HEADER].[PAYLOAD].[SIGNATURE]

Header 是一个 JSON 对象,描述 JWT 的元数据,例如加密算法或类型等

Payload 是一个 JSON 对象,实际需要传递的数据

Signature 是签名,使用密钥对前两部分使用加密算法进行加密后的字符串

定义

JWT 全称 JSON Web Token,定义了一种在网络上安全传输以 JSON 格式包含的声明信息(数据)的标准,也指代了一种登录认证方案

由来

传统的 cookie-session 登录模式流程如下

1、客户端向服务器发送用户名和密码

2、服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等

3、服务器向客户端返回一个 session_id,写入客户端的 Cookie

4、客户端随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器

5、服务器收到 session_id,找到前期保存的数据,由此得知用户的身份

这种模式具有一些明显的弊端

  1. 扩展性不高:在分布式系统中,如果用户的请求被路由到不同的服务器,需要共享 Session,处理这个问题会相对麻烦。这是因为,会话信息通常保存在服务器的内存中,当服务需要扩展时,也需要同步这些会话信息。
  2. 存储压力:如果网站有大量的并发访问,每个用户登录都需要服务器存储用户的会话信息,这无疑会增加服务器的存储压力。
  3. 无法携带数据:客户端的 Cookie 只是一个会话 ID,要获取用户的登录信息,还需要在服务器端进行查询。
  4. CSRF攻击:与跨站点脚本 (XSS) 不同,跨站点脚本 (XSS) 利用用户对特定站点的信任,而 CSRF 利用站点在用户浏览器中的信任。不过现在可以通过 sameSite|secure|httpOnly 等属性解决一定的 cookie 安全性问题
  5. 存在被篡改和伪造的隐患:如果某个人能够拦截这个会话ID,那么他就可以通过伪造这个会话ID来假冒用户身份,这就是所谓的“会话劫持”。此外,如果服务器端的会话存储不当,比如存储在可被外部访问或者能够被SQL注入的数据库,那么这些会话数据就有可能被篡改。

为了提高安全性减轻服务端压力实现单点登录等,JWT 应运而生

组成

JWT 由三部分组成,中间由 . 连接

[HEADER].[PAYLOAD].[SIGNATURE]

Header 是一个 JSON 对象,描述 JWT 的元数据

{
"alg": "HS256", // 加密算法
"typ": "JWT" // token 类型
}

将上面的 JSON 对象使用 Base64URL 算法转成字符串 btoa 解码 atob

注意这里是 Base64URL 而不是 Base64

Base64编码使用的字符包括A-Z,a-z,0-9,+和/,并且在需要的时候使用=作为填充。然而,这些字符在URL中有特殊的含义,可能会被认为是分隔符,也可能在传输过程中被改变。

Base64URL编码是为了解决这个问题而产生的。它将Base64编码中的+和/分别替换为-和_。这样,就可以在URL和文件名中安全地使用Base64URL编码的字符串,不需要进行额外的URL转义处理。

Payload

Payload 也是一个 JSON 对象,用来存放实际需要传递的数据

{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iss": "签发人",
"exp": "过期时间",
"nbf": "生效时间",
"iat": "签发时间",
"jti": "编号"
}

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。

这个 JSON 对象也要使用 Base64URL 算法转成字符串

Signature

Signature 部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),产生前两部分的签名,再经过 Base64URL 编码后作为 JWT 的第三部分

HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)

流程

登录成功后,服务端使用密钥生成 jwt 返回给客户端,客户端保存在本地。再次发起请求时,服务端将 jwt 放到 Authorization 请求头中,服务端拿到 jwt 字符串,使用密钥解密,验证用户是否有效,解密成功且用户有效,则校验通过。

优缺点

  • 优点:解决了上述传统登录模式的问题,提升了数据传输的安全性、减轻服务端压力(自包含用户信息,无状态,无需保持会话状态)
  • 缺点:由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑(黑名单)。