利用緩存實現APP端與服務器接口交互的Session控制

與傳統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配置文件裏添加相應的配置了,配置信息如下:

<!-- 配置緩存管理器工廠 -->
<bean id="cacheManager" class="org.springframework.cache.ehcache.EhCacheManagerFactoryBean">
    <property name="configLocation" value="classpath:ehcache.xml" />
    <property name="shared" value="true" />
</bean>
<!-- 配置緩存工廠,緩存名稱爲myCache -->
<bean id="ehcache" class="org.springframework.cache.ehcache.EhCacheFactoryBean">
    <property name="cacheName" value="myCache" />
    <property name="cacheManager" ref="cacheManager" />
</bean>

另外,Ehcache的配置文件ehcache.xml裏的配置如下:

<?xml version="1.0" encoding="gbk"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="ehcache.xsd">
    <diskStore path="java.io.tmpdir" />

    <!-- 配置一個默認緩存,必須的 -->
    <defaultCache maxElementsInMemory="10000" eternal="false" timeToIdleSeconds="30" timeToLiveSeconds="30" overflowToDisk="false" />

    <!-- 配置自定義緩存 maxElementsInMemory:緩存中允許創建的最大對象數 eternal:緩存中對象是否爲永久的,如果是,超時設置將被忽略,對象從不過期。 
        timeToIdleSeconds:緩存數據的鈍化時間,也就是在一個元素消亡之前, 兩次訪問時間的最大時間間隔值,這只能在元素不是永久駐留時有效, 
        如果該值是 0 就意味着元素可以停頓無窮長的時間。 timeToLiveSeconds:緩存數據的生存時間,也就是一個元素從構建到消亡的最大時間間隔值, 
        這只能在元素不是永久駐留時有效,如果該值是0就意味着元素可以停頓無窮長的時間。 overflowToDisk:內存不足時,是否啓用磁盤緩存。 memoryStoreEvictionPolicy:緩存滿了之後的淘汰算法。 -->
    <cache name="myCache" maxElementsInMemory="10000" eternal="true" overflowToDisk="true" memoryStoreEvictionPolicy="LFU" />
</ehcache>

配置好Ehcache之後,就可以直接通過@Autowired或者@Resource注入緩存實例了。示例代碼如下:

@Component
public class Memory {
    @Autowired
    private Cache ehcache; // 注意這裏引入的Cache是net.sf.ehcache.Cache

    public void setValue(String key, String value) {
        ehcache.put(new Element(key, value));
    }

    public Object getValue(String key) {
        Element element = ehcache.get(key);
        return element != null ? element.getValue() : null;
    }
}

緩存準備完畢,接下來就是模擬用戶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控制部分代碼如下:

@Component
public class Memory {

    @Autowired
    private Cache ehcache;

    /**
     * 關閉緩存管理器
     */
    @PreDestroy
    protected void shutdown() {
        if (ehcache != null) {
            ehcache.getCacheManager().shutdown();
        }
    }

    /**
     * 保存當前登錄用戶信息
     * 
     * @param loginUser
     */
    public void saveLoginUser(LoginUser loginUser) {
        // 生成seed和token值
        String seed = MD5Util.getMD5Code(loginUser.getUsername());
        String token = TokenProcessor.getInstance().generateToken(seed, true);
        // 保存token到登錄用戶中
        loginUser.setToken(token);
        // 清空之前的登錄信息
        clearLoginInfoBySeed(seed);
        // 保存新的token和登錄信息
        String timeout = getSystemValue(SystemParam.TOKEN_TIMEOUT);
        int ttiExpiry = NumberUtils.toInt(timeout) * 60; // 轉換成秒
        ehcache.put(new Element(seed, token, false, ttiExpiry, 0));
        ehcache.put(new Element(token, loginUser, false, ttiExpiry, 0));
    }

    /**
     * 獲取當前線程中的用戶信息
     * 
     * @return
     */
    public LoginUser currentLoginUser() {
        Element element = ehcache.get(ThreadTokenHolder.getToken());
        return element == null ? null : (LoginUser) element.getValue();
    }

    /**
     * 根據token檢查用戶是否登錄
     * 
     * @param token
     * @return
     */
    public boolean checkLoginInfo(String token) {
        Element element = ehcache.get(token);
        return element != null && (LoginUser) element.getValue() != null;
    }

    /**
     * 清空登錄信息
     */
    public void clearLoginInfo() {
        LoginUser loginUser = currentLoginUser();
        if (loginUser != null) {
            // 根據登錄的用戶名生成seed,然後清除登錄信息
            String seed = MD5Util.getMD5Code(loginUser.getUsername());
            clearLoginInfoBySeed(seed);
        }
    }

    /**
     * 根據seed清空登錄信息
     * 
     * @param seed
     */
    public void clearLoginInfoBySeed(String seed) {
        // 根據seed找到對應的token
        Element element = ehcache.get(seed);
        if (element != null) {
            // 根據token清空之前的登錄信息
            ehcache.remove(seed);
            ehcache.remove(element.getValue());
        }
    }
}

Token攔截器部分代碼如下:

public class TokenInterceptor extends HandlerInterceptorAdapter {
    @Autowired
    private Memory memory;

    private List<String> allowList; // 放行的URL列表

    private static final PathMatcher PATH_MATCHER = new AntPathMatcher();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 判斷請求的URI是否運行放行,如果不允許則校驗請求的token信息
        if (!checkAllowAccess(request.getRequestURI())) {
            // 檢查請求的token值是否爲空
            String token = getTokenFromRequest(request);
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setCharacterEncoding("UTF-8");
            response.setHeader("Cache-Control", "no-cache, must-revalidate");
            if (StringUtils.isEmpty(token)) {
                response.getWriter().write("Token不能爲空");
                response.getWriter().close();
                return false;
            }
            if (!memory.checkLoginInfo(token)) {
                response.getWriter().write("Session已過期,請重新登錄");
                response.getWriter().close();
                return false;
            }
            ThreadTokenHolder.setToken(token); // 保存當前token,用於Controller層獲取登錄用戶信息
        }
        return super.preHandle(request, response, handler);
    }

    /**
     * 檢查URI是否放行
     * 
     * @param URI
     * @return 返回檢查結果
     */
    private boolean checkAllowAccess(String URI) {
        if (!URI.startsWith("/")) {
            URI = "/" + URI;
        }
        for (String allow : allowList) {
            if (PATH_MATCHER.match(allow, URI)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 從請求信息中獲取token值
     * 
     * @param request
     * @return token值
     */
    private String getTokenFromRequest(HttpServletRequest request) {
        // 默認從header裏獲取token值
        String token = request.getHeader(Constants.TOKEN);
        if (StringUtils.isEmpty(token)) {
            // 從請求信息中獲取token值
            token = request.getParameter(Constants.TOKEN);
        }
        return token;
    }

    public List<String> getAllowList() {
        return allowList;
    }

    public void setAllowList(List<String> allowList) {
        this.allowList = allowList;
    }
}

到這裏,已經可以在一定程度上確保接口請求的合法性,不至於讓別人那麼容易僞造用戶信息,即便別人通過非法手段拿到了Token也只是臨時的,當緩存失效後或者用戶重新登錄後Token一樣無效。如果服務器接口安全性要求更高一些,可以換成SSL協議以防請求信息被竊取。
原文地址:http://www.cnblogs.com/liujiduo/p/5007043.html

發佈了75 篇原創文章 · 獲贊 71 · 訪問量 49萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章