1. 程式人生 > 程式設計 >SpringBoot整合SpringSecurity和JWT做登陸鑑權的實現

SpringBoot整合SpringSecurity和JWT做登陸鑑權的實現

廢話

目前流行的前後端分離讓Java程式設計師可以更加專注的做好後臺業務邏輯的功能實現,提供如返回Json格式的資料介面就可以。SpringBoot的易用性和對其他框架的高度整合,用來快速開發一個小型應用是最佳的選擇。

一套前後端分離的後臺專案,剛開始就要面對的就是登陸和授權的問題。這裡提供一套方案供大家參考。

主要看點:

  • 登陸後獲取token,根據token來請求資源
  • 根據使用者角色來確定對資源的訪問許可權
  • 統一異常處理
  • 返回標準的Json格式資料

正文

首先是pom檔案:

<dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
    </dependency>
    <!--這是不是必須,只是我引用了裡面一些類的方法-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-solr</artifactId>
    </dependency>
        <!--這是不是必須,只是我引用了裡面一些類的方法-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.mybatis.spring.boot</groupId>
      <artifactId>mybatis-spring-boot-starter</artifactId>
      <version>1.3.2</version>
    </dependency>
    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-configuration-processor</artifactId>
      <optional>true</optional>
    </dependency>
    <dependency>
      <groupId>io.springfox</groupId>
      <artifactId>springfox-swagger2</artifactId>
      <version>2.6.1</version>
    </dependency>
    <dependency>
      <groupId>io.springfox</groupId>
      <artifactId>springfox-swagger-ui</artifactId>
      <version>2.6.1</version>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-rest</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.security</groupId>
      <artifactId>spring-security-jwt</artifactId>
      <version>1.0.9.RELEASE</version>
    </dependency>
    <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt</artifactId>
      <version>0.9.0</version>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

application.yml:

spring :
 datasource :
  url : jdbc:mysql://127.0.0.1:3306/les_data_center?useUnicode=true&amp;characterEncoding=UTF-8&allowMultiQueries=true&useAffectedRows=true&useSSL=false
  username : root
  password : 123456
  driverClassName : com.mysql.jdbc.Driver
 jackson:
  data-format: yyyy-MM-dd HH:mm:ss
  time-zone: GMT+8
mybatis :
 config-location : classpath:/mybatis-config.xml
# JWT
jwt:
 header: Authorization
 secret: mySecret
 #token有效期一天
 expiration: 86400
 tokenHead: "Bearer "

接著是對security的配置,讓security來保護我們的API

SpringBoot推薦使用配置類來代替xml配置。那這裡,我也使用配置類的方式。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

  private final JwtAuthenticationEntryPoint unauthorizedHandler;

  private final AccessDeniedHandler accessDeniedHandler;

  private final UserDetailsService CustomUserDetailsService;

  private final JwtAuthenticationTokenFilter authenticationTokenFilter;

  @Autowired
  public WebSecurityConfig(JwtAuthenticationEntryPoint unauthorizedHandler,@Qualifier("RestAuthenticationAccessDeniedHandler") AccessDeniedHandler accessDeniedHandler,@Qualifier("CustomUserDetailsService") UserDetailsService CustomUserDetailsService,JwtAuthenticationTokenFilter authenticationTokenFilter) {
    this.unauthorizedHandler = unauthorizedHandler;
    this.accessDeniedHandler = accessDeniedHandler;
    this.CustomUserDetailsService = CustomUserDetailsService;
    this.authenticationTokenFilter = authenticationTokenFilter;
  }

  @Autowired
  public void configureAuthentication(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
    authenticationManagerBuilder
        // 設定UserDetailsService
        .userDetailsService(this.CustomUserDetailsService)
        // 使用BCrypt進行密碼的hash
        .passwordEncoder(passwordEncoder());
  }
  // 裝載BCrypt密碼編碼器
  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

  @Override
  protected void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
        .exceptionHandling().accessDeniedHandler(accessDeniedHandler).and()
        // 由於使用的是JWT,我們這裡不需要csrf
        .csrf().disable()
        .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
        // 基於token,所以不需要session
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()

        .authorizeRequests()

        // 對於獲取token的rest api要允許匿名訪問
        .antMatchers("/api/v1/auth","/api/v1/signout","/error/**","/api/**").permitAll()
        // 除上面外的所有請求全部需要鑑權認證
        .anyRequest().authenticated();

    // 禁用快取
    httpSecurity.headers().cacheControl();

    // 新增JWT filter
    httpSecurity
        .addFilterBefore(authenticationTokenFilter,UsernamePasswordAuthenticationFilter.class);
  }

  @Override
  public void configure(WebSecurity web) throws Exception {
    web.ignoring().antMatchers("/v2/api-docs","/swagger-resources/configuration/ui","/swagger-resources","/swagger-resources/configuration/security","/swagger-ui.html"
    );
  }

  @Bean
  @Override
  public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
  }
}

