React.js 和 Spring Data REST(三)

2022-12-29 14:28:57 来源:51CTO博客

第 4 部分 - 事件

在上一节,您引入了条件更新以避免在编辑相同数据时与其他用户发生冲突。您还学习了如何使用乐观锁定对后端的数据进行版本控制。如果有人编辑了同一记录,您会收到通知,以便您可以刷新页面并获取更新。


(资料图)

很好。但是你知道什么更好吗?让 UI 在其他人更新资源时动态响应。

在本节中,您将学习如何使用Spring Data REST的内置事件系统来检测后端中的更改,并通过Spring的WebSocket支持向所有用户发布更新。然后,您将能够在数据更新时动态调整客户端。

随意获取代码从此存储库并继续操作。本节基于上一节的应用程序,并添加了额外的内容。

将 Spring WebSocket 支持添加到项目中

在开始之前,您需要将依赖项添加到项目的pom.xml文件中:

  org.springframework.boot  spring-boot-starter-websocket

这种依赖关系引入了Spring Boot的WebSocket启动器。

使用 Spring 配置 WebSockets

Spring带有强大的WebSocket支持.要认识到的一件事是,WebSocket是一个非常低级的协议。它只不过提供了在客户端和服务器之间传输数据的方法。建议使用子协议(本节为 STOMP)对数据和路由进行实际编码。

以下代码在服务器端配置 WebSocket 支持:

@Component@EnableWebSocketMessageBroker (1)public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { (2)  static final String MESSAGE_PREFIX = "/topic"; (3)  @Override  public void registerStompEndpoints(StompEndpointRegistry registry) { (4)    registry.addEndpoint("/payroll").withSockJS();  }  @Override  public void configureMessageBroker(MessageBrokerRegistry registry) { (5)    registry.enableSimpleBroker(MESSAGE_PREFIX);    registry.setApplicationDestinationPrefixes("/app");  }}

1

​@EnableWebSocketMessageBroker​​打开 WebSocket 支持。

2

​WebSocketMessageBrokerConfigurer​​提供方便的基类来配置基本功能。

3

MESSAGE_PREFIX是您将附加到每条消息路由前面的前缀。

4

​registerStompEndpoints()​​​用于在后端为客户端和服务器配置端点以链接 ()。​​/payroll​

5

​configureMessageBroker()​​用于配置用于在服务器和客户端之间中继消息的代理。

使用此配置,您现在可以利用 Spring Data REST 事件并通过 WebSocket 发布它们。

订阅 Spring 数据 REST 事件

Spring Data REST 生成几个应用程序事件基于存储库上发生的操作。以下代码演示如何订阅其中一些事件:

@Component@RepositoryEventHandler(Employee.class) (1)public class EventHandler {  private final SimpMessagingTemplate websocket; (2)  private final EntityLinks entityLinks;  @Autowired  public EventHandler(SimpMessagingTemplate websocket, EntityLinks entityLinks) {    this.websocket = websocket;    this.entityLinks = entityLinks;  }  @HandleAfterCreate (3)  public void newEmployee(Employee employee) {    this.websocket.convertAndSend(        MESSAGE_PREFIX + "/newEmployee", getPath(employee));  }  @HandleAfterDelete (3)  public void deleteEmployee(Employee employee) {    this.websocket.convertAndSend(        MESSAGE_PREFIX + "/deleteEmployee", getPath(employee));  }  @HandleAfterSave (3)  public void updateEmployee(Employee employee) {    this.websocket.convertAndSend(        MESSAGE_PREFIX + "/updateEmployee", getPath(employee));  }  /**   * Take an {@link Employee} and get the URI using Spring Data REST"s {@link EntityLinks}.   *   * @param employee   */  private String getPath(Employee employee) {    return this.entityLinks.linkForItemResource(employee.getClass(),        employee.getId()).toUri().getPath();  }}

1

​@RepositoryEventHandler(Employee.class)​​标记此类以基于员工捕获事件。

2

​SimpMessagingTemplate​​​并从应用程序上下文自动连线。​​EntityLinks​

3

批注标记需要侦听事件的方法。这些方法必须是公共的。​​@HandleXYZ​

这些处理程序方法中的每一个都调用以通过 WebSocket 传输消息。这是一种发布-订阅方法,以便将一条消息中继到每个连接的使用者。​​SimpMessagingTemplate.convertAndSend()​

每条消息的路由是不同的,允许将多条消息发送到客户端上的不同接收方,同时只需要一个开放的 WebSocket — 这是一种资源节约型方法。

