與傳統B/S模式的Web系統不同,移動端APP與服務器之間的接口交互一般是C/S模式,這種情況下如果涉及到用戶登錄的話,就不能像Web系統那樣依賴于Web容器來管理session了,因為APP每發一次請求都會在服務器端創建一個新的Session。而有些涉及到用戶隱私或者資金交易的接口又必須確認當前用戶登錄的合法性,如果沒有登錄或者登錄已過期則不能進行此類操作。
我見過一種“偷懶”的方式,就是在用戶第一次登錄之后,保存用戶的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了,實現思路是這樣的:
綜上所述,APP端要做的事情就是登錄并從服務器端獲取Token存儲起來,當訪問用戶隱私相關的接口時帶上這個Token標識自己的身份。服務器端要做的就是攔截用戶隱私相關的接口驗證Token和登錄信息,驗證后將Token保存到線程變量里,之后可以在其它操作中取出這個Token并從緩存中獲取當前用戶信息。這樣APP不需要知道用戶ID,它拿到的只是一個身份標識,而且這個標識是可變的,服務器根據這個標識就可以知道要操作的是哪個用戶。
對于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 @PreDestroy11 protected void shutdown() {12 if (ehcache != null) {13 ehcache.getCacheManager().shutdown();14 }15 }16 17 /**18 * 保存當前登錄用戶信息19 * 20 * @param loginUser21 */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 * @return41 */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 token51 * @return52 */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 seed74 */75 public void clearLoginInfoBySeed(String seed) {76 // 根據seed找到對應的token77 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 @Override10 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 URI37 * @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 request55 * @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協議以防請求信息被竊取。
新聞熱點
疑難解答