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

栏目: Java · 发布时间: 6年前

内容简介:设计REST API时,必须考虑如何保护REST API,在基于Spring的应用程序中,Spring Security是一种出色的身份验证和授权解决方案,它提供了几种保护REST API的选项。最简单的方法是使用HTTP Basic,当你启动基于Spring Boot的应用程序时,默认情况下会激活它,这有利于开发,可在开发阶段经常使用,但不建议在生产环境中使用。Spring Session(使用Spring Security)提供了一个简单的策略来创建和验证基于头的令牌(会话ID),它可以用于保护RES

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


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

About Face 4: 交互设计精髓

About Face 4: 交互设计精髓

[美] 艾伦·库伯、[美] 罗伯特·莱曼、[美] 戴维·克罗宁、[美] 克里斯托弗·诺埃塞尔 / 倪卫国、刘松涛、杭敏、薛菲 / 电子工出版社 / 2015-10 / 118.00元

《About Face 4: 交互设计精髓》是《About Face 3:交互设计精髓》的升级版,此次升级把全书的结构重组优化,更加精练和易用;更新了一些适合当下时代的术语和实例,文字全部重新编译,更加清晰易读;增加了更多目标导向设计过程的细节,更新了现行实践,重点增加 移动和触屏平台交互设计,其实《About Face 4: 交互设计精髓》多数内容适用于多种平台。 《About F......一起来看看 《About Face 4: 交互设计精髓》 这本书的介绍吧!

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

URL 编码/解码
URL 编码/解码

URL 编码/解码

MD5 加密
MD5 加密

MD5 加密工具