Spring Security+JWT+Redis实现项目级前后端分离认证授权

1. 整体概述

权限管理 包括用户身份认证和授权两部分,简称认证授权。对于需要访问控制到资源,用户首先经过身份认证,认证通过后用户具有该资源的访问权限方可访问。

1.1 认证概述

认证是确认用户身份的过程,确保用户是谁。

1.1.1 认证组件

Spring Security的认证过程涉及以下组件

AuthenticationManager:作为认证的核心,AuthenticationManager负责处理用户的认证请求。它会委托不同的认证提供者(例如DaoAuthenticationProvider)来验证用户身份。AuthenticationManager接收一个包含用户凭证(如用户名、密码)的Authentication对象,然后通过认证提供者验证这些凭证的正确性。如果验证通过,AuthenticationManager会返回一个包含认证信息的Authentication对象。

Authentication:Authentication表示用户的认证信息,通常包括用户名、密码以及认证后得到的角色和权限。它是Spring Security中用户身份验证的核心数据结构。Authentication接口通常由UsernamePasswordAuthenticationToken等实现类来表示。认证完成后,Authentication对象将包含用户的认证状态和权限信息。

SecurityContextHolder:SecurityContextHolder是Spring Security中的核心类之一,它用来存储当前用户的认证信息。认证成功后,Authentication对象会被保存在SecurityContext中,SecurityContext会由SecurityContextHolder管理。在每个请求周期内,SecurityContextHolder提供对当前用户认证信息的访问,使得后续的请求可以通过SecurityContextHolder.getContext().getAuthentication()来获取当前用户的身份信息。

1.1.2 认证过滤器链

1.1.3 认证流程步骤

1.2 授权概述

授权是根据用户身份信息判断用户是否有权限访问某些资源的过程。Spring Security的授权过程涉及以下组件:

AccessDecisionManager:AccessDecisionManager负责根据用户的认证信息和请求的资源,做出是否允许访问的决策。它会根据配置的权限要求以及用户的角色信息,决定用户是否能够访问特定的资源。AccessDecisionManager会使用多个AccessDecisionVoter来进行投票,综合多个投票结果后,做出最终的访问决策。

AccessDecisionVoter:AccessDecisionVoter是负责对用户访问权限进行投票的组件。它根据用户的Authentication对象和访问资源的ConfigAttribute(例如角色或权限要求)进行匹配。每个AccessDecisionVoter会判断用户是否符合特定的访问要求,并给出投票结果。所有投票的结果会由AccessDecisionManager汇总,最终决定是否允许访问。

ConfigAttribute:ConfigAttribute用于描述受保护资源的权限要求。它定义了访问某个资源所需的权限(如角色或操作权限)。ConfigAttribute通常与AccessDecisionManager结合使用,它告知AccessDecisionVoter该资源需要哪些权限,AccessDecisionVoter则根据这些要求判断用户是否具备访问该资源的权限。

1.3. 引入依赖

一旦引入Spring security依赖后,系统中所有的资源都受保护起来,必须进行认证之后才能够访问,没有认证直接访问资源时,会跳转到Spring security默认的登录页面:http://localhost:8080/login

XML 复制代码
<!--Spring security -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Spring security会默认提供一个用户和密码,用户是user,密码是启动时的一个字符串:

如果觉得启动时生成的默认密码比较长不方便登录,也可以使用配置文件配置固定的用户名和密码,密码前缀{noop}表示密码是明文。

2. 实现思路

整个基于security框架实现认证授权思路如上所示,绿色框是框架已经实现的内容,白色框需要用户改写和实现的内容。

2.1 认证登陆

完成上图中白色框中的内容,具体步骤如下所示。

2.1.1 查询数据库用户

步骤1: 通过登录用户名查数据库中用户信息

通过自定义UserDetailsService,改写里面的loadUserByUsername方法,利用mybatis或其他框架从数据库中查询出用户信息。

除此之外,改写UserDetails接口中的方法,把数据库中查出的用户信息封装成UserDetails进行返回。

