shiro学习总结笔记(混乱版)

shiro登录参考链接

https://juejin.cn/post/6880872387416588295

https://juejin.cn/post/6881493214650433549

shiro学习参考链接

https://www.cnblogs.com/bingfengdev/p/13768829.html

https://www.jianshu.com/p/c15da4e85734

https://www.w3cschool.cn/shiro/co4m1if2.html

关于加盐和MD5加密:

https://juejin.cn/post/6879761189845991438

shiro理解

主体是subject,注入securityManagement来管理,在securityManagement中注入自己实现的Realm来获取用户信息进行验证相关操作

在 shiro 中,用户需要提供 principals (身份)和 credentials(证明)给 shiro,从而应用能验证用户身份:

principals:身份,即主体的标识属性,一般是用户名 / 密码 / 手机号。

credentials:证明 / 凭证,即只有主体知道的安全值,如密码 / 数字证书等。

最常见的 principalscredentials 组合就是用户名 / 密码了。

SubjectRealm,分别是主体及验证主体的数据源。

subject.login 方法进行登录,其会自动委托给 SecurityManager.login 方法进行登录;

subject.logout 退出,其会自动委托给 SecurityManager.logout 方法退出。

Realm:域,ShiroRealm 获取安全数据(如用户、角色、权限),就是说 SecurityManager 要验证用户身份,那么它需要从 Realm 获取相应的用户进行比较以确定用户身份是否合法;也需要从 Realm 得到用户相应的角色 / 权限进行验证用户是否能进行操作;可以把 Realm 看成 DataSource,即安全数据源。

String getName(); //返回一个唯一的Realm名字
  boolean supports(AuthenticationToken token); //判断此Realm是否支持此类型的Token
  AuthenticationInfo getAuthenticationInfo(AuthenticationToken token)
  throws AuthenticationException;  //根据Token获取认证信息

自定义Realm需要继承AuthorizingRealm 需要重写两个方法。doGetAuthenticationInfo方法是通过查询数据库的用户信息,返回一个SimpleAuthenticationInfo来实现登陆信息认证。

protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
  CredentialsMatcher cm = this.getCredentialsMatcher();
  if (cm != null) {
      if (!cm.doCredentialsMatch(token, info)) {
          String msg = "Submitted credentials for token [" + token + "] did not match the expected credentials.";
          throw new IncorrectCredentialsException(msg);
      }
  } else {
      throw new AuthenticationException("A CredentialsMatcher must be configured in order to verify credentials during authentication.  If you do not wish for credentials to be examined, you can configure an " + AllowAllCredentialsMatcher.class.getName() + " instance.");
  }

这是AuthenticatingRealm的一个方法,入参示是登陆时前端传过来的登陆信息和我们自定义Realm返回的信息。通过 this.getredentialMatcher() 获取到一个CredentialsMatcher对象,如果我们不设置的话,默认是使用的SimpleCredentialsMatcher。进入到doCredentialsMatch方法:

protected boolean equals(Object tokenCredentials, Object accountCredentials) {
if (log.isDebugEnabled()) {
 log.debug("Performing credentials equality check for tokenCredentials of type [" + tokenCredentials.getClass().getName() + " and accountCredentials of type [" + accountCredentials.getClass().getName() + "]");
}

if (this.isByteSource(tokenCredentials) && this.isByteSource(accountCredentials)) {
 if (log.isDebugEnabled()) {
     log.debug("Both credentials arguments can be easily converted to byte arrays.  Performing array equals comparison");
 }

 byte[] tokenBytes = this.toBytes(tokenCredentials);
 byte[] accountBytes = this.toBytes(accountCredentials);
 return MessageDigest.isEqual(tokenBytes, accountBytes);
} else {
 return accountCredentials.equals(tokenCredentials);
}
}

public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
Object tokenCredentials = this.getCredentials(token);
Object accountCredentials = this.getCredentials(info);
return this.equals(tokenCredentials, accountCredentials);
}

项目数据库是存储的加密的密码,使用的是BCryptPasswordEncoder来进行hash加密的,是需要用他的BCryptPasswordEncoder::matches(String plainPass,String encodePass) 来验证密码,所以可以自己重写一个matcher,代码如下:

public class CustomerCredentialsMatcher implements CredentialsMatcher {

PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

@Override
public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {
//前端传来的密码
String currentPass = String.copyValueOf((char[]) authenticationToken.getCredentials());
//数据库密码
String dbPass = (String) authenticationInfo.getCredentials();
return passwordEncoder.matches(currentPass,dbPass);
}

}

在自己的Realm里设置一下我们自己的matcher:

public class CustRealm extends AuthorizingRealm {
...
@Override
public void setCredentialsMatcher(CredentialsMatcher credentialsMatcher) {
  CustomerCredentialsMatcher customerCredentialsMatcher = new CustomerCredentialsMatcher();
  super.setCredentialsMatcher(customerCredentialsMatcher);
}
...
}

自定义realm

  • ​ 授权;doGetAuthorizationInfo方法是在我们调用;SecurityUtils.getSubject().isPermitted()这个方法,授权后用户角色及权限会保存在缓存中的

  • 认证,登录,doGetAuthenticationInfo这个方法是在用户登录的时候调用的;也就是执行SecurityUtils.getSubject().login()的时候调用;(即:登录验证),验证通过后会用户保存在缓存中的

doGetAuthenticationInfo方法里最后返回SimpleAuthenticationInfo对象,生成 AuthenticationInfo 信息,交给间接父类 AuthenticatingRealm 使用 CredentialsMatcher 进行判断密码是否匹配,需要传入:身份信息(用户名)、凭据(密文密码)、盐(username+salt),CredentialsMatcher 使用盐加密传入的明文密码和此处的密文密码进行匹配。

