Shiro入门

介绍

Apache Shiro 是 Java 的一个安全(权限)框架。

Shiro 可以完成:认证、授权、加密、会话管理、与Web 集成、缓存等。

shiro功能,重点是认证和授权功能

截屏2024-03-11 23.13.16
  • Authentication:身份认证/登录,验证用户是不是拥有相应的身份

  • Authorization:授权,即权限验证,验证某个已认证的用户是否拥有某个权限;即判断用户是否能进行什么操作,如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限

  • Session Management:会话管理,即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中;会话可以是普通JavaSE环境,也可以是Web 环境的

  • Cryptography:加密,保护数据的安全性,如密码加密存储到数据库,而不是明文存储

  • Web Support:Web 支持,可以非常容易的集成到Web 环境

  • Caching:缓存,比如用户登录后,其用户信息、拥有的角色/权限不必每次去查,这样可以提高效率

  • Concurrency:Shiro支持多线程应用的并发验证,即如在一个线程中开启另一个线程,能把权限自动传播过去

  • Testing:提供测试支持

  • “Run As”:允许一个用户假装为另一个用户(如果他们允许)的身份进行访问

  • Remember Me:记住我,这个是非常常见的功能,即一次登录后,下次再来的话不用登录了

shiro架构

宏观来看:由三个组件构成Subject,SecurityManager,Realm

截屏2024-03-11 23.16.11

Subject:应用代码直接交互的对象是Subject,也就是说Shiro的对外API 核心就是Subject。Subject 代表了当前“用户”,这个用户不一定是一个具体的人,与当前应用交互的任何东西都是Subject,如网络爬虫,机器人等;与Subject 的所有交互都会委托给SecurityManager;Subject 其实是一个门面,SecurityManager才是实际的执行者

SecurityManager:安全管理器;即所有与安全有关的操作都会与SecurityManager交互;且其管理着所有Subject;它是Shiro的核心,它负责与Shiro的其他组件进行交互,它相当于SpringMVC中DispatcherServlet的角色

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

微观来看

截屏2024-03-11 23.18.30
  • Subject:任何可以与应用交互的“用户”;
  • SecurityManager:是Shiro的心脏;所有具体的交互都通过SecurityManager进行控制;它管理着所有Subject、且负责进行认证、授权、会话及缓存的管理。
  • Authenticator:负责Subject 认证,是一个扩展点,可以自定义实现;可以使用认证策略(Authentication Strategy),即什么情况下算用户认证通过了;
  • Authorizer:授权器、即访问控制器,用来决定主体是否有权限进行相应的操作;即控制着用户能访问应用中的哪些功能;
  • Realm:可以有1 个或多个Realm,可以认为是安全实体数据源,即用于获取安全实体的;可以是JDBC 实现,也可以是内存实现等等;由用户提供;所以一般在应用中都需要实现自己的Realm;
  • SessionManager:管理Session 生命周期的组件;
  • CacheManager:缓存控制器,来管理如用户、角色、权限等的缓存的;因为这些数据基本上很少改变,放到缓存中后可以提高访问的性能
  • Cryptography:密码模块,Shiro提高了一些常见的加密组件用于如密码加密/解密。

简单示例

public class Quickstart {
  public static void main(String[] args) {
      // 读取配置文件:
      Factory<SecurityManager> factory = new IniSecurityManagerFactory("classpath:shiro.ini");
      SecurityManager securityManager = factory.getInstance();
SecurityUtils.setSecurityManager(securityManager);
      // 获取当前的用户对象 Subject
      Subject currentUser = SecurityUtils.getSubject();
      //通过当前用户拿到Shiro的Session 可以脱离web存值取值
      Session session = currentUser.getSession();
      session.setAttribute("someKey", "aValue");
      String value = (String) session.getAttribute("someKey");
      if (value.equals("aValue")) {
          log.info("Retrieved the correct value! [" + value + "]");
      }
      //判断当前的用户是否被认证
      if (!currentUser.isAuthenticated()) {
          //Token 令牌
          UsernamePasswordToken token = new UsernamePasswordToken("lonestarr", "vespa");
          //设置记住我
          token.setRememberMe(true);
          try {
              //执行登录操作
              currentUser.login(token);
          } catch (UnknownAccountException uae) {
              log.info("There is no user with username of " + token.getPrincipal());
          } catch (IncorrectCredentialsException ice) {
              log.info("Password for account " + token.getPrincipal() + " was incorrect!");
          } catch (LockedAccountException lae) {
              log.info("The account for username " + token.getPrincipal() + " is locked.  " +
                      "Please contact your administrator to unlock it.");
          } catch (AuthenticationException ae) {

          }
      }
      // 检查角色
      if (currentUser.hasRole("schwartz")) {
          log.info("May the Schwartz be with you!");
      } else {
          log.info("Hello, mere mortal.");
      }
      //粗粒度权限
      if (currentUser.isPermitted("lightsaber:wield")) {
          log.info("You may use a lightsaber ring.  Use it wisely.");
      } else {
          log.info("Sorry, lightsaber rings are for schwartz masters only.");
      }
      //细粒度权限
      if (currentUser.isPermitted("winnebago:drive:eagle5")) {
          log.info("You are permitted to 'drive' the winnebago with license plate (id) 'eagle5'.  " +
                  "Here are the keys - have fun!");
      } else {
          log.info("Sorry, you aren't allowed to drive the 'eagle5' winnebago!");
      }
      //注销
      currentUser.logout();
  }
}

