SpringBoot整合Shiro + JWT实现用户认证

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: SpringBoot整合Shiro + JWT实现用户认证的实现

SpringBoot整合Shiro + JWT实现用户认证

  登录和用户认证是一个网站最基本的功能,在这篇博客里,将介绍如何用SpringBoot整合Shiro + JWT实现登录及用户认证

  Shiro相较于Spring Security而言是一款轻量级的安全框架,使用它我们可以不在数据库中设计权限相关的表,如果我们只需要处理匿名可访问接口和登录后可访问接口,那么使用Shiro将会很方便。在之前的博客里,我介绍了Spring Security的使用,以及JWT的由来等,有需要的可以传送:

【全网最细致】SpringBoot整合Spring Security + JWT实现用户认证

文章目录

  • SpringBoot整合Shiro + JWT实现用户认证
  • 登录及访问接口流程
  • SpringBoot整合Shiro + JWT
  • Shiro整合redis
  • pom.xml添加相应依赖
  • 先写一个JWT工具类:JwtUtils
  • 登录接口
  • shiro进行用户认证的核心类:AuthorizingRealm
  • JwtFilter
  • 整合所有组件,进行Shiro全局配置:ShiroConfig
  • 对需要登录才能请求的接口使用@RequiresAuthentication注解
  • 前端可以做什么:路由权限拦截

登录及访问接口流程

  在此使用最基本的用户名密码登录来举例,首次登录流程如下:

1.png

  用户访问接口流程如下:

2.png

SpringBoot整合Shiro + JWT

Shiro整合redis

  Shiro进行认证和授权是默认基于session实现的,这里有2个问题,一是我们这里要使用JWT进行用户认证,用户不能通过session方式登录,二是Shiro将权限数据和会话信息保存在session中不能在集群或负载均衡中使用(因为不同服务器中的session不共享)。因此,考虑使用redis来存储shiro的缓存和会话信息,这里我们使用一个开源的shiro-redis-spring-boot-starter的jar包:shiro-redis

pom.xml添加相应依赖

  除了一些基本的依赖,我们还需要添加jwt和shiro-redis依赖

<dependency>
             <groupId>org.crazycake</groupId>
             <artifactId>shiro-redis-spring-boot-starter</artifactId>
             <version>3.2.1</version>
        </dependency>
        <!-- jwt -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

先写一个JWT工具类:JwtUtils

  我们需要写一个JWT工具类JwtUtils,该工具类需要有3个功能:生成JWT、解析JWT、判断JWT是否过期。直接上代码:

@Data
@Component
@ConfigurationProperties(prefix = "xiaolinbao.jwt")
public class JwtUtils {
    private long expire;
    private String secret;
    private String header;
    // 生成JWT
    public String generateToken(long userId) {
        Date nowDate = new Date();
        Date expireDate = new Date(nowDate.getTime() + 1000 * expire);
        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setSubject(userId+"")
                .setIssuedAt(nowDate)
                .setExpiration(expireDate)    // 7天过期
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }
    // 解析JWT
    public Claims getClaimsByToken(String jwt) {
        try {
            return Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(jwt)
                    .getBody();
        } catch (Exception e) {
            return null;
        }
    }
    // 判断JWT是否过期
    public boolean isTokenExpired(Claims claims) {
        return claims.getExpiration().before(new Date());
    }
}

登录接口

  登录接口是可匿名访问接口,若用户名密码正确,则生成jwt并写入http response的header中返回:

@PostMapping("/login")
    public Result login(@Validated @RequestBody LoginDto loginDto, HttpServletResponse response) {
        MUser user = userService.getOne(new QueryWrapper<MUser>().eq("username", loginDto.getUsername()));
        Assert.notNull(user, "用户不存在");
        if (!user.getPassword().equals(SecureUtil.md5(loginDto.getPassword()))) {
            return Result.fail("密码不正确");
        }
        String jwt = jwtUtils.generateToken(user.getId());
        response.setHeader("Authorization", jwt);
        response.setHeader("Access-control-Expose-Headers", "Authorization");
        return Result.succ(MapUtil.builder().put("id", user.getId())
                .put("username", user.getUsername())
                .put("avatar", user.getAvatar())
                .put("email", user.getEmail())
                .map());
    }

