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

设计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"}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值