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