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

发布于:2025-02-23 ⋅ 阅读:(14) ⋅ 点赞:(0)

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

<!--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进行返回。

@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);
    }
}
@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配置,定义密码加密方式。

@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返回给前端 

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

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

@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());
        }
    }
}
@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(),不需要任何身份验证,即允许已认证和未认证的用户。

// 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。

@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配置中添加倒数第二行代码

// 4. 添加认证过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

2.2.3 登出接口编写

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

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

@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  // 开启权限注解

@EnableMethodSecurity  // 开启权限注解
public class SecurityConfig  {
    ...
}

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

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

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

http
.authorizeHttpRequests()
.requestMatchers("/api/v1/device/getList")
.hasAuthority("sys:device:getList");

2.3.2 封装权限信息

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

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

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中以供后续过滤器校验。

@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让其后续的过滤器进行权限校验。

// 存入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 认证异常

@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 权限异常 

@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跨域配置

@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跨域配置

// 跨域配置
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攻击,无需进行设置,默认打开的,所以在前后端架构下需要设置为关闭。

http.csrf().disable(); // 关闭csrf

4 总结

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

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

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