java 复制代码
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserMapper userMapper;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //查询用户信息
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getUserAccount,username);
        User user = userMapper.selectOne(queryWrapper);
        if(user==null){
            throw  new RuntimeException("此用户"+username+" 不存在");
        }

        //TODO 查询对应的权限信息

        // 把数据封装成UserDetails
        return new LoginUser(user);
    }
}
java 复制代码
@Data // get set 方法
@NoArgsConstructor // 空参构造器
@AllArgsConstructor // 全参构造器
public class LoginUser implements UserDetails {
    private User user;
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public String getPassword() {
        return user.getPassWord();
    }

    @Override
    public String getUsername() {
        return user.getUserAccount();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

完成以上代码后,在数据库中存入用户,打开security默认登录页面测试是否能够使用数据库中存入的用户进行页面登录。

注意:因为目前还未做用户密码加密,密码是按明文存储的,所以在密码前需要加上{noop}标识

弹出以上界面说明登录成功,第一步操作完成。

2.1.2 密码加密功能

步骤2: 用户密码加密功能

增加security配置,指定加密方式,从而自动实现用户密码按加密方式匹配,并要求用户新增接口和修改接口中,密码字段要调用配置中的加密方式进行明文加密,然后再存储数据库中。

增加security配置,定义密码加密方式。

java 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig  {
    // 配置密码加密方式,全局自动按这个方式加密
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

增加此配置后,所有密码均采用此方式进行加密和匹配,当用户输入明文密码登录时,security框架会自动进行此方法进行加密后和数据库密码进行匹配。

注意:此时数据库存储的密码不应该是明文了,在用户注册或修改密码时,也应该调用此加密方法对明文进行加密并存储到数据库中。

此接口提供了两个方法,一个是明文加密,一个是密文匹配,测试方法如下:

2.1.3 登录接口编写

步骤3: 登录接口实现

首先在security配置类中配置认证管理器AuthenticationManager,此组件的作用是通过传入前端输入的用户名和密码,调用UserDetailsServicel去后台数据库比对用户信息,如果认证成功返回数据库用户的详情信息,失败返回null。

传入用户名和密码前需要封装成authenticationToken对象,根据认证结果编写代码处理逻辑。如果认证失败,抛出异常报错给前端,如果认证成功,返回UserDetailsServicel的LoginUser用户,通过LoginUser获取userID,生成jwt返回给前端

java 复制代码
// 配置认证管理器
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

编写登录接口controller以及service实现类

java 复制代码
@RestController
@CrossOrigin
@RequestMapping("api/v1")
@Tag(name= "用户登录接口文档") // 描述controller类的作用
@Slf4j
public class LoginController {
    @Autowired
    LoginService loginService;
    @PostMapping("/authentication/login")
    @Operation(summary = "用户登陆接口")
    public R login(@RequestBody UserLoginVo user){
        try {
            Map<String, String> userLogin = loginService.login(user);
            return R.ok("登录成功",userLogin);
        }catch (Exception e){
            log.error(e.getMessage());
            return R.error(500,e.getMessage());
        }
    }
}
java 复制代码
@Override
public Map<String, String> login(UserLoginVo user) {
    // AuthenticationManger authenticate 进行用户认证
    UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(user.getUserAccount(),user.getPassWord()); // 用户名和密码封装成 authenticationToken对象
    Authentication authenticate = authenticationManager.authenticate(authenticationToken); // 通过authenticationManager进行验证
    // 如果认证没通过,给出对应提示
    if(Objects.isNull(authenticate)){
        throw new RuntimeException("登录失败");
    }
    // 如果认证通过了,使用userid生成一个jwt, jwt存入ResponseResult返回
    LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
    String userId = loginUser.getUser().getUserId().toString();
    String jwt = JwtUtil.createJWT(userId);
    // 把用户信息存入redis
//        redisService.setValue("login:" + userId, JSON.toJSONString(loginUser));
    redisService.setObject("login:" + userId,loginUser);
    // 把jwt封装成map返回前端
    HashMap<String, String> map = new HashMap<>();
    map.put("token",jwt);
    return map;
}

值得注意的是,由于登录接口是匿名登录的,需要进行放行,否则接口需要认证,无法访问。具体配置如下:

在配置中建议登录接口以及注册接口使用.anonymous(),仅对未认证(匿名)用户开放,已认证用户不可访问此资源。

在一些公开资源、静态资源、开放接口等建议使用.permitAll(),不需要任何身份验证,即允许已认证和未认证的用户。

java 复制代码
// 3.设置路径权限
http
    .authorizeHttpRequests()
    .requestMatchers("/api/v1/authentication/login").anonymous()  //对于登录接口,允许匿名访问
    .requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll() // 允许匿名访问这些路径
    .anyRequest().authenticated(); // 除上面外的所有请求全部需要鉴权认证

通过如上步骤,最终接口登录成功后会返回token

2.2 校验

2.2.1 定义jwt认证过滤器

步骤1:获取token, 解析token获取其中的userid

步骤2:通过userid 从 redis中获取用户信息

步骤3: 将用户信息存入securityContextHolder

如果在登录时存入 SecurityContextHolder,但应用是无状态的,每次请求时SecurityContextHolder 其实都是空的,无法保持状态。因为 Spring Security 的 SecurityContextHolder 只是一个线程级变量(ThreadLocal),它的生命周期仅限于当前请求的处理过程中。当一次 HTTP 请求到达服务器时:服务器分配一个线程处理请求。SecurityContextHolder 仅在该线程内存储 SecurityContext(认证信息)。当请求处理结束后,线程被回收,SecurityContextHolder 也被清空。

正确的做法是在用户每次请求时,解析 Token,并动态地将用户信息存入 SecurityContextHolder。

java 复制代码
@Component
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Autowired
    private RedisService redisService;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 获取token
        String token = request.getHeader("token");
        if (!StringUtils.hasText(token)) {
            // 没有token就放行,让后面过滤器处理
            filterChain.doFilter(request, response);
            return;
        }
        // 解析token
        String userID;
        try {
            Claims claims = JwtUtil.parseJWT(token);
            userID = claims.getSubject();
        } catch (Exception e) {
            throw new RuntimeException("token非法:" + e.getMessage());
        }
        // 获取用户信息
//        Object userObj  = redisService.getValue("login:" + userID);
//        LoginUser user = JSON.parseObject((String) userObj, LoginUser.class);
        LoginUser user = redisService.getObject(("login:" + userID), LoginUser.class);
        if(user==null){
            throw new RuntimeException("用户未登录");
        }
        // 存入SecurityContextHolder
        //TODO 获取权限信息封装到Authentication中
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                new UsernamePasswordAuthenticationToken(user,null,null);  // 用户、密码、权限集合
        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
        // 放行
        filterChain.doFilter(request,response);
    }
}

2.2.2 添加到过滤器链中

先注入过滤器实例,然后在security配置中添加倒数第二行代码

java 复制代码
// 4. 添加认证过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

2.2.3 登出接口编写

因为认证过滤器对用户进行校验时,会根据token获取userid,并读取登录时存入redis中的用户信息,如果redis的用户信息读不到,就会被认证过滤器拦截,根据这一特性,可以设计登出接口,即删除用户在redis中的用户信息。

用户访问登出接口后,在SecurityContextHolder中获取userid,并删除redis中的信息

java 复制代码
@Override
public void logout() {
    // 获取SecurityContextHolder中的用户ID
    UsernamePasswordAuthenticationToken authentication =
            (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    Integer userId = loginUser.getUser().getUserId();
    // 删除redis中的值
    redisService.deleteValue("login:"+userId);
}

2.3 授权

2.3.1 设置资源权限

通过注解的方式进行资源权限的标识,决定各个接口需要什么样的权限才能访问。

步骤1:开启注解,在security配置类添加注解 @EnableMethodSecurity // 开启权限注解

java 复制代码
@EnableMethodSecurity  // 开启权限注解
public class SecurityConfig  {
    ...
}

步骤2: 在接口上添加注解配置权限 @PreAuthorize("hasAuthority('test')")

java 复制代码
@PreAuthorize("hasAuthority('sys:device:getList')")
@GetMapping("/device/getList")
public R getDevice(@PathVariable("id") Integer id){
    ... 
}

步骤2中除了可以在接口上添加注解的方式设置权限,还可以基于配置进行权限设置,在securityConfig配置类中添加如下代码等同于注解:

java 复制代码
http
.authorizeHttpRequests()
.requestMatchers("/api/v1/device/getList")
.hasAuthority("sys:device:getList");

2.3.2 封装权限信息

将用户的权限信息封装成security需要的对象,以便在登录和校验时,能够将权限信息输入到对应的结构中。

在LoginUser类中构造有参构造器,并重写getAuthorities方法;

java 复制代码
public class LoginUser implements UserDetails {
    private User user;
    private List<String> permissions;
    
    /**
     * 有参构造器
     * */
    public LoginUser(User user,List<String> permissions){
        this.user = user;
        this.permissions = permissions;
    }
    /**
     * 获取权限对象
     * */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        log.debug("permissions:"+permissions);
        ArrayList<GrantedAuthority> authorities = new ArrayList<>();
        // 把permissions中string类型的权限信息封装成simpleGrantedAuthority对象
        for (String permission : permissions) {
            SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permission);
            authorities.add(authority);
            log.debug("authorities:"+authorities);
        }
        return authorities;
    }
}

