提示:本节课最终代码为:feature/s18

在企业应用开发中,保证应用的安全至关重要,通常通过以下 3 种手段来保证应用的安全:

  • 认证(Authentication,简称 Authn): 一般指身份验证,指通过一定的手段,完成对用户身份的确认。认证用来证明你是谁。

  • 授权(Authorization,简称 Authz): 授权发生在身份认证成功之后,用来确认你对某个资源是否有某类操作权限。授权用来证明你能做什么。

  • 网络环境: 比如将服务部署在一个隔离的网络环境中(物理隔离/软件隔离),对访问应用的来源 IP 设置防火墙限制,或者部署一些网络监控插件,用来监控异常的网络请求等。

本节课我们就来学下,如何实现用户的身份认证。身份认证是最简单,也是最基本的应用安全保障手段,基本上每一个对外的应用都需要实现身份认证功能。

常用的身份验证手段

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

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

  • 基础认证: 通过<用户名 + 密码>登录系统;

  • 令牌认证: 令牌认证通过 Token 来进行认证,当前最流行的 Token 编码方式是 JWT。

这里来解释下,为什么在前后端分离架构中需要基础认证 + 令牌认证 2 种认证方式,来共同实现身份认证。

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

提示:当前还有很多系统使用短信来验证,因为短信验证依赖于第三方接口,不太适合教学项目,所以本实战项目只采用了 <用户名 + 密码> 这种形式。

<用户名 + 密码> 认证方式的流程为:后台根据用户名查询到用户保存在数据库中加密后的密码串,并加密传入的密码,根据 2 次加密后的密码是否匹配,来验证用户控制台传入的密码是否是设置的密码。

用户登录控制台后,需要做多个操作,如果每次都传入用户名和密码,后台从数据库中查询出已加密的密码并对比,整个过程体验并不友好,并且因为要查询数据进行认证,所以接口性能并不好。那么有没有一种好的方式来解决这种问题呢?

当然是有的,业界当前最通用的方案是:在第一次登录之后产生一个有一定有效期的 token,并将其存储于浏览器的 Cookie 或 LocalStorage 之中,之后的请求都携带该 token,请求到达服务器端后,服务器端用该 token 对请求进行鉴权。

在第一次登录之后,服务器会将这个 token 用文件、数据库或缓存服务器等方法存下来,用于之后请求中的比对。或者,更简单的方法是,直接用密钥对用户信息和时间戳进行签名对称加密,这样就可以省下额外的存储,也可以减少每一次请求时对数据库的查询压力。这种方式,在业界已经有一种标准的实现方式,该方式被称为 JSON Web Token(JWT,音同 jot,详见 JWT RFC 7519)。

提示:token 的意思是“令牌”,里面包含了用于认证的信息。这里的 token 是指 JSON Web Token(JWT)。

JWT 核心内容

因为 miniblog 使用 JWT Token 进行身份认证,为了降低你的学习难度,并为后面的代码实现准备好基础知识,这里会介绍 JWT 的核心内容。

JWT 认证流程

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

13. 应用安全:应用认证功能如何设计、实现? - 图1

  1. 客户端(通常是控制台)使用用户名和密码登录;

  2. 服务端收到请求后会去验证用户名和密码,如果用户名和密码跟数据库记录不一致,则验证失败,如果一致则验证通过,服务端会签发一个 Token 返回给客户端;

  3. 客户端收到请求后会将 Token 缓存起来,比如放在浏览器 Cookie 中或者本地存储中,之后每次请求都会携带该 Token;

  4. 服务端收到请求后会验证请求中携带的 Token,验证通过则进行业务逻辑处理并成功返回数据。

JWT Token 格式

在 JWT 中,Token 有三部分组成,中间用 . 隔开,并使用 Base64 编码:

  • header;

  • payload;

  • signature。

如下是 JWT 中的一个 Token 示例:

  1. eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1MjgwMTY5MjIsImlkIjowLCJuYmYiOjE1MjgwMTY5MjIsInVzZXJuYW1lIjoiYWRtaW4ifQ.LjxrK9DuAwAzUD8-9v43NzWBN7HXsSLfebw92DKd1JQ

header 介绍

JWT Token 的 header 中,包含两部分信息:

  • Token 的类型;

  • Token 所使用的加密算法。

  1. {
  2. "typ": "JWT",
  3. "alg": "HS256"
  4. }