shiro进行用户认证的核心类:AuthorizingRealm

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

  • supports:为了让AuthorizingRealm支持jwt的凭证校验
  • doGetAuthorizationInfo:定义权限校验的过程,由于我们只有匿名接口和登录接口两种情况,不包含权限管理,因此该方法在此可以不进行具体实现
  • doGetAuthenticationInfo:进行用户认证校验,即在访问非匿名接口时,判断请求是否携带了正确的jwt

  shiro默认supports的是UsernamePasswordToken,而我们现在采用了jwt的方式,所以这里我们需要自定义一个JwtToken,来完成shiro的supports方法。JwtToken需要实现AuthenticationToken接口,AuthenticationToken接口中定义了两个方法getPrincipalgetCredentials,本意分别是表示获取用户信息,以及获取只被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接口:

3.png

  该类的第一个属性表示用户信息,我们可以定义一个用户信息封装类AccountProfile,到时候将其作为SimpleAuthenticationInfo构造函数中的第一个参数:

@Data
public class AccountProfile implements Serializable {
    private Long id;
    private String username;
    private String avatar;
    private String email;
}

  AccountRealm类的完整代码如下:

@Component
public class AccountRealm extends AuthorizingRealm {
    @Autowired
    JwtUtils jwtUtils;
    @Autowired
    MUserService userService;
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        return null;
    }
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        JwtToken jwtToken = (JwtToken) authenticationToken;
        String userId = jwtUtils.getClaimsByToken((String) jwtToken.getPrincipal()).getSubject();
        MUser user = userService.getById(Long.valueOf(userId));
        if (user == null) {
            throw new UnknownAccountException("账户不存在");
        }
        if (user.getStatus() == -1) {
            throw new LockedAccountException("账户已被锁定");
        }
        AccountProfile profile = new AccountProfile();
        BeanUtil.copyProperties(user, profile);
        return new SimpleAuthenticationInfo(profile, jwtToken.getCredentials(), getName());
    }
}

JwtFilter

  我们需自定义过滤器JwtFilter,该类继承于Shiro内置的AuthenticatingFilter,AuthenticatingFilter内置了登录方法executeLogin

  我们需要重写以下方法:

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

  JwtFilter的完整代码如下:

@Component
public class JwtFilter extends AuthenticatingFilter {
    @Autowired
    JwtUtils jwtUtils;
    @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);
    }
}

整合所有组件,进行Shiro全局配置:ShiroConfig

  我们主要要在ShiroConfig中做3件事:

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

  ShiroConfig的完整代码如下:

@Configuration
public class ShiroConfig {
    @Autowired
    JwtFilter jwtFilter;
    @Bean
    public SessionManager sessionManager(RedisSessionDAO redisSessionDAO) {
        DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
        sessionManager.setSessionDAO(redisSessionDAO);
        return sessionManager;
    }
    @Bean
    public DefaultWebSecurityManager securityManager(AccountRealm accountRealm,
                                                     SessionManager sessionManager,
                                                     RedisCacheManager redisCacheManager) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(accountRealm);
        securityManager.setSessionManager(sessionManager);
        securityManager.setCacheManager(redisCacheManager);
        /*
         * 关闭shiro自带的session,详情见文档
         */
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
        return securityManager;
    }
    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        Map<String, String> filterMap = new LinkedHashMap<>();
        filterMap.put("/**", "jwt"); // 主要通过注解方式校验权限
        chainDefinition.addPathDefinitions(filterMap);
        return chainDefinition;
    }
    @Bean("shiroFilterFactoryBean")
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,
                                                         ShiroFilterChainDefinition shiroFilterChainDefinition) {
        ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
        shiroFilter.setSecurityManager(securityManager);
        Map<String, Filter> filters = new HashMap<>();
        filters.put("jwt", jwtFilter);
        shiroFilter.setFilters(filters);
        Map<String, String> filterMap = shiroFilterChainDefinition.getFilterChainMap();
        shiroFilter.setFilterChainDefinitionMap(filterMap);
        return shiroFilter;
    }
    // 开启注解代理(默认好像已经开启,可以不要)
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }
    @Bean
    public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
        return creator;
    }
}

对需要登录才能请求的接口使用@RequiresAuthentication注解

  举个logout的例子:

@RequiresAuthentication
    @GetMapping("/logout")
    public Result logout() {
        SecurityUtils.getSubject().logout();
        return Result.succ(null);
    }

