与传统B/S模式的Web系统不同,移动端APP与服务器之间的接口交互一般是C/S模式,这种情况下如果涉及到用户登录的话,就不能像Web系统那样依赖于Web容器来管理Session了,因为APP每发一次请求都会在服务器端创建一个新的Session。而有些涉及到用户隐私或者资金交易的接口又必须确认当前用户登录的合法性,如果没有登录或者登录已过期则不能进行此类操作。
我见过一种“偷懒”的方式,就是在用户第一次登录之后,保存用户的ID在本地存储中,之后跟服务器交互的接口都通过用户ID来标识用户身份。
这种方式主要有两个弊端:
- 只要本地存储的用户ID没有被删掉,就始终可以访问以上接口,不需要重新登录,除非增加有效期的判断或者用户主动退出;
- 接口安全性弱,因为用户ID对应了数据库里的用户唯一标识,别人只要能拿到用户ID或者伪造一个用户ID就可以使用以上接口对该用户进行非法操作。
综上考虑,可以利用缓存在服务器端模拟Session管理机制来解决这个问题,当然这只是目前我所知道的一种比较简单有效的解决APP用户Session的方案。如果哪位朋友有其它好的方案,欢迎在下面留言交流。
这里用的缓存框架是Ehcache,下载地址http://www.ehcache.org/downloads/,当然也可以用Memcached或者其它的。之所以用Ehcache框架,一方面因为它轻量、快速、集成简单等,另一方面它也是Hibernate中默认的CacheProvider,对于已经集成了Hibernate的项目不需要再额外添加Ehcache的jar包了。
有了Ehcache,接着就要在Spring配置文件里添加相应的配置了,配置信息如下:
1 <!-- 配置缓存管理器工厂 --> 2 <bean id="cacheManager" class="org.springframework.cache.ehcache.EhCacheManagerFactoryBean"> 3 <property name="configLocation" value="classpath:ehcache.xml" /> 4 <property name="shared" value="true" /> 5 </bean> 6 <!-- 配置缓存工厂,缓存名称为myCache --> 7 <bean id="ehcache" class="org.springframework.cache.ehcache.EhCacheFactoryBean"> 8 <property name="cacheName" value="myCache" /> 9 <property name="cacheManager" ref="cacheManager" /> 10 </bean>
另外,Ehcache的配置文件ehcache.xml里的配置如下:
1 <?xml version="1.0" encoding="gbk"?> 2 <ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 3 xsi:noNamespaceSchemaLocation="ehcache.xsd"> 4 <diskStore path="java.io.tmpdir" /> 5 6 <!-- 配置一个默认缓存,必须的 --> 7 <defaultCache maxElementsInMemory="10000" eternal="false" timeToIdleSeconds="30" timeToLiveSeconds="30" overflowToDisk="false" /> 8 9 <!-- 配置自定义缓存 maxElementsInMemory:缓存中允许创建的最大对象数 eternal:缓存中对象是否为永久的,如果是,超时设置将被忽略,对象从不过期。 10 timeToIdleSeconds:缓存数据的钝化时间,也就是在一个元素消亡之前, 两次访问时间的最大时间间隔值,这只能在元素不是永久驻留时有效, 11 如果该值是 0 就意味着元素可以停顿无穷长的时间。 timeToLiveSeconds:缓存数据的生存时间,也就是一个元素从构建到消亡的最大时间间隔值, 12 这只能在元素不是永久驻留时有效,如果该值是0就意味着元素可以停顿无穷长的时间。 overflowToDisk:内存不足时,是否启用磁盘缓存。 memoryStoreEvictionPolicy:缓存满了之后的淘汰算法。 --> 13 <cache name="myCache" maxElementsInMemory="10000" eternal="true" overflowToDisk="true" memoryStoreEvictionPolicy="LFU" /> 14 </ehcache>
配置好Ehcache之后,就可以直接通过@Autowired或者@Resource注入缓存实例了。示例代码如下:
1 @Component 2 public class Memory { 3 @Autowired 4 private Cache ehcache; // 注意这里引入的Cache是net.sf.ehcache.Cache 5 6 public void setValue(String key, String value) { 7 ehcache.put(new Element(key, value)); 8 } 9 10 public Object getValue(String key) { 11 Element element = ehcache.get(key); 12 return element != null ? element.getValue() : null; 13 } 14 }
缓存准备完毕,接下来就是模拟用户Session了,实现思路是这样的:
- 用户登录成功后,服务器端按照一定规则生成一个Token令牌,Token是可变的,也可以是固定的(后面会说明);
- 将Token作为key,用户信息作为value放到缓存中,设置有效时长(比如30分钟内没有访问就失效);
- 将Token返回给APP端,APP保存到本地存储中以便请求接口时带上此参数;
- 通过拦截器拦截所有涉及到用户隐私安全等方面的接口,验证请求中的Token参数合法性并检查缓存是否过期;
- 验证通过后,将Token值保存到线程存储中,以便当前线程的操作可以通过Token直接从缓存中索引当前登录的用户信息。
综上所述,APP端要做的事情就是登录并从服务器端获取Token存储起来,当访问用户隐私相关的接口时带上这个Token标识自己的身份。服务器端要做的就是拦截用户隐私相关的接口验证Token和登录信息,验证后将Token保存到线程变量里,之后可以在其它操作中取出这个Token并从缓存中获取当前用户信息。这样APP不需要知道用户ID,它拿到的只是一个身份标识,而且这个标识是可变的,服务器根据这个标识就可以知道要操作的是哪个用户。
对于Token是否可变,处理细节上有所不同,效果也不一样。
- Token固定的情况:服务器端生成Token时将用户名和密码一起进行MD5加密,即MD5(username+password)。这样对于同一个用户而言,每次登录的Token是相同的,用户可以在多个客户端登录,共用一个Session,当用户密码变更时要求用户重新登录;
- Token可变的情况:服务器端生成Token时将用户名、密码和当前时间戳一起MD5加密,即MD5(username+password+timestamp)。这样对于同一个用户而言,每次登录的Token都是不一样的,再清除上一次登录的缓存信息,即可实现唯一用户登录的效果。
为了保证同一个用户在缓存中只有一条登录信息,服务器端在生成Token后,可以再单独对用户名进行MD5作为Seed,即MD5(username)。再将Seed作为key,Token作为value保存到缓存中,这样即便Token是变化的,但每个用户的Seed是固定的,就可以通过Seed索引到Token,再通过Token清除上一次的登录信息,避免重复登录时缓存中保存过多无效的登录信息。
基于Token的Session控制部分代码如下:
1 @Component 2 public class Memory { 3 4 @Autowired 5 private Cache ehcache; 6 7 /** 8 * 关闭缓存管理器 9 */ 10 @PreDestroy 11 protected void shutdown() { 12 if (ehcache != null) { 13 ehcache.getCacheManager().shutdown(); 14 } 15 } 16 17 /** 18 * 保存当前登录用户信息 19 * 20 * @param loginUser 21 */ 22 public void saveLoginUser(LoginUser loginUser) { 23 // 生成seed和token值 24 String seed = MD5Util.getMD5Code(loginUser.getUsername()); 25 String token = TokenProcessor.getInstance().generateToken(seed, true); 26 // 保存token到登录用户中 27 loginUser.setToken(token); 28 // 清空之前的登录信息 29 clearLoginInfoBySeed(seed); 30 // 保存新的token和登录信息 31 String timeout = getSystemValue(SystemParam.TOKEN_TIMEOUT); 32 int ttiExpiry = NumberUtils.toInt(timeout) * 60; // 转换成秒 33 ehcache.put(new Element(seed, token, false, ttiExpiry, 0)); 34 ehcache.put(new Element(token, loginUser, false, ttiExpiry, 0)); 35 } 36 37 /** 38 * 获取当前线程中的用户信息 39 * 40 * @return 41 */ 42 public LoginUser currentLoginUser() { 43 Element element = ehcache.get(ThreadTokenHolder.getToken()); 44 return element == null ? null : (LoginUser) element.getValue(); 45 } 46 47 /** 48 * 根据token检查用户是否登录 49 * 50 * @param token 51 * @return 52 */ 53 public boolean checkLoginInfo(String token) { 54 Element element = ehcache.get(token); 55 return element != null && (LoginUser) element.getValue() != null; 56 } 57 58 /** 59 * 清空登录信息 60 */ 61 public void clearLoginInfo() { 62 LoginUser loginUser = currentLoginUser(); 63 if (loginUser != null) { 64 // 根据登录的用户名生成seed,然后清除登录信息 65 String seed = MD5Util.getMD5Code(loginUser.getUsername()); 66 clearLoginInfoBySeed(seed); 67 } 68 } 69 70 /** 71 * 根据seed清空登录信息 72 * 73 * @param seed 74 */ 75 public void clearLoginInfoBySeed(String seed) { 76 // 根据seed找到对应的token 77 Element element = ehcache.get(seed); 78 if (element != null) { 79 // 根据token清空之前的登录信息 80 ehcache.remove(seed); 81 ehcache.remove(element.getValue()); 82 } 83 } 84 }
Token拦截器部分代码如下:
1 public class TokenInterceptor extends HandlerInterceptorAdapter { 2 @Autowired 3 private Memory memory; 4 5 private List<String> allowList; // 放行的URL列表 6 7 private static final PathMatcher PATH_MATCHER = new AntPathMatcher(); 8 9 @Override 10 public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { 11 // 判断请求的URI是否运行放行,如果不允许则校验请求的token信息 12 if (!checkAllowAccess(request.getRequestURI())) { 13 // 检查请求的token值是否为空 14 String token = getTokenFromRequest(request); 15 response.setContentType(MediaType.APPLICATION_JSON_VALUE); 16 response.setCharacterEncoding("UTF-8"); 17 response.setHeader("Cache-Control", "no-cache, must-revalidate"); 18 if (StringUtils.isEmpty(token)) { 19 response.getWriter().write("Token不能为空"); 20 response.getWriter().close(); 21 return false; 22 } 23 if (!memory.checkLoginInfo(token)) { 24 response.getWriter().write("Session已过期,请重新登录"); 25 response.getWriter().close(); 26 return false; 27 } 28 ThreadTokenHolder.setToken(token); // 保存当前token,用于Controller层获取登录用户信息 29 } 30 return super.preHandle(request, response, handler); 31 } 32 33 /** 34 * 检查URI是否放行 35 * 36 * @param URI 37 * @return 返回检查结果 38 */ 39 private boolean checkAllowAccess(String URI) { 40 if (!URI.startsWith("/")) { 41 URI = "/" + URI; 42 } 43 for (String allow : allowList) { 44 if (PATH_MATCHER.match(allow, URI)) { 45 return true; 46 } 47 } 48 return false; 49 } 50 51 /** 52 * 从请求信息中获取token值 53 * 54 * @param request 55 * @return token值 56 */ 57 private String getTokenFromRequest(HttpServletRequest request) { 58 // 默认从header里获取token值 59 String token = request.getHeader(Constants.TOKEN); 60 if (StringUtils.isEmpty(token)) { 61 // 从请求信息中获取token值 62 token = request.getParameter(Constants.TOKEN); 63 } 64 return token; 65 } 66 67 public List<String> getAllowList() { 68 return allowList; 69 } 70 71 public void setAllowList(List<String> allowList) { 72 this.allowList = allowList; 73 } 74 }
到这里,已经可以在一定程度上确保接口请求的合法性,不至于让别人那么容易伪造用户信息,即便别人通过非法手段拿到了Token也只是临时的,当缓存失效后或者用户重新登录后Token一样无效。如果服务器接口安全性要求更高一些,可以换成SSL协议以防请求信息被窃取。