线上答题系统,微服务架构的小小实践,项目代码
一、服务认证概述
处于安全和保密要求,常常要对API的请求进行鉴权。目前常见的鉴权的方法有JWT、OAuth2.0、OpenID等。我们系统选用的是比较简便的JWT方式。请求端web应用,使用beego框架,服务端是对外提供API的微服务,使用go-micro框架。客户端和服务器端使用RPC(远程过程调用)的方式进行API的调用。因此我们要实现的就是在go-micro框架中,客户端和服务端的RPC请求调用中加上JWT认证授权功能。REST API的请求认证也是类似的思路,可以参考。
二、JWT
2.1 定义
JWT(JSON Web Tokens) ,一种基于token的json格式web认证方法,目前非常流行应用于鉴权和身份认证中。在常见的业务场景中为了安全,需要对每一个请求进行鉴权,通过才能继续请求资源。如果对每个请求都进行登陆密码验证,会十分低效且引发其他风险。JWT的做法是,在客户端第一次登陆请求后,服务端使用密钥、算法和一些信息(时间戳、用户信息等)给它生成个token返回,客户端收到后存储起来,如存在cookie、LocalStorage中,之后客户端的其他请求都携带该token。服务端收到其他请求后,使用密钥解密以验证携带的token是否正确,称之为鉴权。
2.2 认证流程
- 客户端携带用户名和密码发送登录请求
- 服务端收到请求后验证用户名密码是否与数据库一致,若通过则根据密钥、信息、算法等生成 token 返回给客户端
- 客户端收到后存储起来,如存在cookie、LocalStorage中,之后客户端的其他请求都携带该token,如将token放在请求header中
- 服务端收到其他请求后验证请求中携带的 Token是否正确,通过则继续请求资源并返回,否则给客户端返回鉴权失败
2.3 JWT的组成
JWT 标准的 Token 有三个部分:header、payload、signature。各个部分中间用点分隔开,并且都会使用 Base64 编码,所以真正的 Token 看起来像这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJuaW5naGFvLm5ldCIsImV4cCI6IjE0Mzg5NTU0NDUiLCJuYW1lIjoid2FuZ2hhbyIsImFkbWluIjp0cnVlfQ.SwyHTEx_RQppr97g4J5lKXtabJecpejuef8AqKYMAJc
- Header
Header 一般由两部分组成,一个是 Token 的类型,另一个是使用的算法,比如下面类型就是 JWT,使用的算法是 HS256。
{
"typ": "JWT",
"alg": "HS256"
}
上面的内容要用 Base64 的形式编码一下,所以就变成这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
- payload
payload是放置实际有效使用信息的地方。以下是JWT定义的标准字段,使用者也可以添加其他内容
iss:Issuer,发行者
sub:Subject,主题
aud:Audience,接收 JWT Token 的一方
exp:Expiration time,过期时间
nbf:Not before,生效时间
iat:Issued at,发行时间
jti:JWT Token ID
本次使用的payload 内容为:
{
"id": 用户ID,
"username": 用户username,
"nbf":系统时间,
"iat”:系统时间
}
使用 Base64 编码后如下
eyJpYXQiOjE1ODE0NzYzMzcsImlkIjoyLCJuYmYiOjE1ODE0NzYzMzcsInVzZXJuYW1lIjoidXNlciJ9
- Signature
Signature 是 Token 的签名部分,通过如下方式生成:
(1)用 Base64 对 header、payload 的内容编码,然后用.符号连接起来
(2)用 Secret 对编码后的内容进行加密,加密后的内容即为 Signature
var encodedString = base64UrlEncode(header) + "." + base64UrlEncode(payload);
HMACSHA256(encodedString, Secret);
处理后大概是这个样子:
Oh2n9sgIRJ_6DB9YOufu5PifTQBiKRSsKOQbpsVnSJQ
最后将header、payload、Signature的内容用.连接在一起,就是JWT了:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1ODE0NzYzMzcsImlkIjoyLCJuYmYiOjE1ODE0NzYzMzcsInVzZXJuYW1lIjoidXNlciJ9.Oh2n9sgIRJ_6DB9YOufu5PifTQBiKRSsKOQbpsVnSJQ
客户端收到这个 Token 以后把它存储下来,下回向服务端发送请求的时候就带着这个 Token 。服务端收到这个 Token ,然后进行验证,通过以后就会返回给客户端想要的资源。
三、代码编写
要进行的JWT API 身份认证改造包括三步:
- 认证模块签发 token,我们这里的认证模块就是登陆模块
- web应用存储token,且之后的每次请求都带上该token
- 每个服务对外提供的API
添加认证中间件,若认证通过则继续操作,否则返回认证失败 结合我们的答题系统进行说明,web应用是客户端,UserManage.Login等其他一些service模块是服务端。web应用使用的框架是beego,服务端使用的框架是go-micro。通过go-micro的wrapper功能,可以实现JWT鉴权的功能。
3.1 认证模块签发 token
API接口的Secret值存在服务端的conf/config.yaml 配置文件中,用户登录时web应用向UserManage.Login发送请求,UserManage.Login校验用户名和密码,如果用户名密码正确,则生成JWT token并返回,否则返回登陆失败。JWT的生成可以直接使用github.com/dgrijalva/j…这个开源项目,代码如下
import (
"github.com/dgrijalva/jwt-go"
)
type Context struct{
ID int64
Username string
}
func SignJWT(c Context,secret string) (tokenstring string,err error){
if(secret==""){
secret = viper.GetString("jwt.secret")
}
token :=jwt.NewWithClaims(jwt.SigningMethodHS256,jwt.MapClaims{
"id" : c.ID,
"username": c.Username,
"nbf": time.Now().Unix(),
"iat": time.Now().Unix(),
})
tokenstring,err = token.SignedString([]byte(secret))
return tokenstring,err
}
3.2 web应用每次请求都带上该token
web应用收到UserManage.Login返回的JWT token,并将其保存在session中,之后向各服务的请求头都带上,放在header的 Authorization字段。
web应用和各个后端的service模块是通过go-micro的gRPC进行交互的,其中web应用是client,各个后端的service模块是server,但可惜go-micro中的http协议高度封装,没有直接提供方法可以设置client请求的header。怎么把token放到header中费了我一番功夫。
通过go-micro源码的调用关系可以看见,当调用proto中定义的方法时(如UserManage.Login.GetUserById),会调用client模块的Call方法。默认情况下go-micro使用的是RPC调用方式,protobuf编码。
func (c *userManageService) GetUserById(ctx context.Context, in *GetUserByIdReq, opts ...client.CallOption) (*UserMesssage, error) {
req := c.c.NewRequest(c.name, "UserManage.GetUserById", in)
out := new(UserMesssage)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
client的Call()调用rpc_client.go的call()方法,在这个方法中会读取ctx context.Context中的metadata,并将metadata设置的键值对设置到header中(不过该key值必须是协议的header中定义好的,如Authorization)。接着封装Transport和Codec模块进行protobuf编码以及rpc调用,将请求发给server。
因此我们可以通过一开始在ctx context.Context中设置metadata.Metadata{"Authorization": token},将token放入web对service模块的请求头中。
//web应用初始化client service的地方
func ServiceRegistryInit(s session.Store,serviceName string) (micro.Service,context.Context){
//create service
service := micro.NewService(micro.Name(serviceName),
micro.Registry(consul.NewRegistry(func(options *registry.Options) {
options.Addrs = []string{
beego.AppConfig.String("consulhost")+":"+beego.AppConfig.String("consulport"),
}
})))
service.Init()
//设置JWT token
token := ""
if s != nil {
//从session获取token
tokenSession := s.Get("token")
if tokenSession != nil {
token = tokenSession.(string)
}
}
md := metadata.Metadata{
"Authorization": token,
}
ctx := metadata.NewContext(context.TODO(), md)
return service,ctx
}
3.3 每个服务对外提供的API 添加认证中间件
除UserManage.Login外,各个服务收到请求时都要校验JWT是否正确,正确则继续操作,错误则返回身份认证失败。这是一个通用的认证中间件,可以通过go-micro中的wrapper实现。定义一个认证中间件authWrapper,当服务收到请求时取出请求头header中的“Authorization”,并进行校验。其中token的校验主要是调用go-jwt的jwt.Parse(tokenString string, keyFunc Keyfunc)方法验证
import (
"context"
"errors"
"time"
"github.com/astaxie/beego/logs"
"github.com/dgrijalva/jwt-go"
"github.com/micro/go-micro/server"
"github.com/spf13/viper"
)
func authWrapper(fn server.HandlerFunc) server.HandlerFunc {
return func(ctx context.Context, req server.Request, rsp interface{}) error {
logs.Debug("[authWrapper]", req.Endpoint())
//登陆不需要验证
if(req.Endpoint()=="UserManage.Login"){
return fn(ctx, req, rsp)
}
header := req.Header()
if(header==nil){
//没有则返回错误
logs.Error("[JWT auth]","get header wrong")
return errors.New("get header wrong")
}
tokenString := header["Authorization"]
secret := viper.GetString("jwt.secret")
if(tokenString==""){
logs.Error("[JWT auth]","no auth meta-data Authorization found in request")
return errors.New("no auth meta-data Authorization found in request")
}
//token校验
token, err := jwt.Parse(tokenString, secretFunc(secret))
//失败
if err != nil {
logs.Error("[JWT auth]",err)
return err
//token校验成功
} else if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
//ctx.ID = uint64(claims["id"].(float64))
//ctx.Username = claims["username"].(string)
logs.Info("ID=", int64(claims["id"].(float64))," username=",claims["username"].(string))
return fn(ctx, req, rsp)
} else {
logs.Error("[JWT auth]",err)
return err
}
return fn(ctx, req, rsp)
}
}
// secretFunc validates the secret format.
func secretFunc(secret string) jwt.Keyfunc {
return func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(secret), nil
}
}