shiro remembeMe 原理分析

lilingyan發表於2019-04-22

前言:

專案需要使用者重啟瀏覽器後,還能記錄使用者登入狀態。專案鑑權使用了shiro框架,發現rememberMe功能剛好可以實現需求。按照教程把功能實現後,順帶閱讀了一下原始碼,在這裡做下閱讀記錄。

必要知識:

眾所周知,前端訪問後端介面後,後端會向前端cookie寫個sessionid作為會話標記。session有效期為這次關閉瀏覽器,所以只要重啟時,儲存下來,就能實現記錄狀態的功能了。

在shiro提供的SecurityManager中,網站開發,我們常用DefaultWebSecurityManager,它繼承於DefaultSecurityManager。DefaultSecurityManager是shiro自帶實現的最基礎但已直接可用的SecurityManager,它包含了shiro所有主要的鑑權流程。

shiro如何記錄使用者狀態:

使用者登陸:

public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
    ...

    onSuccessfulLogin(token, info, loggedIn);

    return loggedIn;
}
複製程式碼

在使用者登入成功後,會有一個後置處理:

protected void onSuccessfulLogin(AuthenticationToken token, AuthenticationInfo info, Subject subject) {
    rememberMeSuccessfulLogin(token, info, subject);
}
複製程式碼

