用于 REST API 的带有 JWT 的 Spring Security

Posted

技术标签:

【中文标题】用于 REST API 的带有 JWT 的 Spring Security【英文标题】:Spring Security with JWT for REST API 【发布时间】:2021-11-07 02:39:05 【问题描述】:

我有这门课:

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

    private static final String SALT = "fd&lkj§isfs23#$1*(_)nof";

    private final JwtAuthenticationEntryPoint unauthorizedHandler;
    private final JwtTokenUtil jwtTokenUtil;
    private final UserSecurityService userSecurityService;

    @Value("$jwt.header")
    private String tokenHeader;


    public ApiWebSecurityConfig(JwtAuthenticationEntryPoint unauthorizedHandler, JwtTokenUtil jwtTokenUtil,
            UserSecurityService userSecurityService) 
        this.unauthorizedHandler = unauthorizedHandler;
        this.jwtTokenUtil = jwtTokenUtil;
        this.userSecurityService = userSecurityService;
    

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception 
        auth
                .userDetailsService(userSecurityService)
                .passwordEncoder(passwordEncoder());
    

    @Bean
    public BCryptPasswordEncoder passwordEncoder() 
        return new BCryptPasswordEncoder(12, new SecureRandom(SALT.getBytes()));
    

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

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception 

        httpSecurity
                // we don't need CSRF because our token is invulnerable
                .csrf().disable()

                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()

                // don't create session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests()
                // Un-secure H2 Database
                .antMatchers("/h2-console/**/**").permitAll()
                .antMatchers("/api/v1/users").permitAll()
                .antMatchers("/error").permitAll()
                .anyRequest().authenticated();

        // Custom JWT based security filter
        JwtAuthorizationTokenFilter authenticationTokenFilter = new JwtAuthorizationTokenFilter(userDetailsService(), jwtTokenUtil, tokenHeader);
        httpSecurity
                .addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        // disable page caching
        httpSecurity
                .headers()
                .frameOptions()
                .sameOrigin()  // required to set for H2 else H2 Console will be blank.
                .cacheControl();
    

    @Override
    public void configure(WebSecurity web) 

        // AuthenticationTokenFilter will ignore the below paths
        web
                .ignoring()
                .antMatchers(
                        HttpMethod.POST,
                        "/api/v1/auth"
                )
                .antMatchers(
                        HttpMethod.POST,
                        "/api/v1/users"
                )
                .antMatchers(
                        HttpMethod.GET,
                        "/api/v1/countries"
                );

    


@Provider
@Slf4j
public class JwtAuthorizationTokenFilter extends OncePerRequestFilter 

    private UserDetailsService userDetailsService;
    private JwtTokenUtil jwtTokenUtil;
    private String tokenHeader;

    public JwtAuthorizationTokenFilter(UserDetailsService userDetailsService,
            JwtTokenUtil jwtTokenUtil,
            String tokenHeader) 
        this.userDetailsService = userDetailsService;
        this.jwtTokenUtil = jwtTokenUtil;
        this.tokenHeader = tokenHeader;
    


    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) 
        return new AntPathMatcher().match("/api/v1/users",
                request.getServletPath());
    


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

        log.info("processing authentication for ''", request.getRequestURL());
        log.info("tokenHeader ''", tokenHeader);

        final String requestHeader = request.getHeader(this.tokenHeader);

        log.info("requestHeader ''", requestHeader);

        String username = null;
        String authToken = null;

        if (requestHeader != null && requestHeader.startsWith("Bearer ")) 
            authToken = requestHeader.substring(7);

            log.info("authToken ''", authToken);

            try 
                username = jwtTokenUtil.getUsernameFromToken(authToken);
             catch (IllegalArgumentException e) 
                logger.info("an error occured during getting username from token", e);
             catch (ExpiredJwtException e) 
                logger.info("the token is expired and not valid anymore", e);
            
         else 
            logger.info("couldn't find bearer string, will ignore the header");
        

        log.info("checking authentication for user ''", username);

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) 
            logger.info("security context was null, so authorizating user");

            // It is not compelling necessary to load the use details from the database. You could also store the information
            // in the token and read it from it. It's up to you ;)
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            // For simple validation it is completely sufficient to just check the token integrity. You don't have to call
            // the database compellingly. Again it's up to you ;)
            if (jwtTokenUtil.validateToken(authToken, userDetails)) 
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                log.info("authorizated user '', setting security context", username);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            
        
        chain.doFilter(request, response);
    