new SimpleAuthenticationInfo(user, user.getPassWord(), credentialsSalt, this.getClass().getName());
// 第三个参数一般可以是ByteSource.Util.bytes(shiroUser.getUserName()+shiroPasswordService.getPublicSalt())

在父类AuthenticatingRealm中的getAuthenticationInfo中会调用自定义realmdoGetAuthenticationInfo方法获取对应的SimpleAuthenticationInfo对象,然后调用this.assertCredentialsMatch(token, info);来比对token里的用户信息和SimpleAuthenticationInfo中得到的数据库里用户信息,在该方法里调用getCredentialsMatcher方法去获取对应的匹配算法,然后执行cm.doCredentialsMatch(token, info)进行实际的比对

AuthenticatingRealm中的getAuthenticationInfo方法在什么时候执行呢?(该方法的回调一般是通过subject.login(token)方法来实现的)

AuthenticatingFilter在执行executeLogin方法的时候会执行AuthenticationToken token = this.createToken(request, response);来生成token,这里可以继承该filter来实现自定义的生成token的方法也可以使用默认的,然后调用

Subject subject = this.getSubject(request, response);
subject.login(token);

subject.login(token)里会调用AuthenticatingRealm中的getAuthenticationInfo方法,在该方法里调用自定义realmdoGetAuthenticationInfo方法获取对应的SimpleAuthenticationInfo对象

Shiro 拦截器机制

image

  • ShiroFilter 是整个 Shiro 的入口点,用于拦截需要安全控制的请求进行处理

  • AdviceFilter 提供了 AOP 风格的支持,类似于 SpringMVC 中的 Interceptor:

    boolean preHandle(ServletRequest request, ServletResponse response) throws Exception
    void postHandle(ServletRequest request, ServletResponse response) throws Exception
    void afterCompletion(ServletRequest request, ServletResponse response, Exception exception) throws Exception;
    
    • preHandler:类似于 AOP 中的前置增强;在拦截器链执行之前执行;如果返回 true 则继续拦截器链;否则中断后续的拦截器链的执行直接返回;进行预处理(如基于表单的身份验证、授权)
    • postHandle:类似于 AOP 中的后置返回增强;在拦截器链执行完成后执行;进行后处理(如记录执行时间之类的);
    • afterCompletion:类似于 AOP 中的后置最终增强;即不管有没有异常都会执行;可以进行清理资源(如解除 Subject 与线程的绑定之类的);
  • AccessControlFilter 提供了访问控制的基础功能;比如是否允许访问/当访问拒绝时如何处理等:

    上边的AuthenticatingFilter继承的AuthenticationFilter父类就是继承的AccessControlFilter,在该抽象类里定义了getSubject方法通过SecurityUtils.getSubject();实现

    abstract boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception;
    boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception;
    abstract boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception;
    

    isAccessAllowed:表示是否允许访问;mappedValue 就是[urls]配置中拦截器参数部分,如果允许访问返回 true,否则 false;

    onAccessDenied:表示当访问拒绝时是否已经处理了;如果返回 true 表示需要继续处理;如果返回 false 表示该拦截器实例已经处理了,将直接返回即可。

    onPreHandle 会自动调用这两个方法决定是否继续处理;

    AccessControlFilter 还提供了如下方法用于处理如登录成功后/重定向到上一个请求:

    void setLoginUrl(String loginUrl) //身份验证时使用,默认/login.jsp
    String getLoginUrl()
    Subject getSubject(ServletRequest request, ServletResponse response) //获取Subject 实例
    boolean isLoginRequest(ServletRequest request, ServletResponse response)//当前请求是否是登录请求
    void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException //将当前请求保存起来并重定向到登录页面
    void saveRequest(ServletRequest request) //将请求保存起来,如登录成功后再重定向回该请求
    void redirectToLogin(ServletRequest request, ServletResponse response) //重定向到登录页面
    

    如果我们想进行访问访问的控制就可以继承 AccessControlFilter;如果我们要添加一些通用数据我们可以直接继承 PathMatchingFilter

拦截器链

Shiro 对 Servlet 容器的 FilterChain 进行了代理,即 ShiroFilter 在继续 Servlet 容器的 Filter 链的执行之前,通过 ProxiedFilterChain 对 Servlet 容器的 FilterChain 进行了代理;即先走 Shiro 自己的 Filter 体系,然后才会委托给 Servlet 容器的 FilterChain 进行 Servlet 容器级别的 Filter 链执行;Shiro 的 ProxiedFilterChain 执行流程:1、先执行 Shiro 自己的 Filter 链;2、再执行 Servlet 容器的 Filter 链(即原始的 Filter)。

ProxiedFilterChain 是通过 FilterChainResolver 根据配置文件中[urls]部分是否与请求的 URL 是否匹配解析得到的。

FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain);

即传入原始的 chain 得到一个代理的 chain。

Shiro 内部提供了一个路径匹配的 FilterChainResolver 实现:PathMatchingFilterChainResolver,其根据[urls]中配置的 url 模式(默认 Ant 风格)=拦截器链和请求的 url 是否匹配来解析得到配置的拦截器链的;而 PathMatchingFilterChainResolver 内部通过 FilterChainManager 维护着拦截器链,比如 DefaultFilterChainManager 实现维护着 url 模式与拦截器链的关系。因此我们可以通过 FilterChainManager 进行动态动态增加 url 模式与拦截器链的关系。

这里可能就是对shiro的map里put的路径进行的拦截设计

会话管理

登录成功后使用 Subject.getSession() 即可获取会话;其等价于 Subject.getSession(true),即如果当前没有创建 Session 对象会创建一个;另外 Subject.getSession(false),如果当前没有创建 Session 则返回 null(不过默认情况下如果启用会话存储功能的话在创建 Subject 时会主动创建一个 Session)。