該類中配置了幾個bean來供security使用。

  1. JwtAuthenticationTokenFilter:token過濾器來驗證token有效性
  2. UserDetailsService:實現了DetailsService介面,用來做登陸驗證
  3. JwtAuthenticationEntryPoint :認證失敗處理類
  4. RestAuthenticationAccessDeniedHandler: 許可權不足處理類

那麼,接下來一個一個實現這些類:

/**
 * token校驗,引用的stackoverflow一個答案裡的處理方式
 * Author: JoeTao
 * createAt: 2018/9/14
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

  @Value("${jwt.header}")
  private String token_header;

  @Resource
  private JWTUtils jwtUtils;

  @Override
  protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain) throws ServletException,IOException {
    String auth_token = request.getHeader(this.token_header);
    final String auth_token_start = "Bearer ";
    if (StringUtils.isNotEmpty(auth_token) && auth_token.startsWith(auth_token_start)) {
      auth_token = auth_token.substring(auth_token_start.length());
    } else {
      // 不按規範,不允許通過驗證
      auth_token = null;
    }

    String username = jwtUtils.getUsernameFromToken(auth_token);

    logger.info(String.format("Checking authentication for user %s.",username));

    if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
      User user = jwtUtils.getUserFromToken(auth_token);
      if (jwtUtils.validateToken(auth_token,user)) {
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities());
        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        logger.info(String.format("Authenticated user %s,setting security context",username));
        SecurityContextHolder.getContext().setAuthentication(authentication);
      }
    }
    chain.doFilter(request,response);
  }
}
/**
 * 認證失敗處理類,返回401
 * Author: JoeTao
 * createAt: 2018/9/20
 */
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint,Serializable {

  private static final long serialVersionUID = -8970718410437077606L;

  @Override
  public void commence(HttpServletRequest request,AuthenticationException authException) throws IOException {
    //驗證為未登陸狀態會進入此方法,認證錯誤
    System.out.println("認證失敗:" + authException.getMessage());
    response.setStatus(200);
    response.setCharacterEncoding("UTF-8");
    response.setContentType("application/json; charset=utf-8");
    PrintWriter printWriter = response.getWriter();
    String body = ResultJson.failure(ResultCode.UNAUTHORIZED,authException.getMessage()).toString();
    printWriter.write(body);
    printWriter.flush();
  }
}

因為我們使用的REST API,所以我們認為到達後臺的請求都是正常的,所以返回的HTTP狀態碼都是200,用介面返回的code來確定請求是否正常。

/**
* 許可權不足處理類,返回403
 * Author: JoeTao
 * createAt: 2018/9/21
 */
