当前位置: 首页 > news >正文

19 | 实现身份认证功能

提示:

  • 所有体系课见专栏:Go 项目开发极速入门实战课;
  • 本节课最终源码位于 fastgo 项目的 feature/s15 分支;
  • 更详细的课程版本见:Go 项目开发中级实战课:30 | 应用安全:如何设计和实现认证功能?

在现代软件开发中,应用安全是软件开发时必须要考虑或实现的核心功能点。不安全的应用会带来诸如数据泄露、应用服务中断等严重问题。这些问题,不仅会给企业带来经济损失,还会导致企业声誉和品牌受损,影响企业或产品未来的发展。

身份认证是最简单,也是最基本的应用安全保障手段,基本上每一个对外的应用都需要实现身份认证功能。

下面,我们来看下 fastgo 项目具体是如何设计和实现认证功能的。

常用的身份验证手段

当前业界存在多种身份认证手段,例如基础认证(用户名密码认证)、摘要认证(Digest 认证)、开放授权(OAuth 认证)、令牌认证(Bearer 认证)等。

在前后端分离架构中,最常用的认证方式为基础认证与令牌认证的结合:

  • 基础认证: 通过<用户名+密码>的方式登录系统;
  • 令牌认证: 通过 Token 进行认证,当前最流行的 Token 编码方式是 JWT。

在前后端分离架构中,用户通过控制台登录系统时,需要一种简单、易用的认证方式来完成用户身份的验证。目前最简单的方式是 <用户名+密码> 认证,这里的用户名也可以是手机号或邮箱。

<用户名+密码> 认证的流程为:后台根据用户名查询出数据库中保存的加密密码串,并对用户传入的明文密码进行加密,然后比较两次加密后的密码是否相等,以此验证用户传入的密码是否正确。

然而,用户登录控制台后需要执行多个操作。如果每次操作都需要传入用户名和密码,后台再从数据库中查询加密密码并进行对比,整个过程不仅体验不友好,还会因为频繁查询数据库而导致接口性能下降。

为了解决上述问题,业界最常用的方案是在用户首次登录后生成一个具有一定有效期的 Token(令牌),并将其存储在浏览器的 Cookie 或 LocalStorage 中。之后的每次请求都会携带该 Token,服务器接收到请求后,通过 Token 对请求进行鉴权。因为 Token 有过期时间,也可以在 Token 过期之前,调用 Token 刷新接口,续签 Token,实现比 Token 过期时间更长的登录状态。Token 有不同的实现方式,业界用的最多的实现方式是 JSON Web Token,简称 JWT。

JWT 核心内容

由于 fastgo 使用 JWT Token 进行身份认证,为了降低学习难度并为后续代码实现奠定基础,本节课将介绍 JWT 的核心内容。

JWT 认证流程

学习 JWT 的最佳方式是通过其认证流程理解其原理。认证流程如下图所示。


上图中展示了 JWT 的认证流程,具体流程如下:

  1. 客户端(通常是前端)通过用户名和密码进行登录;
  2. 服务端收到请求后会验证用户名和密码,若与数据库记录不一致,则认证失败,若一致,则认证通过。认证通过后,服务端会签发一个具有有效期的 Token 并返回给客户端;
  3. 客户端接收到 Token 后会将其缓存,例如存储在浏览器的 Cookie 或本地存储中,方便下次调用时使用;
  4. 客户端在之后的每次 API 请求中携带缓存的 Token;
  5. 服务端接收到请求后会验证请求中携带的 Token,验证通过后继续处理业务逻辑并返回数据;
  6. 如果 Token 快过期,前端会调用 Token 刷新接口续期 Token,避免用户再次登录。之后,会使用续期后的 Token 发送 API 请求。

提示:Go 项目开发中,Token 有效期通常设置为 2 小时。

JWT Token 格式

在 JWT 中,Token 由 Header、Payload、Signature 三部分组成,中间用英文点号(.)隔开,并使用 Base64 编码。JWT Token 示例如下:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzkwNzgwMDUsImlhdCI6MTczNTQ3ODAwNSwibmJmIjoxNzM1NDc4MDA1LCJ4LXVzZXItaWQiOiJ1c2VyLXc2aXJrZyJ9.GromRG7kK90UfU_Q5iOSHs_xE-zSk0e0HLHqJQUjYMU
(1)Header 介绍

JWT Token 的 Header 中包含两部分信息:Token 的类型和 Token 所使用的加密算法。JWT Header 示例如下:

{
  "typ": "JWT",
  "alg": "HS256"
}