在UserDetailsService中调用LoginUser的有参构造器,将用户信息和权限信息注入到LoginUser实例中,以供登录时AuthenticationManager认证管理器调用返回用户权限信息。登录接口获取到认证管理器的用户权限信息后会存入redis中,在下一步中,认证过滤器会取出相关权限信息存入到SecurityContextHolder中以供后续过滤器校验。

java 复制代码
@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //查询用户信息
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getUserAccount,username);
        User user = userMapper.selectOne(queryWrapper);
        if(user==null){
            throw  new RuntimeException("此用户"+username+" 不存在");
        }

        //查询对应的权限信息
        List<PermissionSelectVo> permissions = userService.getUserByAccount(username).getPermissions();
        ArrayList<String> permissionList = new ArrayList<>();
        for (PermissionSelectVo permission:permissions){
            permissionList.add(permission.getPermissionCode());
        }
        // 把数据封装成UserDetails
        return new LoginUser(user,permissionList);
    }

2.3.3 注入权限信息

在用户登录时,需要把权限信息注入到LoginUser中并存入redis,因为在上一步中已经将权限信息放入LoginUser中,所以在登录后,直接将LoginUser存入redis中就自然存入了权限信息。

在校验用户时,过滤器中从redis读出权限信息,传入UsernamePasswordAuthenticationToken中,生成带权限的用户信息存入SecurityContextHolder让其后续的过滤器进行权限校验。

