SpringBoot整合 shiro + jwt,并会话共享

发布时间:2023-03-11 16:30

SpringBoot整合 shiro + jwt,并会话共享

Shiro

Shrio的主要功能:

  • Authentication:用户认证(登录)
  • Authorization:权限控制
  • Session Management:会话管理
  • Cryptography:数据加密
  • Web Support:支持web的API
  • Caching:缓存
  • Concurrency:支持多线程应用程序
  • Testing:测试的支持
  • “Run As”:假设一个用户为另一个用户的身份
  • “Remember Me”:在Session中保存用户身份

基本原理

Shiro的基本架构:

SpringBoot整合 shiro + jwt,并会话共享_第1张图片

Shiro有三个核心的概念:Subject、SecurityManager 和 Realms。

  • Subject:Subject实质上是一个当前执行用户的特定的安全“视图”,开发者所写的应用代码就通过Subject与Shiro框架进行交互。所有Subject实例都必须绑定到一个SecurityManager上,当使用一个Subject实例时,Subject实例会和SecurityManager进行交互,完成相应操作。
  • SecurityManager:SecurityManager是Shiro的核心部分,作为一种“保护伞”对象来协调内部安全组件共同构成一个对象图。开发人员并不直接操作SecurityManager,而是通过Subject来操作SecurityManager来完成各种安全相关操作。
  • Realms:Realms担当Shiro和应用程序的安全数据之间的“桥梁”或“连接器”。从本质来讲,Realm是一个特定安全的DAO,Realm中封装了数据操作的模块和用户自定义的认证匹配过程。SecurityManager可能配置多个Realms,但至少要有一个。

使用SpringBoot整合

1. 导入 shiro-redis 的starter 的依赖,还有 jwt 工具包,以及为了简化开发,引入 hutool工具包


<dependency>
	<groupId>org.crazycakegroupId>
	<artifactId>shiro-redis-spring-boot-starterartifactId>
	<version>3.3.1version>
dependency>

<dependency>
	<groupId>cn.hutoolgroupId>
	<artifactId>hutool-allartifactId>
	<version>5.7.22version>
dependency>

<dependency>
	<groupId>io.jsonwebtokengroupId>
	<artifactId>jjwtartifactId>
	<version>0.9.1version>
dependency>

2. 编写配置

ShiroConfig

  • com.xxx.config.ShiroConfig
/**
 * shiro启用注解拦截控制器
 */
@Configuration
public class ShiroConfig {

    @Autowired
    private JwtFilter jwtFilter;

    @Bean
    public SessionManager sessionManager(RedisSessionDAO redisSessionDAO) {
        DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
        // 注入 redisSessionDAO
        sessionManager.setSessionDAO(redisSessionDAO);
        return sessionManager;
    }