​getPath()​​使用 Spring Data REST 查找给定类类型和 id 的路径。为了满足客户端的需求,此对象被转换为 Java URI,并提取其路径。​​EntityLinks​​​​Link​

​EntityLinks​​附带了几种实用工具方法,以编程方式查找各种资源的路径,无论是单个资源还是集合路径。

实质上,您正在侦听创建、更新和删除事件,并在它们完成后向所有客户端发送有关它们的通知。您还可以在此类操作发生之前拦截它们,并可能记录它们,出于某种原因阻止它们,或者用额外的信息装饰域对象。(在下一节中,我们将看到一个方便的用法。

配置 JavaScript WebSocket

下一步是编写一些客户端代码来使用 WebSocket 事件。主应用程序中的以下块拉入一个模块:

var stompClient = require("./websocket-listener")

该模块如下所示:

"use strict";const SockJS = require("sockjs-client"); (1)require("stompjs"); (2)function register(registrations) {  const socket = SockJS("/payroll"); (3)  const stompClient = Stomp.over(socket);  stompClient.connect({}, function(frame) {    registrations.forEach(function (registration) { (4)      stompClient.subscribe(registration.route, registration.callback);    });  });}module.exports.register = register;

1

拉入 SockJS JavaScript 库,用于通过 WebSockets 进行对话。

2

拉入 stomp-websocket JavaScript 库以使用 STOMP 子协议。

3

将 WebSocket 指向应用程序的终结点。​​/payroll​

4

遍历提供的数组,以便每个数组都可以在消息到达时订阅回调。​​registrations​

每个注册条目都有一个和一个 .在下一节中,您可以了解如何注册事件处理程序。​​route​​​​callback​

注册 WebSocket 事件

在 React 中,组件的函数在 DOM 中渲染后被调用。这也是注册 WebSocket 事件的合适时机,因为该组件现已联机并准备好开展业务。以下代码执行此操作:​​componentDidMount()​

componentDidMount() {  this.loadFromServer(this.state.pageSize);  stompClient.register([    {route: "/topic/newEmployee", callback: this.refreshAndGoToLastPage},    {route: "/topic/updateEmployee", callback: this.refreshCurrentPage},    {route: "/topic/deleteEmployee", callback: this.refreshCurrentPage}  ]);}

第一行与之前相同,其中所有员工都是使用页面大小从服务器获取的。第二行显示为 WebSocket 事件注册的 JavaScript 对象数组,每个对象都带有 a 和 .​​route​​​​callback​

创建新员工时,行为是刷新数据集,然后使用分页链接导航到最后一页。为什么要在导航到末尾之前刷新数据?添加新记录可能会导致创建新页面。虽然可以计算这是否会发生,但它颠覆了超媒体的观点。与其将自定义的页数拼凑在一起,不如使用现有链接,并且只有在有性能驱动的原因时才走这条路。

更新或删除员工时,行为是刷新当前页面。更新记录时,它会影响您正在查看的页面。当您删除当前页面上的记录时,下一页中的记录将被拉入当前页面 - 因此还需要刷新当前页面。

这些 WebSocket 消息不需要以 开头。这是指示发布-订阅语义的常见约定。​​/topic​

在下一节中,您可以看到执行这些操作的实际操作。

对 WebSocket 事件做出反应并更新 UI 状态

以下代码块包含用于在收到 WebSocket 事件时更新 UI 状态的两个回调:

refreshAndGoToLastPage(message) {  follow(client, root, [{    rel: "employees",    params: {size: this.state.pageSize}  }]).done(response => {    if (response.entity._links.last !== undefined) {      this.onNavigate(response.entity._links.last.href);    } else {      this.onNavigate(response.entity._links.self.href);    }  })}refreshCurrentPage(message) {  follow(client, root, [{    rel: "employees",    params: {      size: this.state.pageSize,      page: this.state.page.number    }  }]).then(employeeCollection => {    this.links = employeeCollection.entity._links;    this.page = employeeCollection.entity.page;    return employeeCollection.entity._embedded.employees.map(employee => {      return client({        method: "GET",        path: employee._links.self.href      })    });  }).then(employeePromises => {    return when.all(employeePromises);  }).then(employees => {    this.setState({      page: this.page,      employees: employees,      attributes: Object.keys(this.schema.properties),      pageSize: this.state.pageSize,      links: this.links    });  });}

​refreshAndGoToLastPage()​​使用熟悉的函数导航到应用了参数的链接,插入 .收到响应后,然后调用最后一部分中的相同函数,并跳转到最后一页,即将找到新记录的页面。​​follow()​​​​employees​​​​size​​​​this.state.pageSize​​​​onNavigate()​

​refreshCurrentPage()​​也使用该函数,但适用于 和 。这将获取您当前正在查看的同一页面并相应地更新状态。​​follow()​​​​this.state.pageSize​​​​size​​​​this.state.page.number​​​​page​

此行为告知每个客户端在发送更新或删除消息时刷新其当前页面。他们的当前页面可能与当前事件无关。但是,要弄清楚这一点可能很棘手。如果删除的记录在第二页上,而您正在查看第三页,该怎么办?每个条目都会改变。但这是想要的行为吗?或。也许不是。

将状态管理移出本地更新

在完成本节之前,有一些东西需要识别。您刚刚为 UI 中的状态添加了更新的新方法:当 WebSocket 消息到达时。但是更新状态的旧方法仍然存在。

若要简化代码的状态管理,请删除旧方法。换句话说,提交您的 、 和调用,但不要使用其结果来更新 UI 的状态。相反,请等待 WebSocket 事件回旋,然后执行更新。​​POST​​​​PUT​​​​DELETE​

以下代码块显示了与上一节相同的函数,只是进行了简化:​​onCreate()​

onCreate(newEmployee) {  follow(client, root, ["employees"]).done(response => {    client({      method: "POST",      path: response.entity._links.self.href,      entity: newEmployee,      headers: {"Content-Type": "application/json"}    })  })}

在这里,函数用于获取链接,然后应用操作。注意怎么有 没有 或 ,像以前一样?用于侦听更新的事件处理程序现在位于 中,您刚刚查看了该事件处理程序。​​follow()​​​​employees​​​​POST​​​​client({method: "GET" …})​​​​then()​​​​done()​​​​refreshAndGoToLastPage()​

将一切整合在一起

完成所有这些修改后,启动应用程序 () 并使用它。打开两个浏览器选项卡并调整大小,以便您可以同时看到它们。开始在一个选项卡中进行更新,看看它们如何立即更新另一个选项卡。打开手机并访问同一页面。找一个朋友,让那个人做同样的事情。您可能会发现这种类型的动态更新更敏锐。​​./mvnw spring-boot:run​

想要挑战吗?尝试上一节中的练习,在两个不同的浏览器选项卡中打开同一记录。尝试在一个中更新它,而在另一个中看不到它更新。如果可能,条件代码仍应保护您。但要做到这一点可能会更棘手!​​PUT​

回顾

在本节中,您将:

配置了Spring的WebSocket支持和SockJS回退。订阅了从 Spring Data REST 创建、更新和删除事件以动态更新 UI。发布了受影响的 REST 资源的 URI 以及上下文消息(“/topic/newEmployee”、“/topic/updateEmployee”等)。在 UI 中注册 WebSocket 侦听器以侦听这些事件。将侦听器连接到处理程序以更新 UI 状态。

有了所有这些功能,可以轻松地并排运行两个浏览器,并查看如何将一个浏览器更新到另一个浏览器。

问题?

虽然多个显示器可以很好地更新,但需要完善精确的行为。例如,创建一个新用户将导致所有用户跳到最后。关于如何处理这个问题的任何想法?

分页很有用,但它提供了一个棘手的管理状态。此示例应用程序的成本很低,而且 React 在更新 DOM 方面非常有效,而不会在 UI 中引起大量闪烁。但是对于更复杂的应用程序,并非所有这些方法都适合。

在设计时考虑分页时,您必须确定客户端之间的预期行为是什么,以及是否需要更新。根据您的要求和系统性能,现有的导航超媒体可能就足够了。

第 5 部分 - 保护 UI 和 API

在上一节,您使用Spring Data REST的内置事件处理程序和Spring Framework的WebSocket支持使应用程序动态响应其他用户的更新。但是,如果不保护整个事情,则任何应用程序都是不完整的,因此只有适当的用户才能访问UI及其背后的资源。

随意获取代码从此存储库并继续操作。本部分基于上一节的应用,添加了额外的内容。

将 Spring 安全性添加到项目中

在开始之前,您需要将几个依赖项添加到项目的pom.xml文件中:

  org.springframework.boot  spring-boot-starter-security  org.thymeleaf.extras  thymeleaf-extras-springsecurity5

这带来了Spring Boot的Spring Security启动器以及一些额外的Thymeleaf标签,以便在网页中进行安全查找。

定义安全模型

在过去的部分中,您使用了一个不错的工资单系统。在后端声明内容并让Spring Data REST完成繁重的工作很方便。下一步是模拟需要建立安全控制的系统。

如果这是一个工资单系统,那么只有经理才能访问它。因此,通过对对象进行建模来开始工作:​​Manager​

@Entitypublic class Manager {  public static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); (1)  private @Id @GeneratedValue Long id; (2)  private String name; (2)  private @JsonIgnore String password; (2)  private String[] roles; (2)  public void setPassword(String password) { (3)    this.password = PASSWORD_ENCODER.encode(password);  }  protected Manager() {}  public Manager(String name, String password, String... roles) {    this.name = name;    this.setPassword(password);    this.roles = roles;  }  @Override  public boolean equals(Object o) {    if (this == o) return true;    if (o == null || getClass() != o.getClass()) return false;    Manager manager = (Manager) o;    return Objects.equals(id, manager.id) &&      Objects.equals(name, manager.name) &&      Objects.equals(password, manager.password) &&      Arrays.equals(roles, manager.roles);  }  @Override  public int hashCode() {    int result = Objects.hash(id, name, password);    result = 31 * result + Arrays.hashCode(roles);    return result;  }  public Long getId() {    return id;  }  public void setId(Long id) {    this.id = id;  }  public String getName() {    return name;  }  public void setName(String name) {    this.name = name;  }  public String getPassword() {    return password;  }  public String[] getRoles() {    return roles;  }  public void setRoles(String[] roles) {    this.roles = roles;  }  @Override  public String toString() {    return "Manager{" +      "id=" + id +      ", name="" + name + "\"" +      ", roles=" + Arrays.toString(roles) +      "}";  }}

