设计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@NoArgsConstructorpublic class Vehicle implements Serializable {@Id@GeneratedValue(strategy = GenerationType.AUTO) private Long id ;@Columnprivate String name;}
创建JPA存储库:
publicinterfaceVehicleRepositoryextendsJpaRepository{}
创建一个Spring MVC basec Controller来公开REST API。
@RestController@RequestMapping("/v1/vehicles")publicclassVehicleController{private VehicleRepository vehicles; public VehicleController(VehicleRepository vehicles) {this.vehicles = vehicles; } @GetMapping("") public ResponseEntity all() {returnok(this.vehicles.findAll()); } @PostMapping("") public ResponseEntity save(@RequestBody VehicleForm form, HttpServletRequest request) { Vehicle saved =this.vehicles.save(Vehicle.builder().name(form.getName()).build());returncreated( ServletUriComponentsBuilder .fromContextPath(request) .path("/v1/vehicles/{id}") .buildAndExpand(saved.getId()) .toUri()) .build(); } @GetMapping("/{id}") public ResponseEntity get(@PathVariable("id") Long id) {returnok(this.vehicles.findById(id).orElseThrow(() ->newVehicleNotFoundException())); } @PutMapping("/{id}")publicResponseEntityupdate(@PathVariable("id") Long id, @RequestBody VehicleForm form){Vehicleexisted=this.vehicles.findById(id).orElseThrow(() ->newVehicleNotFoundException());existed.setName(form.getName());this.vehicles.save(existed);returnnoContent().build(); } @DeleteMapping("/{id}")publicResponseEntitydelete(@PathVariable("id") Long id){Vehicleexisted=this.vehicles.findById(id).orElseThrow(() ->newVehicleNotFoundException());this.vehicles.delete(existed);returnnoContent().build(); }}
这很简单而且不用动脑。我们定义了VehicleNotFoundException,如果相关id车辆未找到将抛出这个错误。
创建一个简单的异常处理程序来处理自定义异常。
@RestControllerAdvice@Slf4jpublic class RestExceptionHandler {@ExceptionHandler(value = {VehicleNotFoundException.class}) public ResponseEntity vehicleNotFound(VehicleNotFoundException ex, WebRequest request) {log.debug("handling VehicleNotFoundException...");returnnotFound().build(); }}
创建一个CommandLineRunnerbean以在应用程序启动阶段初始化一些车辆数据。
@Component@Slf4jpublic class DataInitializer implements CommandLineRunner {@AutowiredVehicleRepository vehicles;@Overridepublic 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")publicinterfaceVehicleRepositoryextendsJpaRepository{}
重新启动应用程序并尝试访问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令牌验证创建过滤器名称。
publicclassJwtTokenFilterextendsGenericFilterBean{privateJwtTokenProviderjwtTokenProvider; publicJwtTokenFilter(JwtTokenProviderjwtTokenProvider) {this.jwtTokenProvider = jwtTokenProvider; }@Overridepublic void doFilter(ServletRequestreq,ServletResponseres,FilterChainfilterChain)throwsIOException,ServletException{Stringtoken = jwtTokenProvider.resolveToken((HttpServletRequest) req);if(token !=null&& jwtTokenProvider.validateToken(token)) {Authenticationauth = token !=null? jwtTokenProvider.getAuthentication(token) :null;SecurityContextHolder.getContext().setAuthentication(auth); } filterChain.doFilter(req, res); }}
它使用JwtTokenProvider处理JWT,例如生成JWT令牌,解析JWT声明。
@ComponentpublicclassJwtTokenProvider { @Value("${security.jwt.token.secret-key:secret}")privateStringsecretKey ="secret"; @Value("${security.jwt.token.expire-length:3600000}")privatelong validityInMilliseconds =3600000;// 1h@AutowiredprivateUserDetailsService userDetailsService; @PostConstructprotectedvoidinit() { secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes()); }publicStringcreateToken(Stringusername, List roles) { Claims claims = Jwts.claims().setSubject(username); claims.put("roles", roles);Datenow =newDate();Datevalidity =newDate(now.getTime() + validityInMilliseconds);returnJwts.builder()//.setClaims(claims)//.setIssuedAt(now)//.setExpiration(validity)//.signWith(SignatureAlgorithm.HS256, secretKey)//.compact(); }publicAuthentication getAuthentication(Stringtoken) { UserDetails userDetails =this.userDetailsService.loadUserByUsername(getUsername(token));returnnewUsernamePasswordAuthenticationToken(userDetails,"", userDetails.getAuthorities()); }publicStringgetUsername(Stringtoken) {returnJwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject(); }publicStringresolveToken(HttpServletRequest req) {StringbearerToken = req.getHeader("Authorization");if(bearerToken !=null&& bearerToken.startsWith("Bearer ")) {returnbearerToken.substring(7, bearerToken.length()); }returnnull; }publicbooleanvalidateToken(Stringtoken) {try{ Jws claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);if(claims.getBody().getExpiration().before(newDate())) {returnfalse; }returntrue; }catch(JwtException | IllegalArgumentException e) {thrownewInvalidJwtAuthenticationException("Expired or invalid JWT token"); } }}
创建一个独立的Configurer类来进行设置JwtTokenFilter。
publicclassJwtConfigurerextendsSecurityConfigurerAdapter{privateJwtTokenProviderjwtTokenProvider; publicJwtConfigurer(JwtTokenProviderjwtTokenProvider) {this.jwtTokenProvider = jwtTokenProvider; }@Overridepublic void configure(HttpSecurityhttp)throwsException{JwtTokenFiltercustomFilter =newJwtTokenFilter(jwtTokenProvider); http.addFilterBefore(customFilter,UsernamePasswordAuthenticationFilter.class); }}
在我们的应用程序作用域中应用此配置器SecurityConfig。
@Configurationpublic class SecurityConfig extends WebSecurityConfigurerAdapter {@AutowiredJwtTokenProvider jwtTokenProvider;@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {returnsuper.authenticationManagerBean(); } @Overrideprotectedvoidconfigure(HttpSecurity http)throwsException{//@formatter:offhttp.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:
@ComponentpublicclassCustomUserDetailsServiceimplementsUserDetailsService{privateUserRepository users;publicCustomUserDetailsService(UserRepository users){this.users = users; }@OverridepublicUserDetailsloadUserByUsername(String username)throwsUsernameNotFoundException{returnthis.users.findByUsername(username) .orElseThrow(() ->newUsernameNotFoundException("Username: "+ username +" not found")); }}
该CustomUserDetailsService试图以用户名为查询参数从数据库中获取用户数据。
User是一个标准的JPA实体,为了简化工作,它还实现了Spring Security特定的UserDetails接口。
@Entity@Table(name="users")@Data@Builder@NoArgsConstructor@AllArgsConstructorpublic class User implements UserDetails {@Id@GeneratedValue(strategy = GenerationType.AUTO) Long id;@NotEmptyprivate String username;@NotEmptyprivate String password;@ElementCollection(fetch = FetchType.EAGER)@Builder.Default private List roles = new ArrayList<>();@Overridepublic Collection getAuthorities() {returnthis.roles.stream().map(SimpleGrantedAuthority::new).collect(toList()); } @OverridepublicStringgetPassword() {returnthis.password; } @OverridepublicStringgetUsername() {returnthis.username; } @OverridepublicbooleanisAccountNonExpired() {returntrue; } @OverridepublicbooleanisAccountNonLocked() {returntrue; } @OverridepublicbooleanisCredentialsNonExpired() {returntrue; } @OverridepublicbooleanisEnabled() {returntrue; }}
在这里给大家推荐一个架构交流群点击链接加入群聊【Java进阶高级架构群】:https://jq.qq.com/?_wv=1027&k=5j3rukD
创建为User实体创建一个Repository接口:
publicinterfaceUserRepositoryextendsJpaRepository{OptionalfindByUsername(String username);}
创建一个控制器来验证用户:
@RestController@RequestMapping("/auth")publicclassAuthController{@Autowired AuthenticationManager authenticationManager; @Autowired JwtTokenProvider jwtTokenProvider; @Autowired UserRepository users; @PostMapping("/signin") public ResponseEntity signin(@RequestBody AuthenticationRequest data) {try{ String username = data.getUsername(); authenticationManager.authenticate(newUsernamePasswordAuthenticationToken(username, data.getPassword())); String token = jwtTokenProvider.createToken(username,this.users.findByUsername(username).orElseThrow(() ->newUsernameNotFoundException("Username "+ username +"not found")).getRoles());Mapmodel=newHashMap<>();model.put("username", username);model.put("token", token);returnok(model); }catch(AuthenticationException e){thrownewBadCredentialsException("Invalid username/password supplied"); } }}
创建端点以获取当前用户信息。
@RestController()publicclassUserinfoController{@GetMapping("/me")publicResponseEntity currentUser(@AuthenticationPrincipalUserDetails userDetails){ Map model = new HashMap<>(); model.put("username", userDetails.getUsername()); model.put("roles", userDetails.getAuthorities() .stream() .map(a -> ((GrantedAuthority) a).getAuthority()) .collect(toList()) );returnok(model); }}
当前用户通过身份验证后,@AuthenticationPrincipal将绑定到当前主体。
在我们的初始化类中添加两个用于测试目的的用户。
@Component@Slf4jpublic class DataInitializer implements CommandLineRunner {//...@AutowiredUserRepository users;@AutowiredPasswordEncoder passwordEncoder;@Overridepublic 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 -XGET http://localhost:8080/me-H"Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlhdCI6MTUyNDY0OTI4OSwiZXhwIjoxNTI0NjUyODg5fQ.Lj1w6vPJNdJbcY6cAhO3DbkgCAqpG7lzztzUeKMyNyE"{"roles": ["ROLE_USER"],"username":"user"}
网友评论