如果是 Web 应用,每次进入 ShiroFilter 都会自动调用 session.touch() 来更新最后访问时间。

Subject.logout() 时会自动调用 stop 方法来销毁会话。

会话管理器

顶层组件 SecurityManager 直接继承了 SessionManager,且提供了SessionsSecurityManager 实现直接把会话管理委托给相应的 SessionManager

在 Servlet 容器中,默认使用 JSESSIONID Cookie 维护会话,且会话默认是跟容器绑定的;

会话存储 / 持久化

Shiro 提供 SessionDAO 用于会话的 CRUD,即 DAO(Data Access Object)模式实现:

//如DefaultSessionManager在创建完session后会调用该方法;如保存到关系数据库/文件系统/NoSQL数据库;即可以实现会话的持久化;返回会话ID;主要此处返回的ID.equals(session.getId());
Serializable create(Session session);
//根据会话ID获取会话
Session readSession(Serializable sessionId) throws UnknownSessionException;
//更新会话;如更新会话最后访问时间/停止会话/设置超时时间/设置移除属性等会调用
void update(Session session) throws UnknownSessionException;
//删除会话;当会话过期/会话停止(如用户退出时)会调用
void delete(Session session);
//获取当前所有活跃用户,如果用户量多此方法影响性能
Collection<Session> getActiveSessions();

如果自定义实现 SessionDAO,继承 CachingSessionDAO 即可:

Realm 缓存

Shiro 提供了 CachingRealm,其实现了 CacheManagerAware 接口,提供了缓存的一些基础实现;另外 AuthenticatingRealmAuthorizingRealm 分别提供了对 AuthenticationInfoAuthorizationInfo 信息的缓存。

总体流程的梳理

  1. //4. 获取当前主题
    Subject subject = SecurityUtils.getSubject();
    
  2. //5.根据登录对象身份凭证信息创建登录令牌
    UsernamePasswordToken token = new UsernamePasswordToken(username,password);
    
  3. 认证:

    subject.login(token);
    

    在该方法中经历了以下流程:

    进入了DelegatingSubject.login方法:

    在该方法中还是调用了securityManager的login方法,真正的认证操作还是由安全管理器对象securityManager执行。:

    // 1. 真正做认证的还是securityManager对象
    Subject subject = this.securityManager.login(this, token);
    

    接着进入到了进入到securityManager的login方法当中:

    在该方法里调用了authenticate(token)方法:

    //调用认证方法
                info = this.authenticate(token);
    

    最终经过多层嵌套进入到了ModularRealmAuthenticator认证器对象的doAuthenticate方法

    public class ModularRealmAuthenticator extends AbstractAuthenticator {
        protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
            this.assertRealmsConfigured();
            Collection<Realm> realms = this.getRealms();
            return realms.size() == 1 ?
                    /**终于到了真正的认证逻辑*/               this.doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken) : this.doMultiRealmAuthentication(realms, authenticationToken);
        }
    }
    

    检验我们的Realms对象创建后,开始进入到doSingleRealmAuthentication方法当中进行认证操作

    protected AuthenticationInfo doSingleRealmAuthentication(Realm realm, AuthenticationToken token) {
        if (!realm.supports(token)) {
            String msg = "Realm [" + realm + "] does not support authentication token [" + token + "].  Please ensure that the appropriate Realm implementation is configured correctly or that the realm accepts AuthenticationTokens of this type.";
            throw new UnsupportedTokenException(msg);
        } else {
            //获取认证信息
            AuthenticationInfo info = realm.getAuthenticationInfo(token);
            if (info == null) {
                String msg = "Realm [" + realm + "] was unable to find account data for the submitted AuthenticationToken [" + token + "].";
                throw new UnknownAccountException(msg);
            } else {
                return info;
            }
        }
    }
    

    getAuthenticationInfo()方法

    在这一步当中开始根据我们传入的令牌获取认证信息

    public abstract class AuthenticatingRealm extends CachingRealm implements Initializable {
    
        public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
            // 首先从缓存中获取
            AuthenticationInfo info = this.getCachedAuthenticationInfo(token);
            if (info == null) {
                //缓存中没有,则从持久化数据中获取
                info = this.doGetAuthenticationInfo(token);
                log.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo", info);
                if (token != null && info != null) {
                    this.cacheAuthenticationInfoIfPossible(token, info);
                }
            } else {
                log.debug("Using cached authentication info [{}] to perform credentials matching.", info);
            }
    
            if (info != null) {
                this.assertCredentialsMatch(token, info);
            } else {
                log.debug("No AuthenticationInfo found for submitted AuthenticationToken [{}].  Returning null.", token);
            }
    
            return info;
        }
    }
    

    这里一般就是调用的自定义的realm里的doGetAuthenticationInfo方法,然后返回SimpleAuthenticationInfo(user, user.getPassWord(), credentialsSalt, this.getClass().getName());,接着回到了AuthenticatingRealmgetAuthenticationInfo方法中,调用assertCredentialsMatch方法开始校验用户凭证,在该方法里获取默认的或者自定义的匹配规则算法将token里的用户凭证和数据库里获取的进行匹配对比验证。

总结:在SimpleAccountRealm对象中的doGetAuthenticationInfo方法中完成账户验证,在AuthenticatingRealmassertCredentialsMatch完成对用户凭证的校验。

在redis中缓存的key的形式:

shiro:cache:com.example.autohomingtest.config.MyShiroRealm.authenticationCache:username

image

image-20201009131904740

三个键值分别对应认证信息缓存、授权信息缓存和会话信息缓存。

登录成功之后看到cookie里存储了对应的token

