Spring Security5 介紹

Spring Security5 介紹

Spring Security 應該屬於 Spring 全家桶中學習曲線比較陡峭的幾個模塊之一,下面我將從起源和定義這兩個方面來簡單介紹一下它。

  • 起源: Spring Security 實際上起源於 Acegi Security,這個框架能爲基於 Spring 的企業應用提供強大而靈活安全訪問控制解決方案,並且框架這個充分利用 Spring 的 IoC 和 AOP 功能,提供聲明式安全訪問控制的功能。後面,隨着這個項目發展, Acegi Security 成爲了Spring官方子項目,後來被命名爲 “Spring Security”。
  • **定義:**Spring Security 是一個功能強大且高度可以定製的框架,側重於爲Java 應用程序提供身份驗證和授權。——官方介紹

Session 和 Token 認證對比

Session 認證圖解

很多時候我們都是通過 SessionID 來實現特定的用戶,SessionID 一般會選擇存放在 Redis 中。舉個例子:用戶成功登陸系統,然後返回給客戶端具有 SessionID 的 Cookie,當用戶向後端發起請求的時候會把 SessionID 帶上,這樣後端就知道你的身份狀態了。

關於這種認證方式更詳細的過程如下:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-C4TcEWB3-1591763252774)(Spring Security 介紹 - 01.png])

  1. 用戶向服務器發送用戶名和密碼用於登陸系統。
  2. 服務器驗證通過後,服務器爲用戶創建一個 Session,並將 Session信息存儲 起來。
  3. 服務器向用戶返回一個 SessionID,寫入用戶的 Cookie。
  4. 當用戶保持登錄狀態時,Cookie 將與每個後續請求一起被髮送出去。
  5. 服務器可以將存儲在 Cookie 上的 Session ID 與存儲在內存中或者數據庫中的 Session 信息進行比較,以驗證用戶的身份,返回給用戶客戶端響應信息的時候會附帶用戶當前的狀態。

Token 認證圖解

在基於 Token 進行身份驗證的的應用程序中,服務器通過PayloadHeader和一個密鑰(secret)創建令牌(Token)並將 Token 發送給客戶端,客戶端將 Token 保存在 Cookie 或者 localStorage 裏面,以後客戶端發出的所有請求都會攜帶這個令牌。你可以把它放在 Cookie 裏面自動發送,但是這樣不能跨域,所以更好的做法是放在 HTTP Header 的 Authorization字段中:Authorization: Bearer Token

關於這種認證方式更詳細的過程如下:

在這裏插入圖片描述

  1. 用戶向服務器發送用戶名和密碼用於登陸系統。
  2. 身份驗證服務響應並返回了簽名的 JWT,上面包含了用戶是誰的內容。
  3. 用戶以後每次向後端發請求都在 Header 中帶上 JWT。
  4. 服務端檢查 JWT 並從中獲取用戶相關信息。

項目涉及到的重要類說明

配置類

在本項目中我們自定義 SecurityConfig 繼承了 WebSecurityConfigurerAdapterWebSecurityConfigurerAdapter提供HttpSecurity來配置 cors,csrf,會話管理和受保護資源的規則。

配置類中我們主要配置了:

  1. 密碼編碼器 BCryptPasswordEncoder(存入數據庫的密碼需要被加密)。
  2. AuthenticationManager 設置自定義的 UserDetailsService以及密碼編碼器;
  3. 在 Spring Security 配置指定了哪些路徑下的資源需要驗證了的用戶才能訪問、哪些不需要以及哪些資源只能被特定角色訪問;
  4. 將我們自定義的兩個過濾器添加到 Spring Security 配置中;
  5. 將兩個自定義處理權限認證方面的異常類添加到 Spring Security 配置中;
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    UserDetailsServiceImpl userDetailsServiceImpl;

    /**
     * 密碼編碼器
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public UserDetailsService createUserDetailsService() {
        return userDetailsServiceImpl;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 設置自定義的userDetailsService以及密碼編碼器
        auth.userDetailsService(userDetailsServiceImpl).passwordEncoder(bCryptPasswordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and()
                // 禁用 CSRF
                .csrf().disable()
                .authorizeRequests()
                .antMatchers(HttpMethod.POST, "/auth/login").permitAll()
                // 指定路徑下的資源需要驗證了的用戶才能訪問
                .antMatchers("/api/**").authenticated()
                .antMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
                // 其他都放行了
                .anyRequest().permitAll()
                .and()
                //添加自定義Filter
                .addFilter(new JWTAuthenticationFilter(authenticationManager()))
                .addFilter(new JWTAuthorizationFilter(authenticationManager()))
                // 不需要session(不創建會話)
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 授權異常處理
                .exceptionHandling().authenticationEntryPoint(new JWTAuthenticationEntryPoint())
                .accessDeniedHandler(new JWTAccessDeniedHandler());

    }

}

跨域:

在這裏踩的一個坑是:如果你沒有設置exposedHeaders("Authorization")暴露 header 中的"Authorization"屬性給客戶端應用程序的話,前端是獲取不到 token 信息的。

@Configuration
public class CorsConfiguration implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                //暴露header中的其他屬性給客戶端應用程序
                //如果不設置這個屬性前端無法通過response header獲取到Authorization也就是token
                .exposedHeaders("Authorization")
                .allowCredentials(true)
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                .maxAge(3600);
    }
}

工具類

/**
 * @author shuang.kou
 */
