天天看點

使用Spring Security和JWT保護REST API實戰源碼

設計REST API時,必須考慮如何保護REST API,在基于Spring的應用程式中,Spring Security是一種出色的身份驗證和授權解決方案,它提供了幾種保護REST API的選項。

最簡單的方法是使用HTTP Basic,當你啟動基于Spring Boot的應用程式時,預設情況下會激活它,這有利于開發,可在開發階段經常使用,但不建議在生産環境中使用。

Spring Session(使用Spring Security)提供了一個簡單的政策來建立和驗證基于頭的令牌(會話ID),它可以用于保護RESTful API。

除此之外,Spring Security OAuth(Spring Security下的子項目)提供OAuth授權的完整解決方案,包括OAuth2協定中定義的所有角色的實作,例如授權伺服器,資源伺服器,OAuth2用戶端等,Spring Cloud在其子項目Spring Cloud Security中給OAuth2用戶端增加了單點登入功能,在基于Spring Security OAuth的解決方案中,通路令牌的内容可以是簽名的JWT令牌或不透明值,我們必須遵循标準OAuth2授權流程來擷取通路令牌。

對于那些沒有計劃将自己API暴露給第三方應用程式的資源完全擁有者來說,基于JWT令牌的簡單授權更簡單合理(我們不需要管理第三方用戶端應用程式的憑據)。

Spring Security本身并沒有提供這樣的選項,幸運的是,通過将我們的自定義過濾器混合到Spring Security Filter Chain中來實作它并不困難。在這篇文章中,我們将建立這樣一個自定義JWT身份驗證解決方案。

在此示例應用程式中,可以将基于自定義JWT令牌的身份驗證流程指定為以下步驟:

1. 從身份驗證端點擷取基于JWT的令牌,例如/auth/signin。

2. 從身份驗證結果中提取令牌。

3. 将HTTP标頭Authorization值設定為Bearer jwt_token。

4. 然後發送一個通路受保護資源的請求。

5. 如果請求的資源受到保護,Spring Security将使用我們的自定義Filter來驗證JWT令牌,并建構一個Authentication對象,把它放入SecurityContextHolder以完成身份驗證流程。

6. 如果JWT令牌有效,它将把請求的資源傳回給用戶端。

生成項目架構

建立新Spring Boot項目的最快方法是使用Spring Initializr生成基本代碼。

打開浏覽器,轉到http://start.spring.io,在Dependencies字段中,選擇Web,Security,JPA,Lombok,然後單擊Generate按鈕或按ALT + ENTER鍵以生成項目架構代碼。

等待一段時間下載下傳生成的代碼,完成後,将zip檔案解壓縮到本地系統。

打開你喜歡的IDE,例如Intellij IDEA,NetBeans IDE,然後導入它。

建立示例REST API

在此應用程式中,我們将公開車輛資源的REST API。

/vehiclesPOST {name:'title'}

/vehicles/{id}GET200, {id:'1', name:'title'}

/vehicles/{id}PUT {name:'title'}

/vehicles/{id}DELETE

建立JPA實體Vehicle。

@Entity
@Table(name="vehicles")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Vehicle implements Serializable {
 @Id
 @GeneratedValue(strategy = GenerationType.AUTO)
 private Long id ;
 @Column
 private String name;
}
      

建立JPA存儲庫:

public interface VehicleRepository extends JpaRepository<Vehicle, Long> {
}
      

建立一個Spring MVC basec Controller來公開REST API。

@RestController
@RequestMapping("/v1/vehicles")
public class VehicleController {
 private VehicleRepository vehicles;
 public VehicleController(VehicleRepository vehicles) {
 this.vehicles = vehicles;
 }
 @GetMapping("")
 public ResponseEntity all() {
 return ok(this.vehicles.findAll());
 }
 @PostMapping("")
 public ResponseEntity save(@RequestBody VehicleForm form, HttpServletRequest request) {
 Vehicle saved = this.vehicles.save(Vehicle.builder().name(form.getName()).build());
 return created(
 ServletUriComponentsBuilder
 .fromContextPath(request)
 .path("/v1/vehicles/{id}")
 .buildAndExpand(saved.getId())
 .toUri())
 .build();
 }
 @GetMapping("/{id}")
 public ResponseEntity get(@PathVariable("id") Long id) {
 return ok(this.vehicles.findById(id).orElseThrow(() -> new VehicleNotFoundException()));
 }
 @PutMapping("/{id}")
 public ResponseEntity update(@PathVariable("id") Long id, @RequestBody VehicleForm form) {
 Vehicle existed = this.vehicles.findById(id).orElseThrow(() -> new VehicleNotFoundException());
 existed.setName(form.getName());
 this.vehicles.save(existed);
 return noContent().build();
 }
 @DeleteMapping("/{id}")
 public ResponseEntity delete(@PathVariable("id") Long id) {
 Vehicle existed = this.vehicles.findById(id).orElseThrow(() -> new VehicleNotFoundException());
 this.vehicles.delete(existed);
 return noContent().build();
 }
}
      

