使用Spring Security进行登录认证

Spring Security的maven依赖(version由spring-boot-starter-parent管理):

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

1、创建 Security 配置类

Security 配置类通过bean方法来提供Spring Security的相关配置工作。

如下所示的SecurityConfig为Security配置类,属于新的配置方式,老的配置方式是配置类需要从WebSecurityConfigurerAdapter类继承,然后重写父类的一些方法。新的配置方式是在Spring Security 5.7版本及以上才能使用(对应SpringBoot版本需要2.7.0及以上)。

java 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter; //JWT认证过滤器,详见下面对其的解释

    @Bean //提供Spring Security的认证管理器,Controller中会通过依赖注入来使用它
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }

    //提供用户信息来源:Spring Security会自动发现并使用UserDetailsService的实现customUserDetailsService来作为用户信息来源,如果像下面这样
    //编写一个Bean方法来手动提供用户信息来源的话一般为测试中方式
    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails user = User.withUsername("user")
                .password(passwordEncoder().encode("password")).roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }

    @Bean //提供认证管理器中的密码编码器
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); //使用BCrypt算法来提供密码加密(存储时)和密码验证(登录时)
    }

    @Bean //提供Web安全定制器,在下面配置的Spring Security过滤器链生效之前进行底层定制
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> {
                web.debug(true); // 开启Spring Security 的调试日志输出(如果想知道一个请求究竟经过了哪些安全过滤器,或者安全配置为何没有生效,启用调试模式非常有帮助)
                web.ignoring().requestMatchers("/css/**", "/js/**", "/images/**") //设置指定的路径不进行过滤(比如静态资源、对外公开访问的API如login)
                                .requestMatchers("/favicon.ico")
                                .requestMatchers("/public/api/**");};
    }

    //配置Spring Security过滤器链
    //Spring Security默认会对所有请求进行认证,如下所示可以配置单个或多个过滤器来指定需要进行认证的路径
    //在过滤器中可以设置认证的方式(如JWT认证、表单认证)、设置认证失败处理、设置Session相关等
    //@Order用来指定过滤器的优先级,数字小的先匹配,当请求被第一个过滤器捕获后,后面的过滤器不会再捕获该请求

    //需要JWT认证的路径:现代SPA项目一般使用JWT进行认证
    @Bean
    @Order(1)
    public SecurityFilterChain securedApiFilterChain(HttpSecurity http) throws Exception {
        http.securityMatcher("/test/**", "/api/**") // 指定该过滤器链只对 /test 和 /api 开头的路径生效.
                .authorizeHttpRequests( //开始配置请求的授权规则
                        authz -> authz
                                .anyRequest() //匹配所有类型的请求(如GET、POST等)
                                .authenticated() //指定这些请求都需要认证
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) //指定进行JWT认证
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))  // 告诉Spring Security不要自动创建Session,即无状态:JWT认证相当于是使用token来维护状态,所以无需使用Session来保持状态
                .csrf(csrf -> csrf.disable())  //禁用CSRF保护:基于表单的认证(Session 认证)使用CSRF Token来杜绝CSRF,而JWT认证使用请求头携带token而不是使用cookie来携带SessionID,所以不需要CSRF保护
                .exceptionHandling(exception -> exception//认证失败(请求无token、token无效或过期、权限不足等)异常处理
                    .authenticationEntryPoint((request, response, authException) -> { //请求无token、token无效或过期
                        //返回需要认证的应答
                        response.setContentType("application/json;charset=UTF-8");
                        response.setStatus(HttpStatus.UNAUTHORIZED.value());
                        response.getWriter().write("{\"error\": \"需要认证访问\"}"); })
                        .accessDeniedHandler((request, response, accessDeniedException) -> { //权限不足
                            //返回权限不足应答
                            response.setContentType("application/json;charset=UTF-8");
                            response.setStatus(HttpStatus.FORBIDDEN.value());
                            response.getWriter().write("{\"error\": \"权限不足\"}"); })
                );

        return http.build();
    }

    //需要表单认证的路径:MPA项目多使用表单认证
    //在Spring Security中,在配置了.formLogin()后,框架会自动处理登录请求(POST方式),比如如下所示,当用户请求了/auth登录接口后,
    //调用配置的UserDetailsService来加载用户信息并进行密码比对等验证工作(从请求中提取出默认字段名为 username和 password的参数),
    //认证成功会重定向到用户最初想访问的页面或配置的成功页面,认证失败重定向到配置的登录失败页面。
    //.formLogin()后默认也会开启Session 会话来维持用户的登录状态,当用户未认证访问页面的时候,会重定向到配置的登录页。
    @Bean
    @Order(2)
    public SecurityFilterChain formFilterChain(HttpSecurity http) throws Exception {
        http.securityMatcher("/**") //指定该过滤器对所有路径生效
                .authorizeHttpRequests(authz -> authz
                        .requestMatchers("/login").permitAll() //Spring Security可能不会自动放行下面通过.loginPage()配置的GET类型的登录页路径,所以这里对登录页显示放行
                        .anyRequest().authenticated())
                .formLogin( //启用表单认证
                        form -> form
                                .loginPage("/login") //登录页
                                .loginProcessingUrl("/auth") //登录接口路径
                                .defaultSuccessUrl("/home") // 登录成功后跳转到用户最初访问的页面或home
                                .failureUrl("/my-login?error") // 登录失败后的跳转页面
                                .permitAll()  //对 /auth 和 /login 放行,不进行认证
                )
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED));  // 有状态

        return http.build();
    }

    //无需进行认证的路径(公共资源如图片、CSS、JS这些静态资源,错误页等)
    @Bean
    @Order(3)
    public SecurityFilterChain publicFilterChain(HttpSecurity http) throws Exception {
        http.securityMatcher("/public/**", "/error") //仅过滤public开头的路径和/error路径
                .authorizeHttpRequests(authz -> authz.anyRequest().permitAll()); //被过滤的请求都不需要进行认证

        return http.build();
    }
}