    /**
     * 创建安全管理器
     * AccountRealm--是 shiro 进行【登录】或者【权限校验】的逻辑所在,算是核心了,需要重写3个方法,分别是:
     * supports(): 为了让 realm 支持 jwt 的凭证校验
     * doGetAuthorizationInfo(): 权限校验
     * doGetAuthenticationInfo(): 登录认证校验
     * @param sessionManager
     * @param redisCacheManager
     *
     */
    @Bean
    public DefaultWebSecurityManager securityManager(AccountRealm accountRealm,
                                                     SessionManager sessionManager,
                                                     RedisCacheManager redisCacheManager) {
        // 创建安全管理器对象
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(accountRealm);
        // 注入 sessionManager
        securityManager.setSessionManager(sessionManager);

        // 关闭 shiro 自带的 session,这样用户就不能再通过 session 方式登录 shiro,后面将采用 jwt 凭证登录。
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);

        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);

        // 注入 redisCacheManager
        securityManager.setCacheManager(redisCacheManager);
        return securityManager;
    }

    /**
     * 在 ShiroFilterChainDefinition 中,我们不在通过编码形式拦截 Controller 的访问路径,而是所有的
     * 路由都需要经过 JwtFilter 这个过滤器,然后判断请求头中是否含有 jwt 的信息,有就登录,没有就跳过。
     * 跳过之后,由 Controller 中的 shiro注解 进行再次拦截,比如 @RequiresAuthentication,从而控制权限访问。
     *
     */
    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();

        Map<String, String> filterMap = new LinkedHashMap<>();

        filterMap.put("/**", "jwt");
        chainDefinition.addPathDefinitions(filterMap);
        return chainDefinition;
    }

    /**
     * 创建 shiroFilter 负责拦截所有请求
     * @param securityManager
     * @param chainDefinition
     * @return
     */
    @Bean("shiroFilterFactoryBean")
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,
                                                         ShiroFilterChainDefinition chainDefinition) {
        ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
        // 给 shiroFilter 设置安全管理器
        shiroFilter.setSecurityManager(securityManager);

        //配置系统受限资源
        //配置系统公共资源
        Map<String, Filter> filters = new HashMap<>();
        // 使用 jwtFilter 过滤器
        filters.put("jwt", jwtFilter);
        shiroFilter.setFilters(filters);

        Map<String, String> filterMap = shiroFilterChainDefinition().getFilterChainMap();

        shiroFilter.setFilterChainDefinitionMap(filterMap);
        return shiroFilter;
    }

    /**
     * 解决 aop 与 shiro 冲突问题
     */
    @Bean
    public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        defaultAdvisorAutoProxyCreator.setUsePrefix(true);

        return defaultAdvisorAutoProxyCreator;
    }
}

上面的 ShiroConfig,主要做了几件事情:

  • 引入 RedisSessionDao 和 RedisCacheManager,为了解决 shiro 的权限数据和会话信息能保存到 redis 中,实现会话共享。
  • 重写了 SessionManager 和 DefaultWebSecurityManager,同时在 DefaultWebSecurityManager 中关闭了 shiro 自带的 session 方式,将其设置为 false,这样用户就不能再通过 session 方式登录 shiro。后面会采用 jwt 凭证登录。
  • 在 ShiroFilterChainDefinition 中,不再通过编码形式拦截请求访问路径,而是所有路由都需要经过 JwtFilter 这个过滤器,然后判断请求头中是否含有 jwt 的信息,有就登录,没有就跳过。跳过之后,由 Controller 中的 shiro 注解再次进行拦截,比如 @RequiresAuthentication,来控制权限访问。

接下来就是 ShiroConfig 出现的 AccountRealm,还有 JwtFilter。

AccountRealm

AccountRealm 是 shiro 进行登录或者权限校验的逻辑所在,需要重写3个方法:

  • supports:为了让 realm 支持 jwtToken 凭证校验,因为shiro 默认支持的是UsernamePasswordToken,所以要自定义一个新类 JwtToken。

com.xxx.shiro.JwtToken

/**
 * 自定义 JwtToken类,来完成 shiro 的 supports 方法
 */
public class JwtToken implements AuthenticationToken {

    private String token;

    public JwtToken(String token) {
        this.token = token;
    }
    @Override
    public Object getPrincipal() {
        return token;
    }

    @Override
    public Object getCredentials() {
        return token;
    }
}
  • doGetAuthenticationInfo:认证
  • doGetAuthorizationInfo:授权

com.xxx.shiro.AccountRealm

/**
 * 登录认证和授权
 * 自定义Realm
 */
@Slf4j
@Component
public class AccountRealm extends AuthorizingRealm {

    @Autowired
    private JwtUtils jwtUtils;

    @Autowired
    private UserService userService;