整合到springboot

可在ShiroConfig中的getShiroFilterFactoryBean方法中添加如下配置

anon: 无需认证就可以访问 authc: 必须认证了才能访问 user: 必须拥有记住我功能才能用 perms: 拥有对某个资源的权限才能访问 roles: 拥有某个角色权限

Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/user/add","authc");
filterMap.put("/user/update","authc");
filterMap.put("/user/add","perms[user:add]");
filterMap.put("/user/update","perms[user:update]");
filterMap.put("/secret","authc,roles[admin]");  
bean.setFilterChainDefinitionMap(filterMap);

登陆接口

登录接口是可匿名访问接口,若用户名密码正确,则生成jwt并返回即可

public LoginResponseBody login(LoginRequestBody loginRequestBody) {

  // 查询用户
  User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getAccount, loginRequestBody.getAccount()));
  if (user == null) {
    throw new UserNotFoundException();
  }
  if (user.getIsDisabled() == 1) {
    throw new UserInvalidException();
  }
  // 需要加密后进行判断
  if (!user.getPassword().equals(PasswordMD5.getPasswordMD5(loginRequestBody.getPassword()))) {
    throw new PasswordErrorException();
  }
  String token;
  try {
    // 生成token
    token = this.JWTUtil.sign(user.getAccount());
  } catch (Exception e) {
    e.printStackTrace();
    throw new SignTokenException();
  }
  return LoginResponseBody.builder()
      .userId(user.getUserId())
      .account(user.getAccount())
      .nickname(user.getNickname())
      .authority(user.getAuthority())
      .type(user.getType())
      .token(token)
      .build();
}

认证类

我们需自定义继承于AuthorizingRealm的类,称为userRealm,该类需重写3个方法,分别是:

  • supports:为了让AuthorizingRealm支持jwt的凭证校验
  • doGetAuthorizationInfo:定义权限校验的过程,访问非匿名借口时是否具有权限
  • doGetAuthenticationInfo:进行用户认证校验,即在访问非匿名接口时,判断请求是否携带了正确的jwt

shiro默认supports的是UsernamePasswordToken,而我们现在采用了jwt的方式,所以这里我们需要自定义一个JwtToken,来完成shiro的supports方法。JwtToken需要实现AuthenticationToken接口,AuthenticationToken接口中定义了两个方法getPrincipal和getCredentials,本意分别是表示获取用户信息,以及获取只被Subject 知道的秘密值。由于我们无法直接从jwt中获得用户的全部信息,只能从jwt中解析出用户名或用户ID,再从数据库中查询才能得到用户实体,所以这两个方法我们都返回jwt token。这样可能有违本意,但不会影响程序运行

public class JwtToken implements AuthenticationToken {
  private String token;

  public JwtToken(String jwt) {
      this.token = jwt;
  }

  @Override
  public Object getPrincipal() {
      return token;
  }

  @Override
  public Object getCredentials() {
      return token;
  }
}

doGetAuthenticationInfo方法中定义用户认证校验过程,该方法处理的是非匿名接口的访问,会去判断请求是否具有正确的JWT。该方法的返回值为AuthenticationInfo接口,表示用户信息的载体,我们可以返回SimpleAuthenticationInfo类,该类间接实现了了AuthenticationInfo接口:

