告别 session,还是这个认证方案优秀!
大家好,我是 杰哥
互联网系统,几乎都离不开认证
一个系统中的大部分功能只能有认证信息的用户访问,但是总不可能每次请求都要求客户端专门认证一次吧,要是那样的话,用户每点一次鼠标,就得输入一次用户名密码,那可真是太麻烦了
那么,如何解决这种情况呢?
一 如何实现认证?
(一) session 机制
一种解决方案是由服务器将 session
数据保存至数据库(mysql
、reids
等)中。服务收到请求后,都向持久层请求数据进行认证操作。也就是我们最初学习 WEB
项目时比较简单直观的 Cookies-Session
机制,相对于其他方案,常被叫做 Session
机制
Session 机制的流程如下:
1、用户输入登录信息(比如用户名密码),由客户端传递至服务端
2、服务端验证无误之后,将 Session
存储至持久层
3、服务端,返回带有 sessionID
的 Cookie
(头部 Set-Cookie
)
4、客户端,保存 Cookie
信息。将 JSESSIONID
保存至 Cookie
中
5、客户端请求服务端时,携带着 Cookie
6、服务端检查是否存在对应的 sessionID
7、若存在,则通过认证,服务端返回响应内容;否则,则返回 403
-禁止访问
这种机制,其实架构还是比较清晰的。但是会存在以下几个缺点:
-
开销大。
session
保存在服务器,随着注册用户的增加,必然会引起服务器更大的开销 -
扩展能力受限。对于分布式环境,如果采用
Session
机制进行认证,那么,就需要每台服务器保存或者共享所有用户的session
信息。有时候为了解决这种session
共享的问题,会采用 redis 集群存储session
的方式来解决。但是这样相对来说复杂度会随之增大 -
CSRF 危险。
Session
是基于Cookie
来进行用户识别的,Cookie
如果被截获,用户就会很容易受到跨站请求伪造(**CSRF
**)的攻击于是便出现了另一种解决方案
于是,便渐渐衍生出另一种解决方案:JWT 机制
(二)JWT 机制
1 认证流程
通俗来讲,JSON Web Token
(JWT
)是一个含签名并携带用户相关信息的加密串,页面请求校验登录接口时,请求头中携带 JWT
串到后端服务,后端通过签名加密串匹配校验,保证信息未被篡改。校验通过则认为是可靠的请求,将正常返回数据
相较于 Session
机制来说,JWT
机制,服务器干脆不保存 session
数据了,所有认证信息只存储在客户端
服务器端只保留一个秘钥,每次进行认证时,只需要通过这个秘钥,重新加密签名之后,与客户端所传递的 token
信息进行对比即可
因此,其流程与 Session
机制差不多,只是少了服务器存储 token
信息的步骤,并且校验 token
时,是服务端重新加密生成之后与所收到的 token
值进行对比的,而不是通过查询持久层获得
流程如下:
1)用户输入登录信息(比如用户名密码)来请求服务器
2)服务器验证用户的信息
3)通过验证之后,服务器会返回一个 token
4)客户端存储 token
(可以选择存储在 Cookie 中,也可以选择存储在 localStorage 中)
5)客户端在请求时一般会通过请求头,携带这个 token
值
6)服务端会使用秘钥以及用户的相关信息重新计算生成一个 token
,然后验证所传递的这个 token
值
7)若验证成功,则返回数据;否则,返回 403
-禁止访问
2 token 的样子
了解了 JWT
的流程,我们来看下 JWT
生成的 token
长什么样
杰哥项目中刚才生成的 token
字符串如下:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJwYXNzd29yZCI6IjUzYjQzMmU2MTMxNGUwMGJjYzk5Mjg3YWY5NTM3ZGM0IiwiaXNzIjoiamllZ2UiLCJleHAiOjE2NTk0NjM4ODUsImlhdCI6MTY1OTQyNzg4NSwidXNlcm5hbWUiOiJhZG1pbiJ9.z4NFlH92V0fzOxWmxfrsxBb6dIfkMUOdj9slINKVPu8
乍一看,好像是一堆”乱码“,其实是由三部分组成:header
(头部)、payload
(载荷)、signature
(签名),以.
进行分割,
即:
header.payload.signature
Header
header
用来声明类型(typ
)和算法(alg
)
{
"typ": "JWT",
"alg": "HS256"
}
alg
属性表示签名的算法(algorithm
),默认是 HMAC SHA256
(写成 HS256
)
typ
属性表示这个令牌(token
)的类型(type
),JWT
令牌统一写为JWT
。
这个 JSON
对象使用 Base64URL
算法转成字符串,就变成上面 token
字符串的第一部分了
Payload
payload
一般存放一些不敏感的信息,比如用户名、权限、角色等。
{
"iss": "jiege",
"exp": 1659463885,
"iat": 1659427885,
"username": "admin"
}
除了传递用户信息以外,payload
也可以传递 JWT
所规定的 7
个官方字段:
-
iss (issuer):签发人
-
exp (expiration time):过期时间
-
sub (subject):主题
-
aud (audience):受众
-
nbf (Not Before):生效时间
-
iat (Issued At):签发时间
-
jti (JWT ID):编号
这个 JSON
对象使用 Base64URL
算法转成字符串,就变成上面 token
字符串的第二部分了
Signature
signature
则是将 header
和 payload
对应的 JSON
结构进行 base64url
编码之后得到的两个串用英文句点号拼接起来,然后根据header
里面 alg
指定的签名算法生成出来的
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
) secret base64 encoded
注意:
前面提到,Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。
JWT
作为一个令牌(token
),有些场合可能会放到URL
(比如 api.example.com/?token=xxx)。Base64
有三个字符+
、/
和=
,在 URL 里面有特殊含义,所以要被替换掉:=
被省略、+
替换成-
,/
替换成_
二 JWT 实战
好了,认识了 JWT
以后,我们趁热打铁,进入实战环节,通过一个简单的例子,来进一步认识 JWT
1 引入依赖
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.7.0</version>
</dependency>
引入 java-jwt 的依赖
2 配置文件 application.yml
server:
port: 8086
spring:
datasource:
url: jdbc:mysql://localhost:3306/jwt_demo?serverTimezone=UTC&useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useSSL=false
username: root
password: 123456
profiles:
#默认配置为dev,会在开发调试时跳过token 的校验,提高调试效率
active: prod
分别配置数据库的连接信息和环境信息
这里的环境信息配置为 dev
,只是为了在项目中的开发环境中,避免进行每次发起请求时 token
的校验,从而提高调试效率
3 访问资源定义
访问资源:UserController
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/common")
public String common() {
return "hello~ common";
}
@GetMapping("/admin")
public String admin() {
return "hello~ admin";
}
}
分别定义 /user/common
和 /user/admin
两个接口作为后续的测试访问资源
4 User 类
@Data
@Builder
public class User {
private Long id;
private String username;
private String password;
private String token;
//刷新 token
private String refreshToken;
}
定义 User
类,分别包含 用户名、密码、token
以及 refreshToken
5 JWT 的工具类 - JwtService
@Slf4j
@Service
public class JwtService {
//加密秘钥
private static final String SECRET_KEY = "wangjienihao";
// 签发人
private static final String ISSUER = "jiege";
// token 过期时间
public static final Long TOKEN_EXSPIRE_TIME = 1000 * 60 * 60 * 10L;
/**
* 生成 token
* @param userVo 用户信息
* @return
*/
public String token(UserVo userVo) {
//1-确定加密算法
Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
Date now = new Date();
//2-开始创建和生成 token
return JWT.create()
.withIssuedAt(now)
.withIssuer(ISSUER)
.withExpiresAt(new Date(now.getTime() + TOKEN_EXSPIRE_TIME))//token 的过期时间
.withClaim("username", userVo.getUsername())
.withClaim("password", userVo.getPassword())
.sign(algorithm);
}
/**
* 校验用户名
* @param token token
* @param username 用户名
* @return
*/
public ResponseResult verifyUsername(String token,String username){
log.info("verify jwt-username - {}",username);
ResponseResult responseResult = new ResponseResult();
try {
//1-定义算法
Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
//2-进行校验
JWTVerifier jwtVerifier = JWT.require(algorithm)
.withIssuer(ISSUER)
.withClaim("username", username)
.build();
jwtVerifier.verify(token);
} catch (Exception ex){
responseResult.setStatus(-1);
responseResult.setMessage("失败!");
log.error("auth verify fail:{}",ex.getMessage());
}
return responseResult;
}
}
分别包括生成 token
方法和校验用户名的方法
1)生成 token
/**
* 生成 token
* @param userVo 用户信息
* @return
*/
public String token(UserVo userVo) {
//1-确定加密算法
Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
Date now = new Date();
//2-开始创建和生成 token
return JWT.create()
//payload 信息开始
.withIssuedAt(now)
.withIssuer(ISSUER)
.withExpiresAt(new Date(now.getTime() + TOKEN_EXSPIRE_TIME))//token 的过期时间
.withClaim("username", userVo.getUsername())
.withClaim("password", userVo.getPassword())
//payload 信息结束
// 签名
.sign(algorithm);
}
预先定义一个加密秘钥 SECRET_KEY:wangjienihao
,并确定加密算法为 HMAC256
采用 JWT.create() 方法,分别添加了签发时间、签发人、token
有效期、用户名、密码这几个信息作为 payload
,然后采用加密算法进行加密,从而生成 token
这里的 head
定义为了:
{
"typ": "JWT",
"alg": "HS256"
}
Payload
定义为了:
{
"password": "53b432e61314e00bcc99287af9537dc4",
"iss": "jiege",
"exp": 1659463885,
"iat": 1659427885,
"username": "admin"
}
而其签名 Signature
则为:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
) secret base64 encoded
2)验证 token
一般服务端需要校验客户端请求时所携带的 token
是否正确或者是否过期,就需要以下的方法进行校验
/**
* 校验用户名
@param token token
* @param username 用户名
@return
*/
public ResponseResult verifyUsername(String token,String username){
log.info("verify jwt-username - {}",username);
ResponseResult responseResult = new ResponseResult();
try {
//1-定义算法
Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
//2-进行校验
JWTVerifier jwtVerifier = JWT.require(algorithm)
.withIssuer(ISSUER)
.withClaim("username", username)
.build();
jwtVerifier.verify(token);
} catch (Exception ex){
responseResult.setStatus(-1);
responseResult.setMessage("失败!");
log.error("auth verify fail:{}",ex.getMessage());
}
return responseResult;
}
首先,依旧是根据已知的秘钥与加密算法,获得加密算法对象 algorithm
;
然后采用该算法对象、签发人、用户名等信息生成 JWTVerifier
对象;
接着,调用 JWTVerifier
的 verify()
方法,进行用户名的校验
verify()
方法,会根据 token
,解密出 token
中的三部分信息,如官网中解析出来的样子
而我们添加了一个当前的用户名:admin
,它会将这个用户名与解密出的 payload
中的 用户名进行比较,如果一致,则表示验证成功,否则验证失败
可以顺便来看下 JWTVerifier
的 verify()
方法:
1)首先根据 token
,采用 parser
生成一个 JWTDecoder
对象
2)进入 JWTDecoder
构造方法
分别解析出头部 header
和 payload
部分 的 json
字符串
3)再退出来,来到 verify(jwt)
方法
如上所示,分别校验如下 3
个部分
a.verifyAlgorithm(jwt, algorithm)
:校验获取到的 jwt
的加密方式和发送方的加密方式是否相同
b.algorithm.verify(jwt)
:通过加密方式的名称,秘钥和头部信息,实体信息等校验获取到的 jwt
的 Signature
和发送方的 Signature
是否一致
c. verifyClaims(jwt, this.claims)
:校验 payload
中的具体数据,如 username
、签发人等
好了,JwtService 类就完成了,接下来,我们就需要分别实现在用户登录时生成 token
,而在接到客户端的请求时,进行 token
的校验工作了~
6 登录 LoginController
一般来讲,正如 Session
会存在过期时间,token
也是会存在过期时间的,比如过了 1
个小时,token
过期了,就需要重新生成 token` 了
@RestController
public class LoginController {
@Resource
private JwtService jwtService;
@Resource
private UserService userService;
@Resource
private RedisTemplate<String, UserVo> redisTemplate;
@PostMapping("/jwt/login")
public ResponseResult login(String username,String password) throws Exception {
//1-校验用户名密码是否为空
if(StrUtil.isBlank(username) || StrUtil.isBlank(password)){
throw new Exception("用户名或密码不能为空!");
}
// 2-根据用户查询用户是否存在
User user = userService.findByUsername(username);
if (user == null){
throw new Exception("用户名或密码有误!");
}
//3-验证用户名密码
password = MD5Util.md5slat(password);
if (!password.equalsIgnoreCase(user.getPassword())){
throw new Exception("用户名或密码有误!");
}
//4-生成 token
UserVo userVo = UserVo.builder().build();
userVo.setId(user.getId());
userVo.setUsername(username);
userVo.setPassword(password);
String token = jwtService.token(userVo);
userVo.setToken(token);
userVo.setRefreshToken(UUID.randomUUID().toString());
//同时存储用户到 redis 中
redisTemplate.opsForValue().set(token,userVo,JwtService.TOKEN_EXSPIRE_TIME, TimeUnit.SECONDS);
return new ResponseResult(userVo);
}
@PostMapping("refreshToken")
public ResponseResult refreshToken(@RequestParam("token") String oldToken){
//1-获取 token
UserVo userVo = redisTemplate.opsForValue().get(oldToken);
if (userVo == null){
return new ResponseResult(500,"user not found!",null);
}
String token = jwtService.token(userVo);
userVo.setToken(token);
userVo.setRefreshToken(UUID.randomUUID().toString());
//同时存储用户到 redis 中
redisTemplate.delete(oldToken);
redisTemplate.opsForValue().set(token,userVo,JwtService.TOKEN_EXSPIRE_TIME +10000, TimeUnit.SECONDS);
return new ResponseResult(userVo);
}
}
这里分别定义了登录接口 /jwt/login
和 刷新 token
接口 /refreshToken
1)登录接口
分别校验了用户名密码是否为空、用户名是否存在以及用户名密码是否正确之后,便可以生成 token
操作,并将其存入 redis
中,同时采用 UUID
的随机数,生成 refreshToken
调用 jwtService.token()
方法,传递的参数为 userVo
对象
其实,你可能也发现了,如果考虑 token
的刷新,将 token
存入 redis
中,实际上也类似于 Session
机制的做法了,因为它也将 token
存储在了服务器
所以,token
的优势,并不在于扩展性,其实主要还是在于在进行 token
认证时,直接计算生成 token
,与客户端所携带的 token
进行对比,而不是从持久层中查询,从而对于大用户量的系统,比较明显地提升了性能
2)刷新 token
其实就是获取 token
,然后重新生成 token
,并存储在 redis
中
可以考虑由前端在访问某个功能调用的时候,若检测到 token
过期,然后调用 /refreshToken
接口进行 token
的刷新操作
7 请求时拦截
1)拦截器
@Slf4j
public class AuthorisationInterceptor implements HandlerInterceptor {
@Autowired
private JwtService jwtService;
@Value("${spring.profiles.active}")
private String profiles;
private static final String AUTH = "Authorization";
private static final String AUTH_USERNAME = "username";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("执行了 AuthorisationInterceptor 的 preHandle 方法");
//1-过滤开发环境,开发环境不需要验证token
if (!StrUtil.isBlank(profiles) && "dev".equals(profiles) ){
return true;
}
//2-ignoreToken,不需要验证 token
if (ignoreToken((HandlerMethod) handler)) return true;
//3- 获取 token
String token = getParamValue(request, AUTH);
//4- 获取并验证 username
String username = getParamValue(request, AUTH_USERNAME);
ResponseResult responseResult = jwtService.verifyUsername(token, username);
if (responseResult.getStatus()!=1){
log.error("用户名校验失败");
throw new ValidationException("300","用户名校验失败!");
}
//这里需要注意:
// 1)如果设置为false时,被请求时,拦截器执行到此处将不会继续操作
// 2)如果设置为true时,请求将会继续执行后面的操作
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
log.info("执行了 AuthorisationInterceptor 的 postHandle 方法");
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
log.info("执行了 AuthorisationInterceptor 的 afterCompletion 方法");
}
private String getParamValue(HttpServletRequest request, String filed) throws ValidationException {
String value = getParam(request,filed);
if (StrUtil.isEmpty(value)){
throw new ValidationException("300",filed+"不允许为空,请重新登录!");
}
return value;
}
/**
* 获取参数的值 -- 若参数中不存在,则从请求头中获取
* @param request 请求
* @param filedName 参数名称
* @return
*/
private static String getParam(HttpServletRequest request,String filedName){
String param = request.getParameter(filedName);
if (StrUtil.isEmpty(param)){
param = request.getHeader(filedName);
}
return param;
}
/**
* 忽略 token 的处理
* @param handler
* @return
*/
private boolean ignoreToken(HandlerMethod handler) {
Method method = handler.getMethod();
if (method.isAnnotationPresent(IgnoreToken.class)){
IgnoreToken ignoreToken = method.getAnnotation(IgnoreToken.class);
return ignoreToken.required();
}
return false;
}
}
通过实现 HandlerInterceptor
,定义 Spring
拦截器类 AuthorisationInterceptor
来进行 token
的拦截验证,当然是在调用接口之前进行处理,所以,我们的逻辑主要由其 preHandle()
方法实现
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("执行了 AuthorisationInterceptor 的 preHandle 方法");
//1-过滤开发环境,开发环境不需要验证token
if (!StrUtil.isBlank(profiles) && "dev".equals(profiles) ){
return true;
}
//2-ignoreToken,不需要验证 token
if (ignoreToken((HandlerMethod) handler)) return true;
//3- 获取 token
String token = getParamValue(request, AUTH);
//4- 获取并验证 username
String username = getParamValue(request, AUTH_USERNAME);
ResponseResult responseResult = jwtService.verifyUsername(token, username);
if (responseResult.getStatus()!=1){
log.error("用户名校验失败");
throw new ValidationException("300","用户名校验失败!");
}
//这里需要注意:
// 1)如果设置为false时,被请求时,拦截器执行到此处将不会继续操作
// 2)如果设置为true时,请求将会继续执行后面的操作
return true;
}
前两步骤,只是为大家提供了一种技巧思路而已
步骤1-过滤开发环境,开发环境不需要验证 token
就是在开发环境如果需要快速测试一下接口,可以考虑先跳过 token
的验证操作
步骤2-ignoreToken
,不需要验证 token
则可以考虑对于个别方法可以通过注解的方式,实现忽略 token
的验证操作
接下来,我们分别获取头信息或者参数中 的 token
和用户名,然后调用 jwtService.verifyUsername(token, username)
进行校验,如果校验失败,表示用户名校验失败,也就是说 token
不正确,那么,抛出异常;
否则就会通过这一关,进入下一关的拦截了~
2)WebMvcConfig - 注册拦截器
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Bean
public AuthorisationInterceptor authorazationIntercepter(){
return new AuthorisationInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 添加拦截器
registry.addInterceptor(authorazationIntercepter())
//指定需要拦截的路径
.addPathPatterns("/user/**");
}
}
注册 AuthorisationInterceptor
拦截器,并指定需要拦截验证的资源路径:/user/*
三 测试
启动项目,并进行如下测试
1 访问登录接口
http://localhost:8086/jwt/login
参数为 username
和 password
如预期,得到了 token
信息
2 资源接口访问测试
1)不带 token
若不配置 header
中的 Authorization
认证信息,则会返回 500
的错误(一般可以配置为 403
-禁止访问)
2) 带上token
访问
/user/admin
接口,参数为 username
、头信息 Authorization
配置为上面获取到的 token
的值
便实现了接口的成功访问
3 刷新 token
传递 token
参数,便可以重新得到一个 token
,然后下次请求重新带上这个 token
即可,直至它过期
总结
从实战中,我们可以发现,使用 JWT
,对于大用户量的系统,可以明显降低服务器查询数据库的次数,从而对服务器的性能提升有一定的正向作用
但是对于用户量很少的一些管理平台,其实没有必要采用 JWT
,这时,采用 Session
反而更简单,既不会有太大的数据库查询性能影响,也不需要引入 Redis
进行 token
的刷新,带来更大的复杂度
JWT
本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。所以为了减少盗用的情况,JWT
的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证
此外,在实际项目中,JWT
不应该使用 HTTP
协议明码传输,要使用 HTTPS
协议传输
文章演示代码地址:github.com/helemile/Sp…
参考链接:
嗯,就这样。每天学习一点,时间会见证你的强大~
欢迎大家关注我们的公众号,一起持续性学习吧~
往期精彩回顾
总结复盘
网络篇
事务篇章
Docker篇(六):Docker Compose如何管理多个容器?
SpringCloud篇章
Spring Cloud(十):消息中心篇-Kafka经典面试题,你都会吗?
Spring Cloud(九):注册中心选型篇-四种注册中心特点超全总结
Spring Cloud(四):公司内部,关于Eureka和zookeeper的一场辩论赛
Spring Boot篇章
Spring Boot(十二):陌生又熟悉的 OAuth2.0 协议,实际上每个人都在用
Spring Boot(七):你不能不知道的Mybatis缓存机制!
翻译
WebTransport 会在不久的将来取代 WebRTC 吗?
.........
职业、生活感悟
..........
转载自:https://juejin.cn/post/7128212823892557861