该例说明 Token 类型是 JWT,加密算法是 HS256(alg 算法可以有多种)。

Payload 载荷介绍

Payload 中携带 Token 的具体内容,里面有一些标准的字段,当然你也可以添加额外的字段,来表达更丰富的信息,可以用这些信息来做更丰富的处理,比如记录请求用户名,标准字段有:

  • iss:JWT Token 的签发者;

  • sub:主题;

  • exp:JWT Token 过期时间;

  • aud:接收 JWT Token 的一方;

  • iat:JWT Token 签发时间;

  • nbf:JWT Token 生效时间 ;

  • jti:JWT Token ID。

本例中的 payload 内容为:

  1. {
  2. "id": 2,
  3. "username": "kong",
  4. "nbf": 1527931805,
  5. "iat": 1527931805
  6. }

Signature 签名介绍

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

  1. 用 Base64 对 header.payload 进行编码;

  2. 用 Secret 对编码后的内容进行加密,加密后的内容即为 Signature

Secret 相当于一个密码,存储在服务端,一般通过配置文件来配置 Secret 的值,本例是配置在 configs/miniblog.yaml 配置文件中:

  1. # 通用配置
  2. runmode: debug # Gin 开发模式, 可选值有:debug, release, test
  3. addr: :8080 # HTTP 服务器监听地址
  4. jwt-secret: Rtg8BPKNEf2mB4mgvKONGPZZQSaJWNLijxR42qRgq0iBb5 # JWT 签发密钥

最后生成的 Token 像这样:

  1. eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1MjgwMTY5MjIsImlkIjowLCJuYmYiOjE1MjgwMTY5MjIsInVzZXJuYW1lIjoiYWRtaW4ifQ.LjxrK9DuAwAzUD8-9v43NzWBN7HXsSLfebw92DKd1JQ

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

miniblog 添加身份认证功能

根据上面我们对身份认证功能的分析,不难得出,miniblog 如果要添加身份认证功能需要实现以下几个功能:

  1. 开发 POST /login 接口,用来实现用户登录;

  2. POST /login 接口后端的路由函数,需要能够查询数据库,并进行密码对比,然后签发 Token 并返回;

  3. 开发 PUT /v1/users/:name/change-password 接口,提供密码修改功能。

身份认证功能实现思路

根据我们的需求,这里介绍下实现思路。

我们要加密密码,并对比密码,并且这 2 个操作,是一个通用操作,所以可开发一个 auth 包供所有项目使用(上一节我们已经开发过了)。

我们需要根据密钥来签发并解析 Token,这 2 个操作,也是一个通用操作,所以可开发一个 token 包供所有项目使用。

因为身份认证需要对所有请求进行认证,所以我们很容易想到使用 Gin 中间件来完成身份认证。

因为我们要实现 POST /loginPUT /v1/users/:name/change-password 2 个新接口,所以,我们要为这 2 个接口在 Store 层、Biz 层、Controller 层按顺序分别开发代码,并将这 2 个接口添加到 Gin 路由中。

根据需要实现的功能、思路和依赖关系,我们整理出以下开发步骤:

  1. 开发 token 包;

  2. 开发 Gin 中间件实现身份认证;

  3. 实现 POST /loginPUT /v1/users/:name/change-password 接口。

miniblog 身份认证功能实现

  1. 开发 token 包。

token 包的开发参考了 iam 项目的相关实现。

这里说一下我的开发思路:签发 token 需要用到 secret key,为了能够直接通过 token.Sign() 这种方式来签发(个人感觉更简洁、方便),而不是通过 t := token.New()t.Sign() 这种方式来签发,需要一个 Init 方法来将 secret key 保存在全局变量中,供 token 包后续签发使用。

有了 Init 函数,一般来说为了防止同一个服务进程多次初始化,需要使用 sync.Once 来确保 token 包只被初始化一次(多看别人的代码,你就会知道这种优雅的实现思路)。

有了 secret key,就可以用来签发并解析 token 了。上面我们介绍过,token 中可以携带一些额外的信息,我们自然会想到,将用户名保存在 token 中,这样通过 token,就能知道用户名了。在 JWT Token 中,类似用户名这种唯一的身份标识,一般会用 identityKey 来指代,identityKey 可以保存在 Token 的 Claims 中。所以,我们签发 token 时,要将用户名保存在 token 中,解析 token 时,要从 token 中解析出用户名并返回。