image

但是这里的token不是以login_token开头的,因为在redisSessionDAO里设置了key的前缀和过期时间,并且注册了redisManagersessionIdGenerator,在sessionIdGenerator里定义了SessionId是以login_token开头的,而redisSessionDAO是在sessionManager()方法里注册的,但是最开始代码里并没有去注册sessionManager方法,所以token格式是上述形式,在securityManager方法里把sessionManager注册进去就可以了

@Bean
public SecurityManager securityManager(@Qualifier("myShiroRealm") MyShiroRealm myShiroRealm){
    DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
    manager.setRealm(myShiroRealm);
    manager.setCacheManager(getCacheManager());
    // 这里是我自己增加的,不然sessionManager没有注册进去
    manager.setSessionManager(sessionManager());
    return manager;
}

结果截图:

image

注册:

先生成一个盐值,然后将用户填写的明文密码和该盐值一起拼接成之后通过md5散列得到一个新的值,将这个值作为数据库中的用户密码,并将盐值记录到数据库中,这样每次用户登录,就是将数据库中的盐值和用户填写的密码明文(token里获取)拼接通过md5散列得到一个值,看该值是否和数据库获取到的密码密文一样,一样说明正确

//三个参数分别对应密码明文、盐值、散列次数
String salt = UUID.randomUUID().toString();
Md5Hash md5Hash = new Md5Hash(user.getPassword(), salt,1024);
log.debug("密文:"+md5Hash.toHex());
log.debug("盐值:"+salt);
user.setPassword(md5Hash.toHex());
user.setSalt(salt);
userMapper.insert(user);

所谓的加盐就是在原密码的基础上,加上一段随机字符串。然后再加密。

后端跨域问题

新建一个过滤器实现WebMvcConfigurer接口即可:

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOriginPatterns("*")
                .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
                .allowCredentials(true)
                .maxAge(3600)
                .allowedHeaders("*");
    }
}

前端跨域问题:

image-20211020214656590

vue.config.js文件里把上边的before: require('./mock/mock-server.js'),注释掉,并添加下边的代码

更改.dev.development文件里的VUE_APP_BASE_API

image-20211020214804750

把utils文件夹里的request.js文件里的下边的code!=20000改为code!=200(这个看不同前端项目而定,如果这里不改,即使获取到了后台的代码,后台默认是200为正确的,这里前台判定是20000,前台就会报错,而不是返回后台的数据显示)

image-20211020215023242

什么时候创建的token,怎么从token里获取用户信息

AuthenticatingFilter在执行executeLogin方法的时候会执行AuthenticationToken token = this.createToken(request, response);来生成token,这里可以继承该filter来实现自定义的生成token的方法也可以使用默认的,然后调用

Subject subject = this.getSubject(request, response);
subject.login(token);

subject.login(token)里会调用AuthenticatingRealm中的getAuthenticationInfo方法,在该方法里调用自定义realmdoGetAuthenticationInfo方法获取对应的SimpleAuthenticationInfo对象

而在本项目的代码里是在如下时机利用用户名和密码创建的token:

Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken token = new UsernamePasswordToken(user.getUsername(), user.getPassword());

subject.login(token);

FormAuthenticationFilter类中通过用户名和密码作为参数创建token

protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
    String username = this.getUsername(request);
    String password = this.getPassword(request);
    return this.createToken(username, password, request, response);
}

然后调用父类AuthenticatingFilter中的createToken方法:

protected AuthenticationToken createToken(String username, String password, ServletRequest request, ServletResponse response) {
    boolean rememberMe = this.isRememberMe(request);
    String host = this.getHost(request);
    return this.createToken(username, password, rememberMe, host);
}

    protected AuthenticationToken createToken(String username, String password, boolean rememberMe, String host) {
        return new UsernamePasswordToken(username, password, rememberMe, host);
    }

最后生成的也是UsernamePasswordToken

在本项目中登录的时候,将会话信息存储在redis中,并在用户认证通过后将会话Id以token的形式返回给用户。用户请求受保护资源时带上这个token,我们根据token信息去redis中获取用户的权限信息,从而做访问控制。

    /**
     * 登录方法
     * 在调用了login方法后,SecurityManager会收到AuthenticationToken,并将其发送给已配置的Realm执行必须的认证检查
     * 每个Realm都能在必要时对提交的AuthenticationTokens作出反应
     * @param user
     * @return
     * @throws AuthenticationException
     */
@RequestMapping("/login")
@ResponseBody
public ResponseWrapper loginUser(@RequestBody User user) throws AuthenticationException {
    ResponseWrapper responseWrapper;
    boolean flags = authcService.login(user);
    if (flags){
        // 将会话信息存储在redis中,并在用户认证通过后将会话Id以token的形式返回给用户。用户请求受保护资源时带上这个token,我们根据token信息去redis中获取用户的权限信息,从而做访问控制。
        Serializable id = SecurityUtils.getSubject().getSession().getId();
        responseWrapper=ResponseWrapper.markSuccess();
        responseWrapper.setExtra("token",id);
    }else {
        responseWrapper = ResponseWrapper.markNoData();
    }

    return responseWrapper;
}

我的理解是这里的token存储的是redis里用户的权限信息对应的key,方便后面用户操作的时候通过这个token作为key从redis里获取权限信息
image

image
image

image

当再次调用需要权限的URL时会调用doGetAuthorizationInfo方法:
image

但是这里有个问题,调用/main的url的时候向后台传递token,传的错误的token也没有报错,如果请求头不带token会跳转到未登录认证的url提示未登录认证

如果是需要权限的URL比如说/manage