上述示例表明,Token 类型是 JWT,加密算法为 HS256alg 支持多种加密算法)。

(2)Payload 载荷介绍

Payload 中携带了 Token 的具体内容,其中包含一些标准字段,当然也可以添加额外字段以表达更丰富的信息。这些信息可以用于更复杂的处理场景,例如记录请求的用户 ID、用户名等。标准字段包括:

  • iss:JWT Token 的签发者;
  • sub:主题;
  • exp:JWT Token 的过期时间;
  • aud:接收 JWT Token 的一方;
  • iat:JWT Token 的签发时间;
  • nbf:JWT Token 的生效时间;
  • jti:JWT Token 的唯一标识(ID)。

Payload 示例如下所示:

{
  "id": 2,
  "userID": "user-p7q78j",
  "nbf": 1527931805,
  "iat": 1527931805
}
(3)Signature 签名介绍

Signature 是 Token 的签名部分,其生成方式如下:

  • 使用 Base64 对 header.payload 进行编码;
  • 使用密钥(Secret)对编码后的内容进行加密,加密后的内容即为 Signature。

密钥相当于一个密码,存储在服务端,通常通过配置文件设置密钥的值。fastgo 项目中,密钥由 configs/fg-apiserver.yaml 配置文件中的 jwt-key 配置项来配置。

最终生成的 Token 如下所示:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzkwNzgwMDUsImlhdCI6MTczNTQ3ODAwNSwibmJmIjoxNzM1NDc4MDA1LCJ4LXVzZXItaWQiOiJ1c2VyLXc2aXJrZyJ9.GromRG7kK90UfU_Q5iOSHs_xE-zSk0e0HLHqJQUjYMU

签名后,服务端会返回生成的 Token。客户端在下次请求时会携带该 Token,服务端收到 Token 后会解析出 header.payload,然后使用相同的加密算法和密码对 header.payload 再次加密,并将加密后的 Token 与收到的 Token 进行比对。如果二者相同,则验证通过;如果不相同,则返回 HTTP 401 Unauthorized 错误。

fastgo 身份认证功能实现

根据功能需求、实现思路以及依赖关系,总结出以下认证功能实现步骤:

  1. 开发 token 包;
  2. 签发 Token;
  3. 实现认证接口;
  4. 实现认证中间件;
  5. 加载认证中间件。

(1)开发 token 包

上面介绍了,fastgo 使用 Token 来认证 API 请求是否合法。所以,首先需要开发一个 token 包用来签发 Token,并解析 Token。

新建 pkg/token/token.go 文件,内容如下:

// Config 包括 token 包的配置选项.
type Config struct {
    // key 用于签发和解析 token 的密钥.
    key string
    // identityKey 是 token 中用户身份的键.
    identityKey string
    // expiration 是签发的 token 过期时间
    expiration time.Duration
}

var (
    config = Config{"Rtg8BPKNEf2mB4mgvKONGPZZQSaJWNLijxR42qRgq0iBb5", "identityKey", 2 * time.Hour}
    once   sync.Once // 确保配置只被初始化一次
)

// Init 设置包级别的配置 config, config 会用于本包后面的 token 签发和解析.
func Init(key string, identityKey string, expiration time.Duration) {
    once.Do(func() {
        if key != "" {
            config.key = key // 设置密钥
        }
        if identityKey != "" {
            config.identityKey = identityKey // 设置身份键
        }
        if expiration != 0 {
            config.expiration = expiration
        }
    })
}

// Parse 使用指定的密钥 key 解析 token,解析成功返回 token 上下文,否则报错.
func Parse(tokenString string, key string) (string, error) {
    // 解析 token
    token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        // 确保 token 加密算法是预期的加密算法
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, jwt.ErrSignatureInvalid
        }

        return []byte(key), nil // 返回密钥
    })
    // 解析失败
    if err != nil {
        return "", err
    }

    var identityKey string
    // 如果解析成功,从 token 中取出 token 的主题
    if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
        if key, exists := claims[config.identityKey]; exists {
            if identity, valid := key.(string); valid {
                identityKey = identity // 获取身份键
            }
        }
    }
    if identityKey == "" {
        return "", jwt.ErrSignatureInvalid
    }

    return identityKey, nil
}

// ParseRequest 从请求头中获取令牌,并将其传递给 Parse 函数以解析令牌.
func ParseRequest(c *gin.Context) (string, error) {
    header := c.Request.Header.Get("Authorization")

    if len(header) == 0 {
        //nolint: err113
        return "", errors.New("the length of the `Authorization` header is zero") // 返回错误
    }

    var token string
    // 从请求头中取出 token
    fmt.Sscanf(header, "Bearer %s", &token)

    return Parse(token, config.key)
}