    /**
     * shiro 默认 supports 的是UsernamePasswordToken,而我们现在采用的是 jwt 方式,
     * 所以这里 自定义一个新类 JwtToken,来完成 shiro 的 supports 方法。
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**
     * 认证
     * @param token 包含用户名和密码的令牌
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        JwtToken jwt = (JwtToken) token;
        log.info("jwt------------------>{ }", jwt);
        // 解析JWTtoken,从令牌 token 中拿到 用户id 和 用户名
        String userId = (String) jwtUtils.parseJWT((String) jwt.getPrincipal()).get("userId");
        String username = (String) jwtUtils.parseJWT((String) jwt.getPrincipal()).get("username");
        // 根据 用户id 查询用户
        User user = userService.getById(userId);
        if (user == null) {
            throw new UnknownAccountException("账户不存在!");
        }
        if (user.getStatus() == -1) {
            throw new LockedAccountException("账户已被锁定!");
        }
        if (!user.getUsername().equals(username)) {
            throw new UnknownAccountException("用户名错误!");
        }
        // 比较密码
        // 登录成功后返回的用户信息的实体
        AccountProfile profile = new AccountProfile();
        BeanUtil.copyProperties(user, profile);
        log.info("profile----------------->{}", profile.toString());
        // 返回认证信息  参数1:用户身份信息 参数2:加密后的密码 参数3:realm的名字
        return new SimpleAuthenticationInfo(profile, jwt.getCredentials(), getName());
    }
    
    /**
     * 授权
     * @param principals 身份集合信息
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        log.info("执行doGetAuthorizationInfo方法进行授权");
        // String username = JwtUtil.getUsername(principalCollection.toString());
        log.info(principals.toString());
        // log.info("登录的用户:" + username);
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();

        // 获取当前登录用户的【主身份信息】
        AccountProfile accountProfile = (AccountProfile) principals.getPrimaryPrincipal();
        // 拿到当前用户的所有角色(因为一个用户可以有多个角色)
        String[] roles = accountProfile.getRole().split(",");
        log.info("roles");
        // 根据用户的角色,来对用户进行授权
        for (String role : roles) {
            info.addRole(role);
            if (role.equals("role_root")) {
                info.addStringPermission("user:create");
                info.addStringPermission("user:update");
                info.addStringPermission("user:read");
                info.addStringPermission("user:delete");
            } else if (role.equals("role_admin")) {
                info.addStringPermission("user:read");
                info.addStringPermission("user:create");
                info.addStringPermission("user:update");
            } else if (role.equals("role_user")) {
                info.addStringPermission("user:read");
                info.addStringPermission("user:create");
            } else if (role.equals("role_guest")) {
                info.addStringPermission("user::read");
            }
        }

        // 返回权限信息
        return info;
    }
}
  • JwtUtils 是个生成和解析 JwtToken 的工具类,其中有些 jwt 相关的密钥信息是从配置文件中配置的。

com.xxx.util.JwtUtils

/**
 * jwt (Json Web Token)工具类
 * 用于 创建jwt字符串 和 解析jwt。
 */
@Slf4j
@Data
@Component
@ConfigurationProperties(prefix = "xxx.jwt")
public class JwtUtils {

    private String secret;
    private long expire;
    private String header;

    /**
     * 生成 Jwt Token 字符串
     * @param userId    签发人id
     * expireDate       过期时间 签发时间
     * claims           额外添加到荷部分的信息。
     *                  例如可以添加用户名、用户ID、用户(加密前的)密码等信息
     */
    public String createJWT(long userId, String username) {
        Date nowDate = new Date();
        // 过期时间
        Date expireDate = new Date(nowDate.getTime() + expire * 1000);
        //创建payload的私有声明(根据特定的业务需要添加,如果要拿这个做验证,一般是需要和jwt的接收方提前沟通好验证方式的)
        Map<String, Object> claims = new HashMap<String, Object>();
        claims.put("userId", userId+"");
        claims.put("username",username);
        return Jwts.builder()   // 创建 JWT 对象
                .setHeaderParam("typ", "JWT")   // 设置头部信息
                .setClaims(claims)      // 设置私有声明
                .setIssuedAt(nowDate)   // 设置payload的签发时间
                .setExpiration(expireDate)  // 这是payload的过期时间
                .signWith(SignatureAlgorithm.HS512, secret)// 设置安全密钥(生成签名所需的密钥和算法)
                .compact();     // 生成JWT token (1.编码 Header 和 Payload 2.生成签名 3.拼接字符串)
    }