1

​PASSWORD_ENCODER​​是加密新密码或获取密码输入并在比较之前对其进行加密的方法。

2

​id​​​、、、 和定义限制访问所需的参数。​​name​​​​password​​​​roles​

3

自定义方法可确保密码永远不会以明文形式存储。​​setPassword()​

在设计安全层时,要记住一件关键的事情。保护正确的数据位(如密码),不要让它们打印到控制台、日志中或通过 JSON 序列化导出。

​@JsonIgnore​​应用于密码字段可防止杰克逊序列化此字段。

创建经理的存储库

Spring Data非常擅长管理实体。为什么不创建一个存储库来处理这些管理器呢?以下代码执行此操作:

@RepositoryRestResource(exported = false)public interface ManagerRepository extends Repository {  Manager save(Manager manager);  Manager findByName(String name);}

而不是扩展通常的,你不需要那么多的方法。相反,您需要保存数据(也用于更新),并且需要查找现有用户。因此,您可以使用Spring Data Common的最小标记接口。它没有预定义的操作。​​CrudRepository​​​​Repository​

默认情况下,Spring Data REST将导出它找到的任何存储库。您不希望此存储库公开用于 REST 操作!应用批注以阻止其导出。这可以防止提供存储库及其元数据。​​@RepositoryRestResource(exported = false)​