// Sign 使用 jwtSecret 签发 token,token 的 claims 中会存放传入的 subject.
func Sign(identityKey string) (string, time.Time, error) {
    // 计算过期时间
    expireAt := time.Now().Add(config.expiration)

    // Token 的内容
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        config.identityKey: identityKey,       // 存放用户身份
        "nbf":              time.Now().Unix(), // token 生效时间
        "iat":              time.Now().Unix(), // token 签发时间
        "exp":              expireAt.Unix(),   // token 过期时间
    })
    if config.key == "" {
        return "", time.Time{}, jwt.ErrInvalidKey
    }

    // 签发 token
    tokenString, err := token.SignedString([]byte(config.key))
    if err != nil {
        return "", time.Time{}, err
    }

    return tokenString, expireAt, nil // 返回 token 字符串、过期时间和错误
}

token 包实现了以下 3 个核心方法:

  • Init:设置包级别的配置 config, config 会用于本包后面的 token 签发和解析。config 包含以下 3 个字段:
    • key:用于签发和解析 token 的密钥;
    • identityKey:token 中用户身份的键,fastgo 中是 UserID;
    • expiration:签发的 token 过期时间。
  • Parse:使用指定的密钥 key 解析 token,解析成功返回 token 上下文(fastgo 中是 UserID),否则报错。
  • ParseRequest:从请求头中获取令牌,并将其传递给 Parse 函数以解析令牌;
  • Sign:使用 JWT Key 签发 token,token 的 claims 中会存放用户身份(fastgo 中是 UserID)、token 生效时间、token 签发时间、token 过期时间。

在开发完 token 包,还需要初始化 token 包。修改 internal/apiserver/server.go 文件,添加 token 包初始化代码,变更代码内容如下:

type Config struct {
    ...
    JWTKey       string
    Expiration   time.Duration
}
...
// NewServer 根据配置创建服务器.
func (cfg *Config) NewServer() (*Server, error) {
    // 初始化 token 包的签名密钥、认证 Key 及 Token 默认过期时间
    token.Init(cfg.JWTKey, known.XUserID, cfg.Expiration)
    ...
}

(2)签发 Token

在 fastgo 项目中,签发 Token 的逻辑位于用户登录和密钥刷新两个 API 接口中。Login 接口 Biz 层签发 Token 的代码位于 internal/apiserver/biz/v1/user/user.go 文件中,代码如下:

// Login 实现 UserBiz 接口中的 Login 方法.
func (b *userBiz) Login(ctx context.Context, rq *apiv1.LoginRequest) (*apiv1.LoginResponse, error) {
    // 获取登录用户的所有信息
    whr := where.F("username", rq.Username)
    userM, err := b.store.User().Get(ctx, whr)
    if err != nil {
        return nil, errorsx.ErrUserNotFound
    }

    // 对比传入的明文密码和数据库中已加密过的密码是否匹配
    if err := auth.Compare(userM.Password, rq.Password); err != nil {
        slog.ErrorContext(ctx, "Failed to compare password", "err", err)
        return nil, errorsx.ErrPasswordInvalid
    }

    // 如果匹配成功,说明登录成功,签发 token 并返回
    tokenStr, expireAt, err := token.Sign(userM.UserID)
    if err != nil {
        slog.ErrorContext(ctx, "Failed to sign token", "err", err)
        return nil, errorsx.ErrSignToken
    }

    return &apiv1.LoginResponse{Token: tokenStr, ExpireAt: expireAt}, nil
}

// RefreshToken 用于刷新用户的身份验证令牌.
// 当用户的令牌即将过期时,可以调用此方法生成一个新的令牌.
func (b *userBiz) RefreshToken(ctx context.Context, rq *apiv1.RefreshTokenRequest) (*apiv1.RefreshTokenResponse, error) {
    // 如果匹配成功,说明登录成功,签发 token 并返回
    tokenStr, expireAt, err := token.Sign(contextx.UserID(ctx))
    if err != nil {
        return nil, errorsx.ErrSignToken.WithMessage(err.Error())
    }
    return &apiv1.RefreshTokenResponse{Token: tokenStr, ExpireAt: expireAt}, nil
}

