大家在使用很多网站的过程中,实际上都只需要cookie来管理用户session就够了,然而实际上,笔者认为如果在cookie的基础上,再加入用户的token,两者共同来管理用户的session,那就更加完美了。
以我新上线的网站微思探索为例,我们使用了cookie的同时,也增加了token作为用户的另外一重身份认证机制。
众所周知,cookie存在CSRF窃取session的问题,当然你可以通过在请求http header中加入任意头来防止CSRF攻击,可以将cookie设置为httponly来防止javascript读取。但是笔者的感受是,如果有token的存在会更加完美。
首先我说一下为什么需要token的原因?
- token使用了签名机制,一次性nonce,以及时间戳判断,更加安全
- token本身自带一些用户信息,后端接口加个@CheckToken标注就能拿到用户信息
- token有一定的时效性,目前我们设置为24小时,24小时后需要自动刷新token,保证了更高的安全性
- token更适合于移动端APP开发,也就是说,后端接口同时满足了web端和移动端的需求
- 针对登陆后接口的限流,可以直接在@CheckToken所在的AOP切面中针对用户id进行限流
话不多说,先上token的结构:
uid:用户id,一般是用户身份的主键id
tid:token id, token的标识,一般是一串唯一字符串,我们采用UUID,由后端生成
nonce:一次性nonce,每个请求都会带上唯一的字符串,由前端生成
ts:时间戳,当前服务器时间,由后端返回
hash:签名,base64(HmacSHA256("tid=xxx&&uid=xxx&&ts=xxx&&nonce=xxx"))
当然,可以按照自己的业务情况在token里加入更多字段,比如用户名等等,这样会方便后端接口获取需要的信息。
token一般保存在浏览器的local storage里面,这样浏览器关闭并不会消失。但是如果存在session storage里面,它跟session cookie一样是会在浏览器关闭时随即消失的。有些人可能会疑问放在localstorage里面的安全性问题,毕竟它不像httponly的cookie一样,它是可以被javascript读取的。但是只要网站能做到防XSS攻击,目前前后端框架都能很好地支持防止脚本注入,只要开发过程中留意一些,不会出什么问题。
一旦防止了XSS攻击,token的安全性是很高的。token在前后端传输过程中,tid对应的tokenKey秘钥是不会一起传输的,而且签名用的是HmacSha256,目前作暴力破解是很难的。到此你可能会有疑问,前端作hmac签名的tokenKey从哪里来的?我们是在用户登录的那一刻返回的tokenKey,以后所有的请求都不会再带上这个tokenKey。此刻你可能又会担心如果一旦登录返回那一刻的响应被人拦截,你的tokenKey被窃取,你的session就被劫持了,问题就严重了。放心,我们有措施完全杜绝这种情况,笔者会在下一篇文章“【微思探索】www.wisexplore.com为什么不用https,但是已足够安全”中进行详细阐述。
token中的nonce是前端通过算法生成的尽可能唯一的字符串:
export function nonce (length = 64) {
var nonce,
hash,
ret = '';
for (var i = 0; i < 2; i++) {
nonce = Math.random() * Math.pow(2, 53);
hash = sjcl.hash.sha256.hash(
nonce.toString() + (new Date()).valueOf()
)
ret += sjcl.codec.base64.fromBits(hash);
}
return ret.substr(0, length);
}
所谓尽可能唯一,只是说它重复的概率很小。一次性nonce可以杜绝用户的请求被拦截,然后被重放的攻击。重放攻击有几种情况:
- 拦截请求后,马上重放到服务器
- 拦截请求后,过一段时间再重放到服务器
一次性nonce可以防止第一种情况,但是防止不了第二种情况,这样说的原因是除非后端永久保存nonce的值,所有的nonce在后端都会有记录存在,从而拒绝该非法请求,然而这样做的成本很高,也没有必要。我们往往都会在缓存中将nonce缓存一段时间,比如缓存半小时,在半个小时内我们可以防止重放攻击,但是一旦是第二种情况,半小时后再发起重放攻击,我们是无法防止的。这时候就是时间戳ts发挥作用的时候了。
服务器端在登录时返回的是服务器端的时间戳,跟客户端的时间有可能是不一致的,甚至可能存在不小的偏差,那如何保持客户端传过来的ts跟服务器时间保持同步呢?客户端在登录后收到服务端返回的时间戳serverTime,同时会在localstorage中保存客户端当前时间clientTime,在下一次请求发起时,token中的
ts = serverTime + (currentTime - clientTime) 其中的currentTime是请求发起时客户端的时间
理论上服务器时间和ts的值只会存在很小的偏差,服务器一旦发现传过来的ts跟服务器时间差的比较大,就可以判断为重放攻击,直接拒绝该请求。
综上所述,token的引入可以防止以下攻击:
1.重放攻击
2.伪造攻击,无法伪造是因为攻击者无法获取tokenKey
3.篡改攻击,攻击者无法篡改任何请求参数,还是因为无法获取tokenKey,一旦篡改任何参数,请求的签名hash值就是非法的。
至此,对于用户登录后的任何后端接口的访问,都可以用token保护起来:
@CheckToken
@PostMapping(value="/test")
public Resource test(TokenContext tokenContext){
return null;
}
其中@CheckToken对应于一个AOP切面, 以下是切面中的主要逻辑:
public void checkToken(TokenContext tokenContext) throws Exception {
if (!needCheck) {
return;
}
logger.info("TokenContext uid:{},tid:{},nonce:{},ts:{},hash:{}",
tokenContext.getUid(), tokenContext.getTid(), tokenContext.getNonce(),
tokenContext.getTs(), tokenContext.getHash());
Assert.notNull(tokenContext.getUid(), "uid null");
Assert.notNull(tokenContext.getTid(), "tid null");
Assert.notNull(tokenContext.getNonce(), "nonce null");
Assert.notNull(tokenContext.getTs(), "ts null");
Assert.notNull(tokenContext.getHash(), "hash null");
//针对用户进行限流
Integer visitTimes = shortTermCache.get(tokenContext.getUid(), Integer.class);
Boolean isLockedHalfHour = lockCache.get(tokenContext.getUid(), Boolean.class);
Integer totalVisitTimes = visitCache.get(tokenContext.getUid(), Integer.class);
if (visitTimes == null) {
visitCache.put(tokenContext.getUid(), 1);
if(isLockedHalfHour == null){
shortTermCache.put(tokenContext.getUid(), 1);
} else {
//继续锁定半小时
lockCache.put(tokenContext.getUid(), true);
throw new IdentityValidationException(applicationContext, IdentityExceptionCode.MALICIOUS_REQUEST);
}
} else if (totalVisitTimes >= visitPermitTimes || isLockedHalfHour != null) {
//3分钟内发起超过N次请求,肯定是接口被刷,接口锁定半小时
lockCache.put(tokenContext.getUid(), true);
throw new IdentityValidationException(applicationContext, IdentityExceptionCode.MALICIOUS_REQUEST);
} else {
visitCache.put(tokenContext.getUid(), totalVisitTimes + 1);
}
String wise = executionContext.isMobile() ? request.getHeader(CommonConstants.MOBILE_COOKIE_HEADER)
: (executionContext.isPlugin() ? request.getHeader(CommonConstants.PLUGIN_COOKIE_HEADER)
: CookieUtils.getCookie(request.getHeader("Cookie"), CommonConstants.COOKIE_NAME));
TokenEntity tokenEntity;
//从缓存获取token
Object cachedToken = tokenCache.get(tokenContext.getTid());
if (cachedToken != null) {
tokenEntity = (TokenEntity) ((Cache.ValueWrapper) cachedToken).get();
} else {
tokenEntity = tokenMapper.findTokenByTokenId(tokenContext.getTid());
if (tokenEntity == null) {
logger.error("token not exists");
throw new UnAuthorizedException();
}
if ((tokenEntity.getExpireTime() - Long.valueOf(tokenContext.getTs())) > (HALF_AN_HOUR + 300000)) {
//token还有至少半小时的有效期
tokenCache.put(tokenContext.getTid(), tokenEntity);
}
}
if (tokenEntity == null
|| !tokenContext.getUid().equals(tokenEntity.getOwnerId().toString())) {
logger.error("token null or token ownerId not match");
throw new UnAuthorizedException();
}
//在没有cookie的情况下,safari插件只校验token
if (StringUtils.isEmpty(wise) && !executionContext.isSafariPlugin()) {
//直接置token失效
logger.info("no cookie, set token {} expired", tokenEntity.getTokenId());
tokenService.deleteToken(tokenEntity.getTokenId());
tokenCache.evict(tokenEntity.getTokenId());
throw new IdentityValidationException(applicationContext, IdentityExceptionCode.TOKEN_EXPIRED_NEED_RELOGIN);
}
if (Long.valueOf(tokenContext.getTs()) > tokenEntity.getExpireTime()) {
logger.error("token expired");
//判断cookie是否还在有效期内
if (StringUtils.hasText(wise)) {
CookieToken cookieToken = cookieTokenService.findByCookie(tokenEntity.getOwnerId(), wise);
if (cookieToken == null //一年的cookie再有不足半小时就过期了
|| (cookieToken.getExpireTime().getTime() - dbUtilMapper.now().getTime()) < HALF_AN_HOUR) {
throw new IdentityValidationException(applicationContext, IdentityExceptionCode.TOKEN_EXPIRED_NEED_RELOGIN);
} else {
if (CommonConstants.TOKEN_REFRESH_URL.equals(request.getRequestURI())) {
//token refresh接口,校验token生效的情况下放行
checkTokenValid(tokenContext, tokenEntity);
return;
}
throw new IdentityValidationException(applicationContext, IdentityExceptionCode.TOKEN_EXPIRED_REFRESH_TOKEN);
}
} else if (executionContext.isSafariPlugin()) {
//safari插件请求,token过期,就让其发起refresh token
if (CommonConstants.TOKEN_REFRESH_URL.equals(request.getRequestURI())) {
logger.info("safari plugin token refresh request");
checkTokenValid(tokenContext, tokenEntity);
return;
}
throw new IdentityValidationException(applicationContext, IdentityExceptionCode.TOKEN_EXPIRED_REFRESH_TOKEN);
}
throw new IdentityValidationException(applicationContext, IdentityExceptionCode.TOKEN_EXPIRED_NEED_RELOGIN);
}
checkTokenValid(tokenContext, tokenEntity);
}
private void checkTokenValid(TokenContext tokenContext, TokenEntity tokenEntity) throws UnsupportedEncodingException {
//检测nonce是否有效
Object value = nonceCache.get(tokenContext.getNonce());
if (value == null) {
//nonce或许使用过,但是已经过期
Date date = dbUtilMapper.now();
Long requestTime = Long.valueOf(tokenContext.getTs());
if ((date.getTime() - requestTime) > nonceValidTime * 1000) {
//过期后重放的请求
logger.error("Repeated nonce after expired,now_date:{},Ts:{}", date.getTime(), requestTime);
throw new UnAuthorizedException();
}
nonceCache.put(tokenContext.getNonce(), tokenContext.getNonce());
} else {
//nonce重复
logger.error("Repeated nonce,{}", tokenContext.getNonce());
throw new UnAuthorizedException();
}
String rawString = "tid=" + tokenContext.getTid() + "&&uid=" + tokenContext.getUid() + + "&&ts=" + tokenContext.getTs() + "&&nonce=" + tokenContext.getNonce() ;
String token_key = tokenEntity.getTokenKey();
String created_signature = Base64Utils.encodeToString(HmacSHA256.encodeHmacSHA256(rawString, token_key.getBytes("UTF-8")));
if (!created_signature.equals(tokenContext.getHash())) {
logger.error("hash not valid");
throw new UnAuthorizedException();
}
}
这样对于后端接口的保护来说是很方便的,而且可以方便地对用户请求进行限流。
有没有感觉到有一种安全感?笔者建议在使用cookie的同时,也同时使用token。欢迎跟我交流!
文章已经收录在此 :
【微思探索】创业团队诚邀前端小伙伴加入我们团队,一起参与后续功能的开发。让我们一同创造用户满意的产品。我们的产品很有潜力哦。