构建 API 接口和用户认证的实践指南 | 青训营

64 阅读5分钟

1. 前言

Go 由于拥有强大的并发能力、易于编写,常常被用于编写服务器后端,并提供各类 RESTful、RPC 等服务,在后端领域撑起了一片天。这篇文章中,我们就来看看如何使用 Go 语言来达成构建 API 接口、用户认证的最佳实践。

2. 构建 API 接口

要构建 API 接口,我们常常会使用 gRPC 或者 REST(HTTP)来设计。无论你打算使用哪种方法来开始,这里都有一些共通的最佳实践,遵循这些实践来设计和构建 API 会使得整个过程更加轻松:

  • 使用正确的 HTTP 动词。HTTP 定义了一些常用的操作方式,如 GETPUTPOST 等,当你在实现 RESTful 接口的时候,应当遵照这些被广泛认可的操作来构建 API,并确保 API 在应当幂等的情况下保证幂等性,这样可以减少分歧与疑惑。对于 gRPC 来说,它的通讯方式并非像 HTTP 那样,而是更多地体现为函数调用,但我们仍然可以从 HTTP 那里提取精华,以类似的方式为服务与函数命名。
  • 正确地返回错误码与错误消息。在响应客户端的请求时,应当在原始的响应外再包上一层,并在其中设置错误码 code 与错误消息 msg,来为调用者提供一个统一的错误处理体验。当请求成功时,使 code 为 0,而当发生错误时,可以在 code 中指定错误所对应的编号,而在 msg 中指定错误的文本信息。对于 RESTful 接口,可以返回 JSON 文本,便于处理,而 gRPC 则可以直接利用内建的错误处理机制,或者在有需要的时候通过 Metadata 机制传回额外的错误信息。
  • 确保接口的安全。如果你的接口会返回一些只对部分用户开放的数据,则一定要给这个 API 通过中间件等方式添加用户权限的检查,否则很容易导致用户信息泄漏、被攻击者破坏等。常见的验证方式有基于 Cookies 的凭证,或者是额外保存的 JWT,等等。除此之外,你还需要对 API 传入的所有参数进行校验,并拦截有风险的输入,防止 SQL 注入、XSS 攻击等行为发生。当然,最佳的方法是从根源上保证参数的安全性——不要去动态地做 SQL 语句拼接等危险操作,而是应该借助已有框架的能力保证类型安全、输入不会被误解。
  • 为 API 打 log。这使得你可以更充分地了解各个 API 的调用情况,并在有需要的时候借助 log 确定问题的根源、对 API 进行性能优化。

3. 构建用户认证

用户认证几乎是任何公开的服务所必须做到的事情。当用户注册账号时,我们应当在数据库中保存经过加盐、加密的密码,以防止数据库泄露时密码被直接明文暴露在攻击者面前,造成严重损失。在前面也已经提到,对于敏感接口必须加上用户的身份验证与权限检查,方法也有很多。如果只有一台服务器的话,可以选择直接用一个全局变量 map 来保存 token 与用户间的关联。这样有一个优势:统计服务器的在线人数十分简单,且当用户选择登出时服务器只需要从 map 中删去对应项即可,即使是从一个地方实现多设备同时登出也是如此。但当你的服务扩大规模、涉及分布式时,之前那种中心化的凭证管理机制就无法保持很好的伸缩性了,这时候无状态的 JWT 便是一个更优的选择。简单来说,JWT 是一个经过签名的 JSON 对象,用于表示由服务器签发的用户身份证明。由于用户身份已经内置于 JWT,故服务器可以在不访问数据库的情况下获得足够用于访问用户相关资源的信息。以下是一个最简单的 JWT 的使用的例子:

package main

import (
	"fmt"
	"time"

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

var hmacSampleSecret []byte = []byte("sample_secret")

func main() {
	// 生成 JWT
	nowTime := time.Now()
	expireTime := nowTime.Add(3 * time.Minute)
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
		ExpiresAt: jwt.NewNumericDate(expireTime),
		Issuer:    "test",
		Subject:   "user1",
	})
	tokenStr, err := token.SignedString(hmacSampleSecret)
	if err != nil {
		panic(err)
	}
	fmt.Println("Generated JWT token:", tokenStr)

	// 解析 JWT
	var claims jwt.RegisteredClaims
	token, err = jwt.ParseWithClaims(tokenStr, &claims, func(token *jwt.Token) (interface{}, error) {
		return hmacSampleSecret, nil
	})
	if err != nil {
		panic(err)
	}
	fmt.Println("Decoded claims:", token.Claims)
}

如果要将 JWT 认证与 Gin 等 Web 框架结合起来的话,可以采用中间件的设计方式,参考代码如下:

package main

import (
	"fmt"
	"net/http"
	"strings"
	"time"

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

func JWTAuthMiddleware() func(c *gin.Context) {
	return func(c *gin.Context) {
		// 假设 token 以 Bearer token 形式位于 Authorization 头中
		authHeader := c.Request.Header.Get("Authorization")
		if authHeader == "" {
			c.JSON(http.StatusOK, gin.H{
				"code": -1,
				"msg":  "Invalid HTTP header Authorization",
			})
			c.Abort()
			return
		}
		const expectedPrefix = "bearer "
		if !strings.HasPrefix(strings.ToLower(authHeader), expectedPrefix) {
			c.JSON(http.StatusOK, gin.H{
				"code": -1,
				"msg":  "Invalid Bearer token",
			})
		}
		tokenStr := authHeader[len(expectedPrefix):]

		var claims jwt.RegisteredClaims
		token, err := jwt.ParseWithClaims(tokenStr, &claims, func(token *jwt.Token) (interface{}, error) {
			return hmacSampleSecret, nil
		})
		if err != nil {
			c.JSON(http.StatusOK, gin.H{
				"code": -1,
				"msg":  "Invalid Bearer token",
			})
			c.Abort()
			return
		}
		subject, err := token.Claims.GetSubject()
		if err != nil {
			c.JSON(http.StatusOK, gin.H{
				"code": -1,
				"msg":  "Subject claim is missing in JWT",
			})
			c.Abort()
			return
		}
		// 将当前请求的用户信息保存到请求的上下文,后续的请求可以使用 c.Get("userid")
		// 来获取用户信息
		c.Set("userid", subject)
		c.Next()
	}
}

var hmacSampleSecret []byte = []byte("sample_secret")

func main() {
	r := gin.Default()
	r.GET("/my-info", JWTAuthMiddleware(), func(c *gin.Context) {
		userid := c.MustGet("userid")
		c.JSON(200, gin.H{
			"code": 0,
			"msg":  "",
			"data": userid,
		})
	})
	r.Run()
}

4. 总结

在学习和研究如何构建 API 接口与用户认证的过程中,我参考了许多资料,对于 REST、JWT、中间件等概念有了更深入的了解。同时我也发现,相关的知识其实还有很多方面是我没有涉及到的,如果能够将它们全部吃透的话,对我的综合编码与解决问题的能力应该会有不小的帮助。