.securityMatcher()用来设置过滤器链级别的匹配,即决定是否进入该过滤器链,.requestMatchers()用来进一步控制授权规则级别的匹配,即进入过滤链后再次决定访问权限,如下所示:

java 复制代码
    @Bean
    public SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception {
        http.securityMatcher("/**")  // 所有请求都进入这个过滤器链,也可以不显示调用该方法,因为默认即为过滤所有请求
                .authorizeHttpRequests(authz -> authz
                        .requestMatchers("/public/**").permitAll()     // public开头路径不进行认证
                        .requestMatchers("/api/**").authenticated()    // API需要认证
                        .requestMatchers("/admin/**").hasRole("ADMIN") // /admin开头路径不仅需要认证还需要有管理员权限
                        .anyRequest().authenticated()                  // 其他所有路径需要认证
                );

        return http.build();
    }

2、认证行为设置和认证过滤

下面为配置类中认证过滤器成员的类型,其用来设置认证的具体动作,比如这里是进行JWT认证:

java 复制代码
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter { //继承自OncePerRequestFilter保证了它在一个请求的生命周期内只会被执行一次

    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService;
    private final PathMatcher pathMatcher = new AntPathMatcher();

    public JwtAuthenticationFilter(JwtUtil jwtUtil, UserDetailsService userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
    }

    //设置对过滤的请求进行JWT验证
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String authorizationHeader = request.getHeader("Authorization");
        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { //判断是否带JWT认证头
            String token = authorizationHeader.substring(7); //提取到的JWT令牌
            if (jwtUtil.validateToken(token)) { //令牌验证通过
                String username = jwtUtil.getUsernameFromToken(token);
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                //创建Authentication认证信息对象
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                //将认证信息对象设置到安全上下文中,相当于告诉Spring Security:"这个用户已成功登录",Spring Security会保存该用户信息以便后续使用
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response); //无论令牌是否存在或验证是否通过,最后必须放行请求
    }

    private static final String[] INCLUDE_PATHS = { "/login", "/public1/*", "/public2/**", "/public3/*.html", };//login(不包含子路径)、/public1及其一级子路径、/public2及其所有子路径、public3下所有后缀名为html的路径

    //返回true表示不对当前请求进行上面doFilterInternal方法中设置的认证,返回false表示进行验证,不重写该方法的话默认会对所有请求进行doFilterInternal设置的认证
    //对于登录、公开资源这些不需要认证的路径可以直接跳过上面doFilterInternal方法中设置的认证流程,以提高效率
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String requestPath = request.getServletPath();
        return !Arrays.stream(INCLUDE_PATHS)
                .anyMatch(pattern -> pathMatcher.match(pattern, requestPath));
    }
}

上面的shouldNotFilter内仅仅是设置不进行认证动作以提高性能,对于不需要进行认证的路径,还是需要在Security配置类中的FilterChainBean过滤器方法中来设置。对于完全不需要进行认证的路径(如静态资源、公开api),一般是直接在Security配置类中的WebSecurityCustomizerBean方法来进行一次性配置,其配置的路径完全绕过整个 Spring Security 过滤器链。对于不需要进行认证但需要在过滤器里进行相关配置,比如对于登录页面,提交登录信息的 POST请求通常需要 CSRF Token 保护,可以在过滤器类的shouldNotFilter内设置不进行认证动作,然后在FilterChainBean过滤器方法中设置相关的配置。

3、实现UserDetailsService

UserDetailsService是用来给Security提供用户名、密码等用户信息的。Security使用这些信息来验证登录请求,以及提供SecurityContextHolder、@AuthenticationPrincipal等方式来获得这些用户信息(详见下面4)。

java 复制代码
@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Override
    //提供用户信息给 Spring Security使用
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.getByName(username); //获得用户信息
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在: " + username);
        }

        //返回包含用户名、密码、权限的用户信息,返回类型为Spring Security 的 UserDetails格式
        return org.springframework.security.core.userdetails.User.builder()
                .username(user.getName())
                .password(user.getPassword())
                .authorities("USER") //用户角色/权限
                .build();
    }
}

4、修改登录接口

