React.js和Spring Data REST:第五部分 - 安全性

工程 | Greg L. Turnquist | 2015年10月28日 | ...
要查看此代码的更新,请访问我们的React.js和Spring Data REST教程

上一节中,您使应用程序能够通过Spring Data REST的内置事件处理程序和Spring Framework的WebSocket支持动态响应其他用户的更新。但是,如果没有安全性保障,任何应用程序都是不完整的,只有合适的用户才能访问UI和背后的资源。

随意从这个仓库获取代码并继续学习。本节基于上一节的应用程序,并添加了一些额外内容。

向项目添加Spring Security

在开始之前,您需要向项目的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启动器,以及一些额外的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;
}

}

  • 经理属性通过JPA的`@ManyToOne`关联。Manager不需要`@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()`上,员工的经理要么为空(分配经理之前创建新员工时),要么员工经理的名称与当前已认证用户的名称匹配。在这里,您使用Spring Security的SpEL表达式来定义访问权限。它带有一个方便的"?." 属性导航器来处理空检查。还要注意在参数上使用`@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()`。然后它填充Spring Security `User`实例,该实例实现`UserDetails`接口。您还使用Spring Security的`AuthorityUtils`将基于字符串的角色数组转换为Java `List` of `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`。
  • 关键的安全策略是用纯Java编写的,使用`configure(HttpSecurity)`。

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

  • 由于没有理由阻止静态Web资源,因此`antMatchers()`中列出的路径被授予无条件访问。
  • 任何不匹配的内容都属于`anyRequest().authenticated()`,这意味着它需要身份验证。
  • 设置这些访问规则后,Spring Security被告知使用基于表单的身份验证,成功后默认为"/",并授予登录页面的访问权限。
  • BASIC登录也已配置,并禁用了CSRF。这主要用于演示,不建议在没有仔细分析的情况下用于生产系统。
  • 注销配置为将用户带到"/"。
警告
当您使用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 来使用正确的名称和角色对加载程序进行身份验证。最后,安全上下文将被清除。

浏览您的安全REST服务

所有这些修改到位后,您可以启动应用程序(`./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://127.0.0.1:8080/api/employees/1"
    }
  }
}

这显示了比第一节更多详细信息。首先,Spring Security启用了一些HTTP协议来防止各种攻击向量(Pragma、Expires、X-Frame-Options等)。您还使用`-u greg:turnquist`发出BASIC凭据,这会呈现授权标头。

在所有标头中,您可以看到版本化资源的**ETag**标头。

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

在UI上显示经理信息

在后端进行了所有这些修改后,您现在可以切换到更新前端的内容。首先,在`` 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元数据

如果数据输出中显示了一个字段,则可以安全地假设它在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);
        }
    });
},

这部分代码类似,并带有定制的错误消息。

向 UI 添加一些安全细节

此版本的应用程序的最后一步是显示当前登录用户并提供注销按钮。

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

综合以上步骤

完成这些前端更改后,重新启动应用程序并导航到 https://127.0.0.1:8080

您将立即被重定向到登录表单。此表单由 Spring Security 提供,但如果您愿意,可以 创建您自己的表单。使用 greg/turnquist 登录。

security 1

您可以看到新添加的 manager 列。翻阅几页,直到找到由 **oliver** 拥有的员工。

security 2

点击 **更新**,进行一些更改,然后点击 **更新**。它应该会失败,并弹出以下窗口

security 3

如果您尝试 **删除**,它应该也会显示类似的消息。创建一个新员工,它应该会被分配给您。

回顾

在本节中

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

问题?

网页已经变得相当复杂。但是如何管理关系和内联数据呢?创建/更新对话框并不真正适合这种情况。这可能需要一些自定义表单。

经理可以访问员工数据。员工应该可以访问吗?如果您要添加更多详细信息,例如电话号码和地址,您将如何建模?您将如何授予员工访问系统的权限,以便他们可以更新这些特定字段?页面上还有哪些超媒体控件会很方便?我希望您喜欢这个系列。

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,以加快您的进度。

了解更多

获取支持

Tanzu Spring 在一个简单的订阅中提供对 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

查看 Spring 社区中所有即将举行的活动。

查看全部