它的內部,就是來向前端cookie中記錄當前登陸狀態,

 protected void rememberMeSuccessfulLogin(AuthenticationToken token, AuthenticationInfo info, Subject subject) {
    RememberMeManager rmm = getRememberMeManager();
    if (rmm != null) {
        try {
            rmm.onSuccessfulLogin(subject, token, info);
        ...
}
複製程式碼

DefaultWebSecurityManager在構造時,預設會設定一個RememberMeManager

public DefaultWebSecurityManager() {
    super();
    ...
    setRememberMeManager(new CookieRememberMeManager());
}
複製程式碼

具體執行cookie記錄(看原始碼註釋: 不管有沒有,先刪除一下,然後判斷現在是否需要rememberMe)

public void onSuccessfulLogin(Subject subject, AuthenticationToken token, AuthenticationInfo info) {
    //always clear any previous identity:
    forgetIdentity(subject);

    //now save the new identity:
    if (isRememberMe(token)) {
        rememberIdentity(subject, token, info);
    ...
}
複製程式碼
  • 刪除cookie的操作,就是把當前key的cookie的maxAge設定為0,然後重新寫回瀏覽器

    public void removeFrom(HttpServletRequest request, HttpServletResponse response) {
        String name = getName();
        String value = DELETED_COOKIE_VALUE;
        String comment = null; //don't need to add extra size to the response - comments are irrelevant for deletions
        String domain = getDomain();
        String path = calculatePath(request);
        int maxAge = 0; //always zero for deletion
        int version = getVersion();
        boolean secure = isSecure();
        boolean httpOnly = false; //no need to add the extra text, plus the value 'deleteMe' is not sensitive at all
    
        addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly);
    
        log.trace("Removed '{}' cookie by setting maxAge=0", name);
    }
    複製程式碼
  • shiro預設是按token實現RememberMeAuthenticationToken這個介面,並設定isRememberMe為true來判斷是否要記錄狀態的。

    1.我們可以讓自己的token實現這個介面

    2.也可以自己寫一個RememberMeManager的實現,重寫isRememberMe,然後替換預設的。

    protected boolean isRememberMe(AuthenticationToken token) {
        return token != null && (token instanceof RememberMeAuthenticationToken) &&
                ((RememberMeAuthenticationToken) token).isRememberMe();
    }
    複製程式碼
  • 前端最終記錄的就是憑證組

    public void rememberIdentity(Subject subject, AuthenticationToken token, AuthenticationInfo authcInfo) {
        PrincipalCollection principals = getIdentityToRemember(subject, authcInfo);
        rememberIdentity(subject, principals);
    }
    複製程式碼
  • shiro會把憑證組序列化後,再加密

    protected byte[] convertPrincipalsToBytes(PrincipalCollection principals) {
        byte[] bytes = serialize(principals);
        if (getCipherService() != null) {
            bytes = encrypt(bytes);
        }
        return bytes;
    }
    複製程式碼
  • 預設使用了AES加密

    public AbstractRememberMeManager() {
        this.serializer = new DefaultSerializer<PrincipalCollection>();
        AesCipherService cipherService = new AesCipherService();
        this.cipherService = cipherService;
        setCipherKey(cipherService.generateNewKey().getEncoded());
    }
    複製程式碼
  • 在最終寫回前端時,shiro還會把加密後的值base64格式化一下,防止一些加密演算法加密出奇怪的值來影響使用

    protected void rememberSerializedIdentity(Subject subject, byte[] serialized) {
    
        ...
    
        //base 64 encode it and store as a cookie:
        String base64 = Base64.encodeToString(serialized);
    
        Cookie template = getCookie(); //the class attribute is really a template for the outgoing cookies
        Cookie cookie = new SimpleCookie(template);
        cookie.setValue(base64);
        cookie.saveTo(request, response);
    }
    複製程式碼

以上,即使瀏覽器重啟,也是會記錄下使用者前一次的登陸資訊了,下次訪問伺服器時,cookie已經帶上了使用者資訊


shiro如何重新讀取使用者狀態

shiro預設會把subject存在當前執行緒中,如果沒有,則會去建立建一個

public Subject createSubject(SubjectContext subjectContext) {
    ...
    //if possible before handing off to the SubjectFactory:
    context = resolvePrincipals(context);

    ...
}
複製程式碼

預設會把subject儲存在session中(也會有快取或者自己寫的儲存機制等),如果沒有,它就會去getRememberedIdentity()方法中獲取

protected SubjectContext resolvePrincipals(SubjectContext context) {

    PrincipalCollection principals = context.resolvePrincipals();

    if (CollectionUtils.isEmpty(principals)) {
        log.trace("No identity (PrincipalCollection) found in the context.  Looking for a remembered identity.");

        principals = getRememberedIdentity(context);

        ...
}
複製程式碼

最終就是從前端cookie中獲取到上面步驟儲存的內容,解密反序列化,得到使用者憑證組資訊(整個邏輯與上面同理相反,就不贅述了)

protected PrincipalCollection getRememberedIdentity(SubjectContext subjectContext) {
    RememberMeManager rmm = getRememberMeManager();
    if (rmm != null) {
        try {
            return rmm.getRememberedPrincipals(subjectContext);
        ...
}
複製程式碼

rememberMe與普通登陸的差別

使用rememberMe的功能時,路徑攔截如果使用authc攔截器,還是會被攔截,需要使用user攔截器才能被通過。

這樣的好處是,可以把重要的,比如說支付之類,需要每次登陸(防止陌生人使用你的電腦),而一些訊息瀏覽的介面(不特別重要),可以讓使用者開啟瀏覽器就能看到

區分攔截的原理:

為何rememberMe的使用者無法訪問authc攔截的內容,只能訪問user攔截的呢!
前文提到,如果當前執行緒沒有subject,shiro會去建立。
預設subject會儲存在session中,並且會有一個標記值authenticated。
而rememberMe的使用者資訊是從cookie中解析出來的,session是剛新建的,裡面沒有登陸標記。
所以最終的subject與登陸後的subject都有憑證資訊,但是登陸標記不一樣。

public Subject createSubject(SubjectContext context) {
    ...
    //從session中獲取登陸標記(獲取不到則為false)
    boolean authenticated = wsc.resolveAuthenticated();
    String host = wsc.resolveHost();
    ServletRequest request = wsc.resolveServletRequest();
    ServletResponse response = wsc.resolveServletResponse();

    return new WebDelegatingSubject(principals, authenticated, host, session, sessionEnabled,
            request, response, securityManager);
}
複製程式碼

shiro儲存在session的登陸標記的預設key

/**
 * The session key that is used to store whether or not the user is authenticated.
 */
public static final String AUTHENTICATED_SESSION_KEY = DefaultSubjectContext.class.getName() + "_AUTHENTICATED_SESSION_KEY";
複製程式碼

authc標記使用的FormAuthenticationFilter攔截器,用了預設的鑑權方法。如果isAuthenticated不是true,就認為沒登陸,所以rememberMe的方式不能通過。

protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
    Subject subject = getSubject(request, response);
    return subject.isAuthenticated();
}
複製程式碼

而user標記使用的UserFilter攔截器,重寫了鑑權方法,它只是判斷了subject中是否有使用者憑證資訊,所以rememberMe的方式才能被通過。

protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
    if (isLoginRequest(request, response)) {
        return true;
    } else {
        Subject subject = getSubject(request, response);
        // If principal is not null, then the user is known and should be allowed access.
        return subject.getPrincipal() != null;
    }
}
複製程式碼

相關文章