如果请求头没有带token,会跳转到未授权的url提示未授权,如果请求头带的token是错误的token,会跳转到未登录认证的URL提示未登录认证,如果请求头是正确的token,但是没有相应的权限,也会跳转到未授权url,如果是过期的token,但是过期了也会跳转到未登录认证url提示未登录认证

上述这两个问题已经解决:在MyshiroFilter里对OPTIONS请求放行,对于有token的不是直接返回true,而是交给父类去处理,不然就跳过了父类对token的判断处理

在shiroConfig里定义了redis的key值的前缀

// CACHE_KEY里是缓存AuthenticationInfo信息和AuthorizationInfo信息的缓存名称的前缀
private static final String CACHE_KEY = "shiro:cache:";
private static final String SESSION_KEY = "shiro:session:";

并且配置了redisManager里的前缀和后缀的形式:

redisCacheManager.setKeyPrefix(CACHE_KEY);
// shiro-redis要求放在session里面的实体类必须有个id标识
//这是组成redis中所存储数据的key的一部分,完整的key的形式:shiro:cache:com.example.autohomingtest.config.MyShiroRealm.authenticationCache:username
redisCacheManager.setPrincipalIdFieldName("username");

我的理解是前端传来的token是redis里的一个key,通过这个key可以获取到用户的信息比如说用户名,然后可以根据用户名去拼接对应的redis里存储的AuthenticationInfo信息和AuthorizationInfo信息的key,然后根据这些key获取对应的认证信息(SimpleAuthenticationInfo)和授权信息(SimpleAuthorizationInfo)

存储的session:
image

将该值序列化,key就是login_token 开头的sessionId,value就是序列化后的值,然后设置到redis里去

但是好像没有从redis里获取

发现是从这里根据token也就是sessionId获取
image
image

如果从ThreadLocal里获取不到对应的session,就从redis里去获取
image

image

如果是不对的token,那么从redis里就获取不到对应的value值
image

如果是正确的token,从redis里获取到对应的值,然后将该值和key进行验证:
image

然后从redis里获取用户信息
image

从AuthenticationInfo里去获取,如果还是为空,就从redis里获取,最后得到用户信息:
image

不正确的token也能认证通过的原因是因为在配置跨域的时候,MyShiroFilter类里的isAccessAllowed方法里除了放行OPTIONS请求之外,对有token的请求都放行了:

if (request.getAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID)!=null) {
    return true;
}

把这几行代码注释掉就可以了

public class MyShiroFilter extends FormAuthenticationFilter {
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        if (request instanceof HttpServletRequest) {
            if (((HttpServletRequest)request).getMethod().toUpperCase().equals("OPTIONS")) {
                return true;
            }
        }
        // if (request.getAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID)!=null) {
        //    return true;
        // }
        return super.isAccessAllowed(request, response, mappedValue);
    }
}

当请求头中错误的token时候,获取的session里的sessionId是空的
image

前端注意

前端每次请求都要在请求头里添加token,以"Authorization"为key,token作为value,可以写在前端的请求拦截器里

shiro 鉴权

1、基于注解(开启注解需要在shiroConfig里重写AuthorizationAttributeSourceAdvisor类)

@RequiresPermissions("user-home")  -- 访问此方法必须具有的权限
@RequiresRoles("admin")  --  访问此方法必须具有的角色

2、基于拦截器

    Map<String,String> filterMap = new LinkedHashMap<>();
   filterMap.put("/home","anon"); // 当前请求url地址可匿名访问
    // 具有某种权限才能访问
   filterMap.put("/user/client","perms[user-client]");
    // 具有某种角色才能访问
   filterMap.put("/user/admin","roles[admin]");
   filterMap.put("/user/**","authc"); // 当前请求地址必须认证之后可访问
    // 设置过滤器
filterFactory.setFilterChainDefinitionMap(filterMap);

shiro 两种鉴权方式的区别

  • 过滤器:如果权限信息不匹配 跳转到setUnauthorizedUrl地址
  • 注解;如果权限信息不匹配,抛出异常

