Gin集成JWT实现登录认证和API Endpoint保护

544 阅读6分钟

JWT

JWT 场景

最最常见场景:接口认证、登录验证

实现目标

  • 实现用户登录验证
    • 实现Access Token、Refresh Token,在Refresh Token未过期情况下,不会登出,前端可以实现续签Access Token逻辑
  • 关键API Endpoint进行权限隔离、实现 简单保护

实现过程

Gin中间件

package middleware

import (
    hrequest "gitlab-ecs.litatom.com/infra/hermes/request"
    "github.com/gin-gonic/gin"
    "github.com/golang-jwt/jwt/v5"
    "gitlab-ecs.litatom.com/infra/saplat/handler/request"
)

const (
    GinHeaderAuthorizationKey = "Authorization"
)

var JWTKey = []byte("my_secret_key")

// JwtMiddleware 检查JWT Token的中间件
func JWTMiddleware() gin.HandlerFunc {

    return func(c *gin.Context) {
        
        var (
            req = hrequest.HGin{C: c}
            tokenString = c.GetHeader(GinHeaderAuthorizationKey)
        )
        
        // 如果Header中没有Token,则返回错误
        if tokenString == "" {
            req.FailResponse("Authorization header is required")
            c.Abort()
            return
        }

        claims := &request.JWTTokenClaims{}

        token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
            return JWTKey, nil
        })

        if err != nil || !token.Valid {
            req.FailResponse("Invalid or expired token")
            c.Abort()
            return
        }

        c.Next()
    }
}

Gin Router


    RegisterOAUTHV2 := r.Group("/api/oauth/v2")
    authR.RegisterOAUTHRouterV2(RegisterOAUTHV2)
    
func RegisterOAUTHRouterV2(rp *gin.RouterGroup) {
    rp.POST("/token/create", auth.OAUTHService.AuthV2)
    rp.POST("/token/refresh", auth.OAUTHService.RefreshTokenV2)
}

OAuth逻辑

创建JWT Token「Access Token、Refresh Token」

// CreateToken 创建Access Token和Refresh Token
func (os *oauthService) CreateTokenV2(c *gin.Context) {

    var (
        req = hrequest.HGin{C: c}
    )

    accessToken, err := os.CreateAccessTokenV2(c)
    if err != nil {
        return
    }

    refreshToken, err := os.CreateRefreshTokenV2(c)
    if err != nil {
        return
    }

    req.Response(200, 0, "success", response.JWTTokenResponse{AccessToken: accessToken, RefreshToken: refreshToken})
}

// CreateToken 创建Access Token
func (os *oauthService) CreateRefreshTokenV2(c *gin.Context) (string, error) {

    v, b := c.Get(GinCredentialsKey)
    if !b {
        fmt.Println("get username err: nil ")
    }

    expirationTime := time.Now().Add(conf.JWTRefreshTokenExpirationTime)
    refreshClaims := &request.JWTTokenClaims{
        Username: v.(request.JWTTokenCredentials).Username,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(expirationTime),
        },
    }

    refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
    refreshTokenString, err := refreshToken.SignedString(JWTKey)
    if err != nil {
        fmt.Printf("failed to create refresh token: %s\n", err.Error())
    }

    return refreshTokenString, nil
}

// CreateToken 创建Refresh Token
func (os *oauthService) CreateAccessTokenV2(c *gin.Context) (string, error) {

    v, b := c.Get(GinCredentialsKey)
    if !b {
        fmt.Println("get username err: nil ")
    }

    expirationTime := time.Now().Add(conf.JWTAccessTokenExpirationTime)
    claims := &request.JWTTokenClaims{
        Username: v.(request.JWTTokenCredentials).Username,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(expirationTime),
            Issuer:    conf.AppID, // 签发人
            // ID:        "1",
        },
    }

    accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    accessTokenString, err := accessToken.SignedString(JWTKey)
    if err != nil {
        fmt.Printf("failed to create access token: %s\n", err.Error())
    }

    return accessTokenString, nil
}