将员工与经理联系起来

建模安全性的最后一点是将员工与经理相关联。在此域中,一个员工可以有一个经理,而一个经理可以有多个员工。以下代码定义该关系:

@Entitypublic class Employee {  private @Id @GeneratedValue Long id;  private String firstName;  private String lastName;  private String description;  private @Version @JsonIgnore Long version;  private @ManyToOne Manager manager; (1)  private Employee() {}  public Employee(String firstName, String lastName, String description, Manager manager) { (2)    this.firstName = firstName;    this.lastName = lastName;    this.description = description;    this.manager = manager;  }  @Override  public boolean equals(Object o) {    if (this == o) return true;    if (o == null || getClass() != o.getClass()) return false;    Employee employee = (Employee) o;    return Objects.equals(id, employee.id) &&      Objects.equals(firstName, employee.firstName) &&      Objects.equals(lastName, employee.lastName) &&      Objects.equals(description, employee.description) &&      Objects.equals(version, employee.version) &&      Objects.equals(manager, employee.manager);  }  @Override  public int hashCode() {    return Objects.hash(id, firstName, lastName, description, version, manager);  }  public Long getId() {    return id;  }  public void setId(Long id) {    this.id = id;  }  public String getFirstName() {    return firstName;  }  public void setFirstName(String firstName) {    this.firstName = firstName;  }  public String getLastName() {    return lastName;  }  public void setLastName(String lastName) {    this.lastName = lastName;  }  public String getDescription() {    return description;  }  public void setDescription(String description) {    this.description = description;  }  public Long getVersion() {    return version;  }  public void setVersion(Long version) {    this.version = version;  }  public Manager getManager() {    return manager;  }  public void setManager(Manager manager) {    this.manager = manager;  }  @Override  public String toString() {    return "Employee{" +      "id=" + id +      ", firstName="" + firstName + "\"" +      ", lastName="" + lastName + "\"" +      ", description="" + description + "\"" +      ", version=" + version +      ", manager=" + manager +      "}";  }}