img

该类第一个属性表示用户信息,第二个表示密码,也就是凭证,第三个表示当前realm的名字

完整的UserRealm如下:

public class UserRealm extends AuthorizingRealm {

  @Resource private UserMapper userMapper;

  Logger logger = Logger.getLogger(UserRealm.class);

  /** 重写此方法,不然Shiro会报错 */
  @Override
  public boolean supports(AuthenticationToken token) {
    return token instanceof JWTToken;
  }

  // 授权
  @Override
  protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    String account = JWTUtil.getAccount(principals.toString());
    User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getAccount, account));
    SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
    switch (user.getAuthority()) {
      case 0:
        simpleAuthorizationInfo.addRole(Roles.Admin.getRole());
        break;
      case 1:
        simpleAuthorizationInfo.addRole(Roles.User.getRole());
      default:
        break;
    }
    return simpleAuthorizationInfo;
  }
  // 验证
  @Override
  protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth)
      throws AuthenticationException {
    // 这里的auth就是刚刚定义的JwtToken
    String token = (String) auth.getCredentials();
    // 解密获得account,用于和数据库进行对比
    String account = JWTUtil.getAccount(token);
    if (account == null) {
      throw new AuthenticationException("token invalid");
    }

    User admin = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getAccount, account));
    if (admin == null) {
      throw new AuthenticationException("User didn't existed!");
    }
    // 第一个参数可以放用户信息,这里直接使用token了
    return new SimpleAuthenticationInfo(token, token, "userRealm");
  }
}

JwtFilter

所有的请求都会首先经过该过滤器,我们需自定义过滤器JwtFilter,该类继承于Shiro内置的AuthenticatingFilter,AuthenticatingFilter内置了登录方法executeLogin   我们需要重写以下方法:

  • createToken:我们需要从http请求的header中拿到jwt,并将其封装成我们自定义的JwtToken
  • onAccessDenied:进行拦截校验过程,当请求不含JWT时,我们直接通过放行(因为匿名也能访问一些接口,即使匿名去访问需要权限的接口,也会被权限注解拦截,因此是安全的);当带有JWT的时候,首先我们校验jwt的有效性,正确我们就直接执行executeLogin方法实现自动登录
  • onLoginFailure:登录异常时候进入的方法,我们直接把异常信息封装然后抛出
  • preHandle:拦截器的前置拦截,在这里由于我写的是前后端分离项目,项目中除了需要跨域全局配置之外,我们在拦截器中也需要提供跨域支持。这样,拦截器才不会在进入Controller之前就被限制了。
@Component
public class JwtFilter extends AuthenticatingFilter {

  @Override
  protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
      HttpServletRequest request = (HttpServletRequest) servletRequest;
      String jwt = request.getHeader("Authorization");
      if (StringUtils.isEmpty(jwt)) {
          return true;
      } else {
          // 校验jwt
          Claims claims = JwtUtils.getClaimsByToken(jwt);
          if (claims == null || JwtUtils.isTokenExpired(claims)) {
              throw new ExpiredCredentialsException("token已失效,请重新登录");
          }
          // 执行登录
          return executeLogin(servletRequest, servletResponse);
      }
  }

  @Override
  protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
      HttpServletRequest request = (HttpServletRequest) servletRequest;
      String jwt = request.getHeader("Authorization");
      if (StringUtils.isEmpty(jwt)) {
          return null;
      }
      return new JwtToken(jwt);
  }

  @Override
  protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
      HttpServletResponse httpServletResponse = (HttpServletResponse) response;
      Throwable throwable = e.getCause() == null ? e : e.getCause();
      Result result = Result.fail(throwable.getMessage());
      String json = JSONUtil.toJsonStr(result);
      try {
          httpServletResponse.getWriter().println(json);
      } catch (IOException ioException) {
      }
      return false;
  }

  @Override
  protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
      HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
      HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
      httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
      httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
      httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
      // 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
      if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
          httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value());
          return false;
      }
      return super.preHandle(request, response);
  }
}

当然也可以继承别的Filter类,如BasicHttpAuthenticationFilter,需要重写的方法不一样,但是逻辑是类似的,下面是一个例子

public class JWTFilter extends BasicHttpAuthenticationFilter {

