Java实现不同用户从不同设备登录的会话管理
有时用户从不同的设备登录服务器,新登录的会话应该要踢掉旧会话。在我们的产品中,用户主要可以通过网页前端登录和从收银机POS登录。用户可以在浏览器和一台POS收银机同时登录,两个会话互不影响。用户在客户端登录前,需要先让POS收银机登录,POS收银机也需要一个会话信息,以便服务器区分不同的POS收银客户端。本文将介绍POS收银机的登录过程和用户在网页端或POS客户端登录后的会话管理,包括:“POS收银机登录”、“用户在网页登录后设置POS会话”、“用户在浏览器、POS收银机不同设备登录后的会话管理”。
1、POS收银机登录。
(1)POS客户端请求服务器的getToken接口,获取RSA公钥的模数和指数。
POS收银机请求服务器,请求携带着自己的posID信息:
//pos GetToken
RequestBody body = new FormBody.Builder()
.add(pos.field.getFIELD_NAME_ID(), String.valueOf(pos.getID()))
.build();
Request request = new Request.Builder()
.url(Configuration.HTTP_IP + "pos/getTokenEx.bx")
.post(body)
.build();
httpEvent.setEventProcessed(false);
httpEvent.setStatus(BaseEvent.EnumEventStatus.EES_Http_ToDo);
HttpRequestUnit hru1 = new PosGetToken();
hru1.setRequest(request);
hru1.setTimeout(TIME_OUT);
hru1.setbPostEventToUI(true);
hru1.setEvent(httpEvent);
HttpRequestManager.getCache(HttpRequestManager.EnumDomainType.EDT_Communication).pushHttpRequest(hru1);
//
long lTimeOut = TIME_OUT;
while (httpEvent.getStatus() != BaseEvent.EnumEventStatus.EES_Http_Done && lTimeOut-- > 0) {
Thread.sleep(1000);
}
if (httpEvent.getStatus() != BaseEvent.EnumEventStatus.EES_Http_Done) {
log.info("pos getToken超时!");
return;
}
if (httpEvent.getLastErrorCode() != ErrorInfo.EnumErrorCode.EC_NoError) {
return;
}
(2)服务器收到客户端的getToken请求,生成RSA公私钥,把公钥的模数和指数发送给客户端。
@RequestMapping(value = "/getTokenEx", produces = "plain/text; charset=UTF-8", method = RequestMethod.POST)
@ResponseBody
protected String getTokenEx(@ModelAttribute("SpringWeb") Pos pos, ModelMap model, HttpSession session) throws Exception {
if (!canCallCurrentAction(session, BaseAction.EnumUserScope.ANYONE.getIndex())) {
logger.debug("无权访问本Action");
return null;
}
logger.info("====Pos token SessionID===" + session.getId());
if (pos.getOperationType() == 0) { // int3为0代表是login操作,需要清空会话,1为其他操作,不需要清空会话
EnumSession.clearAllSession(session);
}
logger.info("POS获取登录密码的公钥,pos" + pos);
logger.info("posBO hash1 = " + posBO.hashCode());
RSAInfo rsa = generateRSA(String.valueOf(pos.getID()));
logger.info("当前的方法名称:" + new Exception().getStackTrace()[0].getMethodName() + "\t\tFiddler的http头的cookie字符串为:\nCookie: JSESSIONID=" + session.getId());
session.setAttribute(EnumSession.SESSION_POS_ID.getName(), String.valueOf(pos.getID()));
Map<String, Object> params = getDefaultParamToReturn(true);
params.put("rsa", rsa);
params.put(BaseAction.JSON_ERROR_KEY, EnumErrorCode.EC_NoError.toString());
params.put(KEY_HTMLTable_Parameter_msg, posBO.getLastErrorMessage());
logger.info("getTokenEx返回的数据=" + params);
return JSONObject.fromObject(params, JsonUtil.jsonConfig).toString();
}
generateRSA生成RSA公私钥,放到RSAInfo类的对象存储。
mapRSA是hashmap结构,存储着本次请求的公私钥信息,key为posID,value为公私钥信息,主要是为了后续的解密客户端的密文密码:
public RSAInfo generateRSA(String id) throws Exception {
HashMap<String, Object> map = RSAUtils.getKeys();
RSAPublicKey publicKey = (RSAPublicKey) map.get("public");
mapRSA.put(id, map);
String modulus = publicKey.getModulus().toString(16);
String exponent = publicKey.getPublicExponent().toString(16);
RSAInfo rsa = new RSAInfo();
rsa.setExponent(exponent);
rsa.setModulus(modulus);
return rsa;
}
(3)客户端收到模数和指数后,对要发送给服务器的登录密码进行加密。
//pos Login
httpEvent.setEventProcessed(false);
Response response = httpEvent.getResponse();
//解析modulus和exponent //...model.parse1
String responseData = httpEvent.getResponseData();
JSONObject jsonObject = new JSONObject(responseData);
String str = jsonObject.getString("rsa");
JSONObject rsaData = new JSONObject(str);
String modulus = rsaData.getString("modulus");
String exponent = rsaData.getString("exponent");
//生成公钥
modulus = new BigInteger(modulus, 16).toString();
exponent = new BigInteger(exponent, 16).toString();
//
RSAPublicKey publicKey = RSAUtils.getPublicKey(modulus, exponent);
//...加密密码
pwdEncrypted = RSAUtils.encryptByPublicKey(pos.getPasswordInPOS(), publicKey);
(4)客户端请求服务器loginEx接口,进行登录,把posID和加密后的密码发给服务器。
//解析Session
Headers headers = response.headers();
List<String> cookies = headers.values("Set-Cookie");
String cookie = cookies.get(0);
GlobalController.getInstance().setSessionID(cookie.substring(0, cookie.indexOf(";")));
log.info("tempSessionID: " + GlobalController.getInstance().getSessionID());
httpEvent.setTempSessionID(GlobalController.getInstance().getSessionID());
httpEvent.setPwdEncrypted(pwdEncrypted);
httpEvent.setStatus(BaseEvent.EnumEventStatus.EES_Http_ToDo);
//
RequestBody loginBody = new FormBody.Builder()
.add(pos.field.getFIELD_NAME_ID(), String.valueOf(pos.getID()))
.add(pos.field.getFIELD_NAME_pwdEncrypted(), pwdEncrypted)
.add(pos.field.getFIELD_NAME_companySN(), Constants.MyCompanySN) //... TODO
//
.add(pos.field.getFIELD_NAME_resetPasswordInPos(), String.valueOf(pos.getResetPasswordInPos()))
.build();
//
Request requestLogin = new Request.Builder()
.url(Configuration.HTTP_IP + "pos/loginEx.bx")
.addHeader(BaseHttpBO.COOKIE, GlobalController.getInstance().getSessionID())
.post(loginBody)
.build();
//
httpEvent.setStatus(BaseEvent.EnumEventStatus.EES_Http_ToDo);
HttpRequestUnit hru = new PosLogin();
hru.setRequest(requestLogin);
hru.setTimeout(TIME_OUT);
hru.setbPostEventToUI(true);
hru.setEvent(httpEvent);
HttpRequestManager.getCache(HttpRequestManager.EnumDomainType.EDT_Communication).pushHttpRequest(hru);
//
while (httpEvent.getStatus() != BaseEvent.EnumEventStatus.EES_Http_Done && lTimeOut-- > 0) {
Thread.sleep(1000);
}
if (httpEvent.getStatus() != BaseEvent.EnumEventStatus.EES_Http_Done) {
log.info("pos login超时!");
return;
}
if (httpEvent.getLastErrorCode() != ErrorInfo.EnumErrorCode.EC_NoError) {
return;
}
} catch (Exception e) {
e.printStackTrace();
httpEvent.setLastErrorCode(ErrorInfo.EnumErrorCode.EC_OtherError); //
log.info("POS登录失败,错误信息:" + e.getMessage());
}
(5)服务器收到请求客户端的login请求。
用私钥解密密文密码,并与数据库的账号密码进行对比,完成登录验证:
public BaseAuthenticationModel login(String dbName, int iUseCaseID, BaseModel bmIn, String sPasswordEncrypted) {
bmIn.setReturnSalt(RETURN_SALT);
DataSourceContextHolder.setDbName(dbName);
BaseModel bm = retrieve1Object(BaseBO.SYSTEM, iUseCaseID, bmIn);
if (getLastErrorCode() != EnumErrorCode.EC_NoError || bm == null) {
lastErrorCode = ErrorInfo.EnumErrorCode.EC_NoSuchData;
return null;
}
if (!isActiveObject(bm)) {
return null;
}
String pwd = decrypt(((BaseAuthenticationModel) bmIn).getKey(), sPasswordEncrypted);
if (pwd == null) {
return null;
}
String md5 = MD5Util.MD5(pwd + BaseAction.SHADOW);
if (md5 == null) {
lastErrorCode = ErrorInfo.EnumErrorCode.EC_OtherError;
return null;
}
if (md5.equals(((BaseAuthenticationModel) bm).getSalt())) {
return (BaseAuthenticationModel) bm;
} else {
lastErrorCode = ErrorInfo.EnumErrorCode.EC_NoSuchData;
}
return null;
}
登录验证通过后,将登录信息保存到session中:
DataSourceContextHolder.setDbName(company.getDbName());
posInOut = (Pos) authenticate(company.getDbName(), BaseBO.INVALID_CASE_ID, posInOut, pos.getPwdEncrypted(), ec);
if (ec.getErrorCode() == EnumErrorCode.EC_NoError && posInOut != null) {
session.setAttribute(EnumSession.SESSION_POS.getName(), posInOut);
Pos posReturn = (Pos) posInOut.clone();
posReturn.clearSensitiveInfo();// 盐值不返回给请求者以免引起安全问题
params.put(KEY_Object, posReturn);
}
2、用户在网页登录后设置POS会话。
用户登录过程在另一篇文章有详细介绍。→《Java使用RSA算法实现安全登录》
用户在网页登录成功后,也会在session设置一个ID为-1的收银机Pos对象存到session中。
这个是为了客户端同步服务器数据而设置,如网页端修改了数据,其它客户端都需要同步这个数据。而POS客户端修改了数据上传到服务器后,其它POS需要同步服务器数据,而自己不需要同步:
//
Pos pos = (Pos) session.getAttribute(EnumSession.SESSION_POS.getName());
if (pos == null) {
pos = new Pos();
pos.setID(BaseAction.INVALID_POS_ID);
session.setAttribute(EnumSession.SESSION_POS.getName(), pos);
}
3、用户在浏览器、POS收银机不同设备登录后的会话管理。
关键的类为 LoginGuard。
LoginGuard有一个内部类LoginDevice,登录设备类,主要存储登录设备数据库表ID和session会话:
public class LoginDevice {
public LoginDevice() {
// 本系统内部的POSID=INVALID_POS_ID=-1时,代表用户在网页端登录。
// POSID>0时,代表用户在POS机登录。
// POSID=0时,代表用户未在网页端和POS机登录。
clientSessionOfWeb = null;
posIDOfPos = 0;
}
protected HttpSession clientSessionOfWeb;
public HttpSession getClientSessionOfWeb() {
return clientSessionOfWeb;
}
public void setClientSessionOfWeb(HttpSession clientSessionOfWeb) {
this.clientSessionOfWeb = clientSessionOfWeb;
}
protected int posIDOfPos;
public int getPosIDOfPos() {
return posIDOfPos;
}
public void setPosIDOfPos(int posIDOfPos) {
this.posIDOfPos = posIDOfPos;
}
}
LoginGuard还定义了一个hashmap变量mapStaffLoginDevice,它的key为公司编号+用户ID,value为LoginDevice:
protected Map<String, LoginDevice> mapStaffLoginDevice;
用户登录后,会进入LoginGurad的login方法:
/**
* 用户(不是POS)登录成功后,可能存在其它同类设备的登录会话,需要用本次登录会话替换掉旧的登录会话。旧的登录会话在发送请求到服务器时将被阻止
* @param staffID 当前登录的用户ID
* @param posID 当前登录的POSID。如果是BaseAction.INVALID_POS_ID,表明是网页端登录。如果是>0,表明是在POS端登录
* @param session 用户登录会话的对象,用来区分不同的连接及其浏览器/设备
*/
public void login(String companySN, int staffID, int posID, HttpSession session) {
if ((posID != BaseAction.INVALID_POS_ID && posID <= 0) || staffID <= 0) {
return;
}
lock.writeLock().lock();
LoginDevice currentLoginDevice = mapStaffLoginDevice.get(generateKey(companySN, staffID));
if (currentLoginDevice != null) {
if (posID == BaseAction.INVALID_POS_ID) {
if (currentLoginDevice.getClientSessionOfWeb() != null && currentLoginDevice.getClientSessionOfWeb().hashCode() != session.hashCode()) {
logger.debug("踢走旧的网页端登录会话,old session=" + currentLoginDevice.getClientSessionOfWeb());
}
currentLoginDevice.setClientSessionOfWeb(session);
} else {
if (currentLoginDevice.getPosIDOfPos() != posID) {
logger.debug("踢走旧的POS端登录会话,old posID=" + currentLoginDevice.getPosIDOfPos());
}
currentLoginDevice.setPosIDOfPos(posID);
}
} else {
// 之前未有任何登录操作,网页,pos都未登录过
LoginDevice ld = new LoginDevice();
if (posID == BaseAction.INVALID_POS_ID) {
ld.setClientSessionOfWeb(session); // 网页端登录
} else {
ld.setPosIDOfPos(posID); // POS机上登录
}
mapStaffLoginDevice.put(generateKey(companySN, staffID), ld);
}
lock.writeLock().unlock();
}
login方法首先判断用户有没有登录过。
如果没有登录过,那么就新建一个LoginDevice类把posID、会话信息存起来,然后放到mapStaffLoginDevice缓存。
如果之前有登录过,那么:
如果是网页登录,判断当前的session与在缓存的mapStaffLoginDevice的session是否一致(通过hashcode比较),不一致的话就更新session(踢掉旧会话)。
如果是在POS收银机登录,判断用户在之前有无登录其它POS机。如果有,则更新用户在当前设备登录。
等下一次用户携带session会话访问服务器时,就会进入LoginGuard的needToIntercept方法:
/**
* 在拦截器中检查此请求是否为旧的用户会话发出的请求。
* @param staffID 当前登录的用户ID
* @param posID 当前登录的POSID。如果是BaseAction.INVALID_POS_ID,表明是网页端登录。如果是>0,表明是在POS端登录
* @param session 用户登录会话的对象,用来区分不同的连接及其浏览器/设备
* @return true,拦截用户的请求,返回错误信息;false,允许用户继续正常操作
*/
public boolean needToIntercept(String companySN, int staffID, int posID, HttpSession session) {
if ((posID != BaseAction.INVALID_POS_ID && posID <= 0) || staffID <= 0) {
return true;
}
boolean needToIntercept = false;
lock.writeLock().lock();
LoginDevice currentLoginDevice = mapStaffLoginDevice.get(generateKey(companySN, staffID));
if (currentLoginDevice != null) {
if (posID == BaseAction.INVALID_POS_ID) {// 网页端登录
if (currentLoginDevice.getClientSessionOfWeb().hashCode() != session.hashCode()) {
needToIntercept = true;
}
} else {// POS机上登录
if (currentLoginDevice.getPosIDOfPos() != posID) {
needToIntercept = true;
}
}
}
lock.writeLock().unlock();
return needToIntercept;
}
needToIntercept方法从mapStaffLoginDevice缓存中获取session。如果是网页登录,判断当前session会话与缓存中的session会话是否一致,不一致的则返回true,表示需要拦截当前请求。
如果是POS收银机登录,判断当前登录的POS设备(通过posID判断)是否与mapStaffLoginDevice缓存的一致。不一致的则返回true,表示需要拦截当前请求。
返回true后,告诉请求这需要重新登录,代码为:
if (loginGuard.needToIntercept(company.getSN(), staff.getID(), posID, session)) {
String str = null;
if (posID == BaseAction.INVALID_POS_ID) { // 这是网页端上的操作
// 判断是否为ajax请求
if (httpRequest.getHeader("x-requested-with") != null) {
httpResponse.addHeader("staffLoginUrl", loginUrl);// 返回url
httpResponse.addHeader("sessionStatus", "duplicatedSession"); // 返回单点登录标识
str = "<script language='window.top.location.href='" + loginUrl + "';</script>";// ......
} else {
str = "<script language='javascript'>alert('你已经在其它地方登录'); window.top.location.href='" + loginUrl + "';</script>";
}
} else { // 这是POS端上的操作
Map<String, Object> params = new HashMap<String, Object>();
params.put(BaseAction.JSON_ERROR_KEY, EnumErrorCode.EC_DuplicatedSession.toString());
params.put(BaseAction.KEY_HTMLTable_Parameter_msg, "你已经在其它地方登录");
str = JSONObject.fromObject(params, JsonUtil.jsonConfig).toString();
}
response.setContentType("text/html;charset=UTF-8");
try {
PrintWriter writer = response.getWriter();
writer.write(str);
writer.flush();
writer.close();
} catch (Exception e) {
e.printStackTrace();
}
return false;
}

浙公网安备 33010602011771号