设计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。
/vehicles POST {name:'title'}
/vehicles/{id} GET 200, {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"
}
github中的源代码 ,它还包括使用JUnit,Spring Boot Test,RestAssured等的测试代码。