利用快取實現APP端與伺服器介面互動的Session控制

無心碼農發表於2015-11-30

與傳統B/S模式的Web系統不同,移動端APP與伺服器之間的介面互動一般是C/S模式,這種情況下如果涉及到使用者登入的話,就不能像Web系統那樣依賴於Web容器來管理Session了,因為APP每發一次請求都會在伺服器端建立一個新的Session。而有些涉及到使用者隱私或者資金交易的介面又必須確認當前使用者登入的合法性,如果沒有登入或者登入已過期則不能進行此類操作。
我見過一種“偷懶”的方式,就是在使用者第一次登入之後,儲存使用者的ID在本地儲存中,之後跟伺服器互動的介面都透過使用者ID來標識使用者身份。

這種方式主要有兩個弊端:

  1. 只要本地儲存的使用者ID沒有被刪掉,就始終可以訪問以上介面,不需要重新登入,除非增加有效期的判斷或者使用者主動退出;
  2. 介面安全性弱,因為使用者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了,實現思路是這樣的:

  1. 使用者登入成功後,伺服器端按照一定規則生成一個Token令牌,Token是可變的,也可以是固定的(後面會說明);
  2. 將Token作為key,使用者資訊作為value放到快取中,設定有效時長(比如30分鐘內沒有訪問就失效);
  3. 將Token返回給APP端,APP儲存到本地儲存中以便請求介面時帶上此引數;
  4. 透過攔截器攔截所有涉及到使用者隱私安全等方面的介面,驗證請求中的Token引數合法性並檢查快取是否過期;
  5. 驗證透過後,將Token值儲存到執行緒儲存中,以便當前執行緒的操作可以透過Token直接從快取中索引當前登入的使用者資訊。

綜上所述,APP端要做的事情就是登入並從伺服器端獲取Token儲存起來,當訪問使用者隱私相關的介面時帶上這個Token標識自己的身份。伺服器端要做的就是攔截使用者隱私相關的介面驗證Token和登入資訊,驗證後將Token儲存到執行緒變數裡,之後可以在其它操作中取出這個Token並從快取中獲取當前使用者資訊。這樣APP不需要知道使用者ID,它拿到的只是一個身份標識,而且這個標識是可變的,伺服器根據這個標識就可以知道要操作的是哪個使用者。

對於Token是否可變,處理細節上有所不同,效果也不一樣。

  1. Token固定的情況:伺服器端生成Token時將使用者名稱和密碼一起進行MD5加密,即MD5(username+password)。這樣對於同一個使用者而言,每次登入的Token是相同的,使用者可以在多個客戶端登入,共用一個Session,當使用者密碼變更時要求使用者重新登入;
  2. 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協議以防請求資訊被竊取。

相關文章