@RestController
@Slf4j
public class AuthenticationRestController 


    private static final Logger LOG = LoggerFactory.getLogger   (AuthenticationRestController.class);

    @Value("$jwt.header")
    private String tokenHeader;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Autowired
    private UserSecurityService userSecurityService;

    @PostMapping(path = "/api/v1/auth", consumes = "application/json", produces = "application/json")
    public ResponseEntity<JwtAuthenticationResponse>
    createAuthenticationToken(  @RequestBody JwtAuthenticationRequest authenticationRequest,
            HttpServletRequest request) throws AuthenticationException 

        LOG.info("authenticating  " , authenticationRequest.getUsername());

        authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword());

        // Reload password post-security so we can generate the token
        final User userDetails = (User) userSecurityService.loadUserByUsername(authenticationRequest.getUsername());

        if (!userDetails.isEnabled()) 
            throw new UserDisabledException();
        

        if (LOG.isDebugEnabled()) 
            LOG.debug("UserDetails userDetails [ " + authenticationRequest.getUsername() + " ]");
        


        final String token = jwtTokenUtil.generateToken(userDetails);

        // Return the token
        return ResponseEntity.ok(new JwtAuthenticationResponse(token));
    


    @GetMapping(path = "$jwt.route.authentication.refresh", consumes = "application/json", produces = "application/json")
    public ResponseEntity<?> refreshAndGetAuthenticationToken(HttpServletRequest request) 
        String authToken = request.getHeader(tokenHeader);
        final String token = authToken.substring(7);
        String username = jwtTokenUtil.getUsernameFromToken(token);
        JwtUser user = (JwtUser) userSecurityService.loadUserByUsername(username);

        if (jwtTokenUtil.canTokenBeRefreshed(token, user.getLastPasswordResetDate())) 
            String refreshedToken = jwtTokenUtil.refreshToken(token);
            return ResponseEntity.ok(new JwtAuthenticationResponse(refreshedToken));
         else 
            return ResponseEntity.badRequest().body(null);
        
    

    @ExceptionHandler(AuthenticationException.class)
    public ResponseEntity<String> handleAuthenticationException(AuthenticationException e) 
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
    

    /**
     * Authenticates the user. If something is wrong, an @link AuthenticationException will be thrown
     */
    private void authenticate(String username, String password) 

        Objects.requireNonNull(username);
        Objects.requireNonNull(password);


        try 
            authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
         catch (DisabledException e) 
            e.printStackTrace();
            throw new AuthenticationException("User is disabled!", e);
         catch (BadCredentialsException e) 
            throw new AuthenticationException("Bad credentials!", e);
         catch (Exception e) 
            e.printStackTrace();
        
    

@RestController
@RequestMapping("/api/v1/styles")
@Slf4j
public class StyleResourceController  

    
    @PutMapping(path = "/styleCode")
    @ResponseStatus(HttpStatus.OK)
    public void setAlerts(@RequestHeader(value = "Authorization") String authHeader, @PathVariable String styleCode)

            throws DataAccessException 

        System.out.println("add style  ");

        final User user = authUserOnPath(authHeader);

        System.out.println("user  " + user);

            protected User authUserOnPath(String authHeader) 

        String authToken = authHeader.substring(7);

        String username = jwtTokenUtil.getUsernameFromToken(authToken);
        User user = userService.findByUserName(username);

        if (user == null)
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "UserNotFound");

        return user;

    


    


