前言:
项目需要用户重启浏览器后,还能记录用户登录状态。项目鉴权使用了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
(); 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; }}复制代码