java 复制代码
// 存入SecurityContextHolder
        // 获取权限信息封装到Authentication中
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());  // 用户、密码、权限集合    SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);

3 其他功能

3.1 异常捕获

如果在认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法进行一场处理。

如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。所以如果我们需要自定义异常处理,给前端返回异常信息,只需要自定义AuthenticationEntryPoin 和 AccessDeniedHandler然后配置给Spring Security 即可。

3.1. 1 认证异常

java 复制代码
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        // 处理异常
        response.setStatus(HttpStatus.UNAUTHORIZED.value()); // 设置状态码
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(JSON.toJSONString(R.error(401,"尚未认证,请进行认证操作!")));
    }
}

3.1.2 权限异常

java 复制代码
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(HttpStatus.FORBIDDEN.value()); // 设置状态码
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(JSON.toJSONString(R.error(403,"无权访问!")));
    }
}

3.2 跨域

在Spring Security框架中,如果已经配置了Spring的跨域处理,通常还需要针对Spring Security进行跨域配置,两者都需要配置才能确保跨域请求能够顺利通过Spring Security的安全检查。一般情况下,为了保证配置的统一性,会把配置集中使用其中一个配置,另一个配置直接放行

3.2.1 Spring跨域配置

java 复制代码
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")		//设置允许跨域的路径
                .allowedOrigins()		//设置允许跨域请求的域名,例如:allowedOrigins("http://localhost:3000", "http://example.com"),如果为空则是允许所有
                .allowCredentials(true) 	//是否允许发送凭证token
                .allowedMethods("GET","POST","PUT","DELETE")  //指定允许的 HTTP 方法
                .maxAge(3600 * 24);		//预检请求有效期
    }
}