前端可以做什么:路由权限拦截

  我们在后端用Shiro定义了哪些接口是只有登录才能请求的,我们还可以在前端加上路由权限拦截,控制一下哪些页面是需要登录之后才能跳转的,如果未登录就访问对应页面就直接重定向到登录页面让用户登录,这样更加安全

  以Vue为例,我们需要在src目录下定义一个js文件,可称为permission.js:

import router from "./router"
// 路由判断登录 根据路由配置文件的参数
router.beforeEach((to, from, next) => {
    if (to.matched.some(record => record.meta.requireAuth)) { // 判断该路由是否需要登录权限
        const token = localStorage.getItem("token")
        console.log("------------" + token)
        if (token) { // 判断当前的token是否存在(登录时存入的token)
            if (to.path === '/login') {
            } else {
                next()
            }
        } else {
            next({
                path: '/login'
            })
        }
    } else {
        next()
    }
})

  然后我们在定义页面路由的时候定义meta信息,指定requireAuth: true,则该路由需要登录才能访问。

4.png

  最后在main.js中import我们的permission.js:

5.png

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
目录
相关文章
|
25天前
|
JSON 安全 算法
|
1月前
|
安全 Java 数据库
shiro学习一:了解shiro,学习执行shiro的流程。使用springboot的测试模块学习shiro单应用(demo 6个)
这篇文章是关于Apache Shiro权限管理框架的详细学习指南,涵盖了Shiro的基本概念、认证与授权流程,并通过Spring Boot测试模块演示了Shiro在单应用环境下的使用,包括与IniRealm、JdbcRealm的集成以及自定义Realm的实现。
43 3
shiro学习一:了解shiro,学习执行shiro的流程。使用springboot的测试模块学习shiro单应用(demo 6个)
|
25天前
|
存储 安全 Java
|
1月前
|
NoSQL Java Redis
shiro学习四:使用springboot整合shiro,正常的企业级后端开发shiro认证鉴权流程。使用redis做token的过滤。md5做密码的加密。
这篇文章介绍了如何使用Spring Boot整合Apache Shiro框架进行后端开发,包括认证和授权流程,并使用Redis存储Token以及MD5加密用户密码。
30 0
shiro学习四:使用springboot整合shiro,正常的企业级后端开发shiro认证鉴权流程。使用redis做token的过滤。md5做密码的加密。
|
15天前
|
JavaScript NoSQL Java
CC-ADMIN后台简介一个基于 Spring Boot 2.1.3 、SpringBootMybatis plus、JWT、Shiro、Redis、Vue quasar 的前后端分离的后台管理系统
CC-ADMIN后台简介一个基于 Spring Boot 2.1.3 、SpringBootMybatis plus、JWT、Shiro、Redis、Vue quasar 的前后端分离的后台管理系统
30 0
|
3月前
|
SQL Java 测试技术
在Spring boot中 使用JWT和过滤器实现登录认证
在Spring boot中 使用JWT和过滤器实现登录认证
222 0
|
6月前
|
安全 数据安全/隐私保护
Springboot+Spring security +jwt认证+动态授权
Springboot+Spring security +jwt认证+动态授权
207 0
|
4月前
|
JSON 安全 Java
使用Spring Boot和JWT实现用户认证
使用Spring Boot和JWT实现用户认证
|
1月前
|
JSON NoSQL Java
springBoot:jwt&redis&文件操作&常见请求错误代码&参数注解 (九)
该文档涵盖JWT(JSON Web Token)的组成、依赖、工具类创建及拦截器配置,并介绍了Redis的依赖配置与文件操作相关功能,包括文件上传、下载、删除及批量删除的方法。同时,文档还列举了常见的HTTP请求错误代码及其含义,并详细解释了@RequestParam与@PathVariable等参数注解的区别与用法。
|
1月前
|
存储 JSON 算法
JWT令牌基础教程 全方位带你剖析JWT令牌,在Springboot中使用JWT技术体系,完成拦截器的实现 Interceptor (后附源码)
文章介绍了JWT令牌的基础教程,包括其应用场景、组成部分、生成和校验方法,并在Springboot中使用JWT技术体系完成拦截器的实现。
72 0
JWT令牌基础教程 全方位带你剖析JWT令牌,在Springboot中使用JWT技术体系,完成拦截器的实现 Interceptor (后附源码)