這很簡單而且不用動腦。我們定義了VehicleNotFoundException,如果相關id車輛未找到将抛出這個錯誤。

建立一個簡單的異常處理程式來處理自定義異常。

@RestControllerAdvice
@Slf4j
public class RestExceptionHandler {
 @ExceptionHandler(value = {VehicleNotFoundException.class})
 public ResponseEntity vehicleNotFound(VehicleNotFoundException ex, WebRequest request) {
 log.debug("handling VehicleNotFoundException...");
 return notFound().build();
 }
}	
      

建立一個CommandLineRunnerbean以在應用程式啟動階段初始化一些車輛資料。

@Component
@Slf4j
public class DataInitializer implements CommandLineRunner {
 @Autowired
 VehicleRepository vehicles;
 @Override
 public void run(String... args) throws Exception {
 log.debug("initializing vehicles data...");
 Arrays.asList("moto", "car").forEach(v -> this.vehicles.saveAndFlush(Vehicle.builder().name(v).build()));
 log.debug("printing all vehicles...");
 this.vehicles.findAll().forEach(v -> log.debug(" Vehicle :" + v.toString()));
 }
}
      

通過在終端中執行指令行mvn spring-boot:run運作,或直接在IDE中運作類來運作應用程式。

打開終端,用于curl測試API:

>curl http://localhost:8080/v1/vehicles
[ {
 "id" : 1,
 "name" : "moto"
}, {
 "id" : 2,
 "name" : "car"
} ]
      

Spring Data Rest能直接通過Repository接口公開API。

@RepositoryRestResource在現有VehicleRepository界面上添加注釋。

@RepositoryRestResource(path = "vehicles", collectionResourceRel = "vehicles", itemResourceRel = "vehicle")
public interface VehicleRepository extends JpaRepository<Vehicle, Long> {
}
      

重新啟動應用程式并嘗試通路http://localhost:8080/vehicles

curl -X GET http://localhost:8080/vehicles 
{
 "_embedded" : {
 "vehicles" : [ {
 "name" : "moto",
 "_links" : {
 "self" : {
 "href" : "http://localhost:8080/vehicles/1"
 },
 "vehicle" : {
 "href" : "http://localhost:8080/vehicles/1"
 }
 }
 }, {
 "name" : "car",
 "_links" : {
 "self" : {
 "href" : "http://localhost:8080/vehicles/2"
 },
 "vehicle" : {
 "href" : "http://localhost:8080/vehicles/2"
 }
 }
 } ]
 },
 "_links" : {
 "self" : {
 "href" : "http://localhost:8080/vehicles{?page,size,sort}",
 "templated" : true
 },
 "profile" : {
 "href" : "http://localhost:8080/profile/vehicles"
 }
 },
 "page" : {
 "size" : 20,
 "totalElements" : 2,
 "totalPages" : 1,
 "number" : 0
 }
}
      

這裡利用Spring HATEOAS項目來暴露更豐富的REST API,這些API屬于Richardson Mature Model Level 3(自我文檔)。

保護REST API
      

現在我們将建立一個基于JWT令牌的自定義身份驗證過濾器來驗證JWT令牌。

JwtTokenFilter為JWT令牌驗證建立過濾器名稱。

