Skip to content

认证

介绍

Goyave 通过 goyave.dev/goyave/v5/auth 包为处理应用程序中的认证提供了方便且可扩展的基础。

通过在注册路由时添加认证中间件并向需要认证的路由器或路由添加 auth.MetaAuth 元数据,可以启用认证。

认证中间件使用一个认证器认证器是一个组件,它实现了方法 Authenticate(request *goyave.Request) (*T, error)。此方法负责检索给定请求中的凭据(通常在 Authorization 头中),检查它们并返回认证用户的 DTO。如果认证失败,它返回一个本地化的错误消息解释认证失败的原因。

因此,认证器依赖于一个服务,该服务允许它们从数据库中检索用户信息。此服务必须实现 auth.UserService[T],它定义了一个方法 FindByUsername(ctx context.Context, username any) (*T, error)。请注意,"username" 可以是任何可以识别用户的东西:ID、电子邮件、唯一用户名等。

认证成功时,认证中间件自动将 request.User 字段设置为认证器返回的值。用户也被注入到请求的上下文中,可以使用 auth.UserFromContext() 检索。

否则返回 401 Unauthorized 和前面提到的本地化消息: {"error": "authentication error reason"}

以下示例使用 dto.InternalUser 中的 Password 字段应用用户的基本认证:

go
import (
	"goyave.dev/goyave/v5/auth"
	"my-project/dto"
	"my-project/service"
	//...
)

userService := server.Service(service.User).(auth.UserService[dto.InternalUser])
authMiddleware := auth.Middleware(auth.NewBasicAuthenticator(userService, "Password"))
router.GlobalMiddleware(authMiddleware).SetMeta(auth.MetaAuth, true)

INFO

  • 如果缺少 auth.MetaAuth 或不等于 true,则跳过认证中间件。在启用认证的路由组中,您可以通过将 auth.MetaAuth 设置为 false 来禁用特定路由或子路由器上的认证。您可以将认证中间件用作全局中间件。
  • 请注意,我们在这里使用 dto.InternalUser 作为用户类型。建议使用与发送给客户端的用户 DTO 不同的 DTO,以更好地控制暴露的信息。
  • "not found" 和 "method not allowed" 路由从不认证,即使中间件是全局的。这是因为这些路由没有父路由器,意味着无法向它们应用元数据。

当用户在受保护的路由上成功认证时,其信息可通过请求的 User 字段在后续处理器的堆栈中使用。

go
func (ctrl *Controller) ShowProfile(response *goyave.Response, request *goyave.Request) {
	user := request.User.(*dto.InternalUser)
	response.JSON(http.StatusOK, typeutil.MustConvert[*dto.User](user))
}

用户服务

以下是 auth.UserService[dto.InternaleUser] 的服务实现示例:

go
// service/user/user.go
func (s *Service) FindByUsername(ctx context.Context, username any) (*dto.InternalUser, error) {
	u, err := s.repository.FindByUsername(ctx, fmt.Sprintf("%v", username))
	return typeutil.MustConvert[*dto.InternalUser](u), errors.New(err)
}
go
// database/repository/user.go
func (r *User) FindByUsername(ctx context.Context, email string) (*model.User, error) {
	var user *model.User
	db := session.DB(ctx, r.DB).Where("email", email).First(&user)
	return user, errors.New(db.Error)
}

TIP

FindByUsername 接收 any 作为用户名。确保从服务内部检查或转换它,以便可以在仓库中安全使用。

基本认证

基本认证是一种使用 Authorization 头和简单的用户名和密码组合的认证方法,格式如下:username:password,以 base64 编码。有两个内置的基本认证认证器:一个使用数据库作为用户提供者,另一个使用配置。

数据库提供者

此认证器从数据库获取用户信息。然后从服务返回的 DTO 中的字段值检索密码。此字段由其名称标识,在 auth.NewBasicAuthenticator(userService, passwordFieldName) 构造函数中给出。

使用 bcrypt 将请求中给出的密码与数据库中存储的哈希密码进行比较。

