基于传统的Session认证
1. 认证方式
我们知道,http协议本身是一种无状态的协议
,而这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,那么下一次请求时,用户还要再一次进行用户认证才行,因为根据http协议,我们并不能知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,告诉其保存为cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这就是传统的基于session认证。
2. 认证流程
3. 暴露问题
-
每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大
-
用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。
-
因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。
-
在前后端分离系统中就更加痛苦:如下图所示
也就是说前后端分离在应用解耦后增加了部署的复杂性。通常用户一次请求就要转发多次。如果用session 每次携带sessionid 到服务器,服务器还要查询用户信息。同时如果用户很多。这些信息存储在服务器内存中,给服务器增加负担。还有就是CSRF(跨站伪造请求攻击)攻击,session是基于cookie进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。还有就是sessionid就是一个特征值,表达的信息不够丰富。不容易扩展。而且如果你后端应用是多节点部署。那么就需要实现session共享机制。 不方便集群应用。
基于JWT的认证
token的使用流程
JWT的资源很多,我在这里就不在赘述,我主要来写出 token 刷新的问题。
当我们做前后端分离项目
的时候我们通常会使用 token 作为代替 session和 cookie的一种实现方案
,因为 token是无状态
的也可以携带一些数据在payload
中,我们在生成 token的时候一般都会设置一个过期时间
,但是这就会有一些其他的问题,当token过期的时候我们不能直接让用户跳转到登录页面,如果用户在做表单提交操作,用户点击提交发现token过期直接跳转到登录页面,用户心里一万头草泥马奔腾而过,这时就需要引入token的刷新
,当token失效的时候我们需要刷新token,也就是获取新的token,继续执行该操作,但是这次确实是可以执行,但是如何将新生成的 token 响应给前端
,因为用户的其他操作也需要携带新的token才可以正常执行,即便将token返回给了前端,前端也将token存在了请求头中,我们还需要继续执行用户触发了 刷新 token 的请求操作
,我们需要做的就是让用户无感知的进行刷新 token
,并且我们需要判断用户的活跃状态
,如果用户长时间没有进行操作,就让用户重新登录,如果用户只是在一定时间内没有进行操作,就可以给用户生成新的 token , 让用户生成新的token 继续操作,这样给用户的体验才会更好。
问题一:如何刷新 token ?
刷新 token 无非就是生产新的 token ,直接利用工具类生成新的 token 即可,当然在这之前我们先需要判断 原有 token 是否过期。
/**
* JwtToken生成的工具类
* JWT token的格式:header.payload.signature
* header的格式(算法、token的类型):
* {"alg": "HS512","typ": "JWT"}
* payload的格式(用户名、创建时间、生成时间):
* {"sub":"wang","created":1489079981393,"exp":1489684781}
* signature的生成算法:
* HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)
*/
public class JwtTokenUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenUtil.class);
private static final String CLAIM_KEY_USERNAME = "sub";
private static final String CLAIM_KEY_CREATED = "created";
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
@Value("${jwt.tokenHead}")
private String tokenHead;
/**
* 根据负责生成JWT的token
*/
private String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims)
.setExpiration(generateExpirationDate())
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
/**
* 从token中获取JWT中的负载
*/
private Claims getClaimsFromToken(String token) {
Claims claims = null;
try {
claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
LOGGER.info("JWT格式验证失败:{}", token);
}
return claims;
}
/**
* 生成token的过期时间
*/
private Date generateExpirationDate() {
return new Date(System.currentTimeMillis() + expiration * 1000);
}
/**
* 从token中获取登录用户名
*/
public String getUserNameFromToken(String token) {
String username;
try {
Claims claims = getClaimsFromToken(token);
username = claims.getSubject();
} catch (Exception e) {
username = null;
}
return username;
}
/**
* 验证token是否还有效
*
* @param token 客户端传入的token
* @param userDetails 从数据库中查询出来的用户信息
*/
public boolean validateToken(String token, UserDetails userDetails) {
String username = getUserNameFromToken(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
/**
* 判断token是否已经失效
*/
public boolean isTokenExpired(String token) {
Date expiredDate = getExpiredDateFromToken(token);
return expiredDate.before(new Date());
}
/**
* 从token中获取过期时间
*/
private Date getExpiredDateFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.getExpiration();
}
/**
* 根据用户信息生成token
*/
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
/**
* 刷新 token 就是生产新的 token
*
* 因为我们使用的是 结合 springsecurity + jwt 实现的权限验证,所有我直接可以在 springSecurity的上下文直接拿出认证对象生成新的 token
*
* @param oldToken 带tokenHead的token
*/
public String refreshHeadToken(String oldToken) {
this.generateToken((UserDetails)SecurityContextHolder.getContext().getAuthentication());
}
/**
* 判断token在指定时间内是否刚刚刷新过
* @param token 原token
* @param time 指定时间(秒)
*/
private boolean tokenRefreshJustBefore(String token, int time) {
Claims claims = getClaimsFromToken(token);
Date created = claims.get(CLAIM_KEY_CREATED, Date.class);
Date refreshDate = new Date();
//刷新时间在创建时间的指定时间内
if(refreshDate.after(created)&&refreshDate.before(DateUtil.offsetSecond(created,time))){
return true;
}
return false;
}
}
问题二:前端如何接收新的 token
我们在响应给前端数据的时候,如何 遇到 token 过期失效,就先生成新的 token ,通过新的 token 进行执行操作,操作完成后我们就把新生成的 token 携带到响应头中,让前端去判断读取,如何有就将新的 token 去替换旧的 token 。这我们就可通过过滤器进行实现
/**
* JWT登录授权过滤器
*/
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private ThreadLocal<String> threadLocal = new ThreadLocal();
private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String authHeader = request.getHeader(this.tokenHeader);
// 如果请求头携带了 token,并以我们指定的 Bearer 开头
if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer "
// TODO 判断 jwttoken 是否过期? 如果过期如何给它生成新的 token 并且考虑到高并发的问题(在分布式环境下可以采用分布式锁)
// TODO 给出一个实现方案,在线判断是否过期,如果过期根据 SecurityContextHolder.getContext().getAuthentication() 获取到用户的认证信息
// ,生成新的的jwt,继续访问该资源,但是访问完之后但是如何将新的jwt传递给前端
// 1. 判断是否有 token 并且 jwt 已经过期
if(tokenHeader != null && jwtTokenUtil.isTokenExpired(authToken)){
// 生成新的 token
authToken = jwtTokenUtil.refreshHeadToken();
// 将新生成的 token 设置到响应头中
response.setHeader("RefreshToken ",newToken);
}
String username = jwtTokenUtil.getUserNameFromToken(authToken);
LOGGER.info("checking username:{}", username);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(authToken, userDetails)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
LOGGER.info("authenticated user:{}", username);
// 验证成功 将认证对象放入的 SpringSecurity的上下文中
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
chain.doFilter(request, response);
}
}
由于这关于前端,我实在有点不是很熟悉,具体的代码我就不写了,我也写不出来。
问题三:如何根据用户的活跃状态判断是否要用户登录
在用户登录已经要生成一个token
, 我们在生成 token 的同时在生成一个refresh_token
,这个 refresh_token
设置的时间要比 token 的时间要长一点,比如 token
的时间设置为 30 分钟, refresh_token
就可以设置为 7 天,可以将这个 refresh_token
存储在 redis
里面,设置有效时间, 另外每次刷新 token ,生成新 token 的时候都可以将 refresh_token 的过期时间重新设置, 如果用户7天内没有进行操作,就让用户进行重新登录
我看其他的博客,还有一种方式就是生成双token , 把两个token ( token 和 refresh_token refresh_token的时间长于 token)
都传递给前端,前端如果利用 token 访问判断显示过期,判断 refresh_token 是否过期,如果没有过期,进行刷新 token ,如果过期了就强制挑战到登录页面
第二种方式的实现参考