# springboot整合springSecurity+JWT **Repository Path**: griffinwh/springboot-security-jwt ## Basic Information - **Project Name**: springboot整合springSecurity+JWT - **Description**: 登录: 自定义登录接口 调用ProviderManager的方法进行认证,如果认证通过生成jwt,把用户信息存入redis中 自定义UserDetailsService 在这个实现查询数据库 校验: 定义jwt认证过滤器 获取token 解析token,获取其中userid 从redis中获取用户信息 存入securityContextHolder中 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2025-03-20 - **Last Updated**: 2025-03-20 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # springboot整合springSecurity+JWT # 1,快速入门 ## 1.1 搭建springboot项目 ![image.png](./assets/1.png) ## 1.2 导 SpringSecurity依赖 ``` org.springframework.boot spring-boot-starter-security ``` 导入依赖后,再启动项目,security会自动拦截访问的接口。用户名:user;密码会在控制台中打印出来。 # 2,认证 ## 2.1 登录校验流程 ![image.png](./assets/2.png) ## 2.1 原理初探 ### 2.2.1 SpringSecurity 完整流程 SpringSecurity的原理其实是一个过滤器链,内部包含各种功能过滤器。下图为引入security依赖后自带的过滤器。 ![image.png](./assets/3.png) - UsernamePasswordAuthenticationFilter:对用户名密码的过滤。 - ExceptionTranslationFilter:对异常的捕获处理。 - FilterSecurityInterceptor:权限校验过滤器。 ![image.png](./assets/4.png) ### 2.2.2 认证流程详解 ![image.png](./assets/5.png) 登录接口 ![image.png](./assets/6.png) 其他接口 ![image.png](./assets/7.png) ## 2.3 解决问题 ### 2.3.1 思路分析 登录: - 自定义登录接口 - 调用ProviderManager的方法进行认证,如果认证通过生成jwt,把用户信息存入redis中 - 自定义UserDetailsService - 在这个实现查询数据库 校验: - 定义jwt认证过滤器 - 获取token - 解析token,获取其中userid - 从redis中获取用户信息 - 存入securityContextHolder中 ### 2.3.2 准备工作 #### 配置redis 添加依赖 ```xml org.springframework.boot spring-boot-starter-data-redis ``` 添加配置类 ```java @Configuration public class RedisConfig { @Bean public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){ RedisTemplate redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory); //key采用String的序列化方式 redisTemplate.setKeySerializer(new StringRedisSerializer()); // hash的key也采用String的序列化方式 redisTemplate.setHashKeySerializer(new StringRedisSerializer()); RedisSerializer serializer = redisSerializer(); // value序列化方式采用jackson redisTemplate.setValueSerializer(serializer); // hash的value序列化方式采用jackson redisTemplate.setHashValueSerializer(serializer); redisTemplate.afterPropertiesSet(); return redisTemplate; } @Bean public RedisSerializer redisSerializer(){ //创建json序列化器 Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer(Object.class); ObjectMapper objectMapper = new ObjectMapper(); objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); //必须设置,否则无法将json转化为对象,会转化为map类型 objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,ObjectMapper.DefaultTyping.NON_FINAL); serializer.setObjectMapper(objectMapper); return serializer; } } ``` 改yml ```yaml spring: redis: host: 127.0.0.1 port: 6379 ``` #### 封装返回结果 ```java public class CommonResult{ private long code; private String message; private T data; protected CommonResult() { } public CommonResult(long code, String message, T data) { this.code = code; this.message = message; this.data = data; } /** * 成功返回结果 * @param data 获取的数据 * @return */ public static CommonResult success(T data){ return new CommonResult(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data); } /** * 成功返回结果 * @param message 提示信息 * @param data 获取的数据 * @return */ public static CommonResult success(String message,T data){ return new CommonResult(ResultCode.SUCCESS.getCode(), message, data); } /** * 失败返回结果 */ public static CommonResult failed(){ return new CommonResult(ResultCode.FAILED.getCode(), ResultCode.FAILED.getMessage(), null); } /** * 失败返回结果 * @param message 提示信息 * @return */ public static CommonResult failed(String message){ return new CommonResult<>(ResultCode.FAILED.getCode(), message,null); } /** * 失败返回结果 * @param errorCode 错误码 * @return */ public static CommonResult failed(IErrorCode errorCode){ return new CommonResult<>(errorCode.getCode(),errorCode.getMessage(),null); } /** * 失败返回结果 * @param errorCode 错误码 * @param message 错误信息 */ public static CommonResult failed(IErrorCode errorCode, String message) { return new CommonResult(errorCode.getCode(), message, null); } /** * 参数验证失败返回结果 */ public static CommonResult validateFailed() { return failed(ResultCode.VALIDATE_FAILED); } /** * 参数验证失败返回结果 * @param message 提示信息 */ public static CommonResult validateFailed(String message) { return new CommonResult(ResultCode.VALIDATE_FAILED.getCode(), message, null); } /** * 未登录返回结果 */ public static CommonResult unauthorized(T data) { return new CommonResult(ResultCode.UNAUTHORIZED.getCode(), ResultCode.UNAUTHORIZED.getMessage(), data); } /** * 未授权返回结果 */ public static CommonResult forbidden(T data) { return new CommonResult(ResultCode.FORBIDDEN.getCode(), ResultCode.FORBIDDEN.getMessage(), data); } public long getCode() { return code; } public void setCode(long code) { this.code = code; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public T getData() { return data; } public void setData(T data) { this.data = data; } } ``` ```java public enum ResultCode implements IErrorCode { SUCCESS(200,"操作成功"), FAILED(500,"操作失败"), VALIDATE_FAILED(404,"参数检验失败"), UNAUTHORIZED(401,"没登录或token过期"), FORBIDDEN(403,"没有相关权限"); private long code; private String message; private ResultCode(long code,String message){ this.code = code; this.message = message; } @Override public long getCode() { return code; } @Override public String getMessage() { return message; } } ``` ```java public interface IErrorCode { long getCode(); String getMessage(); } ``` #### 引入jwt 引依赖 ```xml io.jsonwebtoken jjwt 0.9.1 ``` 工具类 ### 2.3.3 实现 #### 2.3.3.1 数据库校验用户 **数据库配置** 导入数据库依赖 ```xml com.baomidou mybatis-plus-boot-starter 3.5.1 com.baomidou mybatis-plus-generator 3.5.2 org.apache.velocity velocity-engine-core 2.3 mysql mysql-connector-java 8.0.29 com.alibaba druid-spring-boot-starter 1.2.9 ``` mybatis-plus配置 ```java @Configuration public class MybatisPlusConfig { /** * 分页插件 * @return */ @Bean public MybatisPlusInterceptor mybatisPlusInterceptor(){ MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } } ``` 代码生成器配置 ```java public class MybatisPlusGenerator { /** *