public class JwtTokenFilter extends GenericFilterBean {
 private JwtTokenProvider jwtTokenProvider;
 public JwtTokenFilter(JwtTokenProvider jwtTokenProvider) {
 this.jwtTokenProvider = jwtTokenProvider;
 }
 @Override
 public void doFilter(ServletRequest req, ServletResponse res, FilterChain filterChain)
 throws IOException, ServletException {
 String token = jwtTokenProvider.resolveToken((HttpServletRequest) req);
 if (token != null && jwtTokenProvider.validateToken(token)) {
 Authentication auth = token != null ? jwtTokenProvider.getAuthentication(token) : null;
 SecurityContextHolder.getContext().setAuthentication(auth);
 }
 filterChain.doFilter(req, res);
 }
}
      

它使用JwtTokenProvider處理JWT,例如生成JWT令牌,解析JWT聲明。

@Component
public class JwtTokenProvider {
 @Value("${security.jwt.token.secret-key:secret}")
 private String secretKey = "secret";
 @Value("${security.jwt.token.expire-length:3600000}")
 private long validityInMilliseconds = 3600000; // 1h
 @Autowired
 private UserDetailsService userDetailsService;
 @PostConstruct
 protected void init() {
 secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
 }
 public String createToken(String username, List<String> roles) {
 Claims claims = Jwts.claims().setSubject(username);
 claims.put("roles", roles);
 Date now = new Date();
 Date validity = new Date(now.getTime() + validityInMilliseconds);
 return Jwts.builder()//
 .setClaims(claims)//
 .setIssuedAt(now)//
 .setExpiration(validity)//
 .signWith(SignatureAlgorithm.HS256, secretKey)//
 .compact();
 }
 public Authentication getAuthentication(String token) {
 UserDetails userDetails = this.userDetailsService.loadUserByUsername(getUsername(token));
 return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
 }
 public String getUsername(String token) {
 return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
 }
 public String resolveToken(HttpServletRequest req) {
 String bearerToken = req.getHeader("Authorization");
 if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
 return bearerToken.substring(7, bearerToken.length());
 }
 return null;
 }
 public boolean validateToken(String token) {
 try {
 Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
 if (claims.getBody().getExpiration().before(new Date())) {
 return false;
 }
 return true;
 } catch (JwtException | IllegalArgumentException e) {
 throw new InvalidJwtAuthenticationException("Expired or invalid JWT token");
 }
 }
}
      

建立一個獨立的Configurer類來進行設定JwtTokenFilter。

public class JwtConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
 private JwtTokenProvider jwtTokenProvider;
 public JwtConfigurer(JwtTokenProvider jwtTokenProvider) {
 this.jwtTokenProvider = jwtTokenProvider;
 }
 @Override
 public void configure(HttpSecurity http) throws Exception {
 JwtTokenFilter customFilter = new JwtTokenFilter(jwtTokenProvider);
 http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
 }
}
      

在我們的應用程式作用域中應用此配置器SecurityConfig。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
 @Autowired
 JwtTokenProvider jwtTokenProvider;
 @Bean
 @Override
 public AuthenticationManager authenticationManagerBean() throws Exception {
 return super.authenticationManagerBean();
 }
 @Override
 protected void configure(HttpSecurity http) throws Exception {
 //@formatter:off
 http
 .httpBasic().disable()
 .csrf().disable()
 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
 .and()
 .authorizeRequests()
 .antMatchers("/auth/signin").permitAll()
 .antMatchers(HttpMethod.GET, "/vehicles/**").permitAll()
 .antMatchers(HttpMethod.DELETE, "/vehicles/**").hasRole("ADMIN")
 .antMatchers(HttpMethod.GET, "/v1/vehicles/**").permitAll()
 .anyRequest().authenticated()
 .and()
 .apply(new JwtConfigurer(jwtTokenProvider));
 //@formatter:on
 }
}
      

要啟用Spring Security,我們必須在運作時提供自定義UserDetailsService這個bean:

@Component
public class CustomUserDetailsService implements UserDetailsService {
 private UserRepository users;
 public CustomUserDetailsService(UserRepository users) {
 this.users = users;
 }
 @Override
 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
 return this.users.findByUsername(username)
 .orElseThrow(() -> new UsernameNotFoundException("Username: " + username + " not found"));
 }
}
      

該CustomUserDetailsService試圖以使用者名為查詢參數從資料庫中擷取使用者資料。

User是一個标準的JPA實體,為了簡化工作,它還實作了Spring Security特定的UserDetails接口。

