这是我参与「第三届青训营 -后端场」笔记创作活动的第4篇笔记。
用户认证
HTTP是一个无状态的协议,一次请求结束后,下次在发送服务器就不知道这个请求是谁发来的了(同一个IP不代表同一个用户),在Web应用中,用户的认证和鉴权是非常重要的一环,实践中有多种可用方案,并且各有千秋。
1.Cookie- Session 认证模式
在Web应用发展的初期,大部分采用基于Cookie-Session的会话管理方式,逻辑如下:
-
客户端使用用户名、密码进行认证
-
服务端验证用户名、密码正确后生成并存储Session,将SessionID通过Cookie返回给客户端
-
客户端访问需要认证的接口时在Cookie 中携带SessionlD
-
服务端通过SessionID查找Session并进行鉴权,返回给客户端需要的数据
基于Session的方式存在多种问题。
-
服务端需要存储Session,并且由于Session需要经常快速查找,通常存储在内存或内存数据库中,同时在线用户较多时需要占用大量的服务器资源。
-
当需要扩展时,创建Session的服务器可能不是验证Session的服务器,所以还需要将所有Session单独存储并共享。
-
由于客户端使用Cookie存储SessionlD,在跨域场景下需要进行兼容性处理,同时这种方式也难以防范CSRF攻击。
2.Token认证模式
鉴于基于Session 的会话管理方式存在上述多个缺点,基于Token 的无状态会话管理方式诞生了,所谓无状态,就是服务端可以不再存储信息,甚至是不再存储Session,逻辑如下:
- 客户端使用用户名、密码进行认证
- 服务端验证用户名、密码正确后生成Token返回给客户端
- 客户端保存Token,访问需要认证的接口时在URL参数或HTTP Header中加入Token
- 服务端通过解码Token进行授权,返回给客户端需要的数据
3.JWT介绍
JWT是JSON Web Token 的缩写,是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC7519)。JWT本身没有定义任何技术实现,它只是定义了一种基于Token的会话管理的规则,涵盖Token需要包含的标准内容和Token 的生成过程,特别适用于分布式站点的单点登录(SSO)场景。
JWT就是一种基于Token的轻量级认证模式,服务端认证通过后,会生成一个JSON对象,经过签名后得到一个Token(令牌)再发回给用户,用户后续请求只需要带上这个Token,服务端解密之后就能获取该用户的相关信息了。
其他内容,详见在gin框架中使用JWT
以一个JWT Token 为例:
eyJhbGcioiJIUzI1NiIsInR5cCI6IkpxVCJ9
.eyJ1c2VyX2lkIjoyODAxODcyNzQ4ODMyMZU4NSwiZXhwIjoxNTkONTQwMjkxLCJpc3MiOiJibHVlYmVsbCJ9
.lk_ZrAtYGCeZhK3iupHxP1kgjBTzQTVTtX0izYFx9wU
它是由.分隔的三部分组成,这三部分依次是:
- 头部(Header):存储了所使用的加密算法和Token类型
- 负载(Payload):自定义信息内容
- 签名(Signature):头部Base64通过加密算法和密钥生成的,防止令牌信息被篡改
头部和负载以JSON形式存在,这就是JWT中的JSON,三部分的内容都分别单独经过了Base64编码,以.拼接成一个JWT Token。
Header
JWT的Header中存储了所使用的加密算法和Token类型。
{
"alg": "HS256", // HS256:带有SHA-256的HMAC
"TYP": "jwt"
}
Payload
Payload表示负载(将Token当做是一个载体,表示Token里面装的是什么),也是一个JSON对象,JWT规定了7个官方字段供选用
iss (issuer)︰签发人
exp ( expiration time):过期时间
sub ( subject)︰主题
aud (audience)︰受众
nbf (Not Before):生效时间
iat ( Issued At)︰签发时间
jti(JwT ID)∶编号
除了官方字段,开发者也可以自己指定字段和内容,例如下面的内容。
{
"sub" : "1234567890",
"name " : "John Doe" ,
"admin " : true
}
注意,JWT默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。这个JSON对象也要使用Base64URL算法转成字符串。
Signature
Signature部分是对前两部分的签名,防止数据篡改。
首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用Header里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。
HMACSHA256(base64UrlEncode ( header) + "." + base64UrlEncode(payload) ,secret)
JWT优缺点
JWT拥有基于Token的会话管理方式所拥有的一切优势,不依赖Cookie,使得其可以防止CSRF攻击,也能在禁用Cookie的浏览器环境中正常运行。
1、jwt基于json,非常方便解析
2、可以在令牌中自定义丰富的内容,易扩展;
3、通过非对称加密算法及数字签名技术。
而JWT的最大优势是服务端不再需要存储Session,使得服务端认证鉴权业务可以方便扩展,避免存储Session所需要引入的Redis等组件,降低了系统架构复杂度。但这也是JWT最大的劣势,由于有效期存储在Token 中,JWTToken一旦签发,就会在有效期内一直可用,无法在服务端废止,当用户进行登出操作,只能依赖客户端删除掉本地存储的JWT Token,如果需要禁用用户,单纯使用JWT就无法做到了。
JWT常见的加密算法
常见的加密算法:
1、可逆加密算法
加密后,密文可以解密得到原文
一般用于签名和认证。私钥服务器保存, 用来加密, 公钥客户拿着用于对于令牌或 者签名的解密或者校验使用.
常见的非对称加密算法有: RSA、DSA(数字签名用)、ECC(移动设备用)、RS256 (采用 SHA‐256 的 RSA 签名)
2、不可逆加密算法
加密后,不能反向解密得到原文
一般用于敏感信息,密码,卡号等不可解密的信息
常见的不可逆加密算法有:MD5、SHA、HMAC 4.3.Base64编码 HS256:带有SHA-256的HMAC
3、对称加密
4、非对称加密
base64是网络上最常见的用于传输8bit字节代码的编码方式之一。Base64编码可用于在 HTTP环境下传递较长的标识信息。采用Base64编码解码具有不可读性,即所编码的数据 不会被人用肉眼所直接看到。
注意:Base64只是一种编码方式,不算加密方法。
JWT签名算法中HS256和RS256有什么区别
JWT签名算法中,通常有两个选择,一个采用HS256,另一个就是采用RS256。
签名其实是一个加密的过程,生成一段标识(也是JWT的一部分)做为接收方验证信息是否被篡改的依据。算法
- RS256 (采用SHA-256 的 RSA 签名) 是一种非对称算法, 它使用公共/私钥对: 标识提供方采用私钥生成签名, JWT 的使用方获取公钥以验证签名。因为公钥 (与私钥相比) 不须要保护, 所以大多数标识提供方使其易于使用方获取和使用 (一般经过一个元数据URL)。
- 另外一方面, HS256 (带有 SHA-256 的 HMAC 是一种对称算法, 双方之间仅共享一个 密钥。因为使用相同的密钥生成签名和验证签名, 所以必须注意确保密钥不被泄密。编程
在开发应用的时候启用JWT,使用RS256更加安全,你能够控制谁能使用什么类型的密钥。另外,若是你没法控制客户端,没法作到密钥的彻底保密,RS256会是个更佳的选择,JWT的使用方只须要知道公钥。安全
因为公钥一般能够从元数据URL节点得到,所以能够对客户端进行进行编程以自动检索公钥。若是采用这种方式,从服务器上直接下载公钥信息,能够有效的减小配置信息。
基于jwt实现认证实践
前面讲的Token,都是Access Token,也就是访问资源接口时所需要的Token,还有另外一种Token,Refresh Token,通常情况下,Refresh Token的有效期会比较长,而Access Token 的有效期比较短,当Access Token由于过期而失效时,使用Refresh Token就可以获取到新的Access Token如果Refresh Token也失效了,用户就只能重新登录了。
在JWT的实践中,引入Refresh Token,将会话管理流程改进如下。
- 客户端使用用户名密码进行认证
- 服务端生成有效时间较短的Access Token(例如10分钟),和有效时间较长的RefreshToken(例如7天)
- 客户端访问需要认证的接口时,携带Access Token
- 如果Access Token没有过期,服务端鉴权后返回给客户端需要的数据
- 如果携带Access Token访问需要认证的接口时鉴权失败(例如返回401错误),则客户端使用Refresh Token向刷新接口申请新的Access Token
- 如果Refresh Token没有过期,服务端向客户端下发新的Access Token客户端使用新的Access Token访问需要认证的接口
后端需要对外提供一个刷新Token的接口,前端需要实现一个当Access Token过期时自动请求刷新Token接口获取新Access Token的拦载器。
gin框架使用jwt
jwt-go库的基本使用详见:在gin框架中使用JWT
鉴权中间件开发
const (
ContextUserIDKey = "userID"
)
var (
ErrorUserNotLogin = errors.New("当前用户未登录")
)
// JWTAuthMiddleware 基于JWT的认证中间件
func JWTAuthMiddleware() func(c *gin.Context) {
return func(c *gin.Context) {
// 客户端携带Token有三种方式 1.放在请求头 2.放在请求体 3.放在URI
// 这里假设Token放在Header的Authorization中,并使用Bearer开头
// 这里的具体实现方式要依据你的实际业务情况决定
authHeader := c.Request.Header.Get("Authorization")
if authHeader == "" {
controller.ResponseErrorWithMsg(c, controller.CodeInvalidToken, "请求头缺少Auth Token")
c.Abort()
return
}
// 按空格分割
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
controller.ResponseErrorWithMsg(c, controller.CodeInvalidToken, "Token格式不对")
c.Abort()
return
}
// parts[1]是获取到的tokenString,我们使用之前定义好的解析JWT的函数来解析它
mc, err := jwt.ParseToken(parts[1])
if err != nil {
fmt.Println(err)
controller.ResponseError(c, controller.CodeInvalidToken)
c.Abort()
return
}
// 将当前请求的userID信息保存到请求的上下文c上
c.Set(.ContextUserIDKey, mc.UserID)
c.Next() // 后续的处理函数可以用过c.Get(ContextUserIDKey)来获取当前请求的用户信息
}
}
生成access token和refresh token
// GenToken 生成access token 和 refresh token
func GenToken(userID uint64, username string) (Token string, err error) {
// 创建一个我们自己的声明
c := MyClaims{
userID, // 自定义字段
"username", // 自定义字段
jwt.StandardClaims{ // JWT规定的7个官方字段
ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(), // 过期时间
Issuer: "bluebell", // 签发人
},
}
// 加密并获得完整的编码后的字符串token
Token, err = jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(mySecret)
// refresh token 不需要存任何自定义数据
// rToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.StandardClaims{
// ExpiresAt: time.Now().Add(time.Second * 30).Unix(), // 过期时间
// Issuer: "bluebell", // 签发人
//}).SignedString(mySecret) // 使用指定的secret签名并获得完整的编码后的字符串token
return
}
解析access token
// 解析JWT
func ParseToken(tokenString string) (claims *MyClaims, err error) {
// 解析token
var token *jwt.Token
claims = new(MyClaims)
token, err = jwt.ParseWithClaims(tokenString, claims, keyFunc)
if err != nil {
return
}
if !token.Valid { // 校验token
err = errors.New("invalid token")
}
return
}
refresh token
// RefreshToken 刷新AccessToken
func RefreshToken(aToken, rToken string) (newAToken, newRToken string, err error) {
// refresh token无效直接返回
if _, err = jwt.Parse(rToken, keyFunc); err != nil {
return
}
// 从旧access token中解析出claims数据
var claims MyClaims
_, err = jwt.ParseWithClaims(aToken, &claims, keyFunc)
v, _ := err.(*jwt.ValidationError)
// 当access token是过期错误 并且 refresh token没有过期时就创建一个新的access token
if v.Errors == jwt.ValidationErrorExpired {
return GenToken(claims.UserID)
}
return
}