关于Session认证的探索
关于Session认证的探索
欢迎关注驿外残香 | HC的博客
在最近的项目中利用了Session进行用户的认证,乘机总结下踩过的坑以及解决方法
使用环境
SpringBoot 2.x, Spring 4.x
碰到的问题
- 利用过滤器验证用户权限时,由于
httpServletRequest
中的输出输入流只能读取一次,导致在过滤器中取出前端发送的请求值,在将其与Sessionuser
进行权限验证后,在业务逻辑层再次调用httpServletRequest
流时抛出异常。 - 由于WebSocket是基于TCP的一种独立实现,跟HTTP没什么关系。在WebSocket进行权限验证时无法拿到Tomcat内置的
session
。
探索路线
问题1
只能读一次的原因
首先要知道为什么httpServletRequest
的流只能读取一次。
调用httpServletRequest.getInputStream()
可以看到获取的流类型为ServletInputStream
,继承InputStream
。
下面复习下InputStream
,InputStream
的read
方法内部有一个postion,标志当前流读取到的位置,每读取一次,位置就会移动一次,如果读到最后,read()
会返回-1,标志已经读取完了。如果想要重新读取则需要重写reset()
方法,当然能否reset是有条件的,它取决于markSupported()
是否返回true。
在InputStream
源码中默认不实现reset()
,并且markSupported()
默认返回false:
public synchronized void reset() throws IOException {
// 调用重新读取则抛出异常
throw new IOException("mark/reset not supported");
}
public boolean markSupported() {
// 不支持重新读取
return false;
}
而查看ServletInputStream
源码可以发现,该类没有重写mark()
,reset()
以及markSupported()
public abstract class ServletInputStream extends InputStream {
protected ServletInputStream() {
}
public int readLine(byte[] b, int off, int len) throws IOException {
if (len <= 0) {
return 0;
} else {
int count = 0;
int c;
while((c = this.read()) != -1) {
b[off++] = (byte)c;
++count;
if (c == 10 || count == len) {
break;
}
}
return count > 0 ? count : -1;
}
}
public abstract boolean isFinished();
public abstract boolean isReady();
public abstract void setReadListener(ReadListener var1);
}
使用HttpServletRequestWrapper
既然ServletInputStream
不支持重新读写,那么为什么不把流读出来后用容器存储起来,后面就可以多次利用了。
所幸Java提供了一个请求包装器 :HttpServletRequestWrapper
基于装饰者模式实现了HttpServletRequest
介面,只需要继承该类并实现你想要重新定义的方法即可。
/**
* 代码来自网络,侵删
*/
public class BodyReaderHttpServletRequestWrapper extends HttpServletRequestWrapper {
private final byte[] body;
BodyReaderHttpServletRequestWrapper(HttpServletRequest request) {
super(request);
String sessionStream = getBodyString(request);
body = sessionStream.getBytes(Charset.forName("UTF-8"));
}
/**
* 获取请求Body
*
* @param request 请求
* @return Body字符串
*/
private String getBodyString(final ServletRequest request) {
StringBuilder sb = new StringBuilder();
InputStream inputStream = null;
BufferedReader reader = null;
try {
inputStream = cloneInputStream(request.getInputStream());
reader = new BufferedReader(new InputStreamReader(inputStream, Charset.forName("UTF-8")));
String line = "";
while ((line = reader.readLine()) != null) {
sb.append(line);
}
}
catch (IOException e) {
e.printStackTrace();
}
finally {
if (inputStream != null) {
try {
inputStream.close();
}
catch (IOException e) {
e.printStackTrace();
}
}
if (reader != null) {
try {
reader.close();
}
catch (IOException e) {
e.printStackTrace();
}
}
}
return sb.toString();
}
/**
* 复制输入流
* @param inputStream 请求输入流
* @return 复制出来的输入流
*/
private InputStream cloneInputStream(ServletInputStream inputStream) {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
try {
while ((len = inputStream.read(buffer)) > -1) {
byteArrayOutputStream.write(buffer, 0, len);
}
byteArrayOutputStream.flush();
}
catch (IOException e) {
e.printStackTrace();
}
return new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
@Override
public ServletInputStream getInputStream() {
final ByteArrayInputStream bais = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override
public int read() {
return bais.read();
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
};
}
}
并且在读取请求流的过滤器中使用该包装器:
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
BodyReaderHttpServletRequestWrapper myRequestWrapper;
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
// 封装请求参数
myRequestWrapper = new BodyReaderHttpServletRequestWrapper(httpServletRequest);
// 从请求包装器中取出json值
int index = new Gson().fromJson(servletRequest.getReader(), InteractionData.class).getIndex();
// 从请求包装器拿出Session属性user
User user = (User) myRequestWrapper.getSession().getAttribute("user");
// 将请求包装器传递下
filterChain.doFilter(myRequestWrapper, servletResponse);
}
这时候就在后面的代码中再次调用流了
问题二
存储Session
既然不能从Tomcat中拿到需要session,那倒可以在用户登陆时先将用户session用Map存储起来,后面便可以在WebSocket中利用Map拿到相应的用户信息。
首先需要一个用来存储Session
的Map容器,使用了单例模式保证有唯一实例,并且使用Map
存储了JSESSIONID
以及相应的HttpSession
:
public class SessionContext {
private static SessionContext instance;
private Map<String, HttpSession> map;
private SessionContext() {
map = new HashMap<>();
}
public static SessionContext getInstance() {
if (instance == null) {
instance = new SessionContext();
}
return instance;
}
synchronized void AddSession(HttpSession session) {
if (session != null) {
map.put(session.getId(), session);
}
}
synchronized void DelSession(HttpSession session) {
if (session != null) {
map.remove(session.getId());
}
}
public synchronized HttpSession getSession(String session_id) {
if (session_id == null) return null;
return map.get(session_id);
}
}
为此需要,一个HttpSession监听器,监听Session的创建与销毁,并相应新增与删除Map集合中的键值对:
@WebListener
public class SessionListener implements HttpSessionListener {
private SessionContext sessionContext = SessionContext.getInstance();
public void sessionCreated(HttpSessionEvent httpSessionEvent) {
log.info("创建Session");
sessionContext.AddSession(httpSessionEvent.getSession());
}
public void sessionDestroyed(HttpSessionEvent httpSessionEvent) {
HttpSession session = httpSessionEvent.getSession();
sessionContext.DelSession(session);
}
}
最后使用一个WebSocket拦截器,在连接之前进行用户身份的认证:
@Service
public class WebSocketInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse,
WebSocketHandler webSocketHandler, Map<String, Object> map) {
log.info("webSocket握手请求...");
if (serverHttpRequest instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) serverHttpRequest;
// 得到放在url后面的JSESSIONID
String jSessionId = servletRequest.getServletRequest().getParameter("JSESSIONID");
// 得到Map单例类实例
SessionContext sessionContext = SessionContext.getInstance();
// 得到用户session
HttpSession httpSession = sessionContext.getSession(jSessionId);
if (null != httpSession) {
User user = (User) httpSession.getAttribute("user");
if (null != user) {
// 通过
return true;
}
}
// 拦截该连接
return false;
}
@Override
public void afterHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse,
WebSocketHandler webSocketHandler, Exception e) {
log.info("webSocket握手结束...");
}
}
请求header认证
若系统使用spring-session-redis
进行Session的管理,可以使用请求header验证
配置HttpSessionConfig配置类:
@EnableRedisHttpSession(maxInactiveIntervalInSeconds= 1800)
public class HttpSessionConfig {
@Bean
public HeaderHttpSessionIdResolver httpSessionStrategy() {
return new HeaderHttpSessionIdResolver("x-auth-token");
}
}
此时可以发现登陆后返回的 header头部信息中含有x-auth-token
参数,将该参数作为请求头文件即可使用session验证。同时该参数为session的id,可以根据redis的spring:session:sessions:+ sessionId
拿去到相应的用户信息。
目前使用存在许多问题,包括每次发起请求服务器都会存储一个session并返回该session的Id,长此以往,redis将被一堆无用session填满。因此还没有找到一个适合的能够替代上面的解决方法,如果有好的方法欢迎留言。
拓展
SpringSession
利用Spring Session可以使得Session脱离Tomcat本地的限制,在分布式系统上可以很轻松地实现Session共享。
JSON Web Token
除了Session认证,JWT认证也不失为一个好方案,能够实现单点登录以及用户认证。
服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样。
{
"userId":1,
"userPhone":1213213123
}
用户与服务器通信时,服务器根据JSON对象判断用户身份,同时为了防止用户用户篡改数据,利用加密算法生成签名,并附在一个字符串中并返回给用户。
利用JWT认证服务器可以不用存储Session数据,减少了内存的负担。但还是存在着许多问题,包括:
- 敏感信息不能使用JWT传递
- JWT是无状态的导致服务器无法及时注销或修改用户信息,一旦JWT签发了,除非服务器部署额外的逻辑,否则在到期之前就会始终有效。
- JWT续签问题
推荐阅读
-
关于扩展 Laravel 默认 Session 中间件导致的 Session 写入失效问题分析
-
ThinkPHP关于session的操作方法汇总
-
Django下关于session的使用
-
hibernate关于session的关闭实例解析
-
浅谈关于axios和session的一些事
-
关于PHP中的SESSION技术
-
关于用户禁用Cookie的解决办法和Session的图片验证码应用
-
MongoDB 3.0 关于安全认证后使用C#调用碰上“System.TimeoutException”类型的异常在 MongoDB.Driver.Core.d
-
关于Iframe如何跨域访问Cookie和Session的解决方法
-
关于HTTP传输中gzip压缩的秘密探索分析