整体的搭建流程:

  1. 自定义Realm

    重写授权和认证方法,doGetAuthorizationInfo和doGetAuthenticationInfo方法,并且设置密码加密匹配算法,设置开启缓存。

    /**
     * @author :RealGang
     * @description:自定义权限匹配和密码匹配,认证用户,授权
     * @date : 2021/10/18 18:04
     */
    public class MyShiroRealm extends AuthorizingRealm {
        private final static Logger logger = LoggerFactory.getLogger(MyShiroRealm.class);
        /**
         * 延迟加载bean,解决缓存Cache不能正常使用;事务Transaction注解不能正常运行
         */
        @Autowired
        @Lazy
        private UserServiceImpl userService;
    
    
        public MyShiroRealm() {
            //设置凭证匹配器,修改为hash凭证匹配器
            HashedCredentialsMatcher myCredentialsMatcher = new HashedCredentialsMatcher();
            //设置算法
            myCredentialsMatcher.setHashAlgorithmName("md5");
            //散列次数
            myCredentialsMatcher.setHashIterations(1024);
            this.setCredentialsMatcher(myCredentialsMatcher);
            //开启缓存
            this.setCachingEnabled(true);
            this.setAuthenticationCachingEnabled(true);
            this.setAuthorizationCachingEnabled(true);
        }
    
        /**
         * @description: 授权;doGetAuthorizationInfo方法是在我们调用;SecurityUtils.getSubject().isPermitted()这个方法,授权后用户角色及权限会保存在缓存中的
         *  "@RequiresPermissions"这个注解其实就是在执行SecurityUtils.getSubject().isPermitted()
         *  授权
         *  这个方法在每次访问ShiroConfig里面配置的受保护资源时都会调用
         *  因此,需要做缓存
         * @param: principalCollection
         * @return: org.apache.shiro.authz.AuthorizationInfo
         * @author: RealGang
         * @date: 2021/10/19
         */
        @Override
        protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
            User user;
            Object object = principalCollection.getPrimaryPrincipal();
            // 这里用json转化为USER,因为可能从redis获取的用户信息反序列化不能强制转换为user报错
            if (object instanceof User) {
                user = (User) object;
            } else {
                user = JSON.parseObject(JSON.toJSON(object).toString(), User.class);
            }
            String username = user.getUsername();
            SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
    //        User user = (User) principalCollection.fromRealm(this.getClass().getName()).iterator().next();
            //查询数据库
            user = userService.findUserInfo(user.getUsername());
            logger.info("##################执行Shiro权限授权##################user info is:{}" + JSONObject.toJSONString(user));
            Set<String> userPermissions = new HashSet<String>();
            Set<String> userRoles = new HashSet<String>();
            for (Role role : user.getRoles()) {
                userRoles.add(role.getRoleName());
                List<Permission> rolePermissions = role.getPermissions();
                for (Permission permission : rolePermissions) {
                    userPermissions.add(permission.getPermName());
                }
            }
            //角色名集合
            info.setRoles(userRoles);
            //权限名集合,将权限放入shiro中,
            // 这里可以把url,按钮,菜单,api等当做资源来进行权限控制,从而对用户进行权限控制
            info.addStringPermissions(userPermissions);
    
            return info;
        }
    
        /**
         * @description: 认证,登录,doGetAuthenticationInfo这个方法是在用户登录的时候调用的;也就是执行SecurityUtils.getSubject().login()的时候调用;(即:登录验证),验证通过后会用户保存在缓存中的
         * @param: authenticationToken
         * @return: org.apache.shiro.authc.AuthenticationInfo
         * @author: RealGang
         * @date: 2021/10/19
         */
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
            logger.info("##################执行Shiro登录认证##################");
            // 客户端传来的 username 和 password 会自动封装到 token,先根据 username 进行查询.如果返回 null,则表示用户名错误,直接 return null 即可,Shiro 会自动抛出 UnknownAccountException 异常。
    
            if(authenticationToken==null){
                return null;
            }
            String principal = (String) authenticationToken.getPrincipal();
            //查询数据库
            User user = userService.findByUserName(principal);
            //放入shiro.调用CredentialsMatcher检验密码
            if (user != null) {
                // 若存在,将此用户存放到登录认证info中,无需自己做密码对比,Shiro会为我们进行密码对比校验
    
                // 第三个参数一般也可以是ByteSource.Util.bytes(shiroUser.getUserName()+shiroPasswordService.getPublicSalt())
                // //由于shiro-redis插件需要从这个属性中获取id作为redis的key,所有这里传的是user而不是username
    //            return new SimpleAuthenticationInfo(user, user.getPassWord(), credentialsSalt, this.getClass().getName());
                return new SimpleAuthenticationInfo(user, user.getPassword(), new CurrentSalt(user.getSalt()), this.getClass().getName());
            }
            return null;
        }
    }
    
  2. 添加Shiro自定义会话

    添加自定义会话ID生成器

    这里配置token以"login_token"开头的token也就是sessionId

    public class ShiroSessionIdGenerator implements SessionIdGenerator {
    
        /**
         *实现SessionId生成
         * @param session
         * @return
         */
        @Override
        public Serializable generateId(Session session) {
            Serializable sessionId = new JavaUuidSessionIdGenerator().generateId(session);
            return String.format("login_token_%s", sessionId);
        }
    }
    

    添加自定义会话管理器

    public class ShiroSessionManager extends DefaultWebSessionManager {
    
        //定义常量
        private static final String AUTHORIZATION = "Authorization";
        private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request";
        //重写构造器
        public ShiroSessionManager() {
            super();
            this.setDeleteInvalidSessions(true);
        }
    
        /**
         * 重写方法实现从请求头获取Token便于接口统一
         *      * 每次请求进来,
         *      Shiro会去从请求头找Authorization这个key对应的Value(Token)
         * @param request
         * @param response
         * @return
         */
        @Override
        public Serializable getSessionId(ServletRequest request, ServletResponse response) {
            String token = WebUtils.toHttp(request).getHeader(AUTHORIZATION);
            //如果请求头中存在token 则从请求头中获取token
            if (!StringUtils.isEmpty(token)) {
                request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
                request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, token);
                request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
                return token;
            } else {
                // 这里禁用掉Cookie获取方式
                return null;
                // 否则按默认规则从cookie取sessionId
                //return super.getSessionId(request, response);
            }
        }
    }
    
  3. 配置shiro:shiroConfig

    在该配置文件里主要是一个filterFactoryBean,该类里注册SecurityManager,并且可以设置一些自定义过滤器,然后设置过滤url规则,在securityManager方法里注入自定义的realm,并且注入自己重写的redisCacheManager和会话管理器sessionManager

    @Configuration
    public class ShiroConfig {
    
        // CACHE_KEY里是缓存AuthenticationInfo信息和AuthorizationInfo信息的缓存名称的前缀
        private static final String CACHE_KEY = "shiro:cache:";
        private static final String SESSION_KEY = "shiro:session:";
        private static final int EXPIRE = 18000;
        @Value("${spring.redis.host}")
        private String host;
        @Value("${spring.redis.port}")
        private int port;
        @Value("${spring.redis.timeout}")
        private int timeout;
    //    @Value("${spring.redis.password}")
    //    private String password;
    
        @Value("${spring.redis.jedis.pool.min-idle}")
        private int minIdle;
        @Value("${spring.redis.jedis.pool.max-idle}")
        private int maxIdle;
        @Value("${spring.redis.jedis.pool.max-active}")
        private int maxActive;
    
        //开启对shior注解的支持
        @Bean
        public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(org.apache.shiro.mgt.SecurityManager securityManager) {
            AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
            authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
            return authorizationAttributeSourceAdvisor;
        }
    
        /**
         * @description: 自定义过滤器 MyShiroRealm,我们的业务逻辑全部定义在这个 bean 中。
         * @param:
         * @return: com.example.autohomingtest.config.MyShiroRealm
         * @author: RealGang
         * @date: 2021/10/19
         */
        @Bean
        public MyShiroRealm myShiroRealm(){
            return new MyShiroRealm();
        }
    
    
        /**
         * @description: 将 myShiroRealm 注入到 DefaultWebSecurityManager bean 中,完成注册。
         * @param: myShiroRealm
         * @return: org.apache.shiro.web.mgt.DefaultWebSecurityManager
         * @author: RealGang
         * @date: 2021/10/19
         */
        @Bean
        public SecurityManager securityManager(@Qualifier("myShiroRealm") MyShiroRealm myShiroRealm){
            DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
            manager.setRealm(myShiroRealm);
            manager.setCacheManager(redisCacheManager());
            // 这里是我自己增加的,不然sessionManager没有注册进去
            manager.setSessionManager(sessionManager());
            SecurityUtils.setSecurityManager(manager);
            return manager;
        }
    
        /**
         * @description: ShiroFilterFactoryBean,这是 Shiro 自带的一个 Filter 工厂实例,所有的认证和授权判断都是由这个 bean 生成的 Filter 对象来完成的,
         * 这就是 Shiro 框架的运行机制,开发者只需要定义规则,进行配置,具体的执行者全部由 Shiro 自己创建的 Filter 来完成。
         * @param: manager
         * @return: org.apache.shiro.spring.web.ShiroFilterFactoryBean
         * @author: RealGang
         * @date: 2021/10/19
         */
        @Bean
        public ShiroFilterFactoryBean filterFactoryBean(@Qualifier("securityManager") SecurityManager securityManager){
            ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
            factoryBean.setSecurityManager(securityManager);
            Map<String, Filter> filterMap = factoryBean.getFilters();
            // 这里把自定义拦截OPTIONS请求的拦截器注册进来
            filterMap.put("authc",new MyShiroFilter());
            Map<String,String> map = new LinkedHashMap<>();
            /**
             * Shiro 内置过滤器,过滤链定义,从上向下顺序执行
             *  常用的过滤器:
             *      anon:无需认证(登录)可以访问
             *      authc:必须认证才可以访问
             *      user:只要登录过,并且记住了密码,如果设置了rememberMe的功能可以直接访问
             *      perms:该资源必须得到资源权限才可以访问
             *      role:该资源必须得到角色的权限才可以访问
             */
            map.put("/manage","perms[manage]");
            map.put("/administrator","roles[administrator]");
            //anon表示可以匿名访问
            map.put("/index", "anon");
            map.put("/login", "anon");
            map.put("/static/**", "anon");
            map.put("/user/testDb","anon");
            //authc表示需要登录
            map.put("/user/**","authc");
            map.put("/main","authc");
            factoryBean.setFilterChainDefinitionMap(map);
            //设置登录页面,覆盖默认的登录url,这里如果未认证会跳转到/unauthc这里来
            factoryBean.setLoginUrl("/unauthc");
            //未授权页面
            factoryBean.setUnauthorizedUrl("/unauthr");
            // 登录成功后要跳转的链接
            factoryBean.setSuccessUrl("/index");
            return factoryBean;
        }
    
    
        /**
         * 配置Redis管理器
         * @Attention 使用的是shiro-redis开源插件
         * @return
         */
        @Bean
        public RedisManager redisManager() {
            RedisManager redisManager = new RedisManager();
            redisManager.setHost(host);
            redisManager.setPort(port);
            redisManager.setTimeout(timeout);
    //        redisManager.setPassword(password);
            JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
            jedisPoolConfig.setMaxTotal(maxIdle+maxActive);
            jedisPoolConfig.setMaxIdle(maxIdle);
            jedisPoolConfig.setMinIdle(minIdle);
            redisManager.setJedisPoolConfig(jedisPoolConfig);
            return redisManager;
        }
    
    
        @Bean
        public RedisCacheManager redisCacheManager() {
            RedisCacheManager redisCacheManager = new RedisCacheManager();
            redisCacheManager.setRedisManager(redisManager());
            redisCacheManager.setKeyPrefix(CACHE_KEY);
            // shiro-redis要求放在session里面的实体类必须有个id标识
            //这是组成redis中所存储数据的key的一部分,完整的key的形式:shiro:cache:com.example.autohomingtest.config.MyShiroRealm.authenticationCache:username
            redisCacheManager.setPrincipalIdFieldName("username");
            return redisCacheManager;
        }
    
        /**
         * SessionID生成器
         *
         */
        @Bean
        public ShiroSessionIdGenerator sessionIdGenerator(){
            return new ShiroSessionIdGenerator();
        }
    
        /**
         * 配置RedisSessionDAO
         */
        @Bean
        public RedisSessionDAO redisSessionDAO() {
            RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
            redisSessionDAO.setRedisManager(redisManager());
            redisSessionDAO.setSessionIdGenerator(sessionIdGenerator());
            redisSessionDAO.setKeyPrefix(SESSION_KEY);
            redisSessionDAO.setExpire(EXPIRE);
            return redisSessionDAO;
        }
    
        /**
         * 配置Session管理器
         * @Author Sans
         *
         */
        @Bean
        public SessionManager sessionManager() {
            ShiroSessionManager shiroSessionManager = new ShiroSessionManager();
            shiroSessionManager.setSessionDAO(redisSessionDAO());
            //禁用cookie
            shiroSessionManager.setSessionIdCookieEnabled(false);
            //禁用会话id重写
            // ession管理器的setSessionIdUrlRewritingEnabled(false)配置没有生效,导致没有认证直接访问受保护资源出现多次重定向的错误。将shiro版本切换为1.5.0后就解决了这个bug。
            shiroSessionManager.setSessionIdUrlRewritingEnabled(false);
            return shiroSessionManager;
        }
    }
    
  4. 解决跨域问题

    配置CorConfig:

    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
    
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/**")
                    .allowedOriginPatterns("*")
                    .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
                    .allowCredentials(true)
                    .maxAge(3600)
                    .allowedHeaders("*");
        }
    }
    

    配置MyShiroFilter过滤器,这里主要拦截OPTIONS请求,并且注册到上述的filterFactoryBean中去:

    public class MyShiroFilter extends FormAuthenticationFilter {
        @Override
        protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
            if (request instanceof HttpServletRequest) {
                if (((HttpServletRequest)request).getMethod().toUpperCase().equals("OPTIONS")) {
                    return true;
                }
            }
    //        if (request.getAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID)!=null) {
    //            return true;
    //        }
            return super.isAccessAllowed(request, response, mappedValue);
        }
    }
    
  5. 自定义盐值生成方法保证可以序列化(由于shiro当中的ByteSource没有实现序列化接口,缓存时会发生错误,因此,我们需要通过自定义ByteSource的方式实现这个接口):

    public class CurrentSalt implements ByteSource, Serializable {
        private static final long serialVersionUID = 125096758372084309L;
    
        private  byte[] bytes;
        private String cachedHex;
        private String cachedBase64;
    
        public CurrentSalt(){
        }
    
        public CurrentSalt(byte[] bytes) {
            this.bytes = bytes;
        }
    
        public CurrentSalt(char[] chars) {
            this.bytes = CodecSupport.toBytes(chars);
        }
    
        public CurrentSalt(String string) {
            this.bytes = CodecSupport.toBytes(string);
        }
    
        public CurrentSalt(ByteSource source) {
            this.bytes = source.getBytes();
        }
    
        public CurrentSalt(File file) {
            this.bytes = (new CurrentSalt.BytesHelper()).getBytes(file);
        }
    
        public CurrentSalt(InputStream stream) {
            this.bytes = (new CurrentSalt.BytesHelper()).getBytes(stream);
        }
    
        public static boolean isCompatible(Object o) {
            return o instanceof byte[] || o instanceof char[] || o instanceof String || o instanceof ByteSource || o instanceof File || o instanceof InputStream;
        }
    
        public void setBytes(byte[] bytes) {
            this.bytes = bytes;
        }
    
        @Override
        public byte[] getBytes() {
            return this.bytes;
        }
    
    
        @Override
        public String toHex() {
            if(this.cachedHex == null) {
                this.cachedHex = Hex.encodeToString(this.getBytes());
            }
            return this.cachedHex;
        }
    
        @Override
        public String toBase64() {
            if(this.cachedBase64 == null) {
                this.cachedBase64 = Base64.encodeToString(this.getBytes());
            }
    
            return this.cachedBase64;
        }
    
        @Override
        public boolean isEmpty() {
            return this.bytes == null || this.bytes.length == 0;
        }
        @Override
        public String toString() {
            return this.toBase64();
        }
    
        @Override
        public int hashCode() {
            return this.bytes != null && this.bytes.length != 0? Arrays.hashCode(this.bytes):0;
        }
    
        @Override
        public boolean equals(Object o) {
            if(o == this) {
                return true;
            } else if(o instanceof ByteSource) {
                ByteSource bs = (ByteSource)o;
                return Arrays.equals(this.getBytes(), bs.getBytes());
            } else {
                return false;
            }
        }
    
        private static final class BytesHelper extends CodecSupport {
            private BytesHelper() {
            }
    
            public byte[] getBytes(File file) {
                return this.toBytes(file);
            }
    
            public byte[] getBytes(InputStream stream) {
                return this.toBytes(stream);
            }
        }
    
    }
    
  6. 登录接口编写:

@RequestMapping("/login")
@ResponseBody
public ResponseWrapper loginUser(@RequestBody User user) throws AuthenticationException {
    ResponseWrapper responseWrapper;
    boolean flags = authcService.login(user);
    if (flags){
        // 将会话信息存储在redis中,并在用户认证通过后将会话Id以token的形式返回给用户。用户请求受保护资源时带上这个token,我们根据token信息去redis中获取用户的权限信息,从而做访问控制。
        Serializable id = SecurityUtils.getSubject().getSession().getId();
        logger.debug("会话ID:"+id);
        responseWrapper=ResponseWrapper.markSuccess();
        responseWrapper.setExtra("token",id);
    }else {
        responseWrapper = ResponseWrapper.markNoData();
    }

    return responseWrapper;
}

在authService里的login方法里,通过用户名和密码生成UsernamePasswordToken然后调用subject.login(token)让shiro自己去处理:

@Service
public class AuthcServiceImpl implements AuthcService {
    @Override
    public boolean login(User user) throws AuthenticationException {
        if (user==null){
            return false;
        }

        if (user.getUsername()==null||"".equals(user.getUsername())){
            return false;
        }

        if (user.getPassword() == null || "".equals(user.getPassword())){
            return false;
        }
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken token = new UsernamePasswordToken(user.getUsername(), user.getPassword());

        subject.login(token);
        return true;
    }
}

前端获取到token之后可以放到vuex里去,每次向后台发送请求可以在拦截器里将该token添加到Header里的“Authorization"里去

原文地址:https://www.cnblogs.com/RealGang/p/15433954.html