一、token 与横向越权
场景:
当一个用户验证了密保问题,然后进行修改密码的时候,需要向后台传入用户名与新密码。此时,后台接受用户名和新密码,并在数据库中根据用户名来 update 新密码。但,后台仅仅是这样的话,普通的用户也可以通过接口向后台传其他的用户名,并带上自己设置的密码。
用户与用户处在同一级别,他们之间不合规的操作,再加上后台逻辑的不完整,就容易导致用户与用户之间发生横向越权。
如何解决?
- 在用户验证问题,输入密保答案,提交到后台验证后。后台不单单只返回一个验证成功或失败,后台还要生成一个有 过期时间 的 uuid ,作为 token 保存起来,并返回给前台。
- 前台把用户名、新密码以及从后台传回来的 token 一起再提交给后台,后台对比 token ,然后再对数据库进行操作。
如何实现 ?
比较容易理解的做法就是:在用户名、密码、答案验证通过后,把 username 和生成的 token 做成 map(key-value) 并保存起来。
但是这个 map 保存在哪里比较合适呢?因为这个 token 要设置一个存活时间,在存活时间内才有效,否则,token 要是一直有效的话就可以一直通过验证,成功回答一次问题就一直能改密码了。所以,这个 一次性 的 token 放在数据库中显然是不合适的。
最好的做法是放在缓存中。
二、guava 缓存及改进
1.关于Guava缓存的简单介绍:
-
点此查看 guava 缓存介绍
2.原项目代码中的缓存类 TokenCache 及需要改进的点
(1) 原代码 :
public class TokenCache {
private static Logger logger = LoggerFactory.getLogger(TokenCache.class);
public static final String TOKEN_PREFIX = "token_";
//LRU算法
private static LoadingCache localCache = CacheBuilder.newBuilder()
.initialCapacity(1000).maximumSize(10000).expireAfterAccess(12, TimeUnit.HOURS)
.build(new CacheLoader() {
//默认的数据加载实现,当调用get取值的时候,如果key没有对应的值,就调用这个方法进行加载.
@Override
public String load(String s) throws Exception {
return "null";
}
});
public static void setKey(String key,String value){
localCache.put(key,value);
}
public static String getKey(String key){
String value = null;
try {
value = localCache.get(key);
if("null".equals(value)){
return null;
}
return value;
}catch (Exception e){
logger.error("localCache get error",e);
}
return null;
}
}
(2) 需要改进的点 :
- 重写的加载函数没有必要
原来的写法是:重写的加载函数在查找不到该 key 时,返回 “null” ,注意不是 null,是字符串 “null”,默认的方法是直接返回 null。
我们仔细看下他的逻辑,在返回字符串 “null” 作为 value 后,再用 “null”.equals(value), 如果 true 的话,再返回空指针 null,否则返回value。从这里我们就可以看到了,默认的获取 value 的函数没必要重写,我们直接返回从缓存中获取到的 value,并直接 return value 即可。这和原代码重写了获取 value 函数的最终结果是一样的。 - guava的回收
- expireAfterAccess: 当缓存项在指定的时间段内没有被读或写就会被回收。
- expireAfterWrite:当缓存项在指定的时间段内没有更新就会被回收。
逻辑 :
在 service 层,我们在验证 username question answer 后生成一个 uuid 作为 token ,并把(username,token)put 到 guava 缓存中,然后返回给用户浏览器。用户携带着 username newPassword token 来重置密码,我们首先从 guava 缓存中获取 username 对应的 token,对比两个 token 然后决定是否在数据库中更新。
所以,在整个过程中,token 先被 put,然后 get,以后就不会再使用了。如果我们定的回收时间段为30分钟,那么采用 expireAfterAccess 的话,在被 get 后还将继续在缓存中保留30分钟,这和我们的预期设想是不一样的。如果我们采用 expireAfterWrite ,那么 token 在被 put 进缓存后30分钟就将被回收。这两个做法明显第二种更符合我们的预期。所以,我们需要把原项目中使用的 expireAfterAccess 改为 expireAfterWrite。
此外,还有更进一步的做法:通过此 token 成功修改了密码之后,就可以把此 token 直接清除掉,这样就完全符合我们的预期了。
改进后的代码:
public class TokenCache {
private static Logger logger = LoggerFactory.getLogger(TokenCache.class);
/**
* 添加缓存的前缀
*/
public static final String TOKEN_PREFIX = "token_username_";
private static LoadingCache loadingCache = CacheBuilder.newBuilder()
//初始化的容量
.initialCapacity(1000)
/*最大缓存的数目,当缓存中的数目逼近这个数值时,
guava会使用LRU算法来清理(Least Recently Used 最近最少使用)*/
.maximumSize(10000)
//在被写入缓存30分钟后将会被回收掉
.expireAfterWrite(30, TimeUnit.MINUTES)
//这里使用默认的实现,当查不到该key对应的value时,返回null
.build(new CacheLoader() {
@Override
public String load(String s) throws Exception {
return null;
}
});
/**
* description: 把传来的 用户名 和 token 插入到缓存中
*
* @param key 用户名
* @param value token
* @return void
*/
public static void put(String key, String value) {
loadingCache.put(key + TokenCache.TOKEN_PREFIX, value);
}
/**
* description: 根据用户名 获取对应的token
*
* @param key 用户名
* @return java.lang.String
*/
public static String get(String key) {
String value = null;
try {
value = loadingCache.get(key + TokenCache.TOKEN_PREFIX);
} catch (Exception e) {
logger.error("method: get() : " + e);
}
return value;
}
/**
* 描述:在确定该key对应的token不必再用后, 就清除掉
*
* @param key 用户名作为key
* @return void
*/
public static void invalidateToken(String key) {
try {
loadingCache.invalidate(key + TokenCache.TOKEN_PREFIX);
} catch (Exception e) {
logger.error("TokenCache.invalidateToken Execption",e);
}
}
}
3.关于guava 缓存的深入认识 :
- 深入Guava Cache的refresh和expire刷新机制
- LRU算法的介绍
- guava 源码分析
- LRU原理和Redis实现——一个今日头条的面试题
4. 最后的总结 :
目前我的并发编程基本零基础,在对 guava 缓存的分析中,有刷新、加载、回收机制,这些对于数据的处理非常重要。但是因为这些涉及到并发编程,我甚至连一个测试类都写不出来。
虽然我有想深入了解代码背后的逻辑的想法,甚至会设想出各种情况,但是现在自己的水平还远不够,只能浅尝辄止。接下来做完这个项目之后,要把重心放在基础知识上,毕竟 crud 是做不完的。