likes
comments
collection
share

微服务越来越多,如何打通各个服务之间的登录状态?来试试JWT

作者站长头像
站长
· 阅读数 16
  • 我们知道,http是无状态的,即每次http请求对于服务器来说都是新用户,那么我们怎么解决登录的问题呢?

cookie

  • 最早我们可以使用cookie,即一个用户第一次请求的时候,是没有cookie的,这个时候呢,服务器会自动生成一个session_id存放到cookie里面去,也就是说浏客户端的cookie,服务器不仅可以读,而且可以写,正是基于此我们就可以实现用户登录的功能。比如我们首次访问https://www.acurd.com 微服务越来越多,如何打通各个服务之间的登录状态?来试试JWT
  • 我们第二次再访问 微服务越来越多,如何打通各个服务之间的登录状态?来试试JWT

go操作session

go并不像php那样原生就支持session,我们可以根据自己的需求实现一个session库,也可以使用第三方的库,这也再次说明了session并不是什么神秘的技术,而是基于服务端对客户端cookie的读写来实现的。 这里我们使用第三方库来演示一下

package main

import (
	"fmt"
	"github.com/gorilla/mux"
	"github.com/gorilla/sessions"
	"log"
	"net/http"
)

var store = sessions.NewCookieStore([]byte("secret-key"))

// 自定义一个账户和密码
var name, pwd = "acurd", "acurdpwd"

func LoginHandler(w http.ResponseWriter, r *http.Request) {

	//获取session
	session, _ := store.Get(r, "session_id")

	//打印session信息
	fmt.Printf("%+v", session.Values)
	if !session.IsNew {
		fmt.Fprintf(w, "<h1>你已经登录了</h1>")
		return
	}

	r.ParseForm()       // 解析参数,默认是不会解析的
	fmt.Println(r.Form) // 这些信息是输出到服务器端的打印信息
	//有的话校验登录
	username := r.FormValue("username")
	password := r.FormValue("password")
	if username == name && password == pwd {
		session.Values["username"] = username
		// 将session保存
		err := session.Save(r, w)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		fmt.Fprintf(w, "<h1>登录成功</h1>")

		return
	}
	//没有的话提示输入用户名密码
	fmt.Fprintf(w, "<h1>用户名或密码失败</h1>")
}

func main() {
	// 创建路由
	r := mux.NewRouter()
	r.HandleFunc("/login", LoginHandler)
	err := http.ListenAndServe(":8002", r)
	if err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}

缺点

cookie+session这种方式的缺点

  • 依赖cookie实现,所以客户端不能禁用cookie
  • cookie可能被截获,别人利用我们的cookie信息就可以跳过登录验证
  • session是如果是存储在服务器内存的话,当我们跨服务器访问的时候,不能识别登录状态
  • 如果用户量很大的话,session占用的服务器内存也就越大。

Token

  • 基于Token的认证是近几年非常流行的认证方式。基于token的认证中,最常用的是JSON Web Tokens,即JWT(JSON Web Token的缩写),这种方式摒弃了传统的基于cookie和session的登录认证,可以实现跨域认证。

JWT

jwt登录的流程

  • 第一次请求使用 用户名+密码完成登录
  • 登录成功颁发token,token里面可以有你自定义的一些信息,比如用户名,用户id,会被加密后返回给前端
  • 前端保留此token,每次请求的时候带上该token,后端解析后就可以读取token里的用户名和用户id
  • 只要服务端使用的是同一个秘钥,token都会被解析成功,也就是说解决了跨域登录的问题。

jwt的数据结构

我们先来看一个token长什么样子,比如这个eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VybmFtZSI6ImFjdXJkIiwiVWlkIjoxMiwic3ViIjoiVG9rZW4iLCJleHAiOjE2ODc4Njk2NjYsImlhdCI6MTY4Nzg2NjA2Nn0._hIRJDrLV8co1gZPKKxG2AyMVFRapl-nt-Kqbb-r8bg,我们看到这个token被.分割成了三部分,那么这三部分代表什么含义呢?我们先来还原一下这三部分怎么来的

  • 第一部分eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9是对算法和type的加密,加密算法是base64.urlencode,相关代码如下:
func TestDemoTwo28(t *testing.T) {

	type JWTHeader struct {
		Alg string `json:"alg"`
		Typ string `json:"typ"`
	}
	header := JWTHeader{
		Alg: "HS256",
		Typ: "JWT",
	}
	headerBytes, _ := json.Marshal(header)
	headerBase64 := base64.RawURLEncoding.EncodeToString(headerBytes) // 对字节数组进行 Base64 编码
	fmt.Println(headerBase64)                                         // 输出编码后的 JWT Header

}

执行结果是一模一样的 微服务越来越多,如何打通各个服务之间的登录状态?来试试JWT

  • 第二部分eyJVc2VybmFtZSI6ImFjdXJkIiwiVWlkIjoxMiwic3ViIjoiVG9rZW4iLCJleHAiOjE2ODc4Njk2NjYsImlhdCI6MTY4Nzg2NjA2Nn0其实也是base64.urlencode加密得来的,加密的对象就是我们存储的数据,咱们可以用解码的方式解析一下看看

相关代码如下

func TestDemoTwo28(t *testing.T) {
	str := "eyJVc2VybmFtZSI6ImFjdXJkIiwiVWlkIjoxMiwic3ViIjoiVG9rZW4iLCJleHAiOjE2ODc4Njk2NjYsImlhdCI6MTY4Nzg2NjA2Nn0"
	decodeString, _ := base64.RawURLEncoding.DecodeString(str)
	fmt.Printf("%s", string(decodeString))
}

执行结果如下

微服务越来越多,如何打通各个服务之间的登录状态?来试试JWT通过第一部分和第二部分我们看到了,这是一种可逆的加密,所以不要放重要数据(也可以再次对payload的数据对称加密,保证数据不外泄),比如登录密码,手机号。接下来看第三部分。这里我贴一份可逆加密的代码

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"encoding/base64"
	"encoding/hex"
	"fmt"
)

func main() {
	// 原始数据
	source := "Hello, world!"
	fmt.Println("原文:", source)

	// 密钥,必须是 16、24 或 32 字节
	key := "example key 1234"

	// 加密
	encryptCode := AESEncrypt([]byte(source), []byte(key))
	fmt.Println("密文(byte):", encryptCode)

	// 使用 hex 编码打印
	fmt.Println("密文(hex):", hex.EncodeToString(encryptCode))

	// 使用 base64 编码打印
	fmt.Println("密文(base64):", base64.StdEncoding.EncodeToString(encryptCode))

	// 解密
	decryptCode := AESDecrypt(encryptCode, []byte(key))
	fmt.Println("解密后的原文:", string(decryptCode))
}

// AESEncrypt 使用 AES 加密数据
func AESEncrypt(data, key []byte) []byte {
	block, err := aes.NewCipher(key)
	if err != nil {
		panic(err)
	}
	ciphertext := make([]byte, aes.BlockSize+len(data))
	iv := ciphertext[:aes.BlockSize]
	stream := cipher.NewCFBEncrypter(block, iv)
	stream.XORKeyStream(ciphertext[aes.BlockSize:], data)
	return ciphertext
}

// AESDecrypt 使用 AES 解密数据
func AESDecrypt(data, key []byte) []byte {
	block, err := aes.NewCipher(key)
	if err != nil {
		panic(err)
	}
	if len(data) < aes.BlockSize {
		panic("ciphertext too short")
	}
	iv := data[:aes.BlockSize]
	data = data[aes.BlockSize:]
	stream := cipher.NewCFBDecrypter(block, iv)
	stream.XORKeyStream(data, data)
	return data
}
  • 第三部分_hIRJDrLV8co1gZPKKxG2AyMVFRapl-nt-Kqbb-r8bg 这个数值是怎么来的呢?就是基于前两段的值+秘钥计算得来,具体算法如下HMACSHA256( base64UrlEncode(header) + "." +base64UrlEncode(payload), secret ),这一部分才是校验我们数据的准确性,比如数据是否早到篡改。