// ChangePassword 实现 UserBiz 接口中的 ChangePassword 方法.
func (b *userBiz) ChangePassword(ctx context.Context, rq *apiv1.ChangePasswordRequest) (*apiv1.ChangePasswordResponse, error) {
    userM, err := b.store.User().Get(ctx, where.T(ctx))
    if err != nil {
        return nil, err
    }

    if err := auth.Compare(userM.Password, rq.OldPassword); err != nil {
        slog.ErrorContext(ctx, "Failed to compare password", "err", err)
        return nil, errorsx.ErrPasswordInvalid
    }

    userM.Password, _ = auth.Encrypt(rq.NewPassword)
    if err := b.store.User().Update(ctx, userM); err != nil {
        return nil, err
    }

    return &apiv1.ChangePasswordResponse{}, nil
}

在 Login 方法中,首先通过对比密码是否跟数据库中保存的密码是否一致来判断是否允许登录。如果一致,则 Login 方法会调用 token.Sign(userM.UserID) 签发 Token 并返回,在签发 Token 时,会在 Token 的 Payload 中保存 UserID

Login 接口返回了签发的 Token 字符串以及 Token 过期时间。返回 Token 过期时间可以帮助客户端判断 Token 的有效性,从而在 Token 过期之前续期 Token。Login 接口返回示例如下:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzkyMjc4MTEsImlhdCI6MTczNTYyNzgxMSwibmJmIjoxNzM1NjI3ODExLCJ4LXVzZXItaWQiOiJ1c2VyLTl0dXIwMiJ9.YTzAbSE-RZwh4DVJNIobADXXTA0Mn9X1gcTvbKL9QxA",
  "expireAt": {
    "seconds": 1739227811,
    "nanos": 671029750
  }
}

fastgo 项目的 PUT /refresh-token 接口用来续签 Token,在签发 Token 之前,需要先确保认证通过,Token 签发实现跟 Login 接口保持一致,不再介绍。

在 ChangePassword 方法中,会首先对比旧密码是否正确,如果正确则会加密新密码,并保存在数据库中。

(3)实现认证中间件

企业应用开发中,认证能力通常以 Web 中间件的方式来实现。为此,还需要开发 Gin 中间件。Gin 认证中间件实现位于 internal/pkg/middleware/authn.go 文件中,代码内容如下:

// Authn 是认证中间件,用来从 gin.Context 中提取 token 并验证 token 是否合法,
// 如果合法则将 token 中的 sub 作为<用户名>存放在 gin.Context 的 XUsernameKey 键中.
func Authn() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 解析 JWT Token
        userID, err := token.ParseRequest(c)
        if err != nil {
            core.WriteResponse(c, errorsx.ErrTokenInvalid, nil)
            c.Abort()
            return
        }

        // 将用户ID和用户名注入到上下文中
        ctx := contextx.WithUserID(c.Request.Context(), userID)
        c.Request = c.Request.WithContext(ctx)

        // 继续后续的操作
        c.Next()

    }
}

上述代码实现了一个 Gin 认证中间件(Authn),用于对 HTTP 请求进行身份认证。中间件首先通过 token.ParseRequest 从请求中解析出用户 ID,如果解析失败,则直接返回认证失败的错误。

接着,将解析到的 UserID 信息保存在自定义上下文中,以及 HTTP 请求的上线文中,供后续代码提取使用。

(4)加载认证中间件

在实现了认证中间件之后,还需要在服务器启动时加载认证中间件。在 internal/apiserver/server.go 文件中添加以下代码,来给 Gin 服务器加载认证中间件:

// 注册 API 路由。路由的路径和 HTTP 方法,严格遵循 REST 规范.
func (cfg *Config) InstallRESTAPI(engine *gin.Engine, store store.IStore) {
    ...
    // 注册用户登录和令牌刷新接口。这2个接口比较简单,所以没有 API 版本
    engine.POST("/login", handler.Login)
    // 注意:认证中间件要在 handler.RefreshToken 之前加载
    engine.PUT("/refresh-token", mw.Authn(), handler.RefreshToken)

    authMiddlewares := []gin.HandlerFunc{mw.Authn()}

    // 注册 v1 版本 API 路由分组
    v1 := engine.Group("/v1")
    {
        // 用户相关路由
        userv1 := v1.Group("/users")
        {
            // 创建用户。这里要注意:创建用户是不用进行认证和授权的
            userv1.POST("", handler.CreateUser)
            userv1.Use(authMiddlewares...)
            userv1.PUT(":userID/change-password", handler.ChangePassword) // 修改用户密码
            ...
        }

        // 博客相关路由
        postv1 := v1.Group("/posts", authMiddlewares...)
        {
            ...
        }
    }
}

上述代码给 Gin 服务器添加了认证中间件及新的 API 接口:/login/refresh-token/v1/users/:userID/change-password