public class JwtTokenUtils {


    /**
     * 生成足夠的安全隨機密鑰,以適合符合規範的簽名
     */
    private static byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(SecurityConstants.JWT_SECRET_KEY);
    private static SecretKey secretKey = Keys.hmacShaKeyFor(apiKeySecretBytes);

    public static String createToken(String username, List<String> roles, boolean isRememberMe) {
        long expiration = isRememberMe ? SecurityConstants.EXPIRATION_REMEMBER : SecurityConstants.EXPIRATION;

        String tokenPrefix = Jwts.builder()
                .setHeaderParam("typ", SecurityConstants.TOKEN_TYPE)
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .claim(SecurityConstants.ROLE_CLAIMS, String.join(",", roles))
                .setIssuer("SnailClimb")
                .setIssuedAt(new Date())
                .setSubject(username)
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
                .compact();
        return SecurityConstants.TOKEN_PREFIX + tokenPrefix;
    }

    private boolean isTokenExpired(String token) {
        Date expiredDate = getTokenBody(token).getExpiration();
        return expiredDate.before(new Date());
    }

    public static String getUsernameByToken(String token) {
        return getTokenBody(token).getSubject();
    }

    /**
     * 獲取用戶所有角色
     */
    public static List<SimpleGrantedAuthority> getUserRolesByToken(String token) {
        String role = (String) getTokenBody(token)
                .get(SecurityConstants.ROLE_CLAIMS);
        return Arrays.stream(role.split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    private static Claims getTokenBody(String token) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody();
    }
}

獲取保存在服務端的用戶信息類

Spring Security 提供的 UserDetailsService有一個通過名字返回 Spring Security 可用於身份驗證的UserDetails對象的方法:loadUserByUsername()

package org.springframework.security.core.userdetails;
/**
 *加載用戶特定數據的核心接口。
 */
public interface UserDetailsService {
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

UserDetails包含用於構建認證對象的必要信息(例如:用戶名,密碼)。

package org.springframework.security.core.userdetails;
/**
 *提供用戶核心信息的藉口
 */
public interface UserDetails extends Serializable {
  Collection<? extends GrantedAuthority> getAuthorities();
  String getPassword();
  String getUsername();
  boolean isAccountNonExpired();
  boolean isAccountNonLocked();
  boolean isCredentialsNonExpired();
  boolean isEnabled();
}

一般情況下我們需要實現 UserDetailsService 藉口並重寫其中的 loadUserByUsername() 方法。

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserService userService;

    public UserDetailsServiceImpl(UserService userService) {
        this.userService = userService;
    }
    @Override
    public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
        User user = userService.findUserByUserName(name);
        return new JwtUser(user);
    }

}

認證過濾器(重要)

建議看下面的過濾器介紹之前先了解一下過濾器的基礎知識,以及如何在 Spring Boot 中實現過濾器。推薦閱讀這篇文章:SpringBoot 實現過濾器

第一個過濾器主要JWTAuthenticationFilter用於根據用戶的用戶名和密碼進行登錄驗證(用戶請求中必須有用戶名和密碼這兩個參數),爲此我們繼承了 UsernamePasswordAuthenticationFilter 並且重寫了下面三個方法:

  1. attemptAuthentication(): 驗證用戶身份。
  2. successfulAuthentication() : 用戶身份驗證成功後調用的方法。
  3. unsuccessfulAuthentication(): 用戶身份驗證失敗後調用的方法。