go
userService := server.Service(service.User).(auth.UserService[dto.InternalUser])
authMiddleware := auth.Middleware(auth.NewBasicAuthenticator(userService, "Password"))
go
// dto/user.go
type InternalUser struct {
	User
	Password string `json:"password"` // 此字段的值将用于检查密码
}

此提供者支持 Optional 标志,该标志定义认证器是否允许不提供凭据的请求。因此,处理器应检查 request.User 是否不是 nil,然后再访问它。

go
authenticator := auth.NewBasicAuthenticator(userService, "Password")
authenticator.Optional = true

配置提供者

此认证器从配置中获取用户信息。此方法适用于快速概念验证,因为它需要最少的设置,但不应在实际应用程序中使用。

  • auth.basic.username 配置条目定义必须匹配的用户名。
  • auth.basic.password 配置条目定义必须匹配的密码。

要将此保护应用于您的路由,首先在配置的根目录添加 auth 类别,以及 auth.basic 子类别:

json
{
  //...
  "auth": {
    "basic": {
      "username": "admin",
      "password": "admin"
    }
  }
}

然后,添加配置基本认证中间件:

go
router.GlobalMiddleware(auth.ConfigBasicAuth()).SetMeta(auth.MetaAuth, true)
// 或
authMiddleware := auth.Middleware(&auth.ConfigBasicAuthenticator{})
router.GlobalMiddleware(authMiddleware).SetMeta(auth.MetaAuth, true)

此认证器使用的 DTO 是 *auth.BasicUser

go
type BasicUser struct {
	Name string
}

您可以通过如下请求受基本认证保护的路由来测试认证:

sh
$ curl -u username:password http://localhost:8080/hello

JSON Web Token (JWT)

JWT,或 JSON Web Token,是一种开放标准的认证,定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息作为 JSON 对象。此信息可以验证和信任,因为它是数字签名的。JWT 可以使用密钥(使用 HMAC 算法)或使用 RSA 或 ECDSA 的公钥/私钥对进行签名。开箱即用,Goyave 支持 HMAC、RSA(无密钥密码)和 ECDSA。RSA 和 ECDSA 需要 PEM 编码的密钥。Goyave 在后台使用 golang-jwt/jwt 库。

JWT 认证带有 auth.jwt.expiry 配置条目,该条目定义令牌有效的秒数,默认为 300(5 分钟)。

JWT 服务

auth.JWTService 是一个内置服务,管理签名密钥并允许 JWT 生成。它支持使用以下签名方法生成令牌:

  • HMAC:使用配置 auth.jwt.secret 中定义的密钥。
  • RSA:使用配置 auth.jwt.rsa.privateauth.jwt.rsa.public 中定义的密钥对。值表示服务文件系统中包含密钥的文件的路径。密钥必须是 PEM 编码的
  • ECDSA:使用配置 auth.jwt.ecdsa.privateauth.jwt.ecdsa.public 中定义的密钥对。值表示服务文件系统中包含密钥的文件的路径。密钥必须是 PEM 编码的

INFO

密钥被加载和解析一次,然后缓存以获得更好的性能。

WARNING

确保您的 HMAC 密钥安全生成且足够长:

  • HMAC-SHA256:密钥必须为 256+ 位长
  • HMAC-SHA384:密钥必须为 384+ 位长
  • HMAC-SHA512:密钥必须为 512+ 位长

如果服务在初始化内置 JWTAuthenticatorJWTController 时尚未注册。它将使用 osfs.FS 文件系统自动注册。如果您想为密钥存储使用另一个文件系统,可以手动初始化和注册服务:

go
jwtService := auth.NewJWTService(server.Config(), filesystem)
server.RegisterService(jwtService)

生成令牌

使用 auth.JWTServiceGenerateToken()GenerateTokenWithClaims() 生成新的 JWT。

GenerateToken 使用默认设置生成新的 JWT:

  • 令牌使用 HMAC SHA256 方法创建,并使用 auth.jwt.secret 配置条目签名。
  • 令牌设置为在 auth.jwt.expiry 配置条目定义的秒数后过期。
  • 生成的令牌将包含以下声明:
    • sub:具有 id 参数的值。
    • nbf:"Not before",使用当前时间戳。
    • exp:"Expiry",当前时间戳加上 auth.jwt.expiry 配置条目。
  • 令牌作为 string 返回。