在登录接口中可以使用Spring Security来验证用户登录:

java 复制代码
@Controller
public class UserController {
    @Autowired
    ObjectMapper objectMapper;
    @Autowired
    private JwtUtil jwtUtil;

    @PostMapping(value = "/login", produces = "application/json;charset=UTF-8")
    public ResponseEntity<String> login(@RequestBody LoginRequest requestBodyData) {
        String username = requestBodyData.getUsername();
        String password = requestBodyData.getPassword();

        Map<String, String> response = new HashMap<>();

        try {
            // 使用 Spring Security 进行用户名密码认证
            Authentication authentication = authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(username, password) //封装用户名密码作为认证请求的载体
            );

            // 认证成功,生成 token后发送给客户端
            String token = jwtUtil.generateToken(username);
            response.put("token", token);

            return ResponseEntity.ok(objectMapper.writeValueAsString(response));

        } catch (AuthenticationException e) {
            response.put("error", "用户名或密码错误");
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(objectMapper.writeValueAsString(response));
        } catch (Exception e) {
            response.put("error", "登录失败");
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(objectMapper.writeValueAsString(response));
        }
    }

    ......
}

5、Security自动处理认证

用户登录成功后,用户访问需要认证(登录)的接口的时候,因为请求中携带了token,所以Spring Security会自动处理认证,只有认证成功才会进入该请求的Controller处理方法,认证失败的话,不进入Controller方法,直接触发在配置类中配置的认证失败异常处理。在Controller接口中可以直接使用Spring Security相关方法来获得用户信息,因为代码能执行到Controller方法的话说明请求已经认证成功:

java 复制代码
    @GetMapping("/test")
    public ResponseEntity<String> test() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); //获取当前线程的安全上下文
        String username = authentication.getName(); //从认证对象中获得用户名

        Map<String, String> response = new HashMap<>();
        response.put("message", "已认证成功");
        response.put("username", username);

        return ResponseEntity.ok(objectMapper.writeValueAsString(response));
    }

除了使用SecurityContextHolder外,还有多种方式可以获得用户信息,如下所示。

下面的Controller方法中都没有对参数值为null做判断处理,因为只有请求通过了Spring Security认证或者请求的是无需认证的公开路径(请求不会被 Spring Security过滤器链过滤)或者请求的路径授权规则被设置为为.permit()即无需认证,对应的Controller方法才会执行,如果请求没有被认证通过(比如请求无携带token、token过期、无权限等),会触发过滤器链中设置的异常处理。为了防止在无需进行认证路径的Controller方法中误添加了 Principal 参数或 @AuthenticationPrincipal参数注解,还是最好添加参数是否为null的判断。

java 复制代码
@RestController
public class UserController {

    // 方式1:通过 Principal 对象获得用户的简单信息(Principal是Java SE中接口,可以像HttpServletRequest那样直接作为Controller方法中的参数来使用)
    @GetMapping("/user/principal")
    public String getUserByPrincipal(Principal principal) {
        return "当前用户: " + principal.getName();
    }

    // 方式2:通过 Authentication 对象(Authentication继承自Principal)
    @GetMapping("/user/auth")
    public String getUserByAuthentication(Authentication authentication) {
        return "当前用户: " + authentication.getName() +
                ", 权限: " + authentication.getAuthorities();
    }

    // 方式3:通过 @AuthenticationPrincipal 注解获得详细的用户信息到UserDetails参数
    @GetMapping("/user/details")
    public String getUserDetails(@AuthenticationPrincipal UserDetails userDetails) {
        return "用户名: " + userDetails.getUsername() +
                ", 账号未过期: " + userDetails.isAccountNonExpired();
    }

    // 方式4:通过@AuthenticationPrincipal获得详细的用户信息到自定义用户对象CustomUserDetails
    @GetMapping("/user/custom")
    public String getCustomUser(@AuthenticationPrincipal CustomUserDetails user) {
        return "用户ID: " + user.getId() +
                ", 用户名: " + user.getUsername() +
                ", 邮箱: " + user.getEmail();
    }
}
相关推荐
excel2 小时前
🚀 从零开始:如何从 GPTsAPI.net 申请 API Key 并打造自己的 AI 服务
前端
毕设源码-朱学姐2 小时前
【开题答辩全过程】以 三叶草中石油信息管理系统的设计与实现为例,包含答辩的问题和答案
java·eclipse
期待のcode3 小时前
@RequestBody的伪表单提交场景
java·前端·vue.js·后端
栀秋6663 小时前
防抖 vs 节流:从百度搜索到京东电商,看前端性能优化的“节奏哲学”
前端·javascript
一颗烂土豆3 小时前
vfit.js v2.0.0 发布:精简、语义化与核心重构 🎉
前端·vue.js·响应式设计
有意义3 小时前
深入防抖与节流:从闭包原理到性能优化实战
前端·javascript·面试
气π3 小时前
【JavaWeb】——(若依+AI)-帝可得实践项目
java·spring
可观测性用观测云3 小时前
网站/接口可用性拨测最佳实践
前端