使用Spring Security和JWT保护REST API实战源码

banq 18-08-31
                   

设计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等的测试代码。

Protect REST APIs with Spring Security and JWT | C