从零开始探索 Spring Security 使用方法

2023年 10月 10日 63.4k 0

Spring Security 简介

Spring Security 提供了对身份认证、授权和针对常见漏洞的保护的全面支持,可以轻松地集成到任何基于 Spring 的应用程序中。

主要就是提供了:

  • 认证(Authentication):可以理解为登录,验证访问者的身份。包括用户名密码认证、手机号短信验证码认证、指纹识别认证、面容识别认证等等
  • 授权(Authorization):授权发生在系统完成身份认证之后,最终会授予你访问资源(如信息,文件,数据库等等)的权限,授权决定了你访问系统的能力以及达到的程度,比如只有拿到了操作用户的授权,才可以管理用户
  • 漏洞保护:跨域、csrf 等防护

就我个人而言,以前对 Spring Security 的认识非常不清楚,所以这次从零开始一点一点的尝试了一遍目前能遇到的大多数场景,下面是逐步探索 Spring Security 使用方法的整个过程,其中包括:

  • Spring Boot 项目初始化
  • 引入 Spring Security
  • 内存用户登录
  • SecurityConfig
  • UserDetailsService
  • 接口权限限制
  • 获取认证信息
  • 自定义登录页面
  • 自定义登录接口
  • JWT 认证
  • 多个 SecurityFilterChain
  • 项目初始化

    当前的 Spring Boot 版本是 3.1.2,Spring Security 的版本是 6.1.2

    首先使用 Spring Initializr 添加 Spring Web 完成项目的创建

    start

    创建项目并下载打开后,新建一个 index 接口,这样就可以启动服务,访问接口了

    src/main/java/com/hezf/demo/DemoApplication.java同级别目录新建文件IndexController.java后,写下第一个接口:

    package com.example.hezf;
    
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    public class IndexController {
    
      @RequestMapping("/")
      public String index() {
        return "Hello Index!";
      }
    }
    
    

    启动项目,访问本地 8080 接口:http://localhost:8080/,成功访问到接口数据后,继续下面的操作

    引入 Spring Security

    在 build.gradle 添加 implementation 'org.springframework.boot:spring-boot-starter-security' ,完成后需要 gradle 下载引入 security

    下载后,重新启动项目,继续访问 http://localhost:8080/

    这时候发现跳转到了一个登录的页面,这就表明 security 已经起作用了,不让我们直接访问接口了,接口被保护了起来

    我们查看调试信息,会发现一条类似的信息Using generated security password: 9d53cf27-6c2b-4468-809a-247eb5d669da,把这个密码和用户名user填上就可以继续访问刚才的接口

    在密码出现的下方有一条:This generated password is for development use only. Your security configuration must be updated before running your application in production.

    这是告诉我们,这个用户和密码只是在开发阶段调试使用,生产环境不要这么使用,接下来我们自定义用户和密码。

    内存用户登录

    上面的密码使用起来太麻烦了,还是想办法建几个固定账号吧,在src/main/java/com/hezf/demo/DemoApplication.java同级别目录新建文件DefaultSecurityConfig.java

    package com.hezf.demo;
    
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.provisioning.InMemoryUserDetailsManager;
    
    @EnableWebSecurity
    @Configuration
    public class DefaultSecurityConfig {
    
      @Bean
      public UserDetailsService users() {
    
        UserDetails user = User.withDefaultPasswordEncoder().username("user").password("password")
            .roles("user").build();
        return new InMemoryUserDetailsManager(user);
      }
    
    }
    

    这时候重启项目,发现控制台没有Using generated security password等信息出现了,可以使用 userpassword 进行登录,登录后可以访问 IndexController

    这种内存用户可以快速的验证登录和一些权限控制,在项目中添加了 Spring Security 之后,默认对所有接口都开启了访问控制,只有已认证用户(已登录)才可以访问,接下来我们尝试对 security 进行配置

    SecurityConfig

    在项目中添加 Spring Security后,必须登录才能访问接口,那么怎么把这个限制关掉?这时候可以使用 SecurityFilterChain,在 DefaultSecurityConfig 添加 SecurityFilterChain

    @EnableWebSecurity
    @Configuration
    public class DefaultSecurityConfig {
    
      @Bean
      public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http.build();
      }
    
      @Bean
      public UserDetailsService users() {
    
        UserDetails user = User.withDefaultPasswordEncoder().username("user").password("password")
            .roles("user").build();
    
        return new InMemoryUserDetailsManager(user);
      }
    
    }
    
    

    当添加上面的配置重启后,发现接口可以随便访问不需要登录了,这是因为默认情况下,Spring Security 的接口保护、表单登录被启用。然而,只要提供 SecurityFilterChain 配置,就必须显示启用接口保护和表单登录,否咋就不会生效。为了实现之前的接口保护和表单登录,需要添加如下配置,启用接口保护和表单登录:

      @Bean
      public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
        http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated());
    
        http.formLogin(Customizer.withDefaults());
    
        return http.build();
      }
    

    第一句配置了访问任何接口都需要认证,第二句是开启表单登录。如果想把接口保护去掉,那么上面的配置改为http.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()); 意思就是放行所有请求

    UserDetailsService

    前面我们使用了内存用户通过登录获取认证,来访问接口。实际在开发过程中用户信息肯定是要持久化的,要存到数据库中去,这时候最好实现一个 UserDetailsService 用来检索用户名、密码和其他属性。

    新建CustomUserDetailsService.java文件:

    package com.hezf.demo;
    
    import java.util.HashMap;
    import java.util.Map;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.core.userdetails.UsernameNotFoundException;
    import org.springframework.stereotype.Service;
    import jakarta.annotation.PostConstruct;
    
    @Service
    public class CustomUserDetailsService implements UserDetailsService {
    
      private final Map userRegistry = new HashMap();
    
      @PostConstruct
      public void init() {
    
        UserDetails user = User.withDefaultPasswordEncoder().username("user").password("password")
            .roles("user").build();
    
        UserDetails user1 = User.withDefaultPasswordEncoder().username("user1").password("password")
            .roles("user").build();
    
        userRegistry.put("user", user);
        userRegistry.put("user1", user1);
      }
    
      @Override
      public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 生产这里是去数据库查询用户
        UserDetails userDetails = userRegistry.get(username);
        if (userDetails == null) {
          throw new UsernameNotFoundException(username);
        }
        return userDetails;
      }
    }
    
    

    DefaultSecurityConfig 修改一下,去掉 DefaultSecurityConfig 中的 UserDetailsService

    @EnableWebSecurity
    @Configuration
    public class DefaultSecurityConfig {
    
      @Bean
      public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
        http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated());
    
        http.formLogin(Customizer.withDefaults());
    
        return http.build();
      }
    }
    

    然后就可以用 useruser1 登录了,以上是为了模拟真实的环境,一般在 loadUserByUsername 中进行数据库查询用户信息,然后返回装填好信息的 UserDetails 进行认证

    接口权限限制

    在实际开发中,有的接口可以随便访问,比如 login 接口,有的接口必须登录后才可以访问,比如查询当前用户信息的接口。有的接口可以管理其他用户,那就必须具有管理员权限才可以访问。

    接下来创建 3 种接口,一种不需要认证,一种已认证就行,最后一种需要某种权限才可以访问。首先创建这 3 个接口:

    @RestController
    public class IndexController {
    
      // 公开接口,可以随便访问
      @RequestMapping("/public")
      public String index() {
        return "Hello Public!";
      }
    
      // 需要认证用户才可以访问
      @RequestMapping("/user")
      public String user() {
        return "Hello User!";
      }
    
      // 需要具有 ADMIN 权限才可以访问
      @RequestMapping("/admin")
      public String admin() {
        return "Hello Admin!";
      }
    }
    

    接下来配置 SecurityFilterChain

      @Bean
      public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
        // @formatter:off
        http.authorizeHttpRequests(authorize -> authorize
          .requestMatchers("/public").permitAll() // /public 接口可以公开访问
          .requestMatchers("/admin").hasAuthority("ADMIN") // /admin 接口需要 ADMIN 权限
          .anyRequest().authenticated()); // 其他的所以接口都需要认证才可以访问
        // @formatter:on
    
        http.formLogin(Customizer.withDefaults());
    
        return http.build();
      }
    

    然后准备用户数据:

      @PostConstruct
      public void init() {
    
        UserDetails user = User.withDefaultPasswordEncoder().username("user").password("password")
            .authorities("USER").build();
    
        UserDetails admin = User.withDefaultPasswordEncoder().username("admin").password("password")
            .authorities("ADMIN").build();
    
        userRegistry.put("user", user);
        userRegistry.put("admin", admin);
      }
    

    重启项目访问 http://localhost:8080/public 成功访问。接下来访问 http://localhost:8080/user 会要求登录,我们输入 user 的用户名和密码,成功访问。继续访问 http://localhost:8080/admin 发现返回了 403 状态吗,告诉我们权限不正确,这时候清空下 cookie 重新使用 admin 登录即可访问。到此,我们实现了最小型的接口权限控制

    获取认证信息

    当用户第一次访问受保护的接口时,会被重定向到登录页面,这时候后端服务会分配给用户一个会话 ID,存于 Cookies 中的 JSESSIONID。随后的每次请求都会携带这个 Cookie,用于在接下来的会话中认证用户的身份。使用 SecurityContext ,可以获取当前用户的认证信息,他们之间的关系可以看图:

    securitycontextholder

      // 需要认证用户才可以访问
      @RequestMapping("/user")
      public String user() {
        // 静态工具类 SecurityContextHolder 可以获取当前的 SecurityContext 也就是上下文
        SecurityContext context = SecurityContextHolder.getContext();
        // 认证,通过 authentication 可以获取当前用户的一些信息
        Authentication authentication = context.getAuthentication();
    
        // 检查是否已认证
        System.out.println(authentication.isAuthenticated());
    
        // 检查用户详情
        UserDetails userDetail = (UserDetails) authentication.getPrincipal();
        System.out.println(userDetail.getUsername());
        System.out.println(userDetail.getPassword()); // 这里是没有密码的
        System.out.println(userDetail.getAuthorities());
    
        return "Hello User!";
      }
    

    上面的代码只有在已认证的情况下才有效,认证的过程是 Spring Security 提供的登录页面和接口,下一步自己实现登录过程

    自定义登录页面

    首先尝试自定义登录页面,这样可以直观的看到前端页面是怎么提交用户名、密码的

    • build.gradle添加依赖implementation "org.springframework.boot:spring-boot-starter-thymeleaf"
    • 新建 src/main/resources/templates/login.html 页面:
    
    
      
        Custom Log In Page
      
      
        Please Log In
        Invalid username and password.
        You have been logged out.
        
          
            
          
          
            
          
          
        
      
    
    
    • 再配置文件 DefaultSecurityConfig
     http.formLogin(form -> form.loginPage("/login").permitAll());
    
    • 新建文件src/main/java/com/hezf/demo/LoginController.java:
    package com.hezf.demo;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    
    @Controller
    public class LoginController {
    
      @GetMapping("/login")
      String login() {
        return "login";
      }
    }
    
    

    这时候重启项目,继续访问 /user ,会跳转到我们自定义的登录页面,其他和以前的一样。前面的配置修改是告诉 spring security 我们有自己的登录页面请求接口,LoginController 是为了返回这个自定义登录页面,上面添加的 thymeleaf 是为了解析登录页面 login.html

    自定义登录接口

    前面我们自定义了登录页面,如果想自己定义登录接口,就需要把默认的 formLogin 关掉,否则即使声明了 POST 方法的 login 接口也没用,登录请求会被formLogin 拦截。所以我们直接注释掉 formLogin 相关就可以了,这里并不需要将 formLogin disabled 什么的。有了默认的 SecurityFilterChain 后,默认 formLogin 就是关掉的

    • 修改 DefaultSecurityConfig,这里需要注意,需要把 login 接口开放出来,这里新增了两个异常处理,因为我想实现未登录自动跳转到登录页面
    @EnableWebSecurity
    @Configuration
    public class DefaultSecurityConfig {
    
      @Bean
      public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
        // @formatter:off
        http.authorizeHttpRequests(authorize -> authorize
          .requestMatchers("/public","/login").permitAll() // /public 接口可以公开访问
          .requestMatchers("/admin").hasAuthority("ADMIN") // /admin 接口需要 ADMIN 权限
          .anyRequest().authenticated()); // 其他的所以接口都需要认证才可以访问
          // @formatter:on
    
        // 设置异常的EntryPoint的处理
        http.exceptionHandling(exceptions -> exceptions
            // 未登录
            .authenticationEntryPoint(new MyAuthenticationEntryPoint())
            // 权限不足
            .accessDeniedHandler(new MyAccessDeniedHandler()));
    
        // http.formLogin(Customizer.withDefaults());
        // http.formLogin(form -> form.loginPage("/login").permitAll());
    
        return http.build();
      }
    
      @Bean
      public AuthenticationManager authenticationManager(
          AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
      }
    }
    
    • 添加上面的两个异常处理 MyAuthenticationEntryPointMyAccessDeniedHandler,分别是未登录和未授权
    package com.hezf.demo;
    
    import jakarta.servlet.ServletException;
    import jakarta.servlet.http.HttpServletRequest;
    import jakarta.servlet.http.HttpServletResponse;
    import org.springframework.security.core.AuthenticationException;
    import org.springframework.security.web.AuthenticationEntryPoint;
    import org.springframework.stereotype.Component;
    import java.io.IOException;
    
    @Component
    public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
      @Override
      public void commence(HttpServletRequest request, HttpServletResponse response,
          AuthenticationException authException) throws IOException, ServletException {
        response.sendRedirect("login");
      }
    }
    
    package com.hezf.demo;
    
    import com.fasterxml.jackson.databind.ObjectMapper;
    import jakarta.servlet.ServletException;
    import jakarta.servlet.http.HttpServletRequest;
    import jakarta.servlet.http.HttpServletResponse;
    import org.springframework.security.access.AccessDeniedException;
    import org.springframework.security.web.access.AccessDeniedHandler;
    import org.springframework.stereotype.Component;
    import java.io.IOException;
    
    @Component
    public class MyAccessDeniedHandler implements AccessDeniedHandler {
    
      private static ObjectMapper objectMapper = new ObjectMapper();
    
      @Override
      public void handle(HttpServletRequest request, HttpServletResponse response,
          AccessDeniedException accessDeniedException) throws IOException, ServletException {
    
        response.setContentType("application/json;");
    
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    
        response.setContentType("application/json;charset=utf-8");
        objectMapper.writeValue(response.getWriter(), "没有对应的权限");
      }
    }
    
    
    • 添加请求登录接口,这里完成了登录信息的验证,后续 http 请求上下文的保存还有自动跳转之前请求的链接
      @PostMapping("/login")
      void login(HttpServletRequest request, HttpServletResponse response,
          @RequestParam("username") String username, @RequestParam("password") String password)
          throws IOException, ServletException {
    
        UsernamePasswordAuthenticationToken token =
            UsernamePasswordAuthenticationToken.unauthenticated(username, password);
    
        // 通过前端发来的 username、password 进行认证,这里会用到CustomUserDetailsService.loadUserByUsername
        Authentication authentication = authenticationManager.authenticate(token);
        // 设置空的上下文
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        // 设置认证信息
        context.setAuthentication(authentication);
    
        // 这句保证了随后的请求都会有这个上下文,通过回话保持,在前端清理 cookie 之后也就失效了
        securityContextRepository.saveContext(context, request, response);
    
        // 检查是否有之前请求的 URL,如果有就跳转到之前的请求 URL 上去
        SavedRequest savedRequest = new HttpSessionRequestCache().getRequest(request, response);
        if (savedRequest != null) {
          String targetUrl = savedRequest.getRedirectUrl();
          response.sendRedirect(targetUrl);
        } else {
          response.sendRedirect("/public");
        }
      }
    

    运行项目,浏览器直接访问 http://localhost:8080/user 会自动跳转到自定义登录页面,输入 user 用户名和密码后,会自动跳转回刚才访问的 http://localhost:8080/user,这时候继续访问 http://localhost:8080/admin 会返回 "没有对应的权限",到这里我们就完成了:

  • 自定义用户名密码验证的页面和接口
  • 未登录自动跳转到登录页面
  • 登录后自动跳转到之前想访问的接口
  • 接口权限验证
  • JWT 认证

    上面我们完成了自定义的接口,自动跳转等等。。。但是现在更普遍的是前后端分离的项目,这样更容易扩展应用场景。下面来实现登录后颁发 jwt,以及通过 jwt 来进行认证和权限判断

    • 引入所需的 jwt
    // jwt相关
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    
    • 添加 jwt 的默认配置

    src/main/resources 下删除原来的 application.properties,并且创建文件 src/main/resources/application.yml,填入以下内容:

    jwt:
      # 60*60*1
      expire: 3600
      # secret: 秘钥(普通字符串)
      secret: pa1R0cHM6hyGf8Hyb7D34LKJ8b4gldC91LzM2ODE4Njg
    
    • 添加颁发、解析、认证 jwt 等工具类

    新建 src/main/java/com/hezf/demo/JWTProvider.java 文件,这个类的作用是:

    1、生成 jwt(在登录的时候生成,根据 username 和对应的权限列表)
    2、检验 jwt 有效性和提取 jwt 中的认证信息(使用 jwt 访问接口的时候)

    @Component
    public class JWTProvider {
      private static final Logger logger = LoggerFactory.getLogger(JWTProvider.class);
    
      private static final String AUTHORITIES_KEY = "permissions";
    
      private static SecretKey secretKey;
    
      @Value("${jwt.secret}")
      public void setJwtSecret(String secret) {
        secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
      }
    
      private static int jwtExpirationInMs;
    
      @Value("${jwt.expire}")
      public void setJwtExpirationInMs(int expire) {
        jwtExpirationInMs = expire;
      }
    
      // generate JWT token
      public static String generateToken(Authentication authentication) {
        long currentTimeMillis = System.currentTimeMillis();
        Date expirationDate = new Date(currentTimeMillis + jwtExpirationInMs * 1000);
    
        String scope = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));
    
        Claims claims = Jwts.claims().setSubject(authentication.getName());
        claims.put(AUTHORITIES_KEY, scope);
        return Jwts.builder().setClaims(claims).setExpiration(expirationDate)
            .signWith(secretKey, SignatureAlgorithm.HS256).compact();
      }
    
      public static Authentication getAuthentication(String token) {
        Claims claims =
            Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody();
        // 从jwt获取用户权限列
        String permissionString = (String) claims.get(AUTHORITIES_KEY);
    
        List authorities =
            permissionString.isBlank() ? new ArrayList()
                : Arrays.stream(permissionString.split(",")).map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());
    
        // 获取 username
        String username = claims.getSubject();
    
        return new UsernamePasswordAuthenticationToken(username, null, authorities);
      }
    
      // validate Jwt token
      public static boolean validateToken(String token) {
        try {
          Jwts.parserBuilder().setSigningKey(secretKey).build().parse(token);
          return true;
        } catch (MalformedJwtException e) {
          logger.error("Invalid JWT token: {}", e.getMessage());
        } catch (ExpiredJwtException e) {
          logger.error("JWT token is expired: {}", e.getMessage());
        } catch (UnsupportedJwtException e) {
          logger.error("JWT token is unsupported: {}", e.getMessage());
        } catch (IllegalArgumentException e) {
          logger.error("JWT claims string is empty: {}", e.getMessage());
        }
        return false;
      }
    }
    
    

    新建src/main/java/com/hezf/demo/JWTFilter.java文件,这个类的作用是:

    1、除了登录接口以外,其他接口在进入接口之前,都需要经过 JWTFilter 的处理
    2、验证 jwt 的合法性和有效期等
    3、提取 jwt 中的 username 和 权限,生成 Authentication 存到 security 上下文
    4、security 上下文中有了 Authentication ,那就代表着已认证,后续也可以在接口中使用 Authentication 中的信息

    
    @Component
    public class JWTFilter extends OncePerRequestFilter {
    
      private static final Logger LOGGER = LoggerFactory.getLogger(JWTFilter.class);
    
      @Override
      protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
          FilterChain filterChain) throws ServletException, IOException {
    
        // 这部分出错后,直接返回401,不再走后面的filter
        try {
          // 从请求头中获取jwt
          String jwt = getJwtFromRequest(request);
    
          // 校验 jwt 是否有效,包含了过期的验证
          if (StringUtils.hasText(jwt) && JWTProvider.validateToken(jwt)) {
    
            // 通过 jwt 获取认证信息
            Authentication authentication = JWTProvider.getAuthentication(jwt);
    
            // 将认证信息存入 Security 上下文中,可以取出来使用,也代表着已认证
            SecurityContextHolder.getContext().setAuthentication(authentication);
          }
        } catch (Exception ex) {
          LOGGER.error("Could not set user authentication in security context", ex);
        }
    
        filterChain.doFilter(request, response);
      }
    
      private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
    
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
          return bearerToken.substring(7, bearerToken.length());
        }
        return null;
      }
    }
    
    
    • 修改 springsecurity 配置,因为使用了 jwt 进行认证,所以不需要 csrf 保护了
      // 关闭 csrf 保护
      http.csrf(csrf -> csrf.disable());
    
      // 在过滤器链中添加 JWTFilter
      http.addFilterBefore(new JWTFilter(), LogoutFilter.class);
    
    • 重写登录接口,像上次一样,提取 usernamepassword 进行认证,认证成功以后返回 jwt,失败的话返回错误信息
    class LoginRequest {
    
      private String username;
      private String password;
    
    
      public String getUsername() {
        return this.username;
      }
    
      public void setUsername(String username) {
        this.username = username;
      }
    
      public String getPassword() {
        return this.password;
      }
    
      public void setPassword(String password) {
        this.password = password;
      }
    }
    
    @RestController
    public class LoginController {
    
      @Autowired
      private AuthenticationManager authenticationManager;
    
      @PostMapping("/login")
      public Map login(@RequestBody LoginRequest login) {
    
        Map map = new HashMap();
    
        try {
          UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken
              .unauthenticated(login.getUsername(), login.getPassword());
    
          Authentication authentication = authenticationManager.authenticate(token);
    
          String jwt = JWTProvider.generateToken(authentication);
    
          map.put("jwt", jwt);
        } catch (BadCredentialsException ex) {
          map.put("error", ex.getMessage());
        }
        return map;
      }
    }
    
    

    上面的 authenticationManager.authenticate(token); 这句会完成用户名密码的认证工作,会调用 CustomUserDetailsService.loadUserByUsername 后进行对比,失败后返回错误信息

    • 测试接口

    使用 postman 等测试工具,发起 post 请求,格式为 json

  • 当发送的用户名密码错误的时候,返回 {"error":"用户名或密码错误"}
  • 正确的话返回 {"jwt": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwicGVybWlzc2lvbnMiOiJVU0VSIiwiZXhwIjoxNjk1MzUxODAyfQ._cNekfYovmnjWKBaKVCiErzu76q-Aj3gZhUsDiITzAA"}
  • 在 header 中添加 Authorization,并填写值 Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwicGVybWlzc2lvbnMiOiJVU0VSIiwiZXhwIjoxNjk1MzUxODAyfQ._cNekfYovmnjWKBaKVCiErzu76q-Aj3gZhUsDiITzAA,后面的一长串就是上一步得到的 jwt,
  • 发起 GET 请求 http://127.0.0.1:8080/user,这时候得到 Hello User!
  • 继续发起 GET 请求 http://127.0.0.1:8080/user,这时候得到 "没有对应的权限"
  • 多个 SecurityFilterChain

    接下来来看一个更加复杂的情况,如何在已经使用会话做认证的情况下,添加 JWT 认证做 API 接口管理?也就是说,需要同时支持两种认证:

  • 会话认证:访问需要认证的页面,没有认证的情况下自动跳转到登录页面,登录成功后自动跳回刚才访问的页面
  • JWT 认证:支持通过 API 接口进行登录和访问 API 接口
  • 答案是同时可以设置多个 SecurityFilterChain,然后根据访问不同的 URL 确定使用哪个 SecurityFilterChain,只有第一个匹配的 SecurityFilterChain 被调用,如下所示:

    multi-securityfilterchain

    如果请求的 URL/api/user/,它首先与 /api/**SecurityFilterChain0 模式匹配,所以只有 SecurityFilterChain0 被调用,尽管它也与 SecurityFilterChain1 匹配,但是只调用第一个匹配的

    • 首先准备好两套登录接口
    // src/main/java/com/hezf/demo/jwt/JWTLoginController.java
    class LoginRequest {
    
      private String username;
      private String password;
    
    
      public String getUsername() {
        return this.username;
      }
    
      public void setUsername(String username) {
        this.username = username;
      }
    
      public String getPassword() {
        return this.password;
      }
    
      public void setPassword(String password) {
        this.password = password;
      }
    }
    
    
    @RestController
    @RequestMapping("/api")
    public class JWTLoginController {
    
      @Autowired
      private AuthenticationManager authenticationManager;
    
      @PostMapping("/login")
      public Map login(@RequestBody LoginRequest login) {
    
        Map map = new HashMap();
    
        try {
          UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken
              .unauthenticated(login.getUsername(), login.getPassword());
    
          Authentication authentication = authenticationManager.authenticate(token);
    
          String jwt = JWTProvider.generateToken(authentication);
    
          map.put("jwt", jwt);
        } catch (BadCredentialsException ex) {
          map.put("error", ex.getMessage());
        }
        return map;
      }
    }
    
    // src/main/java/com/hezf/demo/session/SessionLoginController.java
    @Controller
    public class SessionLoginController {
    
      @Autowired
      private AuthenticationManager authenticationManager;
    
      private SecurityContextRepository securityContextRepository =
          new HttpSessionSecurityContextRepository();
    
      @GetMapping("/login")
      String login() {
        return "login";
      }
    
      @PostMapping("/login")
      void login(HttpServletRequest request, HttpServletResponse response,
          @RequestParam("username") String username, @RequestParam("password") String password)
          throws IOException, ServletException {
    
        UsernamePasswordAuthenticationToken token =
            UsernamePasswordAuthenticationToken.unauthenticated(username, password);
    
        // 通过前端发来的 username、password 进行认证,这里会用到CustomUserDetailsService.loadUserByUsername
        Authentication authentication = authenticationManager.authenticate(token);
        // 设置空的上下文
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        // 设置认证信息
        context.setAuthentication(authentication);
    
        // 这句保证了随后的请求都会有这个上下文,通过回话保持,在前端清理 cookie 之后也就失效了
        securityContextRepository.saveContext(context, request, response);
    
        // 检查是否有之前请求的 URL,如果有就跳转到之前的请求 URL 上去
        SavedRequest savedRequest = new HttpSessionRequestCache().getRequest(request, response);
        if (savedRequest != null) {
          String targetUrl = savedRequest.getRedirectUrl();
          response.sendRedirect(targetUrl);
        } else {
          response.sendRedirect("/public");
        }
      }
    }
    
    
    • 然后准备好两套未认证和权限错误的处理,这部分代码不贴了,可以自行查找:
    
    // JWT
    src/main/java/com/hezf/demo/jwt/JWTAuthenticationEntryPoint.java
    src/main/java/com/hezf/demo/jwt/JWTAccessDeniedHandler.java
    
    // session
    src/main/java/com/hezf/demo/session/SessionAuthenticationEntryPoint.java
    src/main/java/com/hezf/demo/session/SessionAccessDeniedHandler.java
    
    • 继续准备好两套 user 和 admin 的接口
    src/main/java/com/hezf/demo/jwt/JWTController.java
    src/main/java/com/hezf/demo/session/SessionController.java
    
    • JWT 所需的 JWTProviderJWTFilter 内容不变
    • 最后是配置 SecurityFilterChain
    @EnableWebSecurity
    @Configuration
    public class DefaultSecurityConfig {
    
      @Bean
      @Order(0) // 最高优先级,这里处理的都是以 /api/** 开头的接口,使用 jwt 做认证
      public SecurityFilterChain jwtFilterChain(HttpSecurity http) throws Exception {
    
        // @formatter:off
        http.securityMatcher("/api/**").authorizeHttpRequests(authorize -> authorize
          .requestMatchers("/api/login").permitAll() // /public 接口可以公开访问
          .requestMatchers("/api/admin").hasAuthority("ADMIN") // /admin 接口需要 ADMIN 权限
          .anyRequest().authenticated()); // 其他的所以接口都需要认证才可以访问
          // @formatter:on
    
        // 设置异常的EntryPoint的处理
        http.exceptionHandling(exceptions -> exceptions
            // 未登录
            .authenticationEntryPoint(new JWTAuthenticationEntryPoint())
            // 权限不足
            .accessDeniedHandler(new JWTAccessDeniedHandler()));
    
        // 关闭 csrf 保护
        http.csrf(csrf -> csrf.disable());
    
        // 在过滤器链中添加 JWTFilter
        http.addFilterBefore(new JWTFilter(), LogoutFilter.class);
    
        return http.build();
      }
    
      @Bean
      @Order(1) // 次高优先级,处理会话认证
      public SecurityFilterChain sessionFilterChain(HttpSecurity http) throws Exception {
    
        // @formatter:off
        http.authorizeHttpRequests(authorize -> authorize
          .requestMatchers("/public","/login").permitAll() // /public 接口可以公开访问
          .requestMatchers("/admin").hasAuthority("ADMIN") // /admin 接口需要 ADMIN 权限
          .anyRequest().authenticated()); // 其他的所以接口都需要认证才可以访问
          // @formatter:on
    
        // 设置异常的EntryPoint的处理
        http.exceptionHandling(exceptions -> exceptions
            // 未登录
            .authenticationEntryPoint(new SessionAuthenticationEntryPoint())
            // 权限不足
            .accessDeniedHandler(new SessionAccessDeniedHandler()));
    
        return http.build();
      }
    
      @Bean
      public AuthenticationManager authenticationManager(
          AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
      }
    }
    

    最后的结构是这样的,也可以查看源码:

    结构

    最后我们进行测试,首先是会话认证:

  • 启动项目后,在浏览器访问 http://localhost:8080/user ,会自动跳转到 http://localhost:8080/login
  • 输入用户名、密码后,会自动跳回 http://localhost:8080/user,并显示 Hello User!
  • 最后,将浏览器 访问地址改为 http://localhost:8080/admin ,会显示 没有对应的权限 ,会话认证基本验证完成
  • 接下来测试 JWT 认证:

  • 使用调试工具 POST http://localhost:8080/api/login,body 里面填写 {"username": "user","password": "password"}
  • 登录成功后,获取返回值,复制 jwt 的值,在 Header 中添加 Authorization: Bearer jwt的值
  • 访问 GET http://localhost:8080/api/user,可以正常访问接口,然后携带相同的 Header 继续访问 http://localhost:8080/api/admin, 返回 没有对应的权限
  • 以上说明,两个 SecurityFilterChain 都在运行,并且都是独立的,互不影响。这样做的好处是可以给单独某一些 API 设置独有的认证授权,和其他的互不影响。

    总结

    好了,目前完成了较复杂的 Spring Security 配置、认证和权限验证。整个过程下来之后,认识了一些Spring Security 的默认规则和常用的方法套路,大部分的场景都可以覆盖。根据以上内容可实现基于 RBAC 的访问控制,这部分内容可以参考以前的项目,一般的项目基本上够用了。

    本文源码

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论