@Entity
@Table(name="users")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails {
 @Id
 @GeneratedValue(strategy = GenerationType.AUTO)
 Long id;
 @NotEmpty
 private String username;
 @NotEmpty
 private String password;
 @ElementCollection(fetch = FetchType.EAGER)
 @Builder.Default
 private List<String> roles = new ArrayList<>();
 @Override
 public Collection<? extends GrantedAuthority> getAuthorities() {
 return this.roles.stream().map(SimpleGrantedAuthority::new).collect(toList());
 }
 @Override
 public String getPassword() {
 return this.password;
 }
 @Override
 public String getUsername() {
 return this.username;
 }
 @Override
 public boolean isAccountNonExpired() {
 return true;
 }
 @Override
 public boolean isAccountNonLocked() {
 return true;
 }
 @Override
 public boolean isCredentialsNonExpired() {
 return true;
 }
 @Override
 public boolean isEnabled() {
 return true;
 }
}
      

建立為User實體建立一個Repository接口:

public interface UserRepository extends JpaRepository<User, Long> {
 Optional<User> findByUsername(String username);
}
      

建立一個控制器來驗證使用者:

@RestController
@RequestMapping("/auth")
public class AuthController {
 @Autowired
 AuthenticationManager authenticationManager;
 @Autowired
 JwtTokenProvider jwtTokenProvider;
 @Autowired
 UserRepository users;
 @PostMapping("/signin")
 public ResponseEntity signin(@RequestBody AuthenticationRequest data) {
 try {
 String username = data.getUsername();
 authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, data.getPassword()));
 String token = jwtTokenProvider.createToken(username, this.users.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("Username " + username + "not found")).getRoles());
 Map<Object, Object> model = new HashMap<>();
 model.put("username", username);
 model.put("token", token);
 return ok(model);
 } catch (AuthenticationException e) {
 throw new BadCredentialsException("Invalid username/password supplied");
 }
 }
}
      

建立端點以擷取目前使用者資訊。

@RestController()
public class UserinfoController {
 @GetMapping("/me")
 public ResponseEntity currentUser(@AuthenticationPrincipal UserDetails userDetails){
 Map<Object, Object> model = new HashMap<>();
 model.put("username", userDetails.getUsername());
 model.put("roles", userDetails.getAuthorities()
 .stream()
 .map(a -> ((GrantedAuthority) a).getAuthority())
 .collect(toList())
 );
 return ok(model);
 }
}
      

目前使用者通過身份驗證後,@AuthenticationPrincipal将綁定到目前主體。

在我們的初始化類中添加兩個用于測試目的的使用者。

@Component
@Slf4j
public class DataInitializer implements CommandLineRunner {
 //...
 @Autowired
 UserRepository users;
 @Autowired
 PasswordEncoder passwordEncoder;
 @Override
 public void run(String... args) throws Exception {
 //...
 this.users.save(User.builder()
 .username("user")
 .password(this.passwordEncoder.encode("password"))
 .roles(Arrays.asList( "ROLE_USER"))
 .build()
 );
 this.users.save(User.builder()
 .username("admin")
 .password(this.passwordEncoder.encode("password"))
 .roles(Arrays.asList("ROLE_USER", "ROLE_ADMIN"))
 .build()
 );
 log.debug("printing all users...");
 this.users.findAll().forEach(v -> log.debug(" User :" + v.toString()));
 }
}
      

現在用于curl嘗試此身份驗證流程。

通過user/password登入:

curl -X POST http://localhost:8080/auth/signin -H "Content-Type:application/json" -d "{"username":"user", "password":"password"}"
{
 "username" : "user",
 "token" : "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlhdCI6MTUyNDY0OTI4OSwiZXhwIjoxNTI0NjUyODg5fQ.Lj1w6vPJNdJbcY6cAhO3DbkgCAqpG7lzztzUeKMyNyE"
}
      

将token值放入HTTP标頭Authorization,将其值設定為Bearer token,然後通路目前使用者資訊。

curl -X GET http://localhost:8080/me -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlhdCI6MTUyNDY0OTI4OSwiZXhwIjoxNTI0NjUyODg5fQ.Lj1w6vPJNdJbcY6cAhO3DbkgCAqpG7lzztzUeKMyNyE"
{
 "roles" : [ "ROLE_USER" ],
 "username" : "user"
}