那么,如何开发签发和解析 token 的代码呢?我们自然想到可以在 GitHub 上搜索 Star 数最多的 jwt 包,这里我找到一个:golang-jwt/jwt

如何签发和解析 token,我们自然会想到优先从官方包中找示例,我找到了以下 2 篇示例文章:

所以,最终开发完成后的 token 包代码如下:

  1. package token
  2. import (
  3. "errors"
  4. "fmt"
  5. "sync"
  6. "time"
  7. "github.com/gin-gonic/gin"
  8. jwt "github.com/golang-jwt/jwt/v4"
  9. )
  10. // Config 包括 token 包的配置选项.
  11. type Config struct {
  12. key string
  13. identityKey string
  14. }
  15. // ErrMissingHeader 表示 `Authorization` 请求头为空.
  16. var ErrMissingHeader = errors.New("the length of the `Authorization` header is zero")
  17. var (
  18. config = Config{"Rtg8BPKNEf2mB4mgvKONGPZZQSaJWNLijxR42qRgq0iBb5", "identityKey"}
  19. once sync.Once
  20. )
  21. // Init 设置包级别的配置 config, config 会用于本包后面的 token 签发和解析.
  22. func Init(key string, identityKey string) {
  23. once.Do(func() {
  24. if key != "" {
  25. config.key = key
  26. }
  27. if identityKey != "" {
  28. config.identityKey = identityKey
  29. }
  30. })
  31. }
  32. // Parse 使用指定的密钥 key 解析 token,解析成功返回 token 上下文,否则报错.
  33. func Parse(tokenString string, key string) (string, error) {
  34. // 解析 token
  35. token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
  36. // 确保 token 加密算法是预期的加密算法
  37. if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
  38. return nil, jwt.ErrSignatureInvalid
  39. }
  40. return []byte(key), nil
  41. })
  42. // 解析失败
  43. if err != nil {
  44. return "", err
  45. }
  46. var identityKey string
  47. // 如果解析成功,从 token 中取出 token 的主题
  48. if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
  49. identityKey = claims[config.identityKey].(string)
  50. }
  51. return identityKey, nil
  52. }
  53. // ParseRequest 从请求头中获取令牌,并将其传递给 Parse 函数以解析令牌.
  54. func ParseRequest(c *gin.Context) (string, error) {
  55. header := c.Request.Header.Get("Authorization")
  56. if len(header) == 0 {
  57. return "", ErrMissingHeader
  58. }
  59. var t string
  60. // 从请求头中取出 token
  61. fmt.Sscanf(header, "Bearer %s", &t)
  62. return Parse(t, config.key)
  63. }
  64. // Sign 使用 jwtSecret 签发 token,token 的 claims 中会存放传入的 subject.
  65. func Sign(identityKey string) (tokenString string, err error) {
  66. // Token 的内容
  67. token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
  68. config.identityKey: identityKey,
  69. "nbf": time.Now().Unix(),
  70. "iat": time.Now().Unix(),
  71. "exp": time.Now().Add(100000 * time.Hour).Unix(),
  72. })
  73. // 签发 token
  74. tokenString, err = token.SignedString([]byte(config.key))
  75. return
  76. }

提示:ParseRequest 方法是后面根据需要追加的,后面会介绍。

另外,我们还需要在 internal/miniblog/miniblog.go 文件中添加以下代码,用来初始化 token 包:

  1. // run 函数是实际的业务代码入口函数.
  2. func run() error {
  3. // ...
  4. // 设置 token 包的签发密钥,用于 token 包 token 的签发和解析
  5. token.Init(viper.GetString("jwt-secret"), known.XUsernameKey)
  6. // ...
  7. }
  1. 开发 Gin 中间件实现身份认证。

上面介绍过,认证功能最合适的实现方法,是通过中间件来实现。这里,我复制 internal/pkg/middleware/requestid.go 文件为 internal/pkg/middleware/authn.go,并基于复制的代码进行开发,开发后的代码如下:

  1. func Authn() gin.HandlerFunc {
  2. return func(c *gin.Context) {
  3. // 解析 JWT Token
  4. username, err := token.ParseRequest(c)
  5. if err != nil {
  6. core.WriteResponse(c, errno.ErrTokenInvalid, nil)
  7. c.Abort()
  8. return
  9. }
  10. c.Set(known.XUsernameKey, username)
  11. c.Next()
  12. }
  13. }

