共计 6867 个字符,预计需要花费 18 分钟才能阅读完成。
与传统 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 协议以防请求信息被窃取。
本文永久更新链接地址 :http://www.linuxidc.com/Linux/2016-01/127257.htm