- 越权修改
- Cookie、Session、Token、认证、授权的关系
- 高并发和分布式中的幂等处理
1. 越权修改
1.1 场景
在进行渗透测试的时候,发现了一个越权修改用户密码的例子
在修改密码页面抓包,替换成其他用户的id且用户密码相同的情况下,便可以成功修改该用户密码。由于服务端只将用户的id和旧密码进行了匹配,所以攻击者可以收集大量用户名后,对使用了某些相同弱密码的用户进行批量修改密码
例如:
我们数据库中有
id | password |
---|---|
1 | 123456 |
2 | 123456 |
3 | 1234567 |
我们前台传过来的数据为id=1&oldPassword=123456&newPassword=12345678
,这是只要我们能拦截到该数据,将id改成2就能将id=2的用户密码修改。
1.2 解决方案
从session id 中获取id再进行id与旧密码的匹配。不能直接从用户那获取了id,进行匹配
2 Cookie、Session、Token、认证、授权的关系
Cookie、Session:HTTP是一种无状态的协议,为了分辨链接是谁发起的。引入Cookie和Session,Cookie通过在客户端记录信息确定用户身份,Session通过在服务器端记录信息确定用户身份,这样客户端每次访问服务器都会知道是谁在访问。
认证:就是要证明你是谁,举个例子,你告诉别人你的名字叫Job,怎么样让别人确信你就是Job,这就是Authenticatio。当认证通过后,服务器创建Session,并将认证信息放到Session,客户端创建Cookie,这样下次请求就不需要认证了。
授权:则是当别人已经相信是你以后,你是不是被允不允许做做某件事儿,这就是Authorization。比如,当你已经证明了你就是Job了,你可以随意进你的家门,但是Tom不可以。
Token:就是证明这次访问的是之前登陆的用户,特点:时效性,无状态性
前后端分离具体操作流程如下图所示:
客户端访问地址,如果不是登录会调转到登录界面,发起登录请求,到服务器端认证,认证保存凭证到Session,返回token和凭证,接着就可以调用其他路由了。
2.1 token生成规则
根据登录用户id、当前时间戳和有效时间,进行AES加密,代码如下:
/**
* 生成token
* @param loginId 登录用户id
* @param expires 有效时间 -1->永久有效
* @return token
*/
@SuppressWarnings("unchecked")
public static JSONObject createToken(String loginId, Long expires) {
try {
final String key = "iOV216xn9cQFpd0LI0O80g==";
Long timeStamp = System.currentTimeMillis();
String token = AESUtils.encrypt(StringUtils.join(loginId, "_" , timeStamp, "_", expires), key);
JSONObject authJson = new JSONObject();
authJson.put("timeStamp", timeStamp);
authJson.put("token", token);
authJson.put("expires", expires);
return authJson;
} catch(Exception e) {
if(e instanceof CryptoException) {
LoggerUtils.error(CertificateUtils.class, "创建token,AES加密失败{}", e);
throw new CustomException("AES加密失败");
} else {
LoggerUtils.error(CertificateUtils.class, "创建token异常{}", e);
throw new CustomException("系统异常");
}
}
}
2.2 token校验
在拦截器中获取token,进行AES解密,取出Session中的凭证,比较加密之后的token开头是否是以该凭证id为开头,代码如下:
/**
* 验证token的正确性
* @param token token
* @param loginId 登录用户id
* @return false -> 失败; true -> 成功
*/
public static boolean assertToken(String token, String loginId) {
try {
final String key = "iOV216xn9cQFpd0LI0O80g==";
String data = AESUtils.decrypt(token, key);
if(StringUtils.startsWith(data, StringUtils.join(loginId,"_"))) {
return true;
} else {
return false;
}
} catch(Exception e) {
if(e instanceof CryptoException) {
LoggerUtils.error(CertificateUtils.class, "AES解密失败{}", e);
} else {
LoggerUtils.error(CertificateUtils.class, "系统异常{}", e);
}
return false;
}
}
2.3 token刷新处理
一般为了安全性,token具有时效性,token都会设置一个过期时间,在过期之后就无法请求相关接口了,这时应该怎么办呢,是直接退出登录吗?
Token的刷新目前总结为两种方式:
- 每次请求时判断Token是否超时,若超时则获取新Token
- 每次请求时判断Token是否超时,若超时则跳转到授权页面
后端返回token格式:
编码 | 解析 |
---|---|
timeStamp | 创建token的时间戳 |
expires | 有效时间(秒) |
token | token |
{"rtnCode":10000,"rtnMsg":"SUCCESS","bean":{"token":{"timeStamp":1543400328282,"expires":2400,"token":"fLAo9OAARCzT39p5FSyGS8Xj4ccWrjsKyimHatBenodKJGbfmQ6tY+MjPmtr7XYV"}}}
为了更好的用户体验,我们选择手动刷新token,代码如下:
/**
* 刷新token
* ① 判断token是否过期进行步骤②,否则直接返回config
* ② 过期没有被锁住,刷新token,执行挂起的请求,锁住执行步骤③
* ③ 挂起请求
* @return 没有过期 -> config;过期-> promise
*/
let refreshingTokenLock = false;// false-> 没有刷新,true-> 正在刷新
let handUpRequest = [];
export const doRefreshToken = (config) => {
const tokenData = Session.get(constants.accessCertificate.token);
const pathUri = window.location.pathname;
// 判断是否过期,
if(!isTokenExpired(tokenData) && config.url != '/refreshToken') {
// 判断是否正在刷新
if(!refreshingTokenLock) {
console.info("开始刷新token begin..");
refreshingTokenLock = true;
// 刷新token
http.post("/refreshToken",{"expires":tokenData.expires}).then(result => {
if (result.bean.token == null) {
console.info("后台响应不存在token");
jumpLogin();
return;
}
console.info(`重新获取token${result.bean.token.token},并保存`)
// 保存token
Session.set(constants.accessCertificate.token, result.bean.token);
refreshingTokenLock = false;
// 执行挂起的请求
console.info(`挂起的请求数量为:${handUpRequest.length}`)
handUpRequest.forEach((item,index) => {
item(result.bean.token.token);
});
handUpRequest=[];
console.info("刷新token结束 end..");
});
}
console.info("挂起请求"+config.url);
return new Promise((resolve,reject) => {
handUpRequest.push((token) => {
config.headers[constants.accessCertificate.token] = token;
resolve(config);
});
});
}
config.headers[constants.accessCertificate.token] = tokenData.token;
return config;
}
import axios from 'axios'
import qs from 'qs'
import {Message, MessageBox} from 'element-ui'
import constants from './constants'
import {Session} from './storage'
import login from '@/api/login'
let http = axios.create({
baseURL: 'http://localhost:8081',
timeout: 5000,
headers: {'token': ''}
});
/* 请求 响应拦截器 */
http.interceptors.request.use(function (config) {// 添加请求拦截器
if(window.location.pathname != '/login') {
let c = login.doRefreshToken(config);
return c;
}
return config;
}, function (error) {
return Promise.reject(error);
});
http.interceptors.response.use(function (response) {// 添加响应拦截器
return response.data;
}, function (error) {
Message({
message: error.message,
type: 'error',
duration: 2 * 1000
});
return Promise.reject(error);
});
const handlerError = (result) => {
// 如果响应码是-10000并且uri不等于/login,直接退出重新登陆
if(result.rtnCode === constants.responseStatus.SYS_LOGIN_TIMEOUT ||
result.rtnCode === constants.responseStatus.SYS_LOGIN_TIMEOUT) {
MessageBox({
message: result.rtnMsg,
type: 'warning',
title: '提示',
closeOnClickModal: false,
closeOnPressEscape: false,
showClose: false
}).then(() => {
window.location.href = '/login';
});
return result;
}
if(result.rtnCode !== constants.responseStatus.SUCCESS) {
Message({
message: result.rtnMsg,
type: 'error',
duration: 2 * 1000
});
}
return result;
}
/* http相关请求 */
const get = (url,params) => {// get请求
return http.get(url,params).then(handlerError);
}
const post = (url,params) => {// post请求
return http.post(url,qs.stringify(params)).then(handlerError);
}
export default {
get,
post
}
3 高并发和分布式中的幂等处理
概念
一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同
用通俗的话讲:就是针对一个操作,不管做多少次,产生效果或返回的结果都是一样的
3.1 场景
- 比如前端对同一表单数据的重复提交,后台应该只会产生一个结果
- 比如我们发起一笔付款请求,应该只扣用户账户一次钱,当遇到网络重发或系统bug重发,也应该只扣一次钱
- 比如发送消息,也应该只发一次,同样的短信如果多次发给用户,用户会崩溃
- 比如创建业务订单,一次业务请求只能创建一个,不能出现创建多个订单
3.2 方案
3.2.1 唯一索引,防止新增脏数据
拿资金账户和用户账户来说,每个用户只能有一个资金账户,怎么防止给用户创建资金账户多个,那么给资
金账户表中的用户ID加唯一索引,在新增的时候只有一个能请求成功,剩下都会抛出唯一索引重复异常。比
如org.springframework.dao.DuplicateKeyException
,这时候再查询一次就可以了,数据存在,返回结果
3.2.2 token机制,防止页面重复提交
要求:页面的数据只能被点击提交一次 发生原因:由于重复点击或者网络重发,或者nginx重发等情况会导致数据被重复提交
解决办法: 集群环境:采用token加redis 单JVM环境:采用token加redis或token加jvm内存 处理流程:
数据提交前要向服务的申请token,token放到redis或jvm内存,token有效时间 提交后后台校验token,同时删除token,生成新的token返回
token特点:要申请,一次有效性,可以限流
注意:redis要用删除操作来判断token,删除成功代表token校验通过,如果用select+delete来校验token,
存在并发问题,不建议使用
3.2.3 悲观锁
获取数据的时候加锁获取 select * from table_xxx where id=’xxx’ for update;
注意:id字段一定是主键或者唯一索引,不然是锁表,会出事的。 悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,根据实际情况选用
3.2.4 乐观锁
乐观锁只是在更新数据那一刻锁表,其他时间不锁表,所以相对于悲观锁,效率更高。
乐观锁的实现方式多种多样可以通过version或者其他状态条件:
1.通过版本号实现 update table_xxx set name=#name#,version=version+1 where version=#version#
2.通过条件限制 update table_xxx set avai_amount=avai_amount-#subAmount# where avai_amount-#subAmount# >= 0 要求:avai_amount-subAmount >=0
这个情景适合不用版本号,只更新是做数据安全校验,适合库存模型,扣份额和回滚份额,性能更高。
注意:乐观锁的更新操作,最好用主键或者唯一索引来更新,这样是行锁,否则更新时会锁表,上面两个sql改成下面的两个更好。 update
table_xxx set name=#name#,version=version+1 where id=#id# and
version=#version# update table_xxx set
avai_amount=avai_amount-#subAmount# where id=#id# and
avai_amount-#subAmount# >= 0
3.2.5 分布式锁
还是拿插入数据的例子,如果是分布是系统,构建全局唯一索引比较困难,例如唯一性的字段没法确定,这时候可以引入分布式锁,通过第三方的系统(redis或zookeeper),在业务系统插入数据或者更新数据,获取分布式锁,然后做操作,之后释放锁,其实就是为了控制多线程并发的操作,也是分布式系统中经常用到的解决思路。