上述代码,调用 token.ParseRequest(c)*gin.Context 中解析出 token 字符串(能解析 token,说明认证成功),获取返回的用户名,并将用户名保存在 *gin.Context 中,供后面使用。

为了从 *gin.Context 中解析 token,我在 token 包中添加了 ParseRequest 方法:

  1. func ParseRequest(c *gin.Context) (string, error) {
  2. header := c.Request.Header.Get("Authorization")
  3. if len(header) == 0 {
  4. return "", ErrMissingHeader
  5. }
  6. var t string
  7. // 从请求头中取出 token
  8. fmt.Sscanf(header, "Bearer %s", &t)
  9. return Parse(t, config.key)
  10. }

上述代码中,我们通过 ParseRequest 函数从 *gin.Context 解析出 token 字符串,并调用 Parse 函数解析 Token,并返回用户名。

  1. 实现 POST /loginPUT /v1/users/:name/change-password 接口。

接下来,我们就可以开发业务逻辑了。开发步骤如下。

步骤一:pkg/api/miniblog/v1/user.go 文件中添加请求/返回参数结构体定义:LoginRequestLoginResponseChangePasswordRequest

步骤二: 因为需要从数据库中获取用户信息进行密码对比、修改用户密码,所以 Store 层需要提供 user 表的 GetUpdate 方法,代码如下:

  1. // Get 根据用户名查询指定 user 的数据库记录.
  2. func (u *users) Get(ctx context.Context, username string) (*model.UserM, error) {
  3. var user model.UserM
  4. if err := u.db.Where("username = ?", username).First(&user).Error; err != nil {
  5. return nil, err
  6. }
  7. return &user, nil
  8. }
  9. // Update 更新一条 user 数据库记录.
  10. func (u *users) Update(ctx context.Context, user *model.UserM) error {
  11. return u.db.Save(user).Error
  12. }

上述代码,我使用了 Where("username = ?", username) 的查询格式,而不是 Where(model.UserM{Username: username}) 这种格式,是因为我觉得 Where("username = ?", username) 这种查询格式更加灵活,灵活的查询格式能确保整个项目查询格式统一。当然,弊端就是你需要感知到数据库字段名,但我觉得这种弊端,相较于格式统一、灵活,可以忽略。

更新 user 包记录,我选择了使用 GORM 的 Save 方法,Save 会保存所有的字段,即使字段是零值。使用 Save 更新的好处是,只需要提供一个 Update 方法,便能够应对几乎所有的更新场景,可以使代码简洁、清晰。

步骤三: 在 Biz 层添加 LoginChangePassword 的实现,代码如下:

  1. // ChangePassword 是 UserBiz 接口中 `ChangePassword` 方法的实现.
  2. func (b *userBiz) ChangePassword(ctx context.Context, username string, r *v1.ChangePasswordRequest) error {
  3. userM, err := b.ds.Users().Get(ctx, username)
  4. if err != nil {
  5. return err
  6. }
  7. if err := auth.Compare(userM.Password, r.OldPassword); err != nil {
  8. return errno.ErrPasswordIncorrect
  9. }
  10. userM.Password, _ = auth.Encrypt(r.NewPassword)
  11. if err := b.ds.Users().Update(ctx, userM); err != nil {
  12. return err
  13. }
  14. return nil
  15. }
  16. // Login 是 UserBiz 接口中 `Login` 方法的实现.
  17. func (b *userBiz) Login(ctx context.Context, r *v1.LoginRequest) (*v1.LoginResponse, error) {
  18. // 获取登录用户的所有信息
  19. user, err := b.ds.Users().Get(ctx, r.Username)
  20. if err != nil {
  21. return nil, errno.ErrUserNotFound
  22. }
  23. // 对比传入的明文密码和数据库中已加密过的密码是否匹配
  24. if err := auth.Compare(user.Password, r.Password); err != nil {
  25. return nil, errno.ErrPasswordIncorrect
  26. }
  27. // 如果匹配成功,说明登录成功,签发 token 并返回
  28. t, err := token.Sign(r.Username)
  29. if err != nil {
  30. return nil, errno.ErrSignToken
  31. }
  32. return &v1.LoginResponse{Token: t}, nil
  33. }

