Spring Security With JWT 入門 Demo

Spring Security With JWT

GitHub 源碼地址:https://github.com/yifanzheng/spring-security-jwt

概述

Spring Security 是 Spring 全家桶中一個功能強大且高度可定製的身份驗證和訪問控制框架。與所有 Spring 項目一樣,我們可以輕鬆擴展 Spring Security 以滿足自定義要求。

由於 Spring Security 功能十分強大,相比於其他技術來說很難上手,很多剛接觸 Spring Security 的開發者很難通過文檔或者視頻就能將其進行運用到實際開發中。

在公司實習的時候接觸到的一個項目就使用了 Spring Security 這個強大的安全驗證框架來完成用戶的登錄模塊,並且也是自己負責的一個模塊。當時自己對 Spring Security 基本不熟悉,可以說是第一次接觸,查閱了很多關於這方面的資料,看得似懂非懂的,並且還在導師的指導下都花了將近一週的時間才勉強完成。

Spring Security 對於初學者來說,的確很難上手。於是自己在工作之餘對這部分知識進行了學習,並實現了一個簡單的項目,主要使用了 Spring Boot 技術集成 Spring Security 和 Spring Data Jpa 技術。這個項目實現的比較簡單,還有很多地方需要優化,希望有興趣的朋友可以一起完善,期待你的 PR。

項目下載

  • git clone https://github.com/yifanzheng/spring-security-jwt.git 。

  • 配置好 Maven 倉庫,使用 IntelliJ IDEA 工具打開項目。

  • 在 application.properties 配置文件中將數據庫信息改成你自己的。

項目核心類說明

WebCorsConfiguration

WebCorsConfiguration 配置類,主要解決 HTTP 請求跨域問題。這裏需要注意的是,如果沒有將 Authorization 頭字段暴露給客戶端的話,客戶端是無法獲取到 Token 信息的。

/**
 * WebCorsConfiguration 跨域配置
 *
 * @author star
 */
@Configuration
public class WebCorsConfiguration implements WebMvcConfigurer {

    /**
     * 設置swagger爲默認主頁
     */
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/").setViewName("redirect:/swagger-ui.html");
        registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
        WebMvcConfigurer.super.addViewControllers(registry);
    }

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.setAllowedOrigins(Collections.singletonList("*"));
        config.setAllowedMethods(Collections.singletonList("*"));
        config.setAllowedHeaders(Collections.singletonList("*"));
        // 暴露 header 中的其他屬性給客戶端應用程序
        config.setExposedHeaders(Arrays.asList(
                "Authorization", "X-Total-Count", "Link",
                "Access-Control-Allow-Origin",
                "Access-Control-Allow-Credentials"
        ));
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }

}

WebSecurityConfig

WebSecurityConfig 配置類繼承了 Spring Security 的 WebSecurityConfigurerAdapter 類。WebSecurityConfigurerAdapter 類提供了默認的安全配置,並允許其他類通過覆蓋其方法來擴展它並自定義安全配置。

這裏配置瞭如下內容:

  • 忽略某些不需要驗證的就能訪問的資源路徑;

  • 設置 CustomAuthenticationProvider 自定義身份驗證組件,用於驗證用戶的登錄信息(用戶名和密碼);

  • 在 Spring Security 機制中配置需要驗證後才能訪問的資源路徑、不需要驗證就可以訪問的資源路徑以及指定某些資源只能被特定角色訪問。

  • 配置請求權限認證異常時的處理類;

  • 將自定義的 JwtAuthenticationFilterJwtAuthorizationFilter 兩個過濾器添加到 Spring Security 機制中。