* 读取控制台内容 *

*/ public static String scanner(String tip) { Scanner scanner = new Scanner(System.in); StringBuilder help = new StringBuilder(); append("请输入" + tip + ":"); System.out.println(toString()); if (scanner.hasNext()) { String ipt = scanner.next(); if (StringUtils.isNotEmpty(ipt)) { return ipt; } } throw new MybatisPlusException("请输入正确的" + tip + "!"); } public static void main(String[] args) { //获取程序当前路径 String projectPath = System.getProperty("user.dir"); // String projectPath = "D:\java测试目录"; // 数据源配置 String dbUrl = "jdbc:mysql://localhost:3306/mall_tiny?characterEncoding=UTF-8&useUnicode=true&useSSL=false"; DataSourceConfig.Builder dataSourceConfig = new DataSourceConfig.Builder(dbUrl,"root","12345") .dbQuery(new MySqlQuery()) .typeConvert(new MySqlTypeConvert()) .keyWordsHandler(new MySqlKeyWordsHandler()); // 代码生成器 FastAutoGenerator mpg = FastAutoGenerator.create(dataSourceConfig); mpg.globalConfig(globalBuilder -> globalBuilder.fileOverride().disableOpenDir() .outputDir(projectPath + "/src/main/java") .author("") .commentDate("yyyy-MM-dd HH:mm:ss") .dateType(DateType.TIME_PACK) .enableSwagger()); mpg.packageConfig(packageBuilder -> packageBuilder .parent("com.xybian.mall.tiny") .service("services") .serviceImpl("services.impl") .xml("mybatis") ); mpg.strategyConfig(strategyconfigBuilder -> strategyconfigBuilder .enableCapitalMode() .enableSkipView () .disableSqlFilter() .addInclude(scanner("表名")) ); //entity 生成策略 mpg.strategyConfig(strategyconfigBuilder -> strategyconfigBuilder.entityBuilder() // .enableTableFieldAnnotation () .naming (NamingStrategy.underline_to_camel) .columnNaming (NamingStrategy.underline_to_camel) .idType(IdType.AUTO) // .enableLombok () /*.logicDeleteColumnName ( "deleted ").logicDeletePropertyName ( "deleted ") .addTableFills(new Column( "create_time" , FieldFill.INSERT)) .addTableFills(new Property( "updateTime " ,FieldFill.INSERT_UPDATE))*/ .versionColumnName ("version") .disableSerialVersionUID() ); //controller 生成策略 mpg.strategyConfig( strategyconfigBuilder ->strategyconfigBuilder .controllerBuilder() .enableRestStyle() .enableHyphenStyle() ); //service 生成策略 mpg.strategyConfig( strategyconfigBuilder -> strategyconfigBuilder .serviceBuilder() .formatServiceFileName ("%sService") .formatServiceImplFileName ("%sServiceImpl")); //mapper 生成策略 mpg.strategyConfig ( strategyconfigBuilder ->strategyconfigBuilder.mapperBuilder() .formatMapperFileName ("%sMapper") .formatXmlFileName ( "%sMapper" ) .enableBaseResultMap()); mpg.execute(); } } ``` 改yml ```yaml spring: redis: host: 127.0.0.1 port: 6379 datasource: druid: url: jdbc:mysql://localhost:3306/sys_secutiry?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver #mybatis plus 设置 mybatis-plus: mapper-locations: classpath:/mybatis/*Mapper.xml global-config: db-config: id-type: auto configuration: auto-mapping-behavior: partial map-underscore-to-camel-case: true ``` 添加包扫描 ![image.png](./assets/8.png) 添加controller,mapper,service 并测试 ![image.png](./assets/9.png) **校验配置** 有上面讲解的认证流程,我们可以知道,主要是修改 **UserDetailsService** 这个接口的实现类,来实现从数据库去对 用户名和密码 的校验。 ```java @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private SysUserService sysUserService; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //从数据库查询校验 LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(SysUser::getUserName,username); SysUser sysUser = sysUserService.getOne(wrapper); if (sysUser == null){ throw new UsernameNotFoundException("用户不存在"); } //授权 //返回 return new UserDetailsImpl(sysUser); } } ``` 因为上面的返回,需要为UserDetails接口,所以在定义一个UserDetailsImpl类,实现接口,去返回数据。 ```java @Data @NoArgsConstructor @AllArgsConstructor public class UserDetailsImpl implements UserDetails { private SysUser sysUser; @Override public Collection getAuthorities() { return null; } @Override public String getPassword() { return sysUser.getPassword(); } @Override public String getUsername() { return sysUser.getUserName(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } } ``` 以上修改完成后,就可以做到从数据库去认证用户。 **SpringSecurity密码校验机制**:数据库存储的密码格式为 **{加密方式}password** 。校验时,会先从数据库读取密码,获取密码前的 { } 中的加密方式,然后用这个加密方式对登录时的密码加密,再和数据库密码对比。 注:{noop} 表示不加密,按明文显示。 #### 2.3.3.2 密码加密存储 修改SpringSecurity默认的加密方式,改成 **BCryptPasswordEncoder** ```java @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { //创建 BCryptPasswordEncoder 注入容器 @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } } ``` #### 2.3.3.3 登录接口 先自定义一个登录接口 ```java @RestController public class LoginController { @Autowired private LoginService loginService; @PostMapping("/user/login") public CommonResult login(@RequestBody SysUser user) throws AuthenticationException { CommonResult result = loginService.login(user); return result; } } ``` 登录方法的实现 ```java @Service public class LoginServiceImpl implements LoginService { @Autowired private AuthenticationManager authenticationManager; @Autowired private JwtTokenUtil jwtTokenUtil; @Autowired private RedisTemplate redisTemplate; @Override public CommonResult login(SysUser user) throws AuthenticationException { //封装 Authentication UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword()); //认证用户 Authentication authenticate = authenticationManager.authenticate(authenticationToken); if (authenticate == null){ throw new AuthenticationException("认证失败"); } UserDetailsImpl userDetails = (UserDetailsImpl)authenticate.getPrincipal(); SysUser sysUser = userDetails.getSysUser(); //认证通过,生成jwt Map map = new HashMap<>(); map.put("userId",sysUser.getId()); String token = jwtTokenUtil.generateToken(map); //用户信息存入 redis 中 redisTemplate.opsForValue().set("user-"+sysUser.getId(),sysUser); //将token信息返回 Map dataMap = new HashMap<>(); dataMap.put("token",token); return CommonResult.success(dataMap); } } ``` securityConfig配置 ```java @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { //创建 BCryptPasswordEncoder 注入容器 @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } //为登录接口 提供下一步 调用的方法 @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } //放行登录方法 @Override protected void configure(HttpSecurity http) throws Exception { http //关闭 csrf .csrf().disable() //不通过 session 获取 SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() //对登录接口 允许匿名访问 /user/login .antMatchers("/user/login").anonymous() //除上面外的所有请求全部需要 鉴权认证 .anyRequest().authenticated(); } } ``` 接口测试 ![image.png](./assets/10.png) #### 2.3.3.4 认证过滤器 添加一个对token验证的过滤器,同时设置该过滤器在 **UsernamePasswordAuthenticationFilter** 之前运行,这个保证先验证token,在验证用户登录。 ```java @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private JwtTokenUtil tokenUtil; @Autowired private RedisTemplate redisTemplate; @SneakyThrows @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { // 获取 token String token = httpServletRequest.getHeader("token"); if (!StringUtils.hasText(token)){ filterChain.doFilter(httpServletRequest,httpServletResponse); return; } // 解析token,获取 userId Claims claims = tokenUtil.getClaimsFromToken(token); String userId = claims.get("userId").toString(); //从redis中获取User Object o = redisTemplate.opsForValue().get("user-" + userId); if (ObjectUtils.isEmpty(o)){ throw new AuthenticationException("没有用户信息"); } SysUser user = (SysUser)o; //将User 封装到 securityContextHolder。 封装到securityContextHolder以后,其他过滤器就不会在拦截 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user,null,null); SecurityContextHolder.getContext().setAuthentication(authenticationToken); filterChain.doFilter(httpServletRequest,httpServletResponse); } } ``` securityConfig中的配置 ```java @Override protected void configure(HttpSecurity http) throws Exception { http //关闭 csrf .csrf().disable() //不通过 session 获取 SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() //对登录接口 允许匿名访问 /user/login .antMatchers("/user/login").anonymous() //除上面外的所有请求全部需要 鉴权认证 .anyRequest().authenticated(); //定义filter的先后顺序,保证 jwtFilter比用户验证的过滤器先执行 http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class); } ``` #### 2.3.3.5 退出登录 因为每一次请求都是一个新的 securityContextHolder,所以将 redis 中用户信息删除后,在用token访问接口时,在获取 redis 用户信息时,无法通过,即注销成功。 ```java @GetMapping("/user/logout") public CommonResult logout(){ return loginService.logout(); } ``` ```java @Override public CommonResult logout() { //获取 securityContextHolder Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); SysUser user = (SysUser)authentication.getPrincipal(); //删除redis中的用户信息 redisTemplate.delete("user-"+user.getId()); return CommonResult.success("注销成功",null); } ``` # 3,授权 ## 1,开启权限注解 ![image.png](./assets/11.png) ## 2,在需要设置权限的接口上加上权限限制 ![image.png](./assets/12.png) ## 3,在做登录用户校验的时候,从数据库查询设置该用户的权限,本测试先写死。 ![image.png](./assets/13.png) ![image.png](./assets/14.png) ## 4,对token过滤器修改 ```java @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private JwtTokenUtil tokenUtil; @Autowired private RedisTemplate redisTemplate; @SneakyThrows @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { // 获取 token String token = httpServletRequest.getHeader("token"); if (!StringUtils.hasText(token)){ filterChain.doFilter(httpServletRequest,httpServletResponse); return; } // 解析token,获取 userId Claims claims = tokenUtil.getClaimsFromToken(token); String userId = claims.get("userId").toString(); //从redis中获取User Object o = redisTemplate.opsForValue().get("user-" + userId); if (ObjectUtils.isEmpty(o)){ throw new AuthenticationException("没有用户信息"); } SysUser user = (SysUser)o; //用户权限信息 List authorityList = user.getPermissions().stream().map(permission -> new SimpleGrantedAuthority(permission)).collect(Collectors.toList()); //将User 封装到 securityContextHolder。 封装到securityContextHolder以后,其他过滤器就不会在拦截 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user,null,authorityList); SecurityContextHolder.getContext().setAuthentication(authenticationToken); filterChain.doFilter(httpServletRequest,httpServletResponse); } } ``` **RBAC**权限模型 ![image.png](./assets/15.png) # 4,自定义失败提示 实现这个两个接口。再将这两个实现类添加到securityConfig的配置中。 **AuthenticationEntryPoint 认证异常** **AccessDeineHandler 鉴权异常** ```java @Component public class MyAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException { Map map = new HashMap<>(); map.put("uri",httpServletRequest.getRequestURI()); map.put("msg","鉴权失败"); httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN); httpServletResponse.setCharacterEncoding("utf-8"); httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE); ObjectMapper objectMapper = new ObjectMapper(); String resBody = objectMapper.writeValueAsString(map); PrintWriter printWriter = httpServletResponse.getWriter(); printWriter.print(resBody); printWriter.flush(); printWriter.close(); } } ``` ```java @Component public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { Map map = new HashMap<>(); map.put("uri",httpServletRequest.getRequestURI()); map.put("msg","认证失败"); httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); httpServletResponse.setCharacterEncoding("utf-8"); httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE); ObjectMapper objectMapper = new ObjectMapper(); String resBody = objectMapper.writeValueAsString(map); PrintWriter printWriter = httpServletResponse.getWriter(); printWriter.print(resBody); printWriter.flush(); printWriter.close(); } } ``` # 5,跨域 浏览器出于安全的考虑,使用XMLHttpRequest对象发起HTTP请求时必须遵守同源策略,否则就是跨域的HTTP请求,默认情况下是被禁止的。同源策略要求源相同才能正常进行通信,即**协议、域名、端口号**都完全一致。 先在springboot中配置 ![image.png](./assets/16.png) 再在springsecurity中配置 ![image.png](./assets/17.png) # 6,补充 ## 6.1 自定义权限校验方法 ```java /** * 自定义权限校验方法 */ @Component public class AuthenticationExpression { public boolean hasTrue(String permission){ //从 securityContextHolder中获取用户权限 Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); UserDetailsImpl userDetails = (UserDetailsImpl)authentication.getPrincipal(); //权限信息 List permissions = userDetails.getPermissions(); //判断是否有权限 return permissions.contains(permission); } } ``` ![image.png](./assets/18.png) ## 6.2,基于配置设置设置权限 ![image.png](./assets/19.png) ## 6.3, CSRF CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一。 https://blog.csdn.net/freeking101/article/details/86537087 SpringSecurity去防止CSRF攻击的方式就是通过csrf_token。 后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。 我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是token,而token并不是存储中cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。 ## 6.4 处理器 **认证成功处理器** **认证失败处理器** **登录成功处理器** # 7,代码地址 **gitee**地址:https://gitee.com/xinyunbian/springboot-security.git # 8,参考讲解 - https://home.cnblogs.com/u/bug9/ 的SpringSecurity讲解。