上述代码使用 auth.Compare 来对比密码是否匹配,使用 auth.Encrypt 来加密密码,使用 token.Sign 来签发 token。

步骤四: 在 Controller 层添加 login.gochange_password.go 文件,分别实现 LoginChangePassword 路由处理函数(代码直接复制的 internal/miniblog/controller/v1/user/create.go,因为设计、代码都比较规范,所以拷贝过来做很少的改动即可)。

步骤五:internal/miniblog/router.go 文件中,添加 POST /loginPUT /v1/users/:name/change-password 接口路由和认证中间件:

  1. // installRouters 安装 miniblog 接口路由.
  2. func installRouters(g *gin.Engine) error {
  3. // ...
  4. g.POST( "/login" , uc.Login)
  5. // 创建 v1 路由分组
  6. v1 := g.Group("/v1")
  7. {
  8. // 创建 users 路由分组
  9. userv1 := v1.Group("/users")
  10. {
  11. userv1.POST("", uc.Create)
  12. userv1.PUT( ":name/change-password" , uc.ChangePassword)
  13. userv1.Use(mw.Authn())
  14. }
  15. }
  16. return nil
  17. }

步骤六: 为排障方便,我们可以将用户名添加到日志的输出字段中,为此,我们需要在 internal/pkg/log/log.go 文件中,添加以下代码段:

  1. func (l *zapLogger) C(ctx context.Context) *zapLogger {
  2. // ...
  3. if userID := ctx.Value(known.XUsernameKey); userID != nil {
  4. lc.z = lc.z.With(zap.Any(known.XUsernameKey, userID))
  5. }
  6. return lc
  7. }

至此,我们已经成功为 miniblog 添加了身份认证功能,完整代码见:feature/s18

编译并测试

执行以下命令,编译并运行 miniblog:

  1. $ make
  2. $ _output/miniblog -c configs/miniblog.yaml

服务启动后,在新的 Linux 终端中分别执行以下命令。

  1. 创建测试用户。执行以下命令:
  1. $ curl -XPOST -H"Content-Type: application/json" -d'{"username":"authntest","password":"authntest1234","nickname":"authntest","email":"authntest@qq.com","phone":"1818888xxxx"}' http://127.0.0.1:8080/v1/users
  2. null
  1. 测试用户登录 miniblog。执行以下命令:
  1. $ curl -s -XPOST -H"Content-Type: application/json" -d'{"username":"authntest","password":"authntest1234"}' http://127.0.0.1:8080/login
  2. {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJYLVVzZXJuYW1lIjoiYXV0aG50ZXN0IiwiZXhwIjoyMDMwMjk3NjUyLCJpYXQiOjE2NzAyOTc2NTIsIm5iZiI6MTY3MDI5NzY1Mn0.wzpMG6hOljfPjczAKvRjBRtMa-U6K2Vu9Pmd7t9QDrM"}
  1. 修改测试用户 authuser 的密码。执行以下命令:
  1. $ curl -XPUT -H"Content-Type: application/json" -d'{"oldPassword":"authntest1234","newPassword":"authntest12345"}' http://127.0.0.1:8080/v1/users/authntest/change-password
  2. null
  1. 使用新密码登录 miniblog。执行以下命令:
  1. $ curl -s -XPOST -H"Content-Type: application/json" -d'{"username":"authntest","password":"authntest12345"}' http://127.0.0.1:8080/login
  2. {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJYLVVzZXJuYW1lIjoiYXV0aG50ZXN0IiwiZXhwIjoyMDMwMjk3NzY0LCJpYXQiOjE2NzAyOTc3NjQsIm5iZiI6MTY3MDI5Nzc2NH0.kHyXxrIVitNgb26ajW8E1AA_Y6dZ7oYiY1Sw8Qe8aqY"}

小结

本小节介绍了应用身份验证的相关知识。miniblog 采用的认证方式为 JWT。本节课简单介绍了 JWT 的认证流程,并通过实例展示了具体如何进行 JWT 认证。

最后,给你留一个课后小作业:思考下如何同时使用手机/邮箱来完成验证,试着编写代码实现。