我们可以通过https://jwt.io/更直观的观察结果 微服务越来越多,如何打通各个服务之间的登录状态?来试试JWT 这个时候,是不是上来就是一句卧槽,原来token是可以被解析的,那我的token岂不是不安全,说的对,token只是实现登录认证,并不保证你的数据不外泄,所以我们不能把重要的信息放到token里面去。但是如果你篡改了数据,伪造登录,由于别人没有你的秘钥,导致第三段校验失败,就可以拦截非法登录了。

其实了解了jwt的实现机制,我们自己也可以定义一套自己的token机制来实现登录功能。 下面我们来看一下别人已经造好的轮子是怎么用的。

使用jwt实现登录功能

我们使用go的第三方包来实现jwt登录功能

怎么找包

怎么找包要认真说,还真是一个技术活,你要是网上随便搜一个,那么第一安全性,可靠性都可能会有问题,更别谈后期的维护了。那么一般我们怎么找包呢?github上面通过 语言+关键词来搜索,一般使用star最多的就行了,比如我们在github 搜 go jwt 我们看到第一个和第二个其实是一个仓库,所以我们使用github.com/golang-jwt/jwt/v5这个包 微服务越来越多,如何打通各个服务之间的登录状态?来试试JWT

  • 下面是jwt登录功能的相关代码
package main

import (
	"fmt"
	"github.com/golang-jwt/jwt/v5"
	"github.com/gorilla/mux"
	"log"
	"net/http"
	"time"
)

// 定义自己的秘钥 所有的服务必须用一个秘钥才能正确解析token
var privateKey = []byte("my_secret_key")

// 自定义一个账户和密码 用户uid 这里简单举个例子,一般是去数据库校验
var name, pwd, uid = "acurd", "acurdpwd", 12

// UserClaims 我们声明一个结构体,里面包含我们想要保存的信息
type UserClaims struct {
	Username             string
	Uid                  int64
	jwt.RegisteredClaims // 内嵌标准的声明
}

// GenToken 生成token
func GenToken(username string, uid int64) (string, error) {
	//初始化结构体
	claims := UserClaims{
		Uid:      uid,
		Username: username,
		RegisteredClaims: jwt.RegisteredClaims{
			//设置过期时间
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * 3600)),
			//颁发时间
			IssuedAt: jwt.NewNumericDate(time.Now()),
			//主题
			Subject: "Token",
		},
	}
	//生成token  使用hs256 加密 结构体,然后再用秘钥对其做数字签名
	return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(privateKey)
}

// 解析token
func parseToken(tokenString string) (*UserClaims, error) {
	claims := new(UserClaims)
	token, err := jwt.ParseWithClaims(tokenString, claims, keyFunc)
	//建议token是否有效
	if token.Valid {
		return claims, nil
	}
	return nil, err
}
func keyFunc(token *jwt.Token) (interface{}, error) {
	return privateKey, nil
}

