前言:
又是普通的一天。最近在搞权限配置,用的是Spring security,token是用的jwt,通过服务中心签发token,业务层进行权限的配置。
问题:
权限这块配置上去了,发现在同时调用多个请求的时候,经常会有1到n个接口报错,报错显示是认证未通过。并且单个接口测试都没问题。
先说最终的问题原因:
由于每次作token校验的时候,会使用jjwt的parse方法进行解析, 解析时需要传入rsa的公钥,而这公钥是需要每次进行临时写入系统文件之后再读取的,由于并发量高(也就几个),对同一文件的多个写入冲突了,导致获得公钥失败,从而导致解析失败。
以下是获得公钥的方法:
public static PublicKey getPublicKey() throws Exception {
ClassPathResource resource = new ClassPathResource("rsa/rsa_public_key");
// 临时目录
String tempPath = System.getProperty("java.io.tmpdir") + System.currentTimeMillis() + ".rsa_public_key";
File f = new File(tempPath);
if (!f.exists()) {
InputStream inputStream = null;
try {
inputStream = resource.getInputStream();
IOUtils.copy(inputStream, new FileOutputStream(f));
inputStream.close();
} catch (IOException e) {
System.out.println("fuckfuckfuck");
e.printStackTrace();
}
}else{
log.info("公钥已存在.公钥目录临时存储路径" + tempPath);
}
return getPublicKey(readFile(f));
}
解决办法:
由于多次对同一文件进行写入,还得写入临时文件,效率还比较低,效率低就算了,还造成了这样的问题,索性改成创建静态变量维护byte[],这样就解决了:
private static byte[] PUBLIC_KEY;
static {
ClassPathResource resource = new ClassPathResource("rsa/rsa_public_key");
// 临时目录
String tempPath = System.getProperty("java.io.tmpdir") + ".rsa_public_key";
File f = new File(tempPath);
if (!f.exists()) {
try {
IOUtils.copy(resource.getInputStream(), new FileOutputStream(f));
} catch (IOException e) {
e.printStackTrace();
}
}
try {
PUBLIC_KEY = readFile(f);
} catch (Exception e) {
e.printStackTrace();
}
}
public static PublicKey getPublicKey() throws Exception {
return getPublicKey(PUBLIC_KEY);
}
问题的定位、解决过程
人生就像一场旅行 不必在意目的地 在乎的是沿途的风景和看风景的心情-利群
同理,相比与一个问题的解决,其实问题的定位、和解决的过程也是很重要的,它能够指导你下次碰到其他问题时也能自己去解决。(这也是为什么我们要不断总结、反思)我在这次走了很多弯路,一度认为自己没法解决了,于是请教同事,才找到了解决问题的路,跟着这条路走,问题果然解决了,那么接下来再走一遍这条路。
我们security里添加了两个filter,应对两种不同的token参数,而这个情况是在用户token的过滤时发生的,看下代码:
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Value("${jwt.header}")
private String token_header;
@Resource
private JwtUtils jwtUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String auth_token = request.getHeader(this.token_header);
// final String auth_token_start = "safept ";
// if (StringUtils.isNotEmpty(auth_token) && auth_token.startsWith(auth_token_start)) {
// auth_token = auth_token.substring(auth_token_start.length());
// }
if (StringUtils.isEmpty(auth_token)) {
// 不按规范,不允许通过验证
chain.doFilter(request, response);
// auth_token = null;
return;
}
String username = jwtUtils.getUsernameFromToken(auth_token);
if (jwtUtils.containToken(username, auth_token) && username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
SysUser userDetail = jwtUtils.getUserFromToken(auth_token);
RequestHolder.addUser(userDetail);
if (jwtUtils.validateToken(auth_token, userDetail)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetail, null, userDetail.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
logger.info(String.format("Authenticated userDetail %s, setting security context", username));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
}
基于之前对security filter链的理解,可以知道,用户登陆认证失败,其实就是这边的authentication没有设置进去导致的,导致其没有set进去可能会是上面的三个判断失败导致:
if (StringUtils.isEmpty(auth_token)) {
// 不按规范,不允许通过验证
chain.doFilter(request, response);
return;
}
token每次传入都正确,所以这边不会是原因。
剩下两个就是上面的两个if:
if (jwtUtils.containToken(username, auth_token) && username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
第三个判断条件不用看,就是当目前无authentication时再set,如果有的话,其实就是已经获得用户登陆认证了。
以及
if (jwtUtils.validateToken(auth_token, userDetail)) {
然后看里面的几个方法:
jwtUtils.containToken(username, auth_token)
public boolean containToken(String userName, String token) {
if (userName != null && tokenMap.containsKey(userName) && tokenMap.get(userName).equals(token)) {
return true;
}
return false;
}
之前维护了一个tokenMap存储用户信息,实际上是没有必要的。并且在负载均衡的情况下可能会出问题,果断把这玩意给去了。
username != null
String username = jwtUtils.getUsernameFromToken(auth_token);
public SysUser getUserFromToken(String token) {
SysUser userDetail;
try {
final Claims claims = getClaimsFromToken(token);
long userId = Long.parseLong(String.valueOf(claims.get(CLAIM_KEY_USER_ID)));
String username = claims.getSubject();
List<String> authNameList = (List<String>) claims.get(CLAIM_KEY_AUTHORITIES);
List<SysAuth> auths = new ArrayList<>();
for (String s : authNameList) {
SysAuth auth = new SysAuth();
auth.setName(s);
auths.add(auth);
}
userDetail = new SysUser();
userDetail.setAuths(auths);
业务代码省略...
} catch (Exception e) {
userDetail = null;
}
return userDetail;
}
再看关键的getClaimsFromToken方法:
private Claims getClaimsFromToken(String token) {
Claims claims;
try {
claims = Jwts.parser()
.setSigningKey(getPublicKey())
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
e.printStackTrace();
claims = null;
}
return claims;
}
这边调用的是jjwt包里的parser方法,通过传入公钥进行token的签名认证,并获得body数据。看起来好像没什么问题,那么继续看getPublicKey()方法:
上面也已经列过了,这里再列一遍
public static PublicKey getPublicKey() throws Exception {
ClassPathResource resource = new ClassPathResource("rsa/rsa_public_key");
// 临时目录
String tempPath = System.getProperty("java.io.tmpdir") + System.currentTimeMillis() + ".rsa_public_key";
File f = new File(tempPath);
if (!f.exists()) {
InputStream inputStream = null;
try {
inputStream = resource.getInputStream();
IOUtils.copy(inputStream, new FileOutputStream(f));
inputStream.close();
} catch (IOException e) {
System.out.println("fuckfuckfuck");
e.printStackTrace();
}
}else{
log.info("公钥已存在.公钥目录临时存储路径" + tempPath);
}
return getPublicKey(readFile(f));
}
可以发现,它是写通过ClassPathResource获得相对路径下的公钥文件,然后写入系统文件里,最终通过readFile方法拿到由公钥转换成的byte[],再调用getPublicKey方法,获得一个序列化的key。而这边的写入,可能就是线程不安全的!
别忘了validateToken方法:
public Boolean validateToken(String token) {
// final Date created = getCreatedDateFromToken(token);
return (!isTokenExpired(token)
// && !isCreatedBeforeLastPasswordReset(created, userDetail.getLastPasswordResetDate())
);
}
private Boolean isTokenExpired(String token) {
final Date expiration = getExpirationDateFromToken(token);
return expiration.before(new Date());
}
public Date getExpirationDateFromToken(String token) {
Date expiration;
try {
final Claims claims = getClaimsFromToken(token);
expiration = claims.getExpiration();
} catch (Exception e) {
expiration = null;
}
return expiration;
}
代码很好理解,最终也是会调用getClaimsFromToken方法去解析token。所以问题的核心就是getPublicKey()方法的线程不安全性。找到症结了,就可以对症下药了,药方已在上面开出。
总结:
碰到问题,要有追根溯源的意识,这点以后还要多多加强。