  /**
   * 判断用户是否想要登入。
   * 检测header里面是否包含Authorization字段即可
   */
  @Override
  protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
    HttpServletRequest req = (HttpServletRequest) request;
    String authorization = req.getHeader("Authorization");
    return authorization != null;
  }

  /**
   * 执行登陆方法
   */
  @Override
  protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
    HttpServletRequest httpServletRequest = (HttpServletRequest) request;
    String authorization = httpServletRequest.getHeader("Authorization");
    JWTToken token = new JWTToken(authorization);
    // 提交给realm进行登入,如果错误他会抛出异常并被捕获
    getSubject(request, response).login(token);
    // 如果没有抛出异常则代表登入成功,返回true
    return true;
  }
  /**
   * 这里我们详细说明下为什么最终返回的都是true,即允许访问
   * 例如我们提供一个地址 GET /article
   * 登入用户和游客看到的内容是不同的
   * 如果在这里返回了false,请求会被直接拦截,用户看不到任何东西
   * 所以我们在这里返回true,Controller中可以通过 subject.isAuthenticated() 来判断用户是否登入
   * 如果有些资源只有登入用户才能访问,我们只需要在方法上面加上 @RequiresAuthentication 注解即可
   * 但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大
   */
  @Override
  protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
    if (isLoginAttempt(request, response)) {
      try {
        executeLogin(request, response);
      } catch (Exception e) {
      }
    }
    return true;
  }
  /**
   * 对跨域提供支持
   */
  @Override
  protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
    HttpServletRequest httpServletRequest = (HttpServletRequest) request;
    HttpServletResponse httpServletResponse = (HttpServletResponse) response;
    httpServletResponse.setHeader("Access-Control-Allow-Origin", httpServletRequest.getHeader("Origin"));
    httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
    httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
    // 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
    if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
      httpServletResponse.setStatus(HttpStatus.OK.value());
      return false;
    }
    return super.preHandle(request, response);
  }
}

shiro全局配置

在ShiroConfig中做3件事:

在ShiroFilterChainDefinition中,我们不再通过编码形式拦截Controller访问路径,而是所有的路由都需要经过JwtFilter这个过滤器,然后判断请求头中是否含有jwt的信息,有并且正确就进行Shiro的自动登录,没有JWT就跳过放行。跳过放行之后,有Controller中的shiro注解进行再次拦截,比如@RequiresAuthentication,这样来控制需要权限的访问,因此是安全的。还可以使用@RequiresRoles等注解设置角色权限等

@Configuration
public class ShiroConfig {

  @Resource
  private UserRealm userRealm;

  @Bean("securityManager")
  public DefaultWebSecurityManager getManager(UserRealm realm) {
    DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
    // 使用自己的realm
    manager.setRealm(realm);
    /*
     * 关闭shiro自带的session,详情见文档
     */
    DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
    DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
    defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
    subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
    manager.setSubjectDAO(subjectDAO);
    return manager;
  }
  @Bean("shiroFilter")
  public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
    ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
    // 添加自己的过滤器并且取名为jwt
    Map<String, Filter> filterMap = new HashMap<>();
    filterMap.put("jwt", new JWTFilter());
    factoryBean.setFilters(filterMap);
    factoryBean.setSecurityManager(securityManager);
    // factoryBean.setUnauthorizedUrl("/401");
    /*
     * 自定义url规则
     * http://shiro.apache.org/web.html#urls-
     */
    Map<String, String> filterRuleMap = new HashMap<>();
    // 所有请求通过我们自己的JWT Filter
    filterRuleMap.put("/**", "jwt");
    // 代码map.put("/**", "authc")使得所有请求都需要认证才能请求成功,否则就跳转至setLoginUrl中指定的页面,
    factoryBean.setFilterChainDefinitionMap(filterRuleMap);
    return factoryBean;
  }
  /**
   * 添加注解支持
   */
  @Bean
  @DependsOn("lifecycleBeanPostProcessor")
  public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
    DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
    defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
    return defaultAdvisorAutoProxyCreator;
  }

  @Bean
  public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
    return new LifecycleBeanPostProcessor();
  }

  @Bean
  public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
    AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
    advisor.setSecurityManager(securityManager);
    return advisor;
  }
  @Bean
  public UserRealm userRealm() {
    return new UserRealm();
  }
}
Last Updated:
Contributors: liushun-ing