React.js 和 Spring Data REST(三)

时间:2022-12-29 14:14:45

React.js 和 Spring Data REST(三)

第 4 部分 - 事件

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

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

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

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

将 Spring WebSocket 支持添加到项目中

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

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

这种依赖关系引入了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文件中:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

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

定义安全模型

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

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

@Entity
public 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, Long> {

Manager save(Manager manager);

Manager findByName(String name);

}

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

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

将员工与经理联系起来

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

@Entity
public 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<Employee, Long> {

@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​

@Component
public 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()​

预加载管理器数据

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

@Component
public 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 组件中显示员工的经理:​​<Employee />​

class Employee extends React.Component {

constructor(props) {
super(props);
this.handleDelete = this.handleDelete.bind(this);
}

handleDelete() {
this.props.onDelete(this.props.employee);
}

render() {
return (
<tr>
<td>{this.props.employee.entity.firstName}</td>
<td>{this.props.employee.entity.lastName}</td>
<td>{this.props.employee.entity.description}</td>
<td>{this.props.employee.entity.manager.name}</td>
<td>
<UpdateDialog employee={this.props.employee}
attributes={this.props.attributes}
onUpdate={this.props.onUpdate}
loggedInManager={this.props.loggedInManager}/>
</td>
<td>
<button onClick={this.handleDelete}>Delete</button>
</td>
</tr>
)
}
}

这仅为 添加一列。​​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 添加一些安全详细信息

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

<div>
Hello, <span th:text="${#authentication.name}">user</span>.
<form th:action="@{/logout}" method="post">
<input type="submit" value="Log Out"/>
</form>
</div>

将一切整合在一起

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

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

React.js 和 Spring Data REST(三)

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

React.js 和 Spring Data REST(三)

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

React.js 和 Spring Data REST(三)

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

回顾

在本节中,您将:

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

问题?

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

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