第 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
您可以看到新添加的经理列。浏览几页,直到找到 Oliver 拥有的员工,如下图所示:
单击“更新”,进行一些更改,然后再次单击“更新”。它应该失败并显示以下弹出窗口:
如果尝试删除,它应该会失败并显示类似的消息。如果您创建新员工,则应将其分配给您。
回顾
在本节中,您将:
- 定义模型并通过一对多关系将其链接到员工。
manager
- 为经理创建了一个存储库,并告诉Spring Data REST不要导出。
- 为员工存储库编写了一组访问规则,并编写了安全策略。
- 编写了另一个 Spring Data REST 事件处理程序,以便在创建事件发生之前捕获它们,以便可以将当前用户指定为员工的经理。
- 更新了 UI 以显示员工的经理,并在执行未经授权的操作时显示错误弹出窗口。
问题?
网页已经变得相当复杂。但是,管理关系和内联数据呢?创建和更新对话框并不适合此。它可能需要一些自定义的书面形式。
经理有权访问员工数据。员工是否应该有权访问?如果要添加电话号码和地址等更多详细信息,您将如何建模?您将如何授予员工访问系统的权限,以便他们可以更新这些特定字段?是否有更多可以方便地放在页面上的超媒体控件?