/**
 * Web 安全配置
 *
 * @author star
 **/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Import(SecurityProblemSupport.class)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CorsFilter corsFilter;

    @Autowired
    private UserService userService;

    /**
     * 使用 Spring Security 推薦的加密方式進行登錄密碼的加密
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }


     /**
      * 此方法配置的資源路徑不會進入 Spring Security 機制進行驗證
      */
    @Override
    public void configure(WebSecurity web) {
        web.ignoring()
                .antMatchers(HttpMethod.OPTIONS, "/**")
                .antMatchers("/app/**/*.{js,html}")
                .antMatchers("/v2/api-docs/**")
                .antMatchers("/webjars/springfox-swagger-ui/**")
                .antMatchers("/swagger-resources/**")
                .antMatchers("/i18n/**")
                .antMatchers("/content/**")
                .antMatchers("/swagger-ui.html")
                .antMatchers("/test/**");
    }

    @Override
    protected void configure(AuthenticationManagerBuilder authenticationManagerBuilder) {
        // 設置自定義身份驗證組件,用於從數據庫中驗證用戶登錄信息(用戶名和密碼)
        CustomAuthenticationProvider authenticationProvider = new CustomAuthenticationProvider(bCryptPasswordEncoder());
        authenticationManagerBuilder.authenticationProvider(authenticationProvider);
    }

    /**
     * 定義安全策略,設置 HTTP 訪問規則
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling()
                // 當用戶無權訪問資源時發送 401 響應
                .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
                // 當用戶訪問資源因權限不足時發送 403 響應
                .accessDeniedHandler(new AccessDeniedHandlerImpl())
             .and()
                // 禁用 CSRF
                .csrf().disable()
                .headers().frameOptions().disable()
             .and()
                .authorizeRequests()
                 // 指定路徑下的資源需要進行驗證後才能訪問
                .antMatchers("/").permitAll()
                .antMatchers(HttpMethod.POST, SecurityConstants.AUTH_LOGIN_URL).permitAll()
                .antMatchers("/api/users/register").permitAll()
                // 只允許管理員訪問
                .antMatchers("/api/users/detail").hasRole("ADMIN")
                // 其他請求需驗證
                .anyRequest().authenticated()
             .and()
                // 添加用戶登錄驗證過濾器,將登錄請求交給此過濾器處理
                .addFilter(new JwtAuthenticationFilter(authenticationManager()))
                // 不需要 session(不創建會話)
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
             .and()
               .apply(securityConfigurationAdapter());
        super.configure(http);
    }

    private JwtConfigurer securityConfigurationAdapter() throws Exception{
        return new JwtConfigurer(new JwtAuthorizationFilter(authenticationManager()));
    }
}

CustomAuthenticationProvider

CustomAuthenticationProvider 自定義用戶身份驗證組件類,它用於驗證用戶登錄信息是否正確。需要將其配置到 Spring Sercurity 機制中才能使用。

/**
 * CustomAuthenticationProvider 自定義用戶身份驗證組件
 *
 * <p>
 * 提供用戶登錄密碼驗證功能。根據用戶名從數據庫中取出用戶信息,進行密碼驗證,驗證通過則賦予用戶相應權限。
 *
 * @author star
 */
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final UserService userService;

    private BCryptPasswordEncoder bCryptPasswordEncoder;

    public CustomAuthenticationProvider(BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
        this.userService = SpringSecurityContextHelper.getBean(UserService.class);
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws BadCredentialsException, UsernameNotFoundException {
        // 獲取驗證信息中的用戶名和密碼 (即登錄請求中的用戶名和密碼)
        String userName = authentication.getName();
        String password = authentication.getCredentials().toString();
        // 根據登錄名獲取用戶信息
        User user = userService.getUserByName(userName);
        // 驗證登錄密碼是否正確。如果正確,則賦予用戶相應權限並生成用戶認證信息
        if (user != null && this.bCryptPasswordEncoder.matches(password, user.getPassword())) {
            List<String> roles = userService.listUserRoles(userName);
            // 如果用戶角色爲空,則默認賦予 ROLE_USER 權限
            if (CollectionUtils.isEmpty(roles)) {
                roles = Collections.singletonList(UserRoleConstants.ROLE_USER);
            }
            // 設置權限
            List<GrantedAuthority> authorities = roles.stream()
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());
            // 生成認證信息
            return new UsernamePasswordAuthenticationToken(userName, password, authorities);
        }
        // 驗證不成功就拋出異常
        throw new BadCredentialsException("The userName or password error.");

    }

    @Override
    public boolean supports(Class<?> aClass) {
        return aClass.equals(UsernamePasswordAuthenticationToken.class);
    }

}