go
token, err := jwtService.GenerateToken("johndoe@example.org")

GenerateTokenWithClaims 让您添加自定义声明并使用另一个签名方法:

  • 令牌设置为在 auth.jwt.expiry 配置条目定义的秒数后过期。
  • 根据给定的签名方法,将使用以下配置条目:
    • RSA:auth.jwt.rsa.private:PEM 编码的 RSA 私钥的路径。
    • ECDSA:auth.jwt.ecdsa.private:PEM 编码的 ECDSA 私钥的路径。
    • HMAC:auth.jwt.secret:HMAC 密钥
  • 生成的令牌还将包含以下声明:
    • nbf:"Not before",使用当前时间戳
    • exp:"Expiry",当前时间戳加上 auth.jwt.expiry 配置条目。
  • 如果在 claims 参数中设置了 nbfexp,则可以覆盖它们。
go
claims := jwt.MapClaims{
	"sub": "johndoe@example.org",
}
jwtAsString, err := jwtService.GenerateTokenWithClaims(claims, jwt.SigningMethodES256)

JWT 认证器

go
authenticator := auth.NewJWTAuthenticator(userService)
authMiddleware := auth.Middleware(authenticator)
router.GlobalMiddleware(authMiddleware).SetMeta(auth.MetaAuth, true)

受此认证器保护的路由必须包含以下头:

http
Authorization: Bearer <YOUR_TOKEN>
  • 此提供者支持 Optional 标志,该标志定义认证器是否允许不提供凭据的请求。因此,处理器应检查 request.User 是否不是 nil,然后再访问它。
  • 您可以使用 ClaimName 选项定义自定义 ID 声明名称。默认情况下,sub 声明用于检索发送到用户服务的用户名。
  • 您可以使用 SigningMethod 定义所需的签名方法。
go
import (
	"github.com/golang-jwt/jwt"
	"goyave.dev/goyave/v5/auth"
	//...
)

authenticator := auth.NewJWTAuthenticator(userService)
authenticator.Optional = true
authenticator.ClaimName = "userid"
authenticator.SigningMethod = jwt.SigningMethodES256

如果令牌有效(即使认证失败),其声明将放入 request.Extra 中,键为 auth.ExtraJWTClaims,因此您可以在任何后续处理器中访问它们:

go
import (
	"github.com/golang-jwt/jwt"
	"goyave.dev/goyave/v5/auth"
	//...
)

func (ctrl *Controller) Handler(response *goyave.Response, request *goyave.Request) {
	claims := request.Extra[auth.ExtraJWTClaims{}].(jwt.MapClaims)
	//...
}

RSA

如果您期望令牌使用 RSA 签名,您将需要添加 auth.jwt.rsa.public 配置条目。此条目定义 JWT 服务文件系统中 PEM 编码的 RSA 公钥文件的路径。然后,在 auth.JWTAuthenticatorSigningMethod 字段中指定预期的签名方法。

TIP

您可以在 jwt-go 文档 中找到可用方法的列表。

  • 出于测试目的,您可以使用 OpenSSL 生成 RSA 密钥对:
sh
openssl genrsa -out rsa-private.pem 2048
openssl rsa -in rsa-private.pem -outform PEM -pubout -out rsa-public.pem

ECDSA

如果您期望令牌使用 ECDSA 签名,您将需要添加 auth.jwt.ecdsa.public 配置条目。此条目定义 JWT 服务文件系统中 PEM 编码的 ECDSA 公钥文件的路径。然后,在 auth.JWTAuthenticatorSigningMethod 字段中指定预期的签名方法。

TIP

  • 您可以在 jwt-go 文档 中找到可用方法的列表。
  • 出于测试目的,您可以使用 OpenSSL 生成 ECDSA 密钥对:
sh
openssl ecparam -name prime256v1 -genkey -noout -out ecdsa-private.key
openssl pkcs8 -topk8 -in ecdsa-private.key -out ecdsa-private.pem
openssl ec -in ecdsa-private.pem -pubout -out ecdsa-public.pem

登录控制器

