版权声明:本文为博主原创文章,博客地址:
https://blog.csdn.net/zxy_666/article/details/80021331,未经博主允许不得转载。
前言:
因为http协议是一种无状态协议,这就涉及用户访问系统的状态保持问题。
在登录系统的设计中,当用户访问系统时,服务器需要认证用户登录相关的信息,以决定用户能否登录到系统中。
这一设计一般有2种实现方式:session和jwt。每种开发语言都有其相应的实现包,本文主要介绍jwt在go中的实践。
JSON Web Token(jwt)是一种规范,常用于用户与服务器间的认证。
jwt由以下三部分构成:
* Header:头部 (对应:Header)
* Claims:声明 (对应:Payload)
* Signature:签名 (对应:Signature)
Header中指明jwt的签名算法,如
{
"typ": "JWT",
"alg": "HS256"
}
声明中有jwt自身预置的,使用时可选。当然,我们也可以加入自定义的声明,
如uid,userName之类信息,但一定不要声明重要或私密的信息,因为这些信息是可破解的。
在生成jwt的token(令牌的意思)串时,先将Header和Claims用base64编码,再用Header中指定的加密算法,
将编码后的2个字符串进行加密(签名)。加密时需要用到一个signString签名串,我们可指定自己的signString,
不同的signString生成的加密结果不一样(解密时可能也需要同样的串,视加密算法而定)。
最后生成的jwt token串格式是:Header.Claims.Signature.如
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.
TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
(实际的token串不换行,这里只为展示清晰)
session:将用户登录的状态信息保存在服务器,客户端只存储uid。
客户端访问服务器时,服务器拿着uid去获取相应的服务器session(一个数据结构),并判断用户登录状态是否有效,是则允许登录系统,否则返回客户端(重新登录)。
jwt:将用户登录状态信息保存在客户端(即token串,因为token只保存在客户端),token中可以设置token失效时间。
客户端访问服务器时,带着该token,服务器解析token,解析成功且登录状态有效可以放行,否则返回客户端(具体逻辑视个人情况而定)。
session:因为session会话信息保存在服务器,会增加服务器I/O压力
jwt token:因服务器需要解析token(如base64解码,解密),会增加服务器计算压力。
但token中可以保存一些用户的基本信息,服务器解析即可获取,免去了查数据库的必要
2种方式各有各的好,至于使用哪种方式,视自身情况而定。
本文重点在于jwt的go实现,概念只做大致介绍
对于初次接触登录系统设计或开发的同学,先对相关概念和设计思路有个大致的了解,对于快速熟悉和开发系统有很大的帮助。
可以依次参考以下连接:
我的单点登录系统采用go语言开发,在设计中,采用了beego框架。由于beego彼时对session的支持不太好(现在就不得而知了),
又考虑到jwt中可以保存一些用户的基本信息,免去了查库的麻烦,于是就采用了jwt.
但jwt token存在一个用户主动退出登录的问题:
当用户登录时,服务器会返回一个token给客户端(如保存在浏览器中)。因为登录状态(token失效时间)已在token中设置,
该时间无法在当前token串中修改,除非重新生成一个token。所以,当用户主动退出登录时,问题就来了:
1.token是在用户登录时给用户颁发的令牌(用于用户与服务器间认证),用户主动退出时要重新生成一个时间已经失效的token返回给用户么?
是的话,客户端就得用这个新的token替换原来旧的token。但旧token仍未失效,只要该用户访问时带着它,登录认证一样可以通过。
2.如果不重生成token的话,客户端原来保存的token仍在有效期内,此时带着该token访问服务器时,
登录认证是通过的,但前提是用户已经主动注销了登录。这显然是不可以的。
基于这个情况,可以将用户的状态保存在服务器中,这样就不用在token中设置失效时间了。这里我选择用redis来保存,只存储用户的失效时间
,key可以是uid,value是token失效时间,每当用户访问系统时,重置失效时间。
这看起来与session的保存机制是一样的,确实如此。但这不是session,在服务器保存的session是一个比较大的数据结构,
相比一个简单token失效时间占用的内存要大多了。
单点登录系统的完整项目代码,请移步我的GitHub:https://github.com/yingyan003/
这里只摘取jwt的部分
这里采用jwt的第三方包github.com/dgrijalva/jwt-go来实现。
其中的包含的token的加密算法有很多种,不同的加密算法,需要的参数不一样。
最简单的加密算法是HS256
在生成token时,需要指定一个key,解析时也必须通过同样的key。
token.SignedString([]byte(key))
这里的SignedString参数必须是[]byte类型。
这是个历史问题,这个第三方包旧版本使用的是string类型,出于安全性考虑,后来改为了[]byte类型。
生成token时,需要提供一个privateKey:
token.SignedString(&privateKey)
生成这个privateKey需要类似下面的3个参数(名称随意):
ECDSAKeyD=CCFDFDC9C2572D15C639D07E3C6C8804A1E941B13F5D10C7297A2DFAA70E6393
ECDSAKeyX=EE4C3E11EB1BF081CFD4B5CCC482E069BFBECA07D566238F29191716319B809E
ECDSAKeyY=A40CCD993EC355326588E2A9E202C24A2D5D1BE5128B19885FD9F2C4155C3EF1
这三个参数是用来生成privateKey的,不能乱写,可以用下面的方式生成:
//todo 生成ecdsa.PrivateKey
randKey:=rand.Reader
var err error
prk, err = ecdsa.GenerateKey(elliptic.P256(),randKey )
if err!=nil{
fmt.Println("generate key error",err)
}
puk=prk.PublicKey
fmt.Printf("prkD=%X\n",prk.D)
fmt.Printf("prkX=%X\n",prk.X)
fmt.Printf("prkY=%X\n",prk.Y)
fmt.Println("prk",prk," \npbk",puk)
其中的prk.D,prk.X,prk.Y对应上面的ECDSAKeyD,ECDSAKeyX,ECDSAKeyY。解析时也会用到
如果采用HS256的方式,由于前端需要解析token获取用户相关的信息,需要把key给到前端。
这样一来,导致key可以随意被获取到。由于前边提到我会把存在redis的用户登录状态的redisKey存在token中,
有了key就可以解析token,这样redisKey就会暴露,这是很不安全的,因为用户登录状态就有被修改的可能。
基于这个原因,我使用了双重token。也就是在一个token里保存另一个token。
最外一层token只保存用户基本且不重要的信息,所以采用HS256算法,前端暴露key也无关紧要。
内嵌的token采用的ES256算法,因为生成和解析token的D/X/Y只保存在服务器中,所以理论上是相对安全的。
这里用到了jwt的第三方包:github.com/dgrijalva/jwt-go
如果你本地没有的话,可以通过go get拉到本地:
go get github.com/dgrijalva/jwt-go
package jwtUtil
import (
"math/big"
"github.com/dgrijalva/jwt-go"
"crypto/ecdsa"
"crypto/elliptic"
"fmt"
"sso/common/errors"
"sso/models"
)
const (
//ES256 keys
ECDSAKeyD = "CCFDFDC9C2572D15C639D07E3C6C8804A1E941B13F5D10C7297A2DFAA70E6393"
ECDSAKeyX = "EE4C3E11EB1BF081CFD4B5CCC482E069BFBECA07D566238F29191716319B809E"
ECDSAKeyY = "A40CCD993EC355326588E2A9E202C24A2D5D1BE5128B19885FD9F2C4155C3EF1"
//HS256 signed key
SIGNED_KEY = "yourkey"
)
//获取签名算法为ES256的token
//该token的内容只有Redis的key,用于保存用户的登录状态
func GetEStoken(redisValue string) string {
keyD := new(big.Int)
keyX := new(big.Int)
keyY := new(big.Int)
keyD.SetString(ECDSAKeyD, 16)
keyX.SetString(ECDSAKeyX, 16)
keyY.SetString(ECDSAKeyY, 16)
claims := jwt.MapClaims{
"redisValue": redisValue,
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
publicKey := ecdsa.PublicKey{
Curve: elliptic.P256(),
X: keyX,
Y: keyY,
}
privateKey := ecdsa.PrivateKey{D: keyD, PublicKey: publicKey}
ss, err := token.SignedString(&privateKey)
if err != nil {
fmt.Println("ES256的token生成签名错误,err=%v", err)
return ""
}
return ss
}
//获取签名算法为HS256的token
func GetHStoken(tokenFirst string, user *models.User) string {
claims := jwt.MapClaims{
"tokenES": tokenFirst,
//解析时,该变量的类型被转换成float64
"uid": user.Id,
"username": user.Username,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
//加密算法是HS256时,这里的SignedString必须是[]byte()类型
ss, err := token.SignedString([]byte(SIGNED_KEY))
if err != nil {
fmt.Println("token生成签名错误,err=%v", err)
return ""
}
return ss
}
//解析签名算法为ES256的token
func ParseEStoken(tokenES string) string {
keyX := new(big.Int)
keyY := new(big.Int)
keyX.SetString(ECDSAKeyX, 16)
keyY.SetString(ECDSAKeyY, 16)
publicKey := ecdsa.PublicKey{
Curve: elliptic.P256(),
X: keyX,
Y: keyY,
}
token, err := jwt.Parse(tokenES, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
return &publicKey
})
if err != nil {
fmt.Println("ES256的token解析错误,err=%v", err)
return ""
}
if claims, ok := token.Claims.(jwt.MapClaims); ok {
return claims["redisValue"].(string)
}
fmt.Println("ParseEStoken:Claims类型转换失败")
return ""
}
//解析签名算法为HS256的token
func ParseHStoken(tokenString string) jwt.MapClaims {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(SIGNED_KEY)
})
if err != nil {
fmt.Println("HS256的token解析错误,err:", err)
return nil
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
fmt.Println("ParseHStoken:claims类型转换失败")
return nil
}
return claims
}