    /**
     * 解析 token
     * JWT Token 由 头部 荷载部 和 签名部 三部分组成。签名部分是由加密算法生成,无法反向解密。
     * 而 头部 和 荷载部分是由 Base64 URL算法生成,是可以反向反编码回原样的。
     * 这也是为什么不要在 JWT Token 中放敏感数据的原因。
     *
     * @param token     加密后的token
     * @return  claims  返回荷载部分的键值对
     */
    public Claims parseJWT(String token) {
        try {
            return Jwts.parser()    // 创建解析对象
                    .setSigningKey(secret)  // 设置安全密钥(生成签名所需的密钥和算法)
                    .parseClaimsJws(token)  // 解析 token
                    .getBody();     // 获取 payload 部分内容
        } catch (Exception e) {
            log.debug("validate is token error ", e);
            return null;
        }
    }

    /**
     * token 是否过期
     * @return return true: 过期
     */
    public boolean isTokenExpired(Date expiration) {
        return expiration.before(new Date());
    }
}

  • 在 AccountRealm 中还用到了 AccountProfile,这是为了登录成功之后返回的一个用户信息的载体。

com.xxx.shiro.AccountProfile

**
 * 用于登陆成功后返回的一个【用户信息的载体/实体】
 * avatar   用户头像
 */
@Data
public class AccountProfile implements Serializable {

    /**
     * 用户id
     */
    private Long id;
    /**
     * 用户名
     */
    private String username;
    /**
     * 用户头像
     */
    private String avatar;
    /**
     * 用户角色
     */
    private String role;

}

3. 基本的校验完成后,进行少量的信息配置

application.yml:

shiro-redis:
  enabled: true
  redis-manager:
    host: 127.0.0.0
xxx:
  jwt:
    # 加密密钥, 部署上线务必修改此配置,以保证token的安全性
    secret: xxxxxxx
    expire: 172800
    header: token

4. 定义 jwt 的过滤器 JwtFilter

这个过滤器是重点,这里继承的是Shiro内置的AuthenticatingFilter,一个可以内置自动登录方法的过滤器,也可以继承BasicHttpAuthenticationFilter。

需要重写几个方法:

1) createToken:实现登录,需要生成我们自定义支持的 JwtToken。
2) onAccessDenied:拦截校验,当头部没有Authorization,直接通过,不需要自动登录;当头部带有的时候,首先要验证 jwt 的有效性,没问题就直接执行 executeLogin 方法自动登录。
3) onLoginFailure:登陆异常的时候进入的方法,我们直接把异常信息封装然后抛出
4) preHandle:拦截器的前置拦截,因为是前后端分离项目,项目中除了选用跨域全局配置之外,我们在拦截其中也要提供跨域支持。这样拦截器就不会在进入Controller之前就被限制了。

com.xxx.shiro.JwtFilter总体代码:

@Component
public class JwtFilter extends AuthenticatingFilter {

    @Autowired
    private JwtUtils jwtUtils;

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

    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        String token = request.getHeader("Authorization");
        // 当头部没有 Authorization的时候,直接通过,不需要自动登录;
        if (StringUtils.isEmpty(token)) {
            return true;
        } else {
            // 当头部带有的时候,首先要验证 jwt 的有效性,没问题就直接执行 executeLogin 方法自动登录
            // 判断是否已经过期
            Claims claims = jwtUtils.parseJWT(token);
            if (claims == null || jwtUtils.isTokenExpired(claims.getExpiration())) {
                throw new ExpiredCredentialsException("token已失效,请重新登录!");
            }
        }

        // 没有失效就执行自动登录
        return executeLogin(servletRequest, servletResponse);
    }

    /**
     * 登陆异常的时候进入的方法,我们`在这里插入代码片`直接把异常信息封装然后抛出
     */
    @Override
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        // 处理登录失败的异常
        try {
            Throwable throwable = e.getCause() == null ? e : e.getCause();
            Result re = Result.fail(throwable.getMessage());
            String json = JSONUtil.toJsonStr(re);
            httpResponse.getWriter().print(json);
        } catch (IOException ex) {

        }
        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);
    }
}

到这里 shiro 就已经完成了整合,并且使用了 jwt 进行身份验证。

ItVuer - 免责声明 - 关于我们 - 联系我们

本网站信息来源于互联网,如有侵权请联系:561261067@qq.com

桂ICP备16001015号