先行一步
VMware 提供培训和认证,助你加速发展。
了解更多要查看此代码的更新,请访问我们的React.js 和 Spring Data REST 教程。 |
在上一节中,你通过 Spring Data REST 内置的事件处理器和 Spring Framework 的 WebSocket 支持,使应用程序动态响应其他用户的更新。但任何应用程序如果不对整个系统进行保护,以便只有合适的用户才能访问 UI 及其背后的资源,那它就是不完整的。
随时可以从该仓库中获取代码并跟着操作。本节内容基于上一节的应用程序,并添加了额外内容。
在开始之前,你需要向项目的 pom.xml 文件中添加几个依赖项
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity4</artifactId>
</dependency>
这引入了 Spring Boot 的 Spring Security starter 以及一些额外的 Thymeleaf 标签,用于在网页中进行安全查找。
在上一节中,你使用了一个不错的工资系统。在后端声明内容并让 Spring Data REST 完成繁重的工作非常方便。下一步是为需要建立安全控制的系统建模。
如果这是一个工资系统,那么只有经理才能访问它。因此,首先模拟一个 `Manager` 对象
@Data @ToString(exclude = "password") @Entity public class Manager {
public static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); private @Id @GeneratedValue Long id; private String name; private @JsonIgnore String password; private String[] roles; public void setPassword(String password) { 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; }
}
定制的 `setPassword()` 确保密码永远不会以明文形式存储。
在设计安全层时需要记住一个关键点。保护好正确的数据位(如密码),并且不要让它们被打印到控制台、日志或通过 JSON 序列化导出。
Spring Data 非常擅长管理实体。为什么不创建一个仓库来处理这些经理呢?
@RepositoryRestResource(exported = false) public interface ManagerRepository extends Repository<Manager, Long> {
Manager save(Manager manager); Manager findByName(String name);
}
与其扩展通常的 `CrudRepository`,你不需要那么多方法。相反,你需要保存数据(也用于更新),并且需要查找现有用户。因此,你可以使用 Spring Data Common 的最小 `Repository` 标记接口。它没有任何预定义的操作。
Spring Data REST 默认会导出它找到的任何仓库。你绝对不希望这个仓库被暴露用于 REST 操作!应用 `@RepositoryRestResource(exported = false)` 注解来阻止其导出。这不仅阻止了仓库本身的服务,也阻止了其元数据的服务。
安全建模的最后一部分是将员工与经理关联。在此领域中,一名员工可以有一名经理,而一名经理可以有多名员工
@Data @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; private Employee() {} public Employee(String firstName, String lastName, String description, Manager manager) { this.firstName = firstName; this.lastName = lastName; this.description = description; this.manager = manager; }
}
Spring Security 在定义安全策略方面支持多种选项。在本节中,你希望限制权限,使 ONLY 经理才能查看员工的工资数据,并且保存、更新和删除操作仅限于该员工的经理。换句话说,任何经理都可以登录并查看数据,但只有特定员工的经理才能进行任何更改。
@PreAuthorize("hasRole('ROLE_MANAGER')") 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.findOne(#id)?.manager?.name == authentication?.name") void delete(@Param("id") Long id); @Override @PreAuthorize("#employee?.manager?.name == authentication?.name") void delete(@Param("employee") Employee employee);
}
接口顶部的 `@PreAuthorize` 限制了对拥有 **ROLE_MANAGER** 角色的用户的访问。
在 `save()` 方法上,要么员工的经理为 null(新员工首次创建时未分配经理),要么员工经理的名称与当前认证用户的名称匹配。这里你使用了Spring Security 的 SpEL 表达式来定义访问权限。它带有方便的 "?." 属性导航器来处理 null 检查。同样重要的是要注意在参数上使用 `@Param(…)` 将 HTTP 操作与方法关联起来。
在 `delete()` 方法上,该方法要么能够直接访问员工对象,要么在只知道 id 的情况下,它必须在应用程序上下文中找到 **employeeRepository**,执行 `findOne(id)`,然后检查经理与当前认证用户是否匹配。
与安全集成的一个常见点是定义 `UserDetailsService`。这是将你的用户数据存储连接到 Spring Security 接口的方式。Spring Security 需要一种方式来查找用户进行安全检查,而这就是桥梁。幸运的是,借助 Spring Data,所需的工作量非常少
@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 Security 的 `UserDetailsService` 接口。该接口有一个方法:`loadByUsername()`。此方法旨在返回一个 `UserDetails` 对象,以便 Spring Security 可以查询用户的信息。
由于你已经有了 `ManagerRepository`,无需编写任何 SQL 或 JPA 表达式来获取所需的数据。在此类中,它是通过构造函数注入自动装配的。
`loadByUsername()` 调用了你刚才编写的自定义查找器 `findByName()`。然后它填充了一个 Spring Security 的 `User` 实例,该实例实现了 `UserDetails` 接口。你还使用了 Spring Security 的 `AuthorityUtils` 将字符串数组的角色转换为 Java `List` 的 `GrantedAuthority`。
应用于仓库的 `@PreAuthorize` 表达式是**访问规则**。没有安全策略,这些规则就毫无意义。
@Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired private SpringDataJpaUserDetailsService userDetailsService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth .userDetailsService(this.userDetailsService) .passwordEncoder(Manager.PASSWORD_ENCODER); } @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/bower_components/**", "/*.js", "/*.jsx", "/main.css").permitAll() .anyRequest().authenticated() .and() .formLogin() .defaultSuccessUrl("/", true) .permitAll() .and() .httpBasic() .and() .csrf().disable() .logout() .logoutSuccessUrl("/"); }
}
这段代码相当复杂,所以我们先从注解和 API 入手,然后讨论它定义的安全策略。
安全策略规定,所有请求都应使用之前定义的访问规则进行授权。
警告
|
当你使用 curl 进行实验时,BASIC 认证很方便。使用 curl 访问基于表单的系统非常困难。重要的是要认识到,通过 HTTP(而不是 HTTPS)使用任何机制进行认证都会增加凭据在传输过程中被嗅探的风险。CSRF 是一个应该保持完好的好协议。禁用它仅仅是为了让 BASIC 和 curl 的交互更容易。在生产环境中,最好保持启用状态。 |
一个良好的用户体验是应用程序能够自动应用上下文。在此示例中,如果一个已登录的经理创建了一条新的员工记录,那么让该经理拥有这条记录是很合理的。通过 Spring Data REST 的事件处理器,用户无需明确关联它。这也确保了用户不会意外将记录分配给错误的经理。
@Component @RepositoryEventHandler(Employee.class) public class SpringDataRestEventHandler {
private final ManagerRepository managerRepository; @Autowired public SpringDataRestEventHandler(ManagerRepository managerRepository) { this.managerRepository = managerRepository; } @HandleBeforeCreate 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); }
}
`@RepositoryEventHandler(Employee.class)` 标记此事件处理器仅应用于 `Employee` 对象。 `@HandleBeforeCreate` 注解让你有机会在传入的 `Employee` 记录写入数据库之前对其进行修改。
在这种情况下,你查找当前用户的安全上下文以获取用户的名称。然后使用 `findByName()` 查找相关的经理,并将其应用于经理字段。有一些额外的胶水代码用于创建新的经理,如果系统中尚不存在的话。但这主要是为了支持数据库的初始化。在真实的生产系统中,应删除这段代码,而是依赖于 DBA 或安全运营团队来正确维护用户数据存储。
加载经理并将员工链接到这些经理相当直接
@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 Security 的 `setAuthentication()` API 对此加载器使用正确的名称和角色进行认证。最后,安全上下文被清除。
完成所有这些修改后,你可以启动应用程序(./mvnw spring-boot:run
)并使用 cURL 查看修改。
$ 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 等)。你还使用 `-u greg:turnquist` 发送 BASIC 凭据,这将生成 Authorization 头信息。
在所有头信息中,你可以看到你的版本化资源的 **ETag** 头信息。
最后,在数据本身中,你可以看到一个新的属性:**manager**。你可以看到它包含了名称和角色,但没有密码。这是因为在该字段上使用了 `@JsonIgnore`。由于 Spring Data REST 没有导出该仓库,它的值内联在此资源中。你将在下一节更新 UI 时充分利用这一点。
后端进行了所有这些修改后,你现在可以转移到前端更新。首先,在 `
var Employee = React.createClass({
handleDelete: function () {
this.props.onDelete(this.props.employee);
},
render: function () {
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}/>
</td>
<td>
<button onClick={this.handleDelete}>Delete</button>
</td>
</tr>
)
}
});
这只是为 `this.props.employee.entity.manager.name` 添加了一列。
如果在数据输出中显示一个字段,可以安全地假定它在 JSON Schema 元数据中有一个条目。你可以在下面的摘录中看到它
{ ... "manager" : { "readOnly" : false, "$ref" : "#/descriptors/manager" }, ... }, ... "$schema" : "https://json-schema.fullstack.org.cn/draft-04/schema#" }
经理字段不是你希望用户直接编辑的内容。由于它是内联的,应将其视为只读属性。要从 `CreateDialog` 和 `UpdatDialog` 中过滤掉内联条目,只需在获取 JSON Schema 元数据后删除此类条目。
/** * 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]; } 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: function (employee, updatedEmployee) {
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.');
}
});
},
你之前有代码来捕获 HTTP 412 错误。这段代码捕获 HTTP 403 状态码并提供一个合适的警报。
对删除操作也进行同样的处理
onDelete: function (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);
}
});
},
这段代码编写类似,带有定制的错误消息。
完成此版本应用程序的最后一件事是显示当前登录的用户,并提供一个注销按钮。
<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** 拥有的员工。
点击**更新**,做一些更改,然后点击**更新**。它应该会失败,弹出如下窗口
如果你尝试**删除**,它应该会失败并显示类似的消息。创建一个新员工,它应该会被分配给你。
在本节中
问题?
网页变得相当复杂。但如何管理关系和内联数据呢?创建/更新对话框并非真正适合此类用途。这可能需要一些自定义编写的表单。
经理可以访问员工数据。那员工应该可以访问吗?如果你要添加更多详情,比如电话号码和地址,你将如何建模?你将如何授予员工访问系统的权限,以便他们可以更新这些特定字段?页面上是否还有更多有用的超媒体控件?希望你喜欢这个系列。