1

管理器属性由 JPA 的属性链接。 不需要 ,因为您尚未定义查找该需求。​​@ManyToOne​​​​Manager​​​​@OneToMany​

2

实用程序构造函数调用已更新以支持初始化。

确保员工与经理的关系

在定义安全策略时,Spring 安全性支持多种选项。在本节中,您希望限制以下内容,以便只有经理可以查看员工工资单数据,并且保存、更新和删除操作仅限于员工的经理。换句话说,任何经理都可以登录并查看数据,但只有给定员工的经理才能进行任何更改。以下代码可实现这些目标:

@PreAuthorize("hasRole("ROLE_MANAGER")") (1)public interface EmployeeRepository extends PagingAndSortingRepository {  @Override  @PreAuthorize("#employee?.manager == null or #employee?.manager?.name == authentication?.name")  Employee save(@Param("employee") Employee employee);  @Override  @PreAuthorize("@employeeRepository.findById(#id)?.manager?.name == authentication?.name")  void deleteById(@Param("id") Long id);  @Override  @PreAuthorize("#employee?.manager?.name == authentication?.name")  void delete(@Param("employee") Employee employee);}

1

​@PreAuthorize​​​在界面顶部限制对具有 .​​ROLE_MANAGER​

在 上,员工的经理为 null(在未分配经理时首次创建新员工),或者员工的经理姓名与当前经过身份验证的用户名匹配。在这里,您正在使用​​save()​​Spring Security 的 SpEL 表达式以定义访问权限。它带有一个方便的属性导航器来处理空检查。同样重要的是要注意使用 on 参数将 HTTP 操作与方法链接起来。​​?.​​​​@Param(…)​

在 上,该方法可以访问员工,或者如果它只有一个 ,则必须在应用程序上下文中找到 ,执行 ,并根据当前经过身份验证的用户检查经理。​​delete()​​​​id​​​​employeeRepository​​​​findOne(id)​

编写服务​​UserDetails​

与安全性集成的一个常见点是定义 .这是将用户的数据存储连接到 Spring 安全性界面的方法。Spring 安全性需要一种方法来查找用户以进行安全检查,这就是桥梁。值得庆幸的是,使用Spring Data,工作量非常小:​​UserDetailsService​

@Componentpublic class SpringDataJpaUserDetailsService implements UserDetailsService {  private final ManagerRepository repository;  @Autowired  public SpringDataJpaUserDetailsService(ManagerRepository repository) {    this.repository = repository;  }  @Override  public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {    Manager manager = this.repository.findByName(name);    return new User(manager.getName(), manager.getPassword(),        AuthorityUtils.createAuthorityList(manager.getRoles()));  }}

​SpringDataJpaUserDetailsService​​实现 Spring 安全的 .该接口有一种方法:。此方法旨在返回一个对象,以便 Spring 安全性可以查询用户的信息。​​UserDetailsService​​​​loadUserByUsername()​​​​UserDetails​

因为您有一个 ,所以不需要编写任何 SQL 或 JPA 表达式来获取这些所需的数据。在此类中,它通过构造函数注入自动连接。​​ManagerRepository​

​loadUserByUsername()​​点击您刚才编写的自定义查找器。然后,它填充一个 Spring 安全实例,该实例实现接口。您还使用 Spring Securiy 从基于字符串的角色数组过渡到 Java 类型的 Java。​​findByName()​​​​User​​​​UserDetails​​​​AuthorityUtils​​​​List​​​​GrantedAuthority​

连接您的安全策略

应用于存储库的表达式是访问规则。如果没有安全策略,这些规则是徒劳的:​​@PreAuthorize​