在加载中间件时,要注意加载中间的位置:

  • 不需要认证中间件的接口:/loginPOST /v1/users
  • 其他接口需要认证中间件。

只在需要认证中间件的位置添加中间件即可。

认证功能测试

更新 configs/fg-apiserver.yaml 文件,添加 jwt-key、expiration 配置项。更新后的配置内容如下:

# 通用配置
#

# JWT 签发密钥
jwt-key: Rtg8BPKNEf2mB4mgvKONGPZZQSaJWNLijxR42qRgq0iBb5
# JWT Token 过期时间
expiration: 1000h

# MySQL 数据库相关配置
mysql:
  # MySQL 机器 IP 和端口,默认 127.0.0.1:3306
  addr: 127.0.0.1:3306
  # MySQL 用户名(建议授权最小权限集)
  username: fastgo
  # MySQL 用户密码
  password: fastgo1234
  # fastgo 系统所用的数据库名
  database: fastgo
  # MySQL 最大空闲连接数,默认 100
  max-idle-connections: 100
  # MySQL 最大打开的连接数,默认 100
  max-open-connections: 100
  # 空闲连接最大存活时间,默认 10s
  max-connection-life-time: 10s

log:
  format: text
  level: info
  output: stdout

执行以下命令编译并启动 fg-apiserver:

$ ./build.sh
$ _output/fg-apiserver -c configs/fg-apiserver.yaml

打开一个新的 Linux 终端,分别执行以下命令进行测试。

(1)创建新的用户

创建用户命令如下:

$ curl -XPOST -H'Content-Type: application/json' http://127.0.0.1:6666/v1/users  -d '{"username":"authntest","password":"fastgo1234","nickname":"colin404","email":"colin404@foxmail.com","phone":"18188889999"}'
{"userID":"user-xamuo5"}

上述命令会新建一个名为 authntest 的测试用户,接口返回用户的 ID:user-xamuo5

(2)使用用户名和密码登录

登录命令如下:

 $ token=$(curl -s -XPOST -H"Content-Type: application/json" http://127.0.0.1:6666/login -d'{"username":"authntest","password":"fastgo1234"}'|jq -r .token)

上述命令请求 /login 接口,该接口会返回 Token 及 Token 的过期时间。

(3)使用 Token 创建博客

使用以下命令创建博客:

$ curl -XPOST -H"Content-Type: application/json" -H"Authorization: Bearer ${token}" http://127.0.0.1:6666/v1/posts -d'{"title":"installation","content":"installation."}'
{"postID":"post-cda4g7"}

上述命令调用了 /v1/posts 接口来创建一个博客,调用接口时,传入了认证头:-H"Authorization: Bearer ${token}"${token} 是步骤 2 中 /login 接口返回的 Token。博客创建成功后,会返回博客 ID:post-cda4g7


欢迎加入「云原生AI 实战营」星球,12+ 高质量体系课、20+ 高质量实战项目助你在 AI 时代建立技术竞争力:
Alt文本

相关文章:

  • 实现客户端的网络不影响主线程且随时与服务器通信
  • (每日一题) 力扣 2418. 按身高排序
  • Java多线程与高并发专题——阻塞和非阻塞队列的并发安全原理是什么?
  • H.264语法结构分析之frame_cropping_flag
  • PySide(PyQT)的mouseMoveEvent()和hoverMoveEvent()的区别
  • 关于Flutter中两种Widget的生命周期详解
  • JQuery
  • 泛微ecode的页面开发发送请求参数携带集合
  • 嵌入式八股C语言---面向对象篇
  • Linux多进程学习
  • 【JavaWeb学习Day25】
  • Java 大视界 -- Java 大数据在智能安防视频摘要与检索技术中的应用(128)
  • 1.8 双指针专题:四数之和
  • 销售易CRM:赋能企业销售与客户管理的数字化升级
  • 小凯的疑惑(数论 )
  • LeetCode-122. 买卖股票的最佳时机 II
  • latex问题汇总
  • neo4j图数据库
  • 轻量级模块化前端框架:快速构建强大的Web界面
  • C++- 基于多设计模式下的同步异步日志系统
  • 戛纳打破“疑罪从无”惯例,一法国男演员被拒之门外
  • 全国省市县国土空间总体规划已基本批复完成,进入全面实施阶段
  • 韩正会见美国景顺集团董事会主席瓦格纳
  • 外交部:反对美方人士发表不负责任谬论
  • 科技部等七部门:优先支持取得关键核心技术突破的科技型企业上市融资
  • 乌方:泽连斯基只接受与普京会谈,拒见其他俄代表