func LoginHandler(w http.ResponseWriter, r *http.Request) {

	//限制post提交
	if r.Method != "POST" {
		fmt.Fprintf(w, "<h1>非法登录</h1>")
		return
	}

	//获取token
	auth := r.Header.Get("Authorization")
	if len(auth) > 0 { //刺入还可以加入对
		//打印token信息
		fmt.Println(auth)
		//校验token
		claims, err := parseToken(auth)
		if err != nil {
			fmt.Fprintf(w, "<h1>解析token失败</h1>")
			return
		}
		//
		fmt.Fprintf(w, "<h1>您已经登录了</h1>相关token解析如下:%+v", claims)
		return
	}

	r.ParseForm()       // 解析参数,默认是不会解析的
	fmt.Println(r.Form) // 这些信息是输出到服务器端的打印信息
	//有的话校验登录
	username := r.FormValue("username")
	password := r.FormValue("password")
	if username == name && password == pwd {
		// 生成token并返回
		token, err := GenToken(username, int64(uid))
		if err != nil {
			fmt.Fprintf(w, "<h1>生成token失败</h1>")
		}
		// 将token保存
		fmt.Fprintf(w, "<h1>登录成功</h1>请保存好你的token:%s", token)
		return
	}
	//没有的话提示输入用户名密码
	fmt.Fprintf(w, "<h1>用户名或密码失败</h1>")
}

func main() {
	// 创建路由
	r := mux.NewRouter()
	r.HandleFunc("/login", LoginHandler)
	err := http.ListenAndServe(":8002", r)
	if err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}

  • 我们运行一下上面的代码 第一次获取到token 微服务越来越多,如何打通各个服务之间的登录状态?来试试JWT
  • 第二次带token请求 微服务越来越多,如何打通各个服务之间的登录状态?来试试JWT
  • 如果修改里面的任意一个字符 微服务越来越多,如何打通各个服务之间的登录状态?来试试JWT

jwt的刷新机制

场景1:我们用QQ或者微信,不可能每打开一次都执行一次登录操作,而是好长时间不用,比如一个月没有打开QQ,那么你再次打开QQ,可能就需要登录了,但是如果你经常打开QQ,可能好几个月都不需要登录,那么这是怎么实现的呢?

场景2:你设计了一套jwt的登录系统,token有效期是60分钟,用户在58分钟的时候打开了网页,在61分钟的时候点击提交,发现自己被退出登录了,你说用户气不气?

refresh_token

基于上面的两种场景,我们提出来refresh_token的机制,其实就是多了一个刷新token的触发机制,第一次登录的时候,我们给用户颁发两个token ,一个access_token,有效期比较短,比如是4小时,还有一个refresh_token ,就是刷新token,假设有效期是24小时。

  • 第一次登录,我们返回access_token和refresh_token
  • 后面的请求,我们校验access_token是否过期,没过期的放行通过,过期的话就要校验refresh_token是否过期,如果没有过期,则拿着refresh_token 下发新的access_token和新的refresh_token(下发新的refresh_token是为了避免活跃用户的频繁登录,比如24小时内只要用户登录过,就不会弹出登录窗口。具体根据业务看)。如果两个token都过期了,则重新登录。
  • 相关代码如下
package main

import (
	"fmt"
	"github.com/golang-jwt/jwt/v5"
	"github.com/gorilla/mux"
	"log"
	"net/http"
	"time"
)

// 定义自己的秘钥 所有的服务必须用一个秘钥才能正确解析token
var privateKey = []byte("my_secret_key")

// 自定义一个账户和密码 用户uid 这里简单举个例子,一般是去数据库校验
var name, pwd, uid = "acurd", "acurdpwd", 12

// UserClaims 我们声明一个结构体,里面包含我们想要保存的信息
type UserClaims struct {
	Username             string
	Uid                  int64
	jwt.RegisteredClaims // 内嵌标准的声明
}

// RefreshClaims 用来生成refresh token
type RefreshClaims struct {
	jwt.RegisteredClaims // 内嵌标准的声明
}

// GenToken 生成token
func GenToken(username string, uid int64) (string, error) {
	//初始化结构体
	claims := UserClaims{
		Uid:      uid,
		Username: username,
		RegisteredClaims: jwt.RegisteredClaims{
			//设置过期时间
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * 30)),
			//颁发时间
			IssuedAt: jwt.NewNumericDate(time.Now()),
			//主题
			Subject: "Token",
		},
	}
	//生成token  使用hs256 加密 结构体,然后再用秘钥对其做数字签名
	return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(privateKey)
}