@Configuration@EnableWebSecurity (1)@EnableGlobalMethodSecurity(prePostEnabled = true) (2)public class SecurityConfiguration extends WebSecurityConfigurerAdapter { (3)  @Autowired  private SpringDataJpaUserDetailsService userDetailsService; (4)  @Override  protected void configure(AuthenticationManagerBuilder auth) throws Exception {    auth      .userDetailsService(this.userDetailsService)        .passwordEncoder(Manager.PASSWORD_ENCODER);  }  @Override  protected void configure(HttpSecurity http) throws Exception { (5)    http      .authorizeRequests()        .antMatchers("/built/**", "/main.css").permitAll()        .anyRequest().authenticated()        .and()      .formLogin()        .defaultSuccessUrl("/", true)        .permitAll()        .and()      .httpBasic()        .and()      .csrf().disable()      .logout()        .logoutSuccessUrl("/");  }}

这段代码非常复杂,因此我们将逐步介绍它,首先讨论注释和 API。然后我们将讨论它定义的安全策略。

1

​@EnableWebSecurity​​告诉 Spring Boot 放弃其自动配置的安全策略并改用此策略。对于快速演示,自动配置的安全性是可以的。但对于任何真实的东西,你应该自己写政策。

2

​@EnableGlobalMethodSecurity​​​使用 Spring 安全性的复杂功能打开方法级安全性@Pre和注释@Post.

3

它扩展了,一个方便编写策略的基类。​​WebSecurityConfigurerAdapter​

4

它通过场注入自动连接,然后通过该方法将其插入。发件人也已设置。​​SpringDataJpaUserDetailsService​​​​configure(AuthenticationManagerBuilder)​​​​PASSWORD_ENCODER​​​​Manager​

5

关键安全策略是用纯 Java 编写的,带有方法调用。​​configure(HttpSecurity)​

安全策略规定使用前面定义的访问规则授权所有请求:

中列出的路径被授予无条件访问权限,因为没有理由阻止静态 Web 资源。antMatchers()任何与该策略不匹配的内容都属于 ,这意味着它需要身份验证。anyRequest().authenticated()设置这些访问规则后,Spring 安全性将被告知使用基于表单的身份验证(默认为成功时)并授予对登录页面的访问权限。/基本登录也配置为禁用 CSRF。这主要用于演示,不建议用于未经仔细分析的生产系统。注销配置为将用户带到 。/

当您尝试使用 curl 时,BASIC 身份验证非常方便。使用 curl 访问基于表单的系统是令人生畏的。重要的是要认识到,通过 HTTP(而不是 HTTPS)使用任何机制进行身份验证会使您面临通过网络嗅探凭据的风险。CSRF 是一个很好的协议,可以保持完整。禁用它以使与 BASIC 和 curl 的交互更容易。在生产中,最好保持打开状态。

自动添加安全详细信息

良好用户体验的一部分是应用程序可以自动应用上下文。在此示例中,如果登录的经理创建新的员工记录,则该经理拥有该记录是有意义的。使用Spring Data REST的事件处理程序,用户不需要显式链接它。它还可确保用户不会意外地将记录分配给错误的经理。为我们处理:​​SpringDataRestEventHandler​

@Component@RepositoryEventHandler(Employee.class) (1)public class SpringDataRestEventHandler {  private final ManagerRepository managerRepository;  @Autowired  public SpringDataRestEventHandler(ManagerRepository managerRepository) {    this.managerRepository = managerRepository;  }  @HandleBeforeCreate  @HandleBeforeSave  public void applyUserInformationUsingSecurityContext(Employee employee) {    String name = SecurityContextHolder.getContext().getAuthentication().getName();    Manager manager = this.managerRepository.findByName(name);    if (manager == null) {      Manager newManager = new Manager();      newManager.setName(name);      newManager.setRoles(new String[]{"ROLE_MANAGER"});      manager = this.managerRepository.save(newManager);    }    employee.setManager(manager);  }}

1

​@RepositoryEventHandler(Employee.class)​​​将此事件处理程序标记为仅适用于对象。批注使您有机会在传入记录写入数据库之前对其进行更改。​​Employee​​​​@HandleBeforeCreate​​​​Employee​

在这种情况下,可以查找当前用户的安全上下文以获取用户的名称。然后,您可以使用关联管理器查找该管理器并将其应用于该管理器。如果系统中尚不存在该人员,则有一些额外的粘附代码可以创建新经理。但是,这主要是为了支持数据库的初始化。在实际的生产系统中,应删除该代码,而是依靠 DBA 或安全运营团队来正确维护用户数据存储。​​findByName()​

预加载管理器数据

加载经理并将员工链接到这些经理非常简单:

@Componentpublic class DatabaseLoader implements CommandLineRunner {  private final EmployeeRepository employees;  private final ManagerRepository managers;  @Autowired  public DatabaseLoader(EmployeeRepository employeeRepository,              ManagerRepository managerRepository) {    this.employees = employeeRepository;    this.managers = managerRepository;  }  @Override  public void run(String... strings) throws Exception {    Manager greg = this.managers.save(new Manager("greg", "turnquist",              "ROLE_MANAGER"));    Manager oliver = this.managers.save(new Manager("oliver", "gierke",              "ROLE_MANAGER"));    SecurityContextHolder.getContext().setAuthentication(      new UsernamePasswordAuthenticationToken("greg", "doesn"t matter",        AuthorityUtils.createAuthorityList("ROLE_MANAGER")));    this.employees.save(new Employee("Frodo", "Baggins", "ring bearer", greg));    this.employees.save(new Employee("Bilbo", "Baggins", "burglar", greg));    this.employees.save(new Employee("Gandalf", "the Grey", "wizard", greg));    SecurityContextHolder.getContext().setAuthentication(      new UsernamePasswordAuthenticationToken("oliver", "doesn"t matter",        AuthorityUtils.createAuthorityList("ROLE_MANAGER")));    this.employees.save(new Employee("Samwise", "Gamgee", "gardener", oliver));    this.employees.save(new Employee("Merry", "Brandybuck", "pony rider", oliver));    this.employees.save(new Employee("Peregrin", "Took", "pipe smoker", oliver));    SecurityContextHolder.clearContext();  }}

一个问题是,当这个加载器运行时,Spring Security 处于活动状态,访问规则完全有效。因此,要保存员工数据,您必须使用 Spring 安全性的 API 使用正确的名称和角色对此加载器进行身份验证。最后,将清除安全上下文。​​setAuthentication()​

浏览您的安全 REST 服务

完成所有这些修改后,您可以启动应用程序 () 并使用以下 curl(与其输出一起显示)检查修改:​​./mvnw spring-boot:run​

$ curl -v -u greg:turnquist localhost:8080/api/employees/1*   Trying ::1...* Connected to localhost (::1) port 8080 (#0)* Server auth using Basic with user "greg"> GET /api/employees/1 HTTP/1.1> Host: localhost:8080> Authorization: Basic Z3JlZzp0dXJucXVpc3Q=> User-Agent: curl/7.43.0> Accept: */*>< HTTP/1.1 200 OK< Server: Apache-Coyote/1.1< X-Content-Type-Options: nosniff< X-XSS-Protection: 1; mode=block< Cache-Control: no-cache, no-store, max-age=0, must-revalidate< Pragma: no-cache< Expires: 0< X-Frame-Options: DENY< Set-Cookie: JSESSIONID=E27F929C1836CC5BABBEAB78A548DF8C; Path=/; HttpOnly< ETag: "0"< Content-Type: application/hal+json;charset=UTF-8< Transfer-Encoding: chunked< Date: Tue, 25 Aug 2015 15:57:34 GMT<{  "firstName" : "Frodo",  "lastName" : "Baggins",  "description" : "ring bearer",  "manager" : {    "name" : "greg",    "roles" : [ "ROLE_MANAGER" ]  },  "_links" : {    "self" : {      "href" : "http://localhost:8080/api/employees/1"    }  }}

这显示了比您在第一部分中看到的更多细节。首先,Spring Security 会打开多个 HTTP 协议来抵御各种媒介(Pragma、Expires、X-Frame-Options 等)。您还将颁发用于呈现授权标头的 BASIC 凭据。​​-u greg:turnquist​

在所有标头中,可以看到受版本控制的资源中的标头。​​ETag​

最后,在数据本身内部,您可以看到一个新属性:。您可以看到它包括名称和角色,但不包括密码。这是由于在该字段上使用。因为 Spring Data REST 没有导出该存储库,所以它的值内联在此资源中。在下一节中更新 UI 时,您将充分利用这一点。​​manager​​​​@JsonIgnore​

在 UI 中显示经理信息

通过后端的所有这些修改,您现在可以转向更新前端的内容。首先,您可以在 React 组件中显示员工的经理:​

class Employee extends React.Component {  constructor(props) {    super(props);    this.handleDelete = this.handleDelete.bind(this);  }  handleDelete() {    this.props.onDelete(this.props.employee);  }  render() {    return (              {this.props.employee.entity.firstName}        {this.props.employee.entity.lastName}        {this.props.employee.entity.description}        {this.props.employee.entity.manager.name}                                                              )  }}

这仅为 添加一列。​​this.props.employee.entity.manager.name​

筛选出 JSON 架构元数据

如果数据输出中显示某个字段,则可以安全地假设该字段在 JSON 架构元数据中具有条目。您可以在以下摘录中看到它:

{  ...    "manager" : {      "readOnly" : false,      "$ref" : "#/descriptors/manager"    },    ...  },  ...  "$schema" : "https://json-schema.org/draft-04/schema#"}