JwtAuthenticationFilter

JwtAuthenticationFilter 用戶登錄驗證過濾器,主要配合 CustomAuthenticationProvider 對用戶登錄請求進行驗證,檢查登錄名和登錄密碼。如果驗證成功,則生成 token 返回。

/**
 * JwtAuthenticationFilter 用戶登錄驗證過濾器
 *
 * <p>
 * 用於驗證使用 URL 地址是 {@link SecurityConstants#AUTH_LOGIN_URL} 進行登錄的用戶請求。
 * 通過檢查請求中的用戶名和密碼參數,並調用 Spring 的身份驗證管理器進行驗證。
 * 如果用戶名和密碼正確,那麼過濾器將創建一個 token,並在 Authorization 標頭中將其返回。
 * 格式:Authorization: "Bearer + 具體 token 值"</p>
 *
 * @author star
 **/
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);

    private final AuthenticationManager authenticationManager;

    private final ThreadLocal<Boolean> rememberMeLocal = new ThreadLocal<>();

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        // 指定需要驗證的登錄 URL
        super.setFilterProcessesUrl(SecurityConstants.AUTH_LOGIN_URL);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {
        try {
            // 獲取用戶登錄信息,JSON 反序列化成 UserDTO 對象
            UserLoginDTO loginUser = new ObjectMapper().readValue(request.getInputStream(), UserLoginDTO.class);
            rememberMeLocal.set(loginUser.getRememberMe());
            // 根據用戶名和密碼生成身份驗證信息
            Authentication authentication = new UsernamePasswordAuthenticationToken(loginUser.getUserName(), loginUser.getPassword(), new ArrayList<>());
            // 這裏返回 Authentication 後會通過我們自定義的 {@see CustomAuthenticationProvider} 進行驗證
            return this.authenticationManager.authenticate(authentication);
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }

    }

    /**
     * 如果驗證通過,就生成 token 並返回
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authentication) {
        try {
            // 獲取用戶信息
            String username = null;
            // 獲取身份信息
            Object principal = authentication.getPrincipal();
            if (principal instanceof UserDetails) {
                UserDetails user = (UserDetails) principal;
                username = user.getUsername();
            } else if (principal instanceof String) {
                username = (String) principal;
            }
            // 獲取用戶認證權限
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            // 獲取用戶角色權限
            List<String> roles = authorities.stream()
                    .map(GrantedAuthority::getAuthority)
                    .collect(Collectors.toList());
            boolean isRemember = this.rememberMeLocal.get();
            // 生成 token
            String token = JwtUtils.generateToken(username, roles, isRemember);
            // 將 token 添加到 Response Header 中返回
            response.addHeader(SecurityConstants.TOKEN_HEADER, token);
        } finally {
            // 清除變量
            this.rememberMeLocal.remove();
        }
    }

    /**
     * 如果驗證證不成功,返回錯誤信息提示
     */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException {
        logger.warn(authenticationException.getMessage());

        if (authenticationException instanceof UsernameNotFoundException) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, authenticationException.getMessage());
            return;
        }

        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authenticationException.getMessage());
    }
}

此過濾器繼承了 UsernamePasswordAuthenticationFilter 類,並重寫了三個方法:

  • attemptAuthentication: 此方法用於驗證用戶登錄信息;

  • successfulAuthentication: 此方法在用戶驗證成功後會調用;

  • unsuccessfulAuthentication: 此方法在用戶驗證失敗後會調用。