/**
 * @author shuang.kou
 * 如果用戶名和密碼正確,那麼過濾器將創建一個JWT Token 並在HTTP Response 的header中返回它,格式:token: "Bearer +具體token值"
 */
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private ThreadLocal<Boolean> rememberMe = new ThreadLocal<>();
    private AuthenticationManager authenticationManager;

    public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        // 設置登錄請求的 URL
        super.setFilterProcessesUrl(SecurityConstants.AUTH_LOGIN_URL);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {

        ObjectMapper objectMapper = new ObjectMapper();
        try {
            // 從輸入流中獲取到登錄的信息
            LoginUser loginRequest = objectMapper.readValue(request.getInputStream(), LoginUser.class);
            rememberMe.set(loginRequest.getRememberMe());
            // 這部分和attemptAuthentication方法中的源碼是一樣的,
            // 只不過由於這個方法源碼的是把用戶名和密碼這些參數的名字是死的,所以我們重寫了一下
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                    loginRequest.getUsername(), loginRequest.getPassword());
            return authenticationManager.authenticate(authRequest);
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 如果驗證成功,就生成token並返回
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authentication) {

        JwtUser jwtUser = (JwtUser) authentication.getPrincipal();
        List<String> roles = jwtUser.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());
        // 創建 Token
        String token = JwtTokenUtils.createToken(jwtUser.getUsername(), roles, rememberMe.get());
        // Http Response Header 中返回 Token
        response.setHeader(SecurityConstants.TOKEN_HEADER, token);
    }


    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authenticationException.getMessage());
    }
}

這個過濾器中有幾個比較重要的地方說明:

  1. UsernamePasswordAuthenticationToken:從登錄請求中獲取{用戶名,密碼},AuthenticationManager將使用它來認證登錄帳戶。
  2. authenticationManager.authenticate(authRequest):這段代碼主要對用戶進行認證,當執行這段代碼的時候會跳到UserDetailsServiceImpl中去調用loadUserByUsername()方法來驗證(我們在配置類中配置了AuthenticationManager使用自定義的UserDetailsServiceImpl去驗證用戶信息)。當驗證成功後會返回一個完整填充的Authentication對象(包括授予的權限),然後會去調用successfulAuthentication方法。
package org.springframework.security.authentication;
 /**
  *嘗試驗證Authentication對象,如果成功,將返回一個完整填充的Authentication對象(包括授予的權限)。
  */
public interface AuthenticationManager {
	Authentication authenticate(Authentication authentication)
			throws AuthenticationException;
}

授權過濾器(重要)

這個過濾器繼承了 BasicAuthenticationFilter,主要用於處理身份認證後才能訪問的資源,它會檢查 HTTP 請求是否存在帶有正確令牌的 Authorization 標頭並驗證 token 的有效性。

當用戶使用 token 對需要權限才能訪問的資源進行訪問的時候,這個類是主要用到的,下面按照步驟來說一說每一步到底都做了什麼。

  1. 當用戶使用系統返回的 token 信息進行登錄的時候 ,會首先經過doFilterInternal()方法,這個方法會從請求的Header中取出 token 信息,然後判斷 token 信息是否爲空以及 token 信息格式是否正確。
  2. 如果請求頭中有token 並且 token 的格式正確,則進行解析並判斷 token 的有效性,然後會在 Spring Security 全局設置授權信息SecurityContextHolder.getContext().setAuthentication(getAuthentication(authorization));
/**
 * 過濾器處理所有HTTP請求,並檢查是否存在帶有正確令牌的Authorization標頭。例如,如果令牌未過期或簽名密鑰正確。
 *
 * @author shuang.kou
 */
public class JWTAuthorizationFilter extends BasicAuthenticationFilter {

    private static final Logger logger = Logger.getLogger(JWTAuthorizationFilter.class.getName());

    public JWTAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {

        String authorization = request.getHeader(SecurityConstants.TOKEN_HEADER);
        // 如果請求頭中沒有Authorization信息則直接放行了
        if (authorization == null || !authorization.startsWith(SecurityConstants.TOKEN_PREFIX)) {
            chain.doFilter(request, response);
            return;
        }
        // 如果請求頭中有token,則進行解析,並且設置授權信息
        SecurityContextHolder.getContext().setAuthentication(getAuthentication(authorization));
        super.doFilterInternal(request, response, chain);
    }

    /**
     * 這裏從token中獲取用戶信息並新建一個token
     */
    private UsernamePasswordAuthenticationToken getAuthentication(String authorization) {
        String token = authorization.replace(SecurityConstants.TOKEN_PREFIX, "");

        try {
            String username = JwtTokenUtils.getUsernameByToken(token);
            // 通過 token 獲取用戶具有的角色
            List<SimpleGrantedAuthority> userRolesByToken = JwtTokenUtils.getUserRolesByToken(token);
            if (!StringUtils.isEmpty(username)) {
                return new UsernamePasswordAuthenticationToken(username, null, userRolesByToken);
            }
        } catch (SignatureException | ExpiredJwtException exception) {
            logger.warning("Request to parse JWT with invalid signature . Detail : " + exception.getMessage());
        }
        return null;
    }
}