@Component
public class JwtTokenUtil implements Serializable 

    //static final String CLAIM_KEY_USERNAME = "sub";
    //static final String CLAIM_KEY_CREATED = "iat";
    private static final long serialVersionUID = -3301605591108950415L;
    // @SuppressFBWarnings(value = "SE_BAD_FIELD", justification = "It's okay here")
    private Clock clock = DefaultClock.INSTANCE;

    @Value("$jwt.secret")
    private String secret;

    @Value("$jwt.expiration")
    private Long expiration;

    public String getUsernameFromToken(String token) 
        return getClaimFromToken(token, Claims::getSubject);
    

    public Date getIssuedAtDateFromToken(String token) 
        return getClaimFromToken(token, Claims::getIssuedAt);
    

    public Date getExpirationDateFromToken(String token) 
        return getClaimFromToken(token, Claims::getExpiration);
    

    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) 
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    

    private Claims getAllClaimsFromToken(String token) 
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    

    private Boolean isTokenExpired(String token) 
        final Date expiration = getExpirationDateFromToken(token);
        return expiration.before(clock.now());
    

    private Boolean isCreatedBeforeLastPasswordReset(Date created, Date lastPasswordReset) 
        return (lastPasswordReset != null && created.before(lastPasswordReset));
    

    private Boolean ignoreTokenExpiration(String token) 
        // here you specify tokens, for that the expiration is ignored
        return false;
    

    public String generateToken(UserDetails userDetails) 
        Map<String, Object> claims = new HashMap<>();
        return doGenerateToken(claims, userDetails.getUsername());
    

    private String doGenerateToken(Map<String, Object> claims, String subject) 
        final Date createdDate = clock.now();
        final Date expirationDate = calculateExpirationDate(createdDate);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(createdDate)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    

    public Boolean canTokenBeRefreshed(String token, Date lastPasswordReset) 
        final Date created = getIssuedAtDateFromToken(token);
        return !isCreatedBeforeLastPasswordReset(created, lastPasswordReset)
                && (!isTokenExpired(token) || ignoreTokenExpiration(token));
    

    public String refreshToken(String token) 
        final Date createdDate = clock.now();
        final Date expirationDate = calculateExpirationDate(createdDate);

        final Claims claims = getAllClaimsFromToken(token);
        claims.setIssuedAt(createdDate);
        claims.setExpiration(expirationDate);

        return Jwts.builder()
                .setClaims(claims)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    

    public Boolean validateToken(String token, UserDetails userDetails) 
        JwtUser user = (JwtUser) userDetails;
        final String username = getUsernameFromToken(token);
        final Date created = getIssuedAtDateFromToken(token);
        return (
                username.equals(user.getUsername())
                        && !isTokenExpired(token)
                        && !isCreatedBeforeLastPasswordReset(created, user.getLastPasswordResetDate())
        );
    

    private Date calculateExpirationDate(Date createdDate) 
        return new Date(createdDate.getTime() + expiration * 1000);
    

但是当我从令牌中获取用户时为空:

17:19:22.017 [http-nio-1133-exec-8] INFO  c.d.c.JwtAuthorizationTokenFilter - tokenHeader 'Authorization'
17:19:22.017 [http-nio-1133-exec-8] INFO  c.d.c.JwtAuthorizationTokenFilter - requestHeader 'Bearer eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2OTE3NjcxMzYsImlhdCI6MTYzMTI4NzEzNn0.C9s3dbjWNVyGdV5k0LXsNhMGMvPzboTx1J6sGEbfXVOP1CzCLeZFgVPQ4o8jgugvgURF3BcnsWAk7ygd7RCvdg'
17:19:22.017 [http-nio-1133-exec-8] INFO  c.d.c.JwtAuthorizationTokenFilter - authToken 'eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2OTE3NjcxMzYsImlhdCI6MTYzMTI4NzEzNn0.C9s3dbjWNVyGdV5k0LXsNhMGMvPzboTx1J6sGEbfXVOP1CzCLeZFgVPQ4o8jgugvgURF3BcnsWAk7ygd7RCvdg'
17:19:22.018 [http-nio-1133-exec-8] INFO  c.d.c.JwtAuthorizationTokenFilter - checking authentication for user 'null'

