领先一步
VMware 提供培训和认证,助您加速进步。
了解更多| 要查看此代码的更新,请访问我们的 React.js 与 Spring Data REST 教程。 |
在上一节中,您通过 Spring Data REST 内置的事件处理程序和 Spring 框架的 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; }
}
PASSWORD_ENCODER是用于加密新密码或接收密码输入并在比较之前对其进行加密的机制。id、name、password和roles定义了限制访问所需的参数。自定义的setPassword()确保密码永远不会以明文形式存储。
在设计安全层时,有一点很重要需要牢记。保护好数据(如密码),并且绝对不要让它们打印到控制台、日志中,或通过 JSON 序列化导出。
@ToString(exclude = "password")确保 Lombok 生成的 toString() 方法不会打印密码。@JsonIgnore可以防止 Jackson 序列化此字段。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; }
}
@ManyToOne进行链接。经理不需要@OneToMany,因为您还没有定义需要查找的内容。在定义安全策略时,Spring Security 支持多种选项。在本节中,您希望限制内容,以便只有经理可以查看员工的薪资数据,并且保存、更新和删除操作仅限于该员工的经理。换句话说,任何经理都可以登录并查看数据,但只有特定员工的经理才能进行任何更改。
@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),然后将经理与当前已认证用户进行比较。
UserDetails服务安全集成的常见点是定义一个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()。然后,它填充一个实现了 UserDetails 接口的 Spring SecurityUser实例。您还使用 Spring Security 的AuthorityUtils将字符串角色的数组转换为 JavaList的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。然后,我们将讨论它定义的安全策略。
@EnableWebSecurity告诉 Spring Boot 放弃其自动配置的安全策略,而使用这个策略。对于快速演示,自动配置的安全策略是可以的。但对于任何实际应用,您都应该自己编写策略。@EnableGlobalMethodSecurity使用 Spring Security 复杂的@Pre 和 @Post 注解启用方法级别的安全性。WebSecsurityConfigurerAdapter,这是一个方便的基类,用于编写策略。SpringDataJpaUserDetailsService,然后通过configure(AuthenticationManagerBuilder)方法将其插入。还设置了Manager中的PASSWORD_ENCODER。configure(HttpSecurity)。安全策略规定使用前面定义的访问规则来授权所有请求。
antMatchers()中列出的路径被授予无条件访问权限,因为没有理由阻止静态 Web 资源。anyRequest().authenticated(),这意味着它需要身份验证。|
警告
|
BASIC 身份验证在您使用 curl 进行实验时非常方便。使用 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" : "https://:8080/api/employees/1"
}
}
}
这比第一节显示了更多的细节。首先,Spring Security 启用了多个 HTTP 协议来防止各种攻击(Pragma、Expires、X-Frame-Options 等)。您还使用-u greg:turnquist提供 BASIC 凭据,这会渲染 Authorization 头。
在所有标头中,您可以看到来自您的版本化资源的ETag标头。
最后,在数据本身中,您可以看到一个新属性:manager。您可以看到它包含姓名和角色,但不包含密码。这是由于在该字段上使用了@JsonIgnore。由于 Spring Data REST 没有导出该存储库,因此其值在此资源中内联显示。您将在下一节更新 UI 时充分利用这一点。
有了所有这些后端修改,您现在可以转移到前端更新内容了。首先,在 `<Employee />` React 组件中显示员工的经理。
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://schema.json.js.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>
完成前端的这些更改后,重新启动应用程序并导航到https://:8080。
您将立即被重定向到一个登录表单。此表单由 Spring Security 提供,尽管您也可以创建自己的表单。使用 greg / turnquist 登录。
您可以看到新添加的经理列。翻阅几页,直到找到 oliver 拥有的员工。
点击Update,进行一些更改,然后点击Update。它应该会失败并出现以下弹出窗口
如果您尝试Delete,它应该会以类似的错误消息失败。创建一个新员工,它将被分配给您。
在本节中
问题?
网页已经变得相当复杂。但是管理关系和内联数据呢?创建/更新对话框实际上并不适合这些。这可能需要一些自定义编写的表单。
经理可以访问员工数据。员工应该有访问权限吗?如果您要添加更多详细信息,如电话号码和地址,您将如何建模?您将如何授予员工访问系统以让他们更新这些特定字段?页面上是否有更多有用的超媒体控件?希望您喜欢这个系列。