登录判断

// Auth 用于登录并生成JWT Token
func (os *oauthService) AuthV2(c *gin.Context) {
    var (
        creds request.JWTTokenCredentials
        req   = hrequest.HGin{C: c}
    )

    if err := c.ShouldBindJSON(&creds); err != nil {
        req.FailResponse(err.Error())
        return
    }

    // 在这里可以添加验证用户名和密码的逻辑

    // 假设验证成功,生成Token
    c.Set(GinCredentialsKey, creds)

    at, err := os.CreateAccessTokenV2(c)
    if err != nil {
        req.FailResponse(err.Error())
        return
    }

    rt, err := os.CreateRefreshTokenV2(c)
    if err != nil {
        req.FailResponse(err.Error())
        return
    }

    req.Response(200, 0, "success", response.JWTTokenResponse{AccessToken: at, RefreshToken: rt})
}

刷新Token处理

  1. 判断用户刷新提交的身份和待刷新Access Token身份是否一致
  2. 确保Access有效性、时效性
// Refresh 处理Token刷新
func (os *oauthService) RefreshTokenV2(c *gin.Context) {

    var (
        req = hrequest.HGin{C: c}
        rtr request.JWTRefreshTokenRequest
    )

    // requestedUsername := c.PostForm("username") // 假设前端发送的刷新请求中包含了用户名

    if err := c.BindJSON(&rtr); err != nil {
        req.FailResponse(err.Error())
    }

    // 验证Refresh Token中的用户名是否与请求中的用户名匹配
    //  if rtr.Credentials.Username != requestedUsername {
    //  req.FailResponse("Username mismatch")
    //     return
    // }

    refreshClaims := &request.JWTTokenClaims{}
    token, err := jwt.ParseWithClaims(rtr.RefreshToken, refreshClaims, func(token *jwt.Token) (interface{}, error) {
        return JWTKey, nil
    })

    if err != nil {
        // 解析错误,可能是Refresh Token无效
        req.FailResponse("Invalid refresh token")
        return
    }

    if !token.Valid || time.Now().After(refreshClaims.ExpiresAt.Time) {
        // Refresh Token无效或已过期
        req.FailResponse("Expired or invalid refresh token")
        return
    }

    currentClaims := &request.JWTTokenClaims{}
    currentAccessToken := rtr.AccessToken
    _, err = jwt.ParseWithClaims(currentAccessToken, currentClaims, func(token *jwt.Token) (interface{}, error) {
        return JWTKey, nil
    })

     // 如果当前Access Token仍然有效,则返回它
     if err == nil && time.Now().Before(currentClaims.ExpiresAt.Time) {
        req.Response(200, 0, "success", response.JWTTokenResponse{AccessToken:  currentAccessToken})
        return
    }

    c.Set(GinCredentialsKey, rtr.Credentials)

    // Token验证通过,且未过期,生成新的Access Token
    newAccessToken, err := os.CreateAccessTokenV2(c)
    if err != nil {
        req.FailResponse(err.Error())
        return
    }

    req.Response(200, 0, "success", response.JWTTokenResponse{AccessToken: newAccessToken})

}

全局变量

const (
    JWTTokenSecretKey             = "my_secret_key" // JWT密钥
    JWTAccessTokenExpirationTime  = 1 * time.Hour   // JWT Access Token有效期
    JWTRefreshTokenExpirationTime = 168 * time.Hour // JWT Refresh Token有效期
    AppID                         = "x"
)

引入JWT

// 登录端点,用于生成Token
    r.POST("/login", auth.OAUTHService.AuthV2)

    // Ping test
    r.GET("/ping", middleware.JWTMiddleware(), func(c *gin.Context) {
        c.String(http.StatusOK, "pong")
    })

效果

未携带Token「如未登录状态」

image.png

执行登录获取Token

image.png

直接调用Token 创建接口

image.png

携带Token

image.png

刷新Token

image.png

若Token未过期,返回当前Token