auth.JWTController 是一个简单的控制器,为 JWT 密码授权添加登录路由。与基本认证器类似,它使用从服务返回的 DTO 中的字段值检索密码。使用 bcrypt 将请求中给出的密码与数据库中存储的哈希密码进行比较。此控制器实现 goyave.Registrer,因此在使用 router.Controller() 时其路由将自动注册。

go
userService := server.Service(service.User).(auth.UserService[dto.InternalUser])
router.Controller(auth.NewJWTController(userService, "Password"))
go
// dto/user.go
type InternalUser struct {
	User
	Password string `json:"password"` // 此字段的值将用于检查密码
}

控制器有一个带验证的路由 POST /login。默认情况下,控制器将使用传入请求中的 "username" 和 "password" 字段进行认证过程。可以通过修改控制器的 UsernameFieldPasswordField 字段来更改:

go
jwtController := auth.NewJWTController(userService, "Password")
jwtController.UsernameField = "email"
jwtController.PasswordField = "pwd"

INFO

更改用户名和密码字段也会自动更改验证。

认证成功时,将返回包含令牌的响应:

json
{
  "token": "eyJhbGc..."
}

如果认证失败,则返回带有本地化错误消息(auth.invalid-credentials)和状态代码 401 Unauthorized 的响应。

签名方法

由于 JWTController 为您生成令牌,您也可以自定义它使用的签名方法。默认使用 HMAC-SHA256。您可以通过更改 SigningMethod 字段来覆盖此设置:

go
import "github.com/golang-jwt/jwt"

//...

jwtController := auth.NewJWTController(userService, "Password")
jwtController.SigningMethod = jwt.SigningMethodES256

自定义令牌生成

您还可以通过设置 TokenFunc 字段来覆盖控制器在成功认证时执行的令牌生成逻辑:

go
jwtController := auth.NewJWTController(userService, "Password")
jwtController.TokenFunc = func(_ *goyave.Request, user *dto.InternalUser) (string, error) {
	jwtService := server.Service(auth.JWTServiceName).(*auth.JWTService)
	return jwtService.GenerateTokenWithClaims(jwt.MapClaims{
		"sub":  user.ID,
		"name": user.Name,
	}, jwt.SigningMethodHS256)
}

TIP

auth.TokenFunc[T]func(request *goyave.Request, user *T) (string, error) 的别名

自定义认证器

如果内置的认证方法都不适合您的需求,您可以轻松实现一个新的并将其插入认证中间件中。

典型的 auth.Authenticator 接受一个泛型参数 T,表示用户服务返回的用户 DTO。

在以下示例中,我们将使用存储在数据库中的简单令牌对用户进行认证:

go
// http/auth/custom.go
package auth

import (
	"context"
	"fmt"

	stderrors "errors"

	"gorm.io/gorm"
	"goyave.dev/goyave/v5"
	"goyave.dev/goyave/v5/util/errors"
)

type UserService[T any] interface {
	FindUserByToken(ctx context.Context, token string) (*T, error)
}

type CustomAuthenticator[T any] struct {
	goyave.Component

	UserService UserService[T]
}

func (a *CustomAuthenticator[T]) Authenticate(request *goyave.Request) (*T, error) {
	token, ok := request.BearerToken()

	if !ok {
		return nil, fmt.Errorf(request.Lang.Get("auth.no-credentials-provided"))
	}

	user, err := a.UserService.FindUserByToken(request.Context(), token)
	if err != nil {
		if stderrors.Is(err, gorm.ErrRecordNotFound) {
			return nil, fmt.Errorf(request.Lang.Get("auth.invalid-credentials"))
		}
		panic(errors.New(err))
	}

	return user, nil
}

INFO

没有必要包装认证器返回的错误,因为它们仅用于向客户端写入响应。如果发生错误(例如数据库错误),您可以使用包装错误 panic

auth.Unauthorizer 接口让认证器定义认证失败时的自定义行为。如果您想自定义发送给客户端的响应,这很有用。

go
func (a *CustomAuthenticator[T]) OnUnauthorized(response *goyave.Response, request *goyave.Request, err error) {
	response.JSON(http.StatusUnauthorized, map[string]string{"authError": err.Error()})
}