为什么要用token?
- token完全由应用管理,所以它可以避开同源策略
- token可以避免CSRF攻击
- Token存储在客户端,是无状态的,可以在多个服务间共享
JWT介绍
Json Web Token:定义了一种简洁的、自包含的格式,用于在通信双方以json数据格式安全的传输信息。由于数字签名的存在,这些信息是可靠的。
官网:https://jwt.io/
组成:
- 第一部分:Header(头) ,记录令牌类型、 签名算法等。例如: {“lg”.“HS256” “type” :“JWT”}
- 第二部分:Payload(有效载荷) ,携带一些自定义信息、 默认信息等。例如: {“id”:“1”,“username”:“Tom”}
- 第三部分 Signature(签名),防止Token被篡改、确保安全性。将header.payload,并加入指定秘钥,通过指定签名算法计算而来。
注意:JWT的Payload使用的是Base64编码,因此在JWT中不能存储敏感数据。
场景:登录认证。
①登录成功后, 生成令牌
②后续每个请求,都要携带JWT令牌,系统在每次请求处理之前,先校验令牌,通过后,再处理
登录后下发令牌
思路
- 令牌生成: 登录成功后,生成JWT令牌,并返回给前端。
- 令牌校验:在请求到达服务端后,对令牌进行统一拦截、校验。
注意(把生成的jwt令牌封装到json里,前端会解析)
用户登录成功后,系统会自动下发JWT令牌,然后在后续的每次请求中,都需要在请求头header中携带到服务端,请求头的名称为token,值为登录时下发的JWT令牌。
如何使用JWT?
引入坐标
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.7.0</version>
</dependency>
生成token和解析token示例
@Test
public void testGen(){
Map<String, Object> claims = new HashMap<>();
claims.put("id", 1);
claims.put("username", "张三");
String token = JWT.create()
.withClaim("user", claims.toString()) //添加载荷
.withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 12)) //指定过期时间,12h后过期
.sign(Algorithm.HMAC256("itheima"));//指定算法,配置密钥
System.out.println(token);
}
@Test
public void testParse(){
//定义字符串,模拟用户传递过来的token
String token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9"
+".eyJleHAiOjE3MTI2MDQxMDYsInVzZXIiOiJ7aWQ9MSwgdXNlcm5hbWU95byg5LiJfSJ9"
+".7jp3Sw7dX7x6ezQPUxb3oSHaknGXYzpp6C3UJNu6luk";
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("itheima")).build();
DecodedJWT decodeJWT = jwtVerifier.verify(token);//验证token,生成一个解析后的JWT对象
Map<String, Claim> claims = decodeJWT.getClaims();
//如果篡改头部载荷 或者 密钥 或者token过期 都会验证失败
System.out.println(claims.get("user"));
}
注意:
- JWT校验时生成的签名密钥,必须和生成JWT令牌时使用的密钥是配套的,此处案例使用的是"itheima"
- 如果JWT令牌解析校验时出错,说明JWT令牌被篡改或失效了
@JsonIgnore注解:让springmvc把当前对象转为json字符串的时候,忽略掉password,最终的json字符串中就没有password这个属性了
@JsonIgnore
private String password;//密码
```bash
令牌主动失效机制
问题:用户修改密码后,令牌应当失效
- 登录成功后,给浏览器响应令牌的同时,把该令牌存储到redis中
- LoginInterceptor截器中,需要验证浏览器携带的令牌, 并同时需要获取到redis中存储的与之相同的令牌
- 当用户修改密码成功后,删除redis中存储的旧令牌
问题:token刷新机制
用户长时间没有操作会使Token过期,每次用户点击可以刷新Token过期时间
封装成通用的方法
JWT工具方法
@Component
public class JwtTool {
private final JWTSigner jwtSigner;
public JwtTool(KeyPair keyPair) {
this.jwtSigner = JWTSignerUtil.createSigner("rs256", keyPair);
}
/**
* 创建 access-token
*
* @param userDTO 用户信息
* @return access-token
*/
public String createToken(Long userId, Duration ttl) {
// 1.生成jws
return JWT.create()
.setPayload("user", userId)
.setExpiresAt(new Date(System.currentTimeMillis() + ttl.toMillis()))
.setSigner(jwtSigner)
.sign();
}
/**
* 解析token
*
* @param token token
* @return 解析刷新token得到的用户信息
*/
public Long parseToken(String token) {
// 1.校验token是否为空
if (token == null) {
throw new UnauthorizedException("未登录");
}
// 2.校验并解析jwt
JWT jwt;
try {
jwt = JWT.of(token).setSigner(jwtSigner);
} catch (Exception e) {
throw new UnauthorizedException("无效的token", e);
}
// 2.校验jwt是否有效
if (!jwt.verify()) {
// 验证失败
throw new UnauthorizedException("无效的token");
}
// 3.校验是否过期
try {
JWTValidator.of(jwt).validateDate();
} catch (ValidateException e) {
throw new UnauthorizedException("token已经过期");
}
// 4.数据格式校验
Object userPayload = jwt.getPayload("user");
if (userPayload == null) {
// 数据为空
throw new UnauthorizedException("无效的token");
}
// 5.数据解析
try {
return Long.valueOf(userPayload.toString());
} catch (RuntimeException e) {
// 数据格式有误
throw new UnauthorizedException("无效的token");
}
}
}
生成token返回给前端
@Override
public UserLoginVO login(LoginFormDTO loginDTO) {
// 1.数据校验
String username = loginDTO.getUsername();
String password = loginDTO.getPassword();
// 2.根据用户名或手机号查询
User user = lambdaQuery().eq(User::getUsername, username).one();
Assert.notNull(user, "用户名错误");
// 3.校验是否禁用
if (user.getStatus() == UserStatus.FROZEN) {
throw new ForbiddenException("用户被冻结");
}
// 4.校验密码
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new BadRequestException("用户名或密码错误");
}
// 5.生成TOKEN
String token = jwtTool.createToken(user.getId(), jwtProperties.getTokenTTL());
// 6.封装VO返回
UserLoginVO vo = new UserLoginVO();
vo.setUserId(user.getId());
vo.setUsername(user.getUsername());
vo.setBalance(user.getBalance());
vo.setToken(token);
return vo;
}
定义登陆拦截器
- preHandle:进入controller方法之前进去
- afterCompletion:执行完controller方法之后执行
- ThreadLocal对应的是一个线程的数据,每次http请求,tomcat都会创建一个新的线程,也就是说,当前的ThreadLocal只在当前的线程中
@RequiredArgsConstructor
public class LoginInterceptor implements HandlerInterceptor {
private final JwtTool jwtTool;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的 token
String token = request.getHeader("authorization");
// 2.校验token
Long userId = jwtTool.parseToken(token);
// 3.存入上下文
UserContext.setUser(userId);
// 4.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 清理用户
UserContext.removeUser();
}
}
关于每次请求把token存起来的原因:
- 提高性能,减少重复验证。尤其是在同一请求的多个组件中,而且存储在内存中比在外部存储(如数据库)中检索数据要快得多。
- 上下文共享:可以在整个请求中(服务层、控制层)轻松访问token,而不需要在每个方法中进行传递。
- 存储用户信息:除了token,可能还需要存储用户其他信息(如用户ID、权限等),可以在同一上下文中进行管理
刷新token拦截器
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1、获取请求头中的token
String token = request.getHeader("authorization");
System.out.println("token : " + token);
if (StrUtil.isBlank(token)){
return true;
}
String key = RedisConstants.LOGIN_USER_KEY + token;
//2、基于token获取redis中的用户
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
//3、判断用户是否存在
if (userMap.isEmpty()){
return true;
}
//4、将查询到的hash对象转化为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//5、存在,保存用户信息到ThreadLocal中
UserHolder.saveUser(userDTO);
//6、刷新token有效期
stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//7、放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
使用token的优势
- 避免同源策略
- 可以用来避免CSRF攻击
- 无状态可共享
- 有效期设置及对应有过期策略