同時,通過 super.setFilterProcessesUrl(SecurityConstants.AUTH_LOGIN_URL) 方法重新指定需要進行驗證的登錄請求。

當登錄請求進入此過濾器時,會先進入 attemptAuthentication 方法,通過此方法從登錄請求中獲取用戶名和密碼,並使用authenticationManager.authenticate(authenticate) 對用戶信息進行認證,當執行此方法後會進入 CustomAuthenticationProvider 組件並調用 authenticate(Authentication authentication) 方法進行驗證。如果驗證成功後會返回一個 Authentication 對象(它裏面包含了用戶的完整信息,如角色權限),然後會去調用 successfulAuthentication 方法;如果驗證失敗,就會去調用 unsuccessfulAuthentication 方法。

至此,整個驗證過程就結束了。

JwtAuthorizationFilter

JwtAuthorizationFilter 用戶請求授權過濾器,用於從用戶請求中獲取 token 信息,並對其進行驗證,同時加載與 token 相關聯的用戶身份認證信息,並添加到 Spring Security 上下文中。

/**
 * JwtAuthorizationFilter 用戶請求授權過濾器
 *
 * <p>
 * 提供請求授權功能。用於處理所有 HTTP 請求,並檢查是否存在帶有正確 token 的 Authorization 標頭。
 * 如果 token 有效,則過濾器會將身份驗證數據添加到 Spring Security 上下文中,並授權此次請求訪問資源。</p>
 *
 * @author star
 */
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private UserService userService;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
        this.userService = SpringSecurityContextHelper.getBean(UserService.class);
    }

    @Override
    protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException {
        // 從 HTTP 請求中獲取 token
        String token = this.getTokenFromHttpRequest(request);
        // 驗證 token 是否有效
        if (StringUtils.isNotEmpty(token) && JwtUtils.validateToken(token)) {
            // 獲取認證信息
            Authentication authentication = this.getAuthentication(token);
            // 將認證信息存入 Spring 安全上下文中
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        // 放行請求
        filterChain.doFilter(request, response);

    }

    /**
     * 從 HTTP 請求中獲取 token
     *
     * @param request HTTP 請求
     * @return 返回 token
     */
    private String getTokenFromHttpRequest(HttpServletRequest request) {
        String authorization = request.getHeader(SecurityConstants.TOKEN_HEADER);
        if (authorization == null || !authorization.startsWith(SecurityConstants.TOKEN_PREFIX)) {
            return null;
        }
        // 從請求頭中獲取 token
        return authorization.replace(SecurityConstants.TOKEN_PREFIX, "");
    }

    private Authentication getAuthentication(String token) {
        // 從 token 信息中獲取用戶名
        String userName = JwtUtils.getUserName(token);
        if (StringUtils.isNotEmpty(userName)) {
            // 從數據庫中獲取用戶權限,保證權限的及時性
            List<String> roles = userService.listUserRoles(userName);
            // 如果用戶角色爲空,則默認賦予 ROLE_USER 權限
            if (CollectionUtils.isEmpty(roles)) {
                roles = Collections.singletonList(UserRoleConstants.ROLE_USER);
            }
            // 設置權限
            List<GrantedAuthority> authorities = roles.stream()
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());
            // 認證信息
            return new UsernamePasswordAuthenticationToken(userName, null, authorities);
        }
        return null;
    }
}

所有的用戶請求都會經過此過濾器,當請求進入過濾器後會經歷如下步驟:

  • 首先,從請求中獲取 token 信息,並檢查 token 的有效性。

  • 如果 token 有效,則解析 token 獲取用戶名,然後使用用戶名從數據庫中獲取用戶角色信息,並在 Spring Security 的上下文中設置身份驗證。

  • 如果 token 無效或請求不帶 token 信息,則直接放行。