【问题讨论】:

您的令牌不包含用户名,因此请将您的精力集中在创建部分。 在您的令牌中找不到任何与用户相关的数据。粘贴您的 JWT 令牌 here。当你创建它们时,你需要在你的令牌中添加与用户相关的数据。 如 sam 和 ray 所示,您的令牌不包含任何用户信息(例如,如果您将令牌复制并粘贴到 jet.io 中,它将在有效负载中不提供 sub 值)。 create createAuthenticationToken 方法看起来和generateToken 一样好。请尝试在此行之前调试您的 ÙserDetails` 实例:final String token = jwtTokenUtil.generateToken(userDetails);。另外,请确保您的UserDetails 正确实现了getUsername 方法,有时我们从数据库中派生它并且我们没有正确实现它。尝试调试方法... doGenerateToken 以及查看subject 变量的值。我希望我能帮上忙。 对不起,我想说jwt.io,而不是jet.io。我的电脑翻译器。 【参考方案1】:

仔细检查您的 jwt 令牌。我认为它错过了子属性(此处的主题或用户名)。

我还强烈建议您为 JwtTokenUtil 等少数类编写少量单元测试,以确保您的代码按预期工作。您可以使用 spring-test 轻松完成。

它可以帮助您更轻松、更快地发现错误。

这是我用来测试命令“jwt generate”和“jwt parse”的几个测试

@Test
  public void testInvalidKey() 
    String result = execute("jwt generate ");
    Assertions.assertEquals(JsonWebToken.ERR_LOAD_PRIVATE_KEY, result);
  

  @Test
  public void testInvalidExpiredDate() 
    String result = execute(
        "jwt generate -exp \"12-12-2020\"");
    Assertions.assertEquals(JsonWebToken.ERR_INVALID_EXPIRED_DATE, result);
  

  @Test
  public void testGenerateOk() 
    String result = execute(
        "jwt generate");
    Assertions.assertNotNull(result);
    Assertions.assertTrue(result.length() > 100);
  

  @Test
  public void testParseToken() 
    String result = execute(
        "jwt generate -sub designer");
    Assertions.assertNotNull(result);
    String parse = execute("jwt parse -token " + result);    
    Assertions.assertTrue(parse.contains("sub:designer"));
  

这是我用来检查控制器和过滤器的其他一些测试。

@Test
  public void testInValidToken() throws Exception
    Map<String, Object> map = new LinkedHashMap<>(reqObj);
      mockMvc.perform(postWithTokenAndData(getUrlProvider(), map)
          .header(HEADER_AUTHORIZATION, String.format("Bearer %s", INVALID_JWT))
          .contentType(MediaType.APPLICATION_JSON))
          .andExpect(MockMvcResultMatchers.status().is4xxClientError());
  
  
  @Test
  public void testExpired() throws Exception
    Date exp = Date.from(Instant.now().minusSeconds(3600));
      Map<String, Object> map = new LinkedHashMap<>(reqObj);
      mockMvc.perform(postWithTokenAndData(getUrlProvider(), map)
          .header(HEADER_AUTHORIZATION, String.format("Bearer %s", JwtHelperTest.getJwtToken(exp,
              "designer")))
          .contentType(MediaType.APPLICATION_JSON))
          .andExpect(MockMvcResultMatchers.status().is4xxClientError());
  

【讨论】:

以上是关于用于 REST API 的带有 JWT 的 Spring Security的主要内容,如果未能解决你的问题,请参考以下文章

JSON:带有 django-rest-framework-json-api 和 JWT 的 API

带有 jwt 身份验证的 django rest api 要求 csrf 令牌

带有自定义 API Rest Endpoint 的 WordPress 和 JWT

使用 JWT 访问 Rest API 适用于 Postman 但不适用于 Axios

带有 okta OAUTH 令牌身份验证的 Django Rest API

如何在 Django 和 Python 中使用 JWT(JSON Web 令牌)来创建用于注册和登录的 REST API