shiro remembeMe 原理分析

2,422 阅读5分钟

前言:

项目需要用户重启浏览器后,还能记录用户登录状态。项目鉴权使用了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;
    }
}