特別說明,這裏用戶的角色信息,是從數據庫中重新獲取的。其實,這裏也可以換成從 token 信息中解析出用戶角色,這樣可以避免直接訪問數據庫。

但是,直接從數據庫獲取用戶信息也是很有幫助的。例如,如果用戶角色已更改,則可能要禁止使用此 token 進行訪問。

JwtUtils

JwtUtils 工具類,在用戶登錄成功後,主要用於生成 token,並驗證用戶請求中發送的 token。

/**
 * Jwt 工具類,用於生成、解析與驗證 token
 *
 * @author star
 **/
public final class JwtUtils {

    private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class);

    private static final byte[] secretKey = DatatypeConverter.parseBase64Binary(SecurityConstants.JWT_SECRET_KEY);

    private JwtUtils() {
        throw new IllegalStateException("Cannot create instance of static util class");
    }

    /**
     * 根據用戶名生成 token
     *
     * @param userName   用戶名
     * @param roles      用戶角色
     * @param isRemember 是否記住我
     * @return 返回生成的 token
     */
    public static String generateToken(String userName, List<String> roles, boolean isRemember) {
        byte[] jwtSecretKey = DatatypeConverter.parseBase64Binary(SecurityConstants.JWT_SECRET_KEY);
        // 過期時間
        long expiration = isRemember ? SecurityConstants.EXPIRATION_REMEMBER_TIME : SecurityConstants.EXPIRATION_TIME;
        // 生成 token
        String token = Jwts.builder()
                // 生成簽證信息
                .setHeaderParam("typ", SecurityConstants.TOKEN_TYPE)
                .signWith(Keys.hmacShaKeyFor(jwtSecretKey), SignatureAlgorithm.HS256)
                .setSubject(userName)
                .claim(SecurityConstants.TOKEN_ROLE_CLAIM, roles)
                .setIssuer(SecurityConstants.TOKEN_ISSUER)
                .setIssuedAt(new Date())
                .setAudience(SecurityConstants.TOKEN_AUDIENCE)
                // 設置有效時間
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
                .compact();
        // jwt 前面一般都會加 Bearer,在請求頭裏加入 Authorization,並加上 Bearer 標註
        return SecurityConstants.TOKEN_PREFIX + token;
    }

    /**
     * 驗證 token,返回結果
     *
     * <p>
     * 如果解析失敗,說明 token 是無效的
     */
    public static boolean validateToken(String token) {
        if (StringUtils.isEmpty(token)) {
            throw new RuntimeException("Miss token");
        }
        try {
            Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token);
            return true;
        } catch (ExpiredJwtException e) {
            logger.warn("Request to parse expired JWT : {} failed : {}", token, e.getMessage());
        } catch (UnsupportedJwtException e) {
            logger.warn("Request to parse unsupported JWT : {} failed : {}", token, e.getMessage());
        } catch (MalformedJwtException e) {
            logger.warn("Request to parse invalid JWT : {} failed : {}", token, e.getMessage());
        } catch (IllegalArgumentException e) {
            logger.warn("Request to parse empty or null JWT : {} failed : {}", token, e.getMessage());
        }
        return false;
    }

    public static String getUserName(String token) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

}

請求認證流程說明

本項目中出現了兩個過濾器,分別是 JwtAuthenticationFilterJwtAuthorizationFilter。當用戶發起請求時,都會先進入 JwtAuthorizationFilter 過濾器。如果請求是登錄請求,又會進入 JwtAuthorizationFilter 過濾器。也就是說,只有是指定的登錄請求才會進入 JwtAuthorizationFilter 過濾器。通過過濾器後,就進入 Spring Security 機制中。

測試 API

註冊賬號
register
登錄
login
帶上正確的 token 訪問需要身份驗證的資源
correctToken
帶上不正確的 token 訪問需要身份驗證的資源
incorrectToken

不帶 token 訪問需要身份驗證的資源
noToken

參考文檔

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