该字段不是您希望人们直接编辑的内容。由于它是内联的,因此应将其视为只读属性。要从 和 中筛选出内联条目,您可以在获取 中的 JSON 架构元数据后删除此类条目:​​manager​​​​CreateDialog​​​​UpdateDialog​​​​loadFromServer()​

/** * Filter unneeded JSON Schema properties, like uri references and * subtypes ($ref). */Object.keys(schema.entity.properties).forEach(function (property) {  if (schema.entity.properties[property].hasOwnProperty("format") &&    schema.entity.properties[property].format === "uri") {    delete schema.entity.properties[property];  }  else if (schema.entity.properties[property].hasOwnProperty("$ref")) {    delete schema.entity.properties[property];  }});this.schema = schema.entity;this.links = employeeCollection.entity._links;return employeeCollection;

此代码修剪了 URI 关系以及$ref条目。

陷阱用于未经授权的访问

通过在后端配置安全检查,您可以添加处理程序,以防有人尝试未经授权更新记录:

onUpdate(employee, updatedEmployee) {  if(employee.entity.manager.name === this.state.loggedInManager) {    updatedEmployee["manager"] = employee.entity.manager;    client({      method: "PUT",      path: employee.entity._links.self.href,      entity: updatedEmployee,      headers: {        "Content-Type": "application/json",        "If-Match": employee.headers.Etag      }    }).done(response => {      /* Let the websocket handler update the state */    }, response => {      if (response.status.code === 403) {        alert("ACCESS DENIED: You are not authorized to update " +          employee.entity._links.self.href);      }      if (response.status.code === 412) {        alert("DENIED: Unable to update " + employee.entity._links.self.href +          ". Your copy is stale.");      }    });  } else {    alert("You are not authorized to update");  }}

您有代码可以捕获 HTTP 412 错误。这会捕获 HTTP 403 状态代码并提供合适的警报。

您可以对删除操作执行相同的操作:

onDelete(employee) {  client({method: "DELETE", path: employee.entity._links.self.href}  ).done(response => {/* let the websocket handle updating the UI */},  response => {    if (response.status.code === 403) {      alert("ACCESS DENIED: You are not authorized to delete " +        employee.entity._links.self.href);    }  });}

这与定制错误消息的编码类似。

向 UI 添加一些安全详细信息

为此版本的应用程序加冕的最后一件事是显示谁已登录,并通过在文件前面包含此新功能来提供注销按钮:​

​​​index.html​​​​react​​​

Hello, user.

将一切整合在一起

若要在前端查看这些更改,请重新启动应用程序并导航到​​http://localhost:8080​​.

您将立即被重定向到登录表单。此表格由Spring Security提供,但您可以创建您自己的如果你愿意。以 / 身份登录,如下图所示:​​greg​​​​turnquist​

您可以看到新添加的经理列。浏览几页,直到找到Oliver拥有的员工,如下图所示:

单击“​更新”,进行一些更改,然后再次单击“更新”。它应该失败并显示以下弹出窗口:

如果尝试删除,它应该会失败并显示类似的消息。如果您创建新员工,则应将其分配给您。

回顾

在本节中,您将:

定义模型并通过一对多关系将其链接到员工。manager为经理创建了一个存储库,并告诉Spring Data REST不要导出。为员工存储库编写了一组访问规则,并编写了安全策略。编写了另一个 Spring Data REST 事件处理程序,以便在创建事件发生之前捕获它们,以便可以将当前用户指定为员工的经理。更新了 UI 以显示员工的经理,并在执行未经授权的操作时显示错误弹出窗口。

问题?

网页已经变得相当复杂。但是,管理关系和内联数据呢?创建和更新对话框并不适合此。它可能需要一些自定义的书面形式。

经理有权访问员工数据。员工是否应该有权访问?如果要添加电话号码和地址等更多详细信息,您将如何建模?您将如何授予员工访问系统的权限,以便他们可以更新这些特定字段?是否有更多可以方便地放在页面上的超媒体控件?

标签: 应用程序 安全策略 身份验证

上一篇:天天最资讯丨Spring Boot 启动和 OAuth2
下一篇:世界看点:React.js 和 Spring Data REST(二)