若Token过期,Refresh未过期,不会登出,会返回新的一次性Access Token 同时会校验申请刷新用户和提交的用户是否一致

Q&A

与前端对接

  • 服务端生成的 jwt 返回客户端可以存到 cookie 也可以存到 localStorage 中(相比 cookie 容量大),存在 cookie 中需加上 HttpOnly 的标记,可以防止 XSS) 攻击。
  • 尽量用 https 带证书网址访问。
  • 传参时,按照约束前端一般携带 Bearer + Token

golang构建JWT

  1. Payload支持默认字段,也支持自定义私有字段 jwt.MapClaims
  2. 标准解析 jwt.ParseWithClaims 、标准字段 jwt.StandardClaims

是前端存储localStorage还是后端发送Cookies

JWT (JSON Web Token) 的使用,确实存在两种主流策略来处理其存储问题,分别是前端存储(如在 localStorage)和通过 HTTP cookies 由服务器端设置。

  1. 前端存储:
  • 当服务端返回JWT后,前端应用可以选择将其存放在 localStoragesessionStorage 中,便于随后的请求中添加到 HTTP 请求头(一般是 Authorization 请求头)。

  • 优点是:易于实现,尤其是在单页应用(SPA)中。这种方式对于单页应用(SPA)比较方便,因为JavaScript代码可以轻松管理Token。

  • 缺点是:如前所述,localStorage 容易受到 XSS 攻击。存储在这里的JWT可以通过JavaScript访问到,这意味着如果网站上出现了跨站脚本(XSS)漏洞,攻击者可能能够获取这些Token。

  1. 后端设置 HTTP-only Cookie:
  • 在这种方案中,JWT 由服务端通过设置一个 HTTP-only 的 Cookie 返回给客户端,客户端浏览器会自动处理 Cookie,并在随后的请求中包含这个 Cookie。

  • 优点是:增加了安全性,因为这个 JWT 对于前端JavaScript是不可访问的,减少了XSS攻击窃取Token的风险。如果Cookie设置为Secure,则仅能在HTTPS连接中传输,增加安全性。还可以设置SameSite属性,来进一步加强对跨站请求伪造(CSRF)攻击的防御。

  • 缺点是:如果没有正确地设置SameSite属性,可能会让应用面临CSRF攻击的风险。

从安全的角度来说,服务器端通过设置HTTP-only和Secure属性的Cookie来存储JWT是一种更为推荐的做法。这样做的一个额外好处是,能够利用浏览器的同源策略来增强安全防护,因为HTTP-only Cookie不会被客户端JavaScript访问,同时Secure标记确保仅通过HTTPS发送Cookie。

当然,这也意味着服务端需要负责管理Token的创建、发送、验证以及续订等工作流程。前端应用无需管理JWT的存储,它只需要确保发送请求时浏览器能够包括这个Cookie。

无论采用哪种方法,安全的HTTP通信(如使用HTTPS)都是强烈推荐的,能有效防止MITM(中间人攻击)对通信内容的截取。同时确保您的网站安全,没有XSS、CSRF等漏洞,遵守安全的编码实践。

将JWT存储在Cookies中,确实可以为客户端的Token提供额外的安全层。但是,您依然需要在服务器端进行认证和授权检查,因为通过Cookies只是一种传输认证数据的方式,而不是认证本身。

为了最大化安全性,可以考虑以下做法:

  • 使用HttpOnlySecure标志的Cookies存储Token。

  • 设置合适的SameSite属性以降低CSRF的风险,如SameSite=LaxSameSite=Strict

  • 如果使用Cookies存储Token,确保您的应用程序正确地验证和更新了它们,并在不使用时删除Cookies。

  • 对Token的生成和验证使用合适的秘钥管理策略,并定期旋转秘钥。

  • 不要在Token中存储敏感信息,因为JWT通常可以被客户端读取到。

  • 对您的应用应用严格的CORS策略,避免来自不被信任来源的请求。

  • 定时刷新Token,限制Token的有效期,并在服务器端验证Token的有效性。

引用