// 解析token
func parseToken(tokenString string) (*UserClaims, error) {
	claims := new(UserClaims)
	_, err := jwt.ParseWithClaims(tokenString, claims, keyFunc)
	return claims, err
}
func keyFunc(token *jwt.Token) (interface{}, error) {
	return privateKey, nil
}

// GenRefreshToken 生成token
func GenRefreshToken() (string, error) {
	//初始化结构体
	claims := UserClaims{
		RegisteredClaims: jwt.RegisteredClaims{
			//设置过期时间
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * 3600)),
			//颁发时间
			IssuedAt: jwt.NewNumericDate(time.Now()),
			//主题
			Subject: "RefreshToken",
		},
	}
	//生成token  使用hs256 加密 结构体,然后再用秘钥对其做数字签名
	return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(privateKey)
}

// 解析token
func parseRefreshToken(tokenString string) (*RefreshClaims, error) {
	claims := new(RefreshClaims)
	token, err := jwt.ParseWithClaims(tokenString, claims, keyFunc)
	//建议token是否有效
	if token.Valid {
		return claims, nil
	}
	return nil, err
}

func LoginHandler(w http.ResponseWriter, r *http.Request) {

	//限制post提交
	if r.Method != "POST" {
		fmt.Fprintf(w, "<h1>非法登录</h1>")
		return
	}

	//获取token
	auth := r.Header.Get("Authorization")
	tokenRefresh := r.Header.Get("AuthorizationRef")
	fmt.Println(auth)
	fmt.Println(tokenRefresh)
	if len(auth) > 0 {
		// 解析refresh token
		claimsR, err := parseRefreshToken(tokenRefresh)
		if err != nil {
			fmt.Fprintf(w, "token 错误或过期%s", err)
			return
		}
		fmt.Printf("claimsR %v", claimsR)
		//解析token
		claims, err := parseToken(auth)
		fmt.Printf("claims %+v  err:%v", claims, err)
		//token未过期
		if claims != nil && !claims.Expired() {
			fmt.Fprintf(w, "<h1>您已经登录了</h1>相关token解析如下:%+v", claims)
			return
		}
		token, _ := GenToken(claims.Username, int64(uid))
		refreshToken, _ := GenRefreshToken()
		// 将token保存
		fmt.Fprintf(w, "<h1>已经更新token</h1>请保存好你的token:%s;refreshToken:%s", token, refreshToken)
		return
	}

	r.ParseForm()       // 解析参数,默认是不会解析的
	fmt.Println(r.Form) // 这些信息是输出到服务器端的打印信息
	//有的话校验登录
	username := r.FormValue("username")
	password := r.FormValue("password")
	if username == name && password == pwd {
		// 生成token并返回
		token, _ := GenToken(username, int64(uid))
		refreshToken, _ := GenRefreshToken()
		// 将token保存
		fmt.Fprintf(w, "<h1>登录成功</h1>请保存好你的token:%s;refreshToken:%s", token, refreshToken)
		return
	}
	//没有的话提示输入用户名密码
	fmt.Fprintf(w, "<h1>用户名或密码失败</h1>")
}

// Expired 检查自定义的token结构体是否过期
func (token *UserClaims) Expired() bool {

	fmt.Println(time.Now().Unix())
	fmt.Println(token.ExpiresAt.Unix())
	return time.Now().Unix() > token.ExpiresAt.Unix()
}

func main() {
	// 创建路由
	r := mux.NewRouter()
	r.HandleFunc("/login", LoginHandler)
	err := http.ListenAndServe(":8002", r)
	if err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}

微服务越来越多,如何打通各个服务之间的登录状态?来试试JWT

jwt的缺点

由于jwt是无状态的,而且token是在登录时生成的,所以会有以下两个问题

  • 数据更新的问题,如果用户信息发生变更,但是token解析的还是老数据
  • token一旦签发无法收回和废弃。