3.2.2 Security跨域配置

java 复制代码
// 跨域配置
http.cors().configurationSource(corsConfigurationSource()) //跨域解决方案
@Bean
CorsConfigurationSource corsConfigurationSource(){
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
        corsConfiguration.setAllowedMethods(Arrays.asList("*"));
        corsConfiguration.setAllowedOrigins(Arrays.asList("*"));
        corsConfiguration.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**",corsConfiguration);
        return source;
    }

3.3 csrf攻击

前后端分离架构本身就是无状态token校验的,所以天然防范csrf攻击,无需进行设置,默认打开的,所以在前后端架构下需要设置为关闭。

java 复制代码
http.csrf().disable(); // 关闭csrf

4 总结

本文通过引入security框架,在登录接口中通过传入页面用户登录信息(用户名、密码)给UsernamePasswordAuthenticationToken来验证用户身份,通过后返回token给前端并存入redis用户信息。其中验证的用户身份是通过实现接口UserDetailsService从数据库中获取用户信息并封装到LoginUser对象中。校验时,通过添加认证过滤器完成从token获取用户信息,并调用UsernamePasswordAuthenticationToken验证用户权限。

security的完整配置文件如下所示:

java 复制代码
@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // 开启权限注解
public class SecurityConfig  {
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;// 注入token校验过滤器
    @Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;  //注入授权异常处理器
     @Autowired
    private AccessDeniedHandler accessDeniedHandler; //注入认证异常处理器
    // 配置密码加密方式,全局自动按这个方式加密
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    // 配置认证管理器
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
    // security 配置
    @Bean
    public SecurityFilterChain httpSecurity(HttpSecurity http) throws Exception {
        // 1.前后端分离架构本身就是无状态token校验的,所以天然防范csrf攻击,可以关闭
        http
                .csrf()
                .disable(); // 关闭csrf
//                .csrfTokenRepository(CookieCsrfTokenRepository. withHttpOnlyFalse());  // 将令牌保存到cookie中允许cookie前端获取
        // 2.前后端分离架构不通过Session获取SecurityContext
        http
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
       // 3.设置路径权限
        http
                .authorizeHttpRequests()
                .requestMatchers("/api/v1/authentication/login").anonymous()  //对于登录接口,允许匿名访问
                .requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll() // 允许匿名访问这些路径
                .anyRequest().authenticated(); // 除上面外的所有请求全部需要鉴权认证
        // 4. 添加认证过滤器
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 5. 配置异常处理器
        http.exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)  // 配置认证失败处理器
                .accessDeniedHandler(accessDeniedHandler);  // 配置授权失败处理器
        // 6.跨域配置
        http.cors(); // 允许跨域

        return http.build();
    }
}
相关推荐
wno7042 分钟前
Spring Boot JdbcTemplate配置Druid多数据源
java·spring boot·后端
聊浮游33 分钟前
JAVA2026最新全套学习资料、学习路线
java·开发语言·jvm·mysql·spring·maven·idea
MacroZheng1 小时前
完美替代 Navicat!这款内置 AI 的数据库工具,太香了!
java·后端·mysql
SamDeepThinking1 小时前
从REST到gRPC,一个API选型的思考框架
java·后端·程序员
Zane19941 小时前
接口都能写默认实现了,为什么还需要抽象类
java·后端
未秃头的程序猿1 小时前
读Spring AI源码的方法——不是硬啃,是带着问题去翻
java·后端·spring
用户3126874877201 小时前
别再 Thread.sleep 硬等了!并发工具类到底怎么选?
java
Java内核笔记2 小时前
万字长文剖析 Spring Boot 4 自动配置机制源码:从 @EnableAutoConfiguration 到条件装配
java·后端
evans在进步2 小时前
Spring MVC 核心机制详解:全局异常处理、请求跳转与数据绑定
java·spring·mvc
weixin_BYSJ19872 小时前
springboot小区物业管理小程序---附源码45555
java·javascript·spring boot·python·django·flask·php