獲取當前用戶

我們在講過濾器的時候說過,當認證成功的用戶訪問系統的時候,它的認證信息會被設置在 Spring Security 全局中。那麼,既然這樣,我們在其他地方獲取到當前登錄用戶的授權信息也就很簡單了,通過SecurityContextHolder.getContext().getAuthentication();方法即可。

SecurityContextHolder 保存 SecurityContext 的信息,SecurityContext保存已通過認證的 Authentication 認證信息。

爲此,我們實現了一個專門用來獲取當前用戶的類:

/**
 * @author shuang.kou
 * 獲取當前請求的用戶
 */
@Component
public class CurrentUser {

    private final UserDetailsServiceImpl userDetailsService;

    public CurrentUser(UserDetailsServiceImpl userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    public JwtUser getCurrentUser() {
        return (JwtUser) userDetailsService.loadUserByUsername(getCurrentUserName());
    }

    /**
     * TODO:由於在JWTAuthorizationFilter這個類注入UserDetailsServiceImpl一致失敗,
     * 導致無法正確查找到用戶,所以存入Authentication的Principal爲從 token 中取出的當前用戶的姓名
     */
    private static String getCurrentUserName() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null && authentication.getPrincipal() != null) {
            return (String) authentication.getPrincipal();
        }
        return null;
    }
}

異常相關

AccessDeniedHandler

JWTAccessDeniedHandler實現了AccessDeniedHandler主要用來解決認證過的用戶訪問需要權限才能訪問的資源時的異常。

/**
 * @author shuang.kou
 * AccessDeineHandler 用來解決認證過的用戶訪問需要權限才能訪問的資源時的異常
 */
public class JWTAccessDeniedHandler implements AccessDeniedHandler {
    /**
     * 當用戶嘗試訪問需要權限才能的REST資源而權限不足的時候,
     * 將調用此方法發送401響應以及錯誤信息
     */
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        accessDeniedException = new AccessDeniedException("Sorry you don not enough permissions to access it!");
        response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
    }
}

AuthenticationEntryPoint

JWTAuthenticationEntryPoint 實現了 AuthenticationEntryPoint 用來解決匿名用戶訪問需要權限才能訪問的資源時的異常

/**
 * @author shuang.kou
 * AuthenticationEntryPoint 用來解決匿名用戶訪問需要權限才能訪問的資源時的異常
 */
public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint {
    /**
     * 當用戶嘗試訪問需要權限才能的REST資源而不提供Token或者Token過期時,
     * 將調用此方法發送401響應以及錯誤信息
     */
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
    }
}

驗證權限配置的 Controller

這個是 UserControler 主要用來檢測權限配置是否生效。

getAllUser()方法被註解@PreAuthorize("hasAnyRole('ROLE_DEV','ROLE_PM')")修飾代表這個方法可以被DEV,PM 這兩個角色訪問,而deleteUserById() 被註解@PreAuthorize("hasAnyRole('ROLE_ADMIN')")修飾代表只能被 ADMIN 訪問。

/**
 * @author shuang.kou
 */
@RestController
@RequestMapping("/api")
public class UserController {

    private final UserService userService;

    private final CurrentUser currentUser;

    public UserController(UserService userService, CurrentUser currentUser) {
        this.userService = userService;
        this.currentUser = currentUser;
    }

    @GetMapping("/users")
    @PreAuthorize("hasAnyRole('ROLE_DEV','ROLE_PM')")
    public ResponseEntity<Page<User>> getAllUser(@RequestParam(value = "pageNum", defaultValue = "0") int pageNum, @RequestParam(value = "pageSize", defaultValue = "10") int pageSize) {
        System.out.println("當前訪問該接口的用戶爲:" + currentUser.getCurrentUser().toString());
        Page<User> allUser = userService.getAllUser(pageNum, pageSize);
        return ResponseEntity.ok().body(allUser);
    }


    @DeleteMapping("/user")
    @PreAuthorize("hasAnyRole('ROLE_ADMIN')")
    public ResponseEntity<User> deleteUserById(@RequestParam("username") String username) {
        userService.deleteUserByUserName(username);
        return ResponseEntity.ok().build();
    }
}

推薦閱讀

結束語

原文來源:Java源碼網
原文地址:Spring Security5 介紹

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章