@Component("RestAuthenticationAccessDeniedHandler")
public class RestAuthenticationAccessDeniedHandler implements AccessDeniedHandler {
  @Override
  public void handle(HttpServletRequest httpServletRequest,AccessDeniedException e) throws IOException,ServletException {
    //登陸狀態下,許可權不足執行該方法
    System.out.println("許可權不足:" + e.getMessage());
    response.setStatus(200);
    response.setCharacterEncoding("UTF-8");
    response.setContentType("application/json; charset=utf-8");
    PrintWriter printWriter = response.getWriter();
    String body = ResultJson.failure(ResultCode.FORBIDDEN,e.getMessage()).toString();
    printWriter.write(body);
    printWriter.flush();
  }
}
/**
 * 登陸身份認證
 * Author: JoeTao
 * createAt: 2018/9/14
 */
@Component(value="CustomUserDetailsService")
public class CustomUserDetailsService implements UserDetailsService {
  private final AuthMapper authMapper;

  public CustomUserDetailsService(AuthMapper authMapper) {
    this.authMapper = authMapper;
  }

  @Override
  public User loadUserByUsername(String name) throws UsernameNotFoundException {
    User user = authMapper.findByUsername(name);
    if (user == null) {
      throw new UsernameNotFoundException(String.format("No user found with username '%s'.",name));
    }
    Role role = authMapper.findRoleByUserId(user.getId());
    user.setRole(role);
    return user;
  }
}

登陸邏輯:

 public ResponseUserToken login(String username,String password) {
    //使用者驗證
    final Authentication authentication = authenticate(username,password);
    //儲存認證資訊
    SecurityContextHolder.getContext().setAuthentication(authentication);
    //生成token
    final User user = (User) authentication.getPrincipal();
//    User user = (User) userDetailsService.loadUserByUsername(username);
    final String token = jwtTokenUtil.generateAccessToken(user);
    //儲存token
    jwtTokenUtil.putToken(username,token);
    return new ResponseUserToken(token,user);
  }

private Authentication authenticate(String username,String password) {
    try {
      //該方法會去呼叫userDetailsService.loadUserByUsername()去驗證使用者名稱和密碼,如果正確,則儲存該使用者名稱密碼到“security 的 context中”
      return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username,password));
    } catch (DisabledException | BadCredentialsException e) {
      throw new CustomException(ResultJson.failure(ResultCode.LOGIN_ERROR,e.getMessage()));
    }
  }

自定義異常:

@Getter
public class CustomException extends RuntimeException{
  private ResultJson resultJson;

  public CustomException(ResultJson resultJson) {
    this.resultJson = resultJson;
  }
}

統一異常處理:

/**
 * 異常處理類
 * controller層異常無法捕獲處理,需要自己處理
 * Created by jt on 2018/8/27.
 */
@RestControllerAdvice
@Slf4j
public class DefaultExceptionHandler {

  /**
   * 處理所有自定義異常
   * @param e
   * @return
   */
  @ExceptionHandler(CustomException.class)
  public ResultJson handleCustomException(CustomException e){
    log.error(e.getResultJson().getMsg().toString());
    return e.getResultJson();
  }
}

所有經controller轉發的請求丟擲的自定義異常都會被捕獲處理,一般情況下就是返回給呼叫方一個json的報錯資訊,包含自定義狀態碼、錯誤資訊及補充描述資訊。

值得注意的是,在請求到達controller之前,會被Filter攔截,如果在controller或者之前丟擲的異常,自定義的異常處理器是無法處理的,需要自己重新定義一個全域性異常處理器或者直接處理。

Filter攔截請求兩次的問題

跨域的post的請求會驗證兩次,get不會。網上的解釋是,post請求第一次是預檢請求,Request Method: OPTIONS。
解決方法:

在webSecurityConfig裡新增

.antMatchers(HttpMethod.OPTIONS,"/**").permitAll()

就可以不攔截options請求了。

這裡只給出了最主要的程式碼,還有controller層的訪問許可權設定,返回狀態碼,返回類定義等等。

所有程式碼已上傳GitHub,專案地址

到此這篇關於SpringBoot整合SpringSecurity和JWT做登陸鑑權的實現的文章就介紹到這了,更多相關SpringBoot JWT 登陸鑑權內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!