在开发完业务接口后,需要对API接口进行访问控制,以免所有接口直接暴露在外,非常不安全。
目前常见得API访问控制有两种方案:
- OAuth 2.0
- JWT
OAuth 2.0本质上是一个授权的行业标准协议,提供了一整套授权机制的指导标准,常用于使用第三方登录的情况。例如,在登录某些网站时,也可以用第三方站点(例如用微信、QQ、GitHub账号)关联登录,这些往往是用OAuth 2.0的标准实现的。OAuth 2.0 相对会“重”一些,常常还会授予第三方应用获取对应账号的个人基本信息等。
JWT与OAuth 2.0完全不同,它常用于前后端分离的情况,能够非常便捷地给API接口提供安全鉴权。
JWT简介
JSON Web令牌(JWT)是一个开放标准(RFC7519),它定义了一种紧凑且自包含的方式,用于在各方之间以JSON对象安全地传输信息。由于此信息是经过数字签名的,因此可以被验证和信任。我们可以使用RSA或ECDSA的公用或专用密钥对JWT进行签名。
JWT是以紧凑的形式由三部分组成的,这三部分之间以点“.”分隔,组成“xxxxx.yyyyy.zzzzz”的格式,三个部分的含义如下:
- Header:头部。
- Payload:有效载荷。
- Signature:签名。
Header
Header (头部)通常由两部分组成,分别是令牌的类型和所使用的签名算法(HMAC SHA256、RSA等),它们会组成一个JSON对象,用于描述其元数据,例如:
{"alg": "HS256","typ": "JWT"}
在上述JSON对象中,alg字段用来表示使用的签名算法,默认是HMAC SHA256(HS256)。type字段用来表示使用的令牌类型,这里使用的是JWT。最后,用base64UrlEncode算法对上面的JSON对象进行转换,使其成为JWT的第一部分。
Payload
Payload(有效负载)是一个JSON对象,主要用于存储在JWT中实际传输的数据,代码如下(注意,这里只截选了部分代码):
{"sub": "123456","name": "joker","admin": "true"}
参数有:
- aud(Audience):受众,即接受JWT的一方。
- exp(ExpiresAt):所签发的JWT过期时间,过期时间必须大于签发时间。
- jti(JWT Id):JWT的唯一标识。
- iat(IssuedAt):签发时间
- iss(Issuer):JWT的签发者。
- nbf(Not Before):JWT的生效时间,如果未到这个时间,则不可用。
- sub(Subject):主题。
同样,使用base64UrlEncode算法对该JSON对象进行转换,使其成为JWT Token的第二部分。需要注意的是,JWT 在转换时用的是base64UrlEncode 算法,而该算法是可逆的,因此一些敏感信息建议不要放到JWT中。如果一定要放,则应进行一定的加密处理。
Signature
Signature(签名)部分是对前面两个部分(Header+Payload)进行约定算法和规则的签名。签名一般用于校验消息在整个过程中有没有被篡改,并且对使用了私钥进行签名的令牌,它还可以验证JWT的发送者是否是它的真实身份。
在生成签名时,首先在应用程序中指定密钥(secret),然后使用传入的指定签名算法(默认是HMAC SHA256)通过下述签名方式生成Signature,代码如下:
HMACSHA256{base64UrlEncode(header) + "." +base64UrlEncode(payload),secret}
由此可以看出,JWT 的第三部分是由Header、Payload和secret的算法组成的,因而可以用来校验消息是否被篡改。因为一旦消息被篡改,Signature就无法对上。
Base64UrlEncode算法
Base64UrlEncode算法是Base64算法的变种。为什么要变呢?原因是JWT令牌经常被放在Header或Query Param中,即URL中。
而在URL中,一些个别字符是有特殊意义的,如“+”“/”“=”等。因此在Base64 UrlEncode算法中,会对其进行替换。例如,把“+”替换为“-”、把“/”替换为“_”,而”=” 会被忽略处理,以此保证JWT令牌在URL中的可用性和准确性。
JWT使用场景
首先,在内部约定好JWT令牌的交流方式,比如可以存储在Header、QueryParam、cookie或session 中,最常见的是存储在Header中。然后,服务器端提供一个获取JWT令牌的接口方法,返回给客户端使用。当客户端请求其余接口时,需要带上所签发的JWT令牌,而服务器端接口也会到约定位置获取JWT令牌进行鉴权处理。
安装JWT
首先拉取jwt-go库,该库提供了JWT的Go实现,能够便捷地提供JWT支持,然后执行如下命令:
go get -u github.com/dgrijalva/jwt-go
配置JWT
创建表
create table `auth` (`id` int(10) unsigned NOT NULL AUTO_INCREMENT,`app_key` varchar(20) DEFAULT '' COMMENT 'key',`app_secret` varchar(50) DEFAULT '' COMMENT 'Secret',`created_on` int(10) unsigned default '0' comment '创建时间',`created_by` varchar(100) default '' comment '创建人',`modified_on` int(10) unsigned default '0' comment '修改时间',`modified_by` varchar(100) default '' comment '修改人',`deleted_on` int(10) unsigned default '0' comment '删除时间',`is_del` tinyint(3) unsigned default '0' comment '是否删除,0表示未删除,1表示删除',PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT="认证管理";
新增一条测试数据:
INSERT INTO `blog_auth`(`id`, `app_key`, `app_secret`, `created_on`, `created_by`, `modified_on`, `modified_by`, `deleted_on`, `is_del`) VALUES (1, 'coolops', 'mysecret', 0, 'joker', 0, '', 0, 0);
新建Model对象
package modelimport "github.com/jinzhu/gorm"// 公共type Model struct {ID uint32 `gorm:"primary_key" json:"id"`CreatedBy string `json:"created_by"`ModifiedBy string `json:"modified_by"`CreatedOn uint32 `json:"created_on"`ModifiedOn uint32 `json:"modified_on"`DeletedOn uint32 `json:"deleted_on"`IsDel uint8 `json:"is_del"`}// 认证type Auth struct {*ModelAppKey string `json:"app_key"`AppSecret string `json:"app_secret"`}func (a Auth) TableName() string {return "auth"}
初始化配置
在配置文件config.yaml中新增JWT配置,如下:
# 认证JWT:Secret: mysecretIssuer: blog_serviceExpire: 7200
新增配置文件解析结构体,如下:
// JWT配置type JWTSettingS struct {Secret stringIssuer stringExpire time.Duration}
将JWTSettingS加入全局配置中,如下:
package global// 全局配置文件import "code.coolops.cn/blog_services/pkg/setting"var (...JWTSetting *setting.JWTSettingS)
配置main.go,读取配置文件
func setupSetting() error {setting, err := setting2.NewSetting()if err != nil {return err}......err = setting.ReadSection("JWT", &global.JWTSetting)if err != nil {return err}global.JWTSetting.Expire *= time.Secondreturn nil}
需要注意的是,千万不要把Secret暴露给外部,即只能让服务器端知道,否则一旦被解密出来,会非常危险。
处理JWT令牌
虽然jwt-go库能够快捷地处理JWT令牌相关的行为,但是仍需要根据项目特性对其进行设计。简单来讲,就是组合其提供的API,设计鉴权场景。
打开pkg/app并创建jwt.go文件,写下如下代码:
package appimport ("code.coolops.cn/blog_services/global""code.coolops.cn/blog_services/pkg/util""github.com/dgrijalva/jwt-go""time")type Claims struct {AppKey string `json:"app_key"`AppSecret string `json:"app_secret"`jwt.StandardClaims}func GetJWTSecret() []byte {return []byte(global.JWTSetting.Secret)}
这里主要涉及JWT的一些基本属性。第一个是GetJWTSecret方法,它用于获取该项目的JWT Secret,目前我们使用的是默认配置的Secret。第二个是Claims结构体,它分为两大块:第一块是嵌入的AppKey和AppSecret,用于我们自定义的认证信息;第二块是jwt.StandardClaims结构体,它是在jwt-go库中预定义的,涉及的字段如下:
type StandardClaims struct {Audience string `json:"aud,omitempty"`ExpiresAt int64 `json:"exp,omitempty"`Id string `json:"jti,omitempty"`IssuedAt int64 `json:"iat,omitempty"`Issuer string `json:"iss,omitempty"`NotBefore int64 `json:"nbf,omitempty"`Subject string `json:"sub,omitempty"`}
这些字段都是非强制性的,但官方建议使用预定义权利要求,能够提供一组有用的、可相互操作的约定。
然后写下如下代码:
// 生成Tokenfunc GenerateToken(appKey, appSecret string) (string, error) {nowTime := time.Now()expireTime := nowTime.Add(global.JWTSetting.Expire)claims := Claims{AppKey: util.EncodeMD5(appKey),AppSecret: util.EncodeMD5(appSecret),StandardClaims: jwt.StandardClaims{ExpiresAt: expireTime.Unix(),Issuer: global.JWTSetting.Issuer,},}tokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)token, err := tokenClaims.SignedString(GetJWTSecret())return token, err}// 校验Tokenfunc ParseToken(token string) (*Claims, error) {tokenClaims, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (interface{}, error) {return GetJWTSecret(), nil})if tokenClaims != nil {claims, ok := tokenClaims.Claims.(*Claims)if ok && tokenClaims.Valid {return claims, nil}}return nil, err}
GenerateToken方法的主要功能是生成JWT Token,其流程是根据客户端传入的AppKey和AppSecret,以及在项目配置中设置的签发者(Issuer)和过期时间(ExpiresAt),根据指定的算法生成签名后的Token。这其中涉及两个内部方法,具体如下。
- jwt.NewWithClaims:根据Claims结构体创建Token实例。它一共包含两个形参,第一个形参是 SigningMethod,其包含 SigningMethodHS256、SigningMethodHS384 和SigningMethodHS512三种crypto.Hash加密算法的方案。第二个形参是Claims,主要用于传递用户预定义的一些权利要求,以便后续的加密、校验等行为。
- tokenClaims.SignedString:生成签名字符串,根据传入的 Secret,进行签名并返回标准的Token。
ParseToken方法的主要的功能是解析和校验Token,其流程是解析传入的Token,然后根据Claims的相关属性要求进行校验。这其中涉及两个内部方法,具体如下。
- ParseWithClaims:用于解析鉴权的声明,方法内部是具体的解码和校验的过程,最终返回*Token。
- Valid:验证基于时间的声明,如过期时间(ExpiresAt)、签发者(Issuer)、生效时间(Not Before)。需要注意的是,即便在令牌中没有任何声明,也仍然被认为是有效的。
至此我们介绍了JWT令牌的生成、解析和校验的方法,在后续应用中间件时,会对其进行调用,使其能够在应用程序中将一整套的动作串联起来。
获取JWT令牌
新增Model方法
为了获取令牌信息,需要新增Model方法,如下:
func (a Auth) Get(db *gorm.DB) (Auth, error) {var auth Authdb = db.Where("app_key = ? AND app_secret = ? AND is_del = ?",a.AppKey, a.AppSecret, 0,)err := db.First(&auth).Errorif err != nil {return auth, err}return auth, nil}
上述方法主要用于服务器端在获取客户端传入的 app_key 和 app_secret 后,根据传入的认证信息进行验证,以此判别是否真的存在这样一条数据。
新增dao方法
package daoimport "code.coolops.cn/blog_services/internal/model"func (d *Dao) GetAuth(appKey, appSecret string) (model.Auth, error) {auth := model.Auth{AppKey: appKey,AppSecret: appSecret,}return auth.Get(d.engine)}
新增service方法
新增service方法,对基本逻辑进行处理。
package serviceimport "errors"type AuthRequest struct {AppKey string `form:"app_key" binding:"required"`AppSecret string `form:"app_secret" binding:"required"`}func (s *Service) CheckAuth(param *AuthRequest) error {auth, err := s.dao.GetAuth(param.AppKey, param.AppSecret)if err != nil {return err}if auth.ID > 0 {return nil}return errors.New("auth info does not exist")}
在上述代码中,我们声明了AuthRequest结构体,用于接口入参的校验。AppKey和AppSecret都设置为必填项。在CheckAuth方法中,我们使用客户端传入的认证信息作为筛选条件获取数据行,根据是否取到认证信息ID判定认证信息ID是否存在。
新增路由方法
package apiimport ("code.coolops.cn/blog_services/global""code.coolops.cn/blog_services/internal/service""code.coolops.cn/blog_services/pkg/app""code.coolops.cn/blog_services/pkg/errcode""github.com/gin-gonic/gin")func GetAuth(ctx *gin.Context) {param := service.AuthRequest{AppKey: ctx.GetHeader("app_key"),AppSecret: ctx.GetHeader("app_secret"),}response := app.Response{Ctx: ctx}valid, errs := app.BindAndValid(ctx, ¶m)if !valid {global.Logger.ErrorF("app.BindAndValid err: %v", errs)response.ToErrorResponse(errcode.InvalidParams.WithDetails(errs.Errors()...))return}svc := service.NewService(ctx)err := svc.CheckAuth(¶m)if err != nil {global.Logger.ErrorF("svc.CheckAuth err: %v", err)response.ToErrorResponse(errcode.UnauthorizedAuthNotExist)return}token, err := app.GenerateToken(param.AppKey, param.AppSecret)if err != nil {global.Logger.ErrorF("app.GenerateToken err: %v", err)response.ToErrorResponse(errcode.UnauthorizedTokenGenerate)return}response.ToResponse(gin.H{"token": token,})}
这部分的主要逻辑是在校验及获取入参后,通过Query获取的app_key和app_secrect进行数据库查询,检查认证信息是否存在,若存在则进行Token的生成并返回。
然后新增路由:
r.POST("/auth", api.GetAuth)
接口验证
启动服务,使用postman进行验证,如下:
处理应用中间件
编写JWT中间件
虽然能获取Token了,但是对于其他的业务接口,它还没产生任何作用,应如何将整个应用流程串联起来呢?此时就涉及特定类别的接口统一处理了,即选择应用中间件的方式。打开internal/middleware,新建jwt.go文件,写入如下代码:
package middlewareimport ("code.coolops.cn/blog_services/pkg/app""code.coolops.cn/blog_services/pkg/errcode""github.com/dgrijalva/jwt-go""github.com/gin-gonic/gin")func JWT() gin.HandlerFunc {return func(ctx *gin.Context) {var (token stringecode = errcode.Success)if s,exist:=ctx.GetQuery("token");exist{token = s}else {token = ctx.GetHeader("token")}if token == ""{ecode = errcode.InvalidParams}else {_, err := app.ParseToken(token)if err != nil {switch err.(*jwt.ValidationError).Errors {case jwt.ValidationErrorExpired:ecode = errcode.UnauthorizedTokenTimeoutdefault:ecode = errcode.UnauthorizedTokenError}}}if ecode != errcode.Success{response := app.NewResponse(ctx)response.ToErrorResponse(ecode)ctx.Abort()return}ctx.Next()}}
在上述代码中,我们通过GetHeader方法从Header中获取token参数,并调用ParseToken对其进行解析,再根据返回的错误类型进行断言,其返回的错误类型所包含的场景如下:
const (ValidationErrorMalformed uint32 = 1 << iota // Token is malformedValidationErrorUnverifiable // Token could not be verified because of signing problemsValidationErrorSignatureInvalid // Signature validation failed// Standard Claim validation errorsValidationErrorAudience // AUD validation failedValidationErrorExpired // EXP validation failedValidationErrorIssuedAt // IAT validation failedValidationErrorIssuer // ISS validation failedValidationErrorNotValidYet // NBF validation failedValidationErrorId // JTI validation failedValidationErrorClaimsInvalid // Generic claims validation error)
接入JWT中间件
在编写完JWT的中间件后,我们需要将其接入应用流程中。需要注意的是,并非所有的接口都需要用到JWT中间件,因此我们需要利用gin中的分组路由的概念,只对apiv1的路由分组进行JWT中间件的引用。也就是说,只有apiv1路由分组里的路由方法会受此中间件的约束,代码如下:
apiv1 := r.Group("/api/v1")apiv1.Use(middleware.JWT()){apiv1.POST("/tags", tag.Create)apiv1.DELETE("/tags/:id", tag.Delete)apiv1.PUT("/tags/:id", tag.Update)apiv1.PATCH("/tags/:id/state", tag.Update)apiv1.GET("/tags", tag.List)}return r
验证
(1)没加Token
(2)加入错误Token
(3)加入正确Token
JWT反向解密
JWT 令牌的内容是非严格加密的。也就是说,对JWT 令牌机制有一定了解的人可以进行反向解密.
调用接口/auth获取到Token,如下:
{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBfa2V5IjoiYjdkZjVhOTMyYzk4OTBlMzE2OTZhNzFkMmNiNTJhMTIiLCJhcHBfc2VjcmV0IjoiMDZjMjE5ZTViYzgzNzhmM2E4YTNmODNiNGI3ZTQ2NDkiLCJleHAiOjE2MTI0Mjk2OTMsImlzcyI6ImJsb2dfc2VydmljZSJ9.Q6OH9wPSkO-V7smeo6FEaHRPSMyvhYjQVL4oE3rqW5Q"}
针对新获取的Token值,手动复制中间一段(即Payload),编写一个测试Demo进行base64的解码。Demo代码如下:
package mainimport ("encoding/base64""fmt")func main() {paylod, _ := base64.StdEncoding.DecodeString("eyJhcHBfa2V5IjoiYjdkZjVhOTMyYzk4OTBlMzE2OTZhNzFkMmNiNTJhMTIiLCJhcHBfc2VjcmV0IjoiMDZjMjE5ZTViYzgzNzhmM2E4YTNmODNiNGI3ZTQ2NDkiLCJleHAiOjE2MTI0MzA4ODYsImlzcyI6ImJsb2dfc2VydmljZSJ9")fmt.Println(string(paylod))}
运行后得到如下代码:
{"app_key":"b7df5a932c9890e31696a71d2cb52a12","app_secret":"06c219e5bc8378f3a8a3f83b4b7e4649","exp":1612430886,"iss":"blog_service"}
可以看到,假设有人拦截到Token后,是可以通过解密该Token来获取Payload信息的。也就是说,在 Payload 中不应该明文存储重要的信息,若一定要存,则必须进行不可逆加密,以确保信息的安全性。
过期时间是存储在Payload中的,也就是说,JWT令牌一旦签发,在没有做特殊逻辑的情况下,过期时间是不可以再度变更的,因此请务必根据实际项目情况进行设计。
文章来自《Go语言编程之旅》
