博客 / 詳情

返回

從零開始探索 Spring Security 使用方法

Spring Security 簡介

Spring Security 提供了對身份認證、授權和針對常見漏洞的保護的全面支持,可以輕鬆地集成到任何基於 Spring 的應用程序中。

主要就是提供了:

  • 認證(Authentication):可以理解為登錄,驗證訪問者的身份。包括用户名密碼認證、手機號短信驗證碼認證、指紋識別認證、面容識別認證等等
  • 授權(Authorization):授權發生在系統完成身份認證之後,最終會授予你訪問資源(如信息,文件,數據庫等等)的權限,授權決定了你訪問系統的能力以及達到的程度,比如只有拿到了操作用户的授權,才可以管理用户
  • 漏洞保護:跨域、csrf 等防護

就我個人而言,以前對 Spring Security 的認識非常不清楚,所以這次從零開始一點一點的嘗試了一遍目前能遇到的大多數場景,下面是逐步探索 Spring Security 使用方法的整個過程,其中包括:

  1. Spring Boot 項目初始化
  2. 引入 Spring Security
  3. 內存用户登錄
  4. SecurityConfig
  5. UserDetailsService
  6. 接口權限限制
  7. 獲取認證信息
  8. 自定義登錄頁面
  9. 自定義登錄接口
  10. JWT 認證
  11. 多個 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<String, UserDetails> 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 頁面:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
  <head>
    <title>Custom Log In Page</title>
  </head>
  <body>
    <h1>Please Log In</h1>
    <div th:if="${param.error}">Invalid username and password.</div>
    <div th:if="${param.logout}">You have been logged out.</div>
    <form th:action="@{/login}" method="post">
      <div>
        <input type="text" name="username" placeholder="Username" />
      </div>
      <div>
        <input type="password" name="password" placeholder="Password" />
      </div>
      <input type="submit" value="Log in" />
    </form>
  </body>
</html>
  • 再配置文件 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;charset=utf-8");

    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 會返回 "沒有對應的權限",到這裏我們就完成了:

  1. 自定義用户名密碼驗證的頁面和接口
  2. 未登錄自動跳轉到登錄頁面
  3. 登錄後自動跳轉到之前想訪問的接口
  4. 接口權限驗證

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<SimpleGrantedAuthority> authorities =
        permissionString.isBlank() ? new ArrayList<SimpleGrantedAuthority>()
            : 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<String, Object> login(@RequestBody LoginRequest login) {

    Map<String, Object> 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

  1. 當發送的用户名密碼錯誤的時候,返回 {"error":"用户名或密碼錯誤"}
  2. 正確的話返回 {"jwt": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwicGVybWlzc2lvbnMiOiJVU0VSIiwiZXhwIjoxNjk1MzUxODAyfQ._cNekfYovmnjWKBaKVCiErzu76q-Aj3gZhUsDiITzAA"}
  3. 在 header 中添加 Authorization,並填寫值 Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwicGVybWlzc2lvbnMiOiJVU0VSIiwiZXhwIjoxNjk1MzUxODAyfQ._cNekfYovmnjWKBaKVCiErzu76q-Aj3gZhUsDiITzAA,後面的一長串就是上一步得到的 jwt,
  4. 發起 GET 請求 http://127.0.0.1:8080/user,這時候得到 Hello User!
  5. 繼續發起 GET 請求 http://127.0.0.1:8080/user,這時候得到 "沒有對應的權限"

多個 SecurityFilterChain

接下來來看一個更加複雜的情況,如何在已經使用會話做認證的情況下,添加 JWT 認證做 API 接口管理?也就是説,需要同時支持兩種認證:

  1. 會話認證:訪問需要認證的頁面,沒有認證的情況下自動跳轉到登錄頁面,登錄成功後自動跳回剛才訪問的頁面
  2. 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<String, Object> login(@RequestBody LoginRequest login) {

    Map<String, Object> 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();
  }
}

最後的結構是這樣的,也可以查看源碼:

結構

最後我們進行測試,首先是會話認證:

  1. 啓動項目後,在瀏覽器訪問 http://localhost:8080/user ,會自動跳轉到 http://localhost:8080/login
  2. 輸入用户名、密碼後,會自動跳回 http://localhost:8080/user,並顯示 Hello User!
  3. 最後,將瀏覽器 訪問地址改為 http://localhost:8080/admin ,會顯示 沒有對應的權限 ,會話認證基本驗證完成

接下來測試 JWT 認證:

  1. 使用調試工具 POST http://localhost:8080/api/login,body 裏面填寫 {"username": "user","password": "password"}
  2. 登錄成功後,獲取返回值,複製 jwt 的值,在 Header 中添加 Authorization: Bearer jwt的值
  3. 訪問 GET http://localhost:8080/api/user,可以正常訪問接口,然後攜帶相同的 Header 繼續訪問 http://localhost:8080/api/admin, 返回 沒有對應的權限

以上説明,兩個 SecurityFilterChain 都在運行,並且都是獨立的,互不影響。這樣做的好處是可以給單獨某一些 API 設置獨有的認證授權,和其他的互不影響。

總結

好了,目前完成了較複雜的 Spring Security 配置、認證和權限驗證。整個過程下來之後,認識了一些Spring Security 的默認規則和常用的方法套路,大部分的場景都可以覆蓋。根據以上內容可實現基於 RBAC 的訪問控制,這部分內容可以參考以前的項目,一般的項目基本上夠用了。

本文源碼

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.