你好👋,我在写“一文搞定” 的系列文章。感谢大家的支持和阅读。
欢迎关注我的Github,了解更多 Go 相关知识。
我们在使用一个互联网服务(如网站,app,小程序)时,很多场景下都需要登陆。首先很多服务,如论坛、社交媒体,需要辨识个人身份,以便在不同设备、不同时间都能同步自己过去使用该产品的记录,其次游戏、音视频网站,需要包月、买断等方式购买会员,以获得增值服务和体验,再次,谚语云:“免费的东西最昂贵”,一些软件,如QQ、微信、百度系等,看似免费,实则企业会强制收集用户信息和机器识别码,以变相获取收益。
下面我们来看看用户登陆验证是怎么实现的。
用户认证
HTTP 是一个无状态的协议,一次请求结束后,下次在发送服务器就不知道这个请求是谁发来的了(同一个IP不代表同一个用户),在 Web 应用中,用户的认证和鉴权是非常重要的一环,实践中有多种可用方案,并且各有千秋。
Cookie - Session 认证模式
在Web应用发展的初期,大部分采用基于Cookie-Session的会话管理方式,逻辑如下。
- 客户端使用用户名、密码进行认证
- 服务端验证用户名、密码正确后生成并存储
Session
,将SessionID
通过Cookie返回给客户端 - 客户端访问需要认证的接口时在Cookie中携带
SessionID
,服务端通过SessionID
查找Session
并进行鉴权,返回给客户端需要的数据
示意图如下:
基于 Session
的方式存在多种问题。
- 服务端需要存储
Session
, 并且由于Session
需要经常快速查找,通常存储在内存或内存数据库中,同时在线用户较多时需要占用大量的服务器资源。 - 当需要扩展时,创建
Session
的服务器可能不是验证Session
的服务器,所以还需要将所有Session
单独存储并共享。 - 由于客户端使用 Cookie 存储
SessionID
,在跨域场景下需要进行兼容性处理,同时这种方式也难以防范CSRF攻击。
Token认证模式
鉴于基于 Session
的会话管理方式存在上述多个缺点,基于 Token
的无状态会话管理方式诞生了,所谓无状态,就是服务端可以不再存储信息,甚至是不再存储Session
,逻辑如下:
- 客户端使用用户名、密码进行认证
- 服务端验证用户名、密码正确后生成Token返回给客户端
- 客户端保存
Token
,访问需要认证的接口时在URL参数或HTTP Header
中加入Token - 服务端通过解码
Token
进行鉴权,返回给客户端需要的数据
示意图如下:
基于Token的会话管理方式有效解决了基于Session的会话管理方式带来的问题。
- 服务端不需要存储和用户鉴权有关的信息,鉴权信息会被加密到Tokn中,服务端只需要读取
Token
中包含的鉴权信息即可 - 避免了共享Session导致的不易扩展问题
- 不需要依赖Cookie,有效避免Cookie带来的CSRF攻击问题
- 使用CORS可以快速解决跨域问题
JWT 介绍
JWT
是 JSON Web Token
的缩写,是为了在网络应用环境间传递声明而执行的一种基于 JSON
的开放标准(RFC7519)。JWT
本身没有定义任何技术实现,它只是定义了一种基于Token的会话管理的规则,涵盖Token需要包含的标准内容和Token的生成过程,特别适用于分布式站点的单点登录(SSO)场景。
一个JWT Token
就像这样:
eyJhbGcioiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX21kIjoyODAxODcyNzQ40DMyMzU4NSwiZXhwIjoxNTkONTQwMjkxLCJpc3MioiJibHVIYmVsbCJ9.1k ZrAtYGCeZhK3iupHxP1kgjBJzQTVTtX0iZYFx9wU
它是由.
分隔的三部分组成,这三部分依次是:
- 头部 Header
- 负载 Payload
- 签名 Signature
头部和负载以
JSON
形式存在,这就是JWT
中的JSON
,三部分的内容都分别单独经过了Base64
编码,以.
拼接成一个JWT Token
。
Header
JWT
的 Header
中存储了所使用的加密算法和 Token
类型。
{
"a1g":"HS256",
"typ":"JwT",
}
Payload
Payload
表示负载,也是一个 JSON
对象,JWT
规定了 7 个官方字段供选用,
iss (issuer): 签发人
exp (expiration time): 过期时间
sub (subject): 主题
aud (audience): 受众
nbf (Not Before): 生效时间
iat (Issued At): 签发时间
jti (JWT ID): 编号
除了官方字段,开发者也可以自己指定字段和内容,例如下面的内容。
"sub": "一文搞定用户登陆验证 和 JWT (含基于 Go 的基本实现)",
"name": "Vooce",
"admin": true
注意,JWT
默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。这个 JSON
对象也要使用 Base64URL
算法转成字符串。
Signature
Signature
部分是对前两部分的签名,防止数据篡改。
首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header
里面指定的签名算法(默认是 HMAC SHA256
),按照下面的公式产生签名。
HMACSHA256( base64UrlEncode( header ) + "." + base64UrlEncode( payload ), secret )
JWT 优缺点
JWT拥有基于 Token
的会话管理方式所拥有的一切优势,不依赖Cookie,使得其可以防止CSRF攻击,也能在禁用 Cookie
的浏览器环境中正常运行。
而 JWT
的最大优势是服务端不再需要存储 Session
,使得服务端认证鉴权业务可以方便扩展,避免存储 Session
所需要引入的 Redis
等组件,降低了系统架构复杂度。
但这也是JWT最大的劣势,由于有效期存储在 Token
中,JWT Token
一旦签发,就会在有效期内一直可用,无法在服务端废止,当用户进行登出操作,只能依赖客户端删除掉本地存储的 JWT Token
,如果需要禁用用户,单纯使用 JWT
就无法做到了。
基于 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分钟),和有效时间较长的Refresh Token(例如7天)
- 客户端访问需要认证的接口时,携带Access Token
- 如果Access Token没有过期,服务端鉴权后返回给客户端需要的数据
- 如果携带Access Token访问需要认证的接口时鉴权失败(例如返回401错误),则客户端使用 Refresh Token向刷新接口申请新的Access Token
- 如果Refresh Token没有过期,服务端向客户端下发新的Access Token
- 客户端使用新的Access Token访问需要认证的接口
JWT之限制同一账号同一时间只能登陆一个设备
附录:我用 golang-jwt/jwt 对 jwt 的 Go 实现
package jwt
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt"
"github.com/spf13/viper"
)
var mySecret = []byte("jwt")
func keyFunc(_ *jwt.Token) (i interface{}, err error) {
return mySecret, nil
}
// MyClaims 自定义声明结构体并内嵌 jwt.StandardClaims
// jwt包自带的jwt.StandardClaims只包含了官方字段,若需要额外记录其他字段,就可以自定义结构体
// 如果想要保存更多信息,都可以添加到这个结构体中
type MyClaims struct {
UserID int64 `json:"user_id"`
jwt.StandardClaims
}
func GenToken(userID int64) (string, error) {
// 创建一个我们自己的声明的数据
c := MyClaims{
userID,
jwt.StandardClaims{
ExpiresAt: time.Now().Add(
time.Duration(viper.GetInt("auth.jwt_expire")) * time.Hour).Unix(), // 过期时间
Issuer: "jwt", // 签发人
},
}
// 使用指定的签名方法创建签名对象
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
// 使用指定的secret签名并获得完整的编码后的字符串token
return token.SignedString(mySecret)
}
// ParseToken 解析JWT
func ParseToken(tokenString string) (*MyClaims, error) {
// 解析token
var mc = new(MyClaims)
token, err := jwt.ParseWithClaims(tokenString, mc, keyFunc)
if err != nil {
fmt.Println(err.Error())
return nil, err
}
fmt.Println(token)
// 校验token
if token.Valid {
return mc, nil
}
return nil, errors.New("invalid 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 {
token, _ := GenToken(claims.UserID)
return token, "", nil
}
return
}