設計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"
}