16. [课程]:使用 Spring Security 保障 Web 服务访问安全
关键词:多层架构、Spring、依赖注入、安全 Web 服务 / JSON、客户端 / 服务器
16.1. 支持
![]() | ![]() |
本章的项目位于 [support / chap-16] 文件夹中。该 SQL 脚本用于生成测试所需的数据库。
16.2. Spring Security 在 Web 应用程序中的作用
让我们将 Spring Security 置于 Web 应用程序的开发背景中。通常,Web 应用程序会基于如下所示的多层架构构建:
![]() |
- [Spring Security] 层仅允许经过授权的用户访问 [Web] 层。
16.3. Spring Security 入门教程
![]() |
![]() |
该项目包括以下内容:
- 在 [templates] 文件夹中,您将找到该项目的 HTML 页面;
- [Application]:是项目的可执行类;
- [MvcConfig]:是 Spring MVC 的配置类;
- [WebSecurityConfig]:是 Spring Security 的配置类;
16.3.1. Maven 配置
项目 [3] 是一个 Maven 项目。让我们查看其 [pom.xml] 文件,了解其依赖关系:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframework</groupId>
<artifactId>gs-securing-web</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.3.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- tag::security[] -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- end::security[] -->
</dependencies>
<properties>
<start-class>hello.Application</start-class>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- 第 10–14 行:该项目是一个 Spring Boot 项目;
- 第 17–20 行:对 [Thymeleaf] 框架的依赖;
- 第 22–25 行:依赖 Spring Security 框架;
16.3.2. Thymeleaf 视图
![]() |
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click <a th:href="@{/hello}">here</a> to see a greeting.
</p>
</body>
</html>
- 第 12 行:[th:href="@{/hello}"] 属性将生成 [<a>] 标签的 [href] 属性。值 [@{/hello}] 将生成路径 [<context>/hello],其中 [context] 是 Web 应用程序的上下文;
生成的 HTML 代码如下:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click
<a href="/hello">here</a>
to see a greeting.
</p>
</body>
</html>
[hello.html] 视图如下:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
</body>
</html>
- 第 9 行:[th:inline="text"] 属性将生成 [<h1>] 标签中的文本。该文本包含一个必须进行求值的 $ 表达式。元素 [[${#httpServletRequest.remoteUser}]] 是当前 HTTP 请求中 [RemoteUser] 属性的值。这是已登录用户的名称;
- 第 10 行:一个 HTML 表单。属性 [th:action="@{/logout}"] 将生成 [form] 标签的 [action] 属性。值 [@{/logout}] 将生成路径 [<context>/logout],其中 [context] 是 Web 应用程序上下文;
生成的 HTML 代码如下:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1>Hello user!</h1>
<form method="post" action="/logout">
<input type="submit" value="Sign Out" />
<input type="hidden" name="_csrf" value="b152e5b9-d1a4-4492-b89d-b733fe521c91" />
</form>
</body>
</html>
- 第 8 行:Hello [[${#httpServletRequest.remoteUser}]]! 的翻译;
- 第 9 行:@{/logout} 的翻译;
- 第 11 行:一个名为 _csrf 的隐藏字段(name 属性);
[login.html] 视图如下:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<div th:if="${param.error}">Invalid username and password.</div>
<div th:if="${param.logout}">You have been logged out.</div>
<form th:action="@{/login}" method="post">
<div>
<label> User Name : <input type="text" name="username" />
</label>
</div>
<div>
<label> Password: <input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
</form>
</body>
</html>
- 第 9 行:属性 [th:if="${param.error}"] 确保只有当显示登录页面的 URL 包含 [error] 参数(http://context/login?error)时,才会生成 <div> 标签;
- 第 10 行:属性 [th:if="${param.logout}"] 确保只有当显示登录页面的 URL 包含 [logout] 参数(http://context/login?logout)时,才会生成 <div> 标签;
- 第 11–23 行:一个 HTML 表单;
- 第 11 行:表单将提交至 URL [<context>/login],其中 <context> 是 Web 应用程序上下文;
- 第 13 行:一个名为 [username] 的输入字段;
- 第 17 行:一个名为 [password] 的输入字段;
生成的 HTML 代码如下:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example </title>
</head>
<body>
<div>
You have been logged out.
</div>
<form method="post" action="/login">
<div>
<label>
User Name :
<input type="text" name="username" />
</label>
</div>
<div>
<label>
Password:
<input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
<input type="hidden" name="_csrf" value="ef809b0a-88b4-4db9-bc53-342216b77632" />
</form>
</body>
</html>
请注意第 28 行,Thymeleaf 添加了一个名为 [_csrf] 的隐藏字段。
16.3.3. Spring MVC 配置
![]() |
[MvcConfig] 类用于配置 Spring MVC 框架:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
public class MvcConfig extends WebMvcConfigurerAdapter {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/home").setViewName("home");
registry.addViewController("/").setViewName("home");
registry.addViewController("/hello").setViewName("hello");
registry.addViewController("/login").setViewName("login");
}
}
- 第 7 行:[@Configuration] 注解将 [MvcConfig] 类定义为配置类;
- 第 8 行:[MvcConfig] 类继承自 [WebMvcConfigurerAdapter] 类,以重写某些方法;
- 第 10 行:重定义了父类中的一个方法;
- 第 11–16 行:[addViewControllers] 方法允许将 URL 与 HTML 视图关联。其中建立了以下关联:
视图 | |
/templates/home.html | |
/templates/hello.html | |
/templates/login.html |
后缀 [html] 和 [templates] 文件夹是 Thymeleaf 使用的默认值。它们可以通过配置进行更改。 [templates] 文件夹必须位于项目类路径的根目录下:
![]() |
在上文的 [1] 中,[java] 和 [resources] 文件夹均为源文件夹。这意味着它们的内容将位于项目类路径的根目录下。因此,在 [2] 中,[hello] 和 [templates] 文件夹将位于类路径的根目录下。
16.3.4. Spring Security 配置
![]() |
[WebSecurityConfig] 类用于配置 Spring Security 框架:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
- 第 9 行:[@Configuration] 注解将 [WebSecurityConfig] 类定义为配置类;
- 第 10 行:[@EnableWebSecurity] 注解将 [WebSecurityConfig] 类定义为 Spring Security 配置类;
- 第 11 行:[WebSecurity] 类继承自 [WebSecurityConfigurerAdapter] 类,以重写某些方法;
- 第 12 行:重写了父类中的一个方法;
- 第 13–16 行:重写 [configure(HttpSecurity http)] 方法,用于定义应用程序各 URL 的访问权限;
- 第 14 行:[http.authorizeRequests()] 方法允许将 URL 与访问权限关联。其中建立了以下关联:
规则 | 代码 | |
无需身份验证的访问 | | |
仅限经过身份验证的访问 |
- 第 15 行:定义身份验证方法。身份验证通过一个对所有人开放的 URL 表单 [/login] 进行 [http.formLogin().loginPage("/login").permitAll()]。注销功能也对所有人开放;
- 第 19–21 行:重新定义管理用户的 [configure(AuthenticationManagerBuilder auth)] 方法;
- 第 20 行:使用硬编码的用户进行身份验证 [auth.inMemoryAuthentication()]。此处通过登录名 [user]、密码 [password] 和角色 [USER] 定义用户。具有相同角色的用户可被授予相同的权限;
16.3.5. 可执行类
![]() |
[Application] 类的定义如下:
package hello;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@EnableAutoConfiguration
@Configuration
@ComponentScan
public class Application {
public static void main(String[] args) throws Throwable {
SpringApplication.run(Application.class, args);
}
}
- 第 8 行:[@EnableAutoConfiguration] 注解指示 Spring Boot(第 3 行)执行开发者未显式配置的配置;
- 第 9 行:将 [Application] 类设为 Spring 配置类;
- 第 10 行:指示系统扫描包含 [Application] 类的目录以查找 Spring 组件。因此,[MvcConfig] 和 [WebSecurityConfig] 这两个类会被发现,因为它们带有 [@Configuration] 注解;
- 第 13 行:可执行类的 [main] 方法;
- 第 14 行:以 [Application] 配置类作为参数执行静态方法 [SpringApplication.run]。我们之前已经遇到过这个过程,知道项目 Maven 依赖中嵌入的 Tomcat 服务器将被启动,项目也将部署到该服务器上。我们看到有四个 URL 被管理 [/, /home, /login, /hello],其中部分 URL 受访问权限保护。
16.3.6. 测试应用程序
让我们先请求 URL [/],这是四个被接受的 URL 之一。它关联的视图是 [/templates/home.html]:
![]() |
请求的 URL [/] 对所有人开放。这就是我们能够获取它的原因。链接 [此处] 如下:
点击该链接时,系统将请求 URL [/hello]。该 URL 受保护:
规则 | 代码 | |
无需身份验证的访问 | | |
仅限经过身份验证的访问 |
您必须经过身份验证才能访问该页面。随后,Spring Security 将把客户端浏览器重定向至身份验证页面。根据所示配置,该页面位于 URL [/login]。该页面对所有人开放:
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
因此我们得到 [1]:
![]() |
获取到的页面源代码如下:
- 第 7 行出现了一个隐藏字段,该字段在原始的 [login.html] 页面中并不存在。这是 Thymeleaf 添加的。这段代码被称为 CSRF(跨站请求伪造),旨在消除一个安全漏洞。该令牌必须与身份验证信息一起发回给 Spring Security,才能被接受;
我们回顾一下,Spring Security 仅识别用户名/密码对。如果我们在 [2] 中输入其他内容,将看到 [3] 处带有错误信息的同一页面。Spring Security 已将浏览器重定向至 URL [http://localhost:8080/login?error]。由于存在 [error] 参数,触发了以下标签的显示:
<div th:if="${param.error}">Invalid username and password.</div>
现在,让我们输入预期的用户名/密码值 [4]:
![]() |
- 在 [4] 中,我们登录;
- 在 [5] 中,Spring Security 将我们重定向到 URL [/hello],因为这是我们在被重定向到登录页面时请求的 URL。用户的身份通过 [hello.html] 中的以下代码行显示:
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
页面 [5] 显示如下表单:
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
当您点击 [注销] 按钮时,系统会向 URL [/logout] 发送一个 POST 请求。与 URL [/login] 一样,该 URL 对所有人开放:
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
在我们的 URL/视图映射中,我们尚未为 URL [/logout] 定义任何内容。会发生什么?让我们试一试:
![]() |
- 在 [6] 中,我们点击了 [Sign Out] 按钮;
- 在[7]中,我们可以看到已被重定向至 URL [http://localhost:8080/login?logout]。此次重定向是由 Spring Security 发起的。URL 中包含 [logout] 参数,导致视图中显示了以下内容:
<div th:if="${param.logout}">You have been logged out.</div>
16.3.7. 结论
在上一个示例中,我们本可以先编写 Web 应用程序,然后再对其进行安全加固。Spring Security 具有非侵入性。您可以为已经编写好的 Web 应用程序实现安全功能。此外,我们还发现了以下几点:
- 可以定义身份验证页面;
- 身份验证必须伴随 Spring Security 生成的 CSRF 令牌;
- 如果身份验证失败,系统会将您重定向至身份验证页面,且 URL 中会附加一个错误参数;
- 若认证成功,系统将重定向至认证时请求的页面。若直接请求认证页面而不经过中间页面,Spring Security 会将您重定向至 URL [/](此情况未在示例中演示);
- 通过 POST 请求访问 URL [/logout] 可完成注销。随后 Spring Security 会将您重定向至认证页面,且 URL 中包含 "logout" 参数;
以上结论均基于 Spring Security 的默认行为。通过重写 [WebSecurityConfigurerAdapter] 类的某些方法,可通过配置更改此行为。
之前的教程对我们后续的工作帮助不大。实际上,我们将使用:
- 一个数据库来存储用户、密码及其角色;
- 基于 HTTP 头部的身份验证;
关于我们要实现的功能,现有的教程寥寥无几。我们将提出的解决方案是整合了从各处收集的代码片段。
16.4. 在产品 Web 服务 / JSON 中实现安全功能
16.4.1. 数据库
[dbintrospringdata] 数据库正在更新,以包含用户、密码及其角色。将新增三个表:

表 [USERS]:用户
- ID:主键;
- VERSION:行版本控制列;
- IDENTITY:用户的描述性标识符;
- LOGIN:用户的登录名;
- PASSWORD:用户的密码;
在 USERS 表中,密码不会以明文形式存储:
![]() |
用于加密密码的算法是 BCRYPT 算法。
[ROLES] 表:角色
- ID:主键;
- VERSION:该行的版本控制列;
- NAME:角色名称。默认情况下,Spring Security 期望名称采用 ROLE_XX 的格式,例如 ROLE_ADMIN 或 ROLE_GUEST;
![]() |
表 [USERS_ROLES]:USERS/ROLES 关联表
一个用户可以拥有多个角色,一个角色也可以包含多个用户。这是一种多对多关系,由 [USERS_ROLES] 表表示。
- ID:主键;
- VERSION:行版本控制列;
- USER_ID:用户标识符;
- ROLE_ID:角色标识符;
![]() |
16.4.2. Eclipse 项目
我们创建以下 Eclipse 项目:
1 ![]() |
- 在 [1] 中:包含以下包的新项目:
- [spring.security.entities]:包含与三个新数据库表对应的 JPA 实体;
- [spring.security.repositories]:包含与这三个新表关联的 Spring Data 存储库;
- [spring.security.dao]:包含基于 [repositories] 的服务;
- [spring.security.config]:包含项目配置,包括 Web 服务的安全访问配置;
- [spring.security.boot]:包含安全 Web 服务的启动类;
16.4.3. Maven 配置
新项目是一个由以下 [pom.xml] 文件配置的 Maven 项目:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.spring.security</groupId>
<artifactId>intro-spring-security-server-01</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>intro-spring-security-server-01</name>
<description>démo spring security</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.7.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>istia.st.webjson</groupId>
<artifactId>intro-server-webjson-01</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<!-- Spring security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring logs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- plugins -->
<build>
<plugins>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.18.1</version>
</plugin>
</plugins>
</build>
</project>
- 第 23–27 行:我们复用了之前分析过的 Web 服务/JSON 归档文件中的现有代码;
- 第 29–32 行:引入 Spring Security 类的依赖项;
- 第 34–37 行:日志记录库;
- 第 39–42 行:支持使用 Spring Boot 注解的库;
- 第 44–48 行:测试所需的库;
16.4.4. 新的 [JPA] 实体
![]() |
![]() |
[User] 类表示 [USERS] 表:
package spring.security.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import spring.data.entities.AbstractEntity;
@Entity
@Table(name = "USERS")
public class User extends AbstractEntity {
// properties
@Column(name = "NAME")
private String name;
@Column(name = "LOGIN")
private String login;
@Column(name = "PASSWORD")
private String password;
// manufacturer
public User() {
}
public User(String name, String login, String password) {
this.name = name;
this.login = login;
this.password = password;
}
// getters and setters
...
}
- 第 11 行:该类继承了其他实体已使用的 [AbstractEntity] 类;
[Role] 类表示 [ROLES] 表:
package spring.security.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import spring.data.entities.AbstractEntity;
@Entity
@Table(name = "ROLES")
public class Role extends AbstractEntity {
// properties
@Column(name="NAME")
private String name;
// manufacturers
public Role() {
}
public Role(String name) {
this.name = name;
}
// getters and setters
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
[UserRole] 类表示 [USERS_ROLES] 表:
package spring.security.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import spring.data.entities.AbstractEntity;
@Entity
@Table(name = "USERS_ROLES")
public class UserRole extends AbstractEntity {
// foreign keys
@Column(name = "USER_ID", insertable = false, updatable = false)
private Long userId;
@Column(name = "ROLE_ID", insertable = false, updatable = false)
private Long roleId;
// a UserRole refers to a User
@ManyToOne
@JoinColumn(name = "USER_ID")
private User user;
// a UserRole refers to a Role
@ManyToOne
@JoinColumn(name = "ROLE_ID")
private Role role;
// manufacturers
public UserRole() {
}
public UserRole(User user, Role role) {
this.user = user;
this.role = role;
}
// getters and setters
...
}
- 第 22–24 行:定义从 [USERS_ROLES] 表到 [USERS] 表的外键;
- 第 27–29 行:定义从 [USERS_ROLES] 表到 [ROLES] 表的外键;
16.4.5. [repositories]
![]() |
上述每个 JPA 实体均由一个 Spring Data [存储库] 管理:
![]() |
[UserRepository] 接口负责管理对 [User] 实体的访问:
package spring.security.repositories;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import spring.security.entities.Role;
import spring.security.entities.User;
public interface UserRepository extends CrudRepository<User, Long> {
// liste des rôles d'un utilisateur identifié par son id
@Query("select ur.role from UserRole ur where ur.user.id=?1")
Iterable<Role> getRoles(long id);
// liste des rôles d'un utilisateur identifié par son login unique
@Query("select ur.role from UserRole ur where ur.user.login=?1 and ur.user.password=?2")
Iterable<Role> getRoles(String login, String password);
// recherche d'un utilisateur via son login
User findUserByLogin(String login);
}
- 第 9 行:[UserRepository] 接口继承了 Spring Data 的 [CrudRepository] 接口(第 4 行);
- 第 12-13 行:[getRoles(User user)] 方法根据 [id] 检索指定用户的全部角色
- 第 16-17 行:与上文相同,但针对通过登录名和密码标识的用户;
- 第 20 行:根据用户登录名查找用户;
[RoleRepository] 接口管理对 [Role] 实体的访问:
package spring.security.repositories;
import org.springframework.data.repository.CrudRepository;
import spring.security.entities.Role;
public interface RoleRepository extends CrudRepository<Role, Long> {
// search for a role by name
Role findRoleByName(String name);
}
- 第 7 行:[RoleRepository] 接口继承自 [CrudRepository] 接口;
- 第 10 行:您可以按名称搜索角色;
[UserRoleRepository] 接口管理对 [UserRole] 实体的访问:
package spring.security.repositories;
import org.springframework.data.repository.CrudRepository;
import spring.security.entities.UserRole;
public interface UserRoleRepository extends CrudRepository<UserRole, Long> {
}
- 第 5 行:[UserRoleRepository] 接口仅继承了 [CrudRepository] 接口,未添加任何新方法;
16.4.6. 用户和角色管理类
![]() |
![]() |
Spring Security 要求创建一个实现以下 [UsersDetail] 接口的类:
![]() |
此接口在此由 [AppUserDetails] 类实现:
package spring.security.dao;
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import spring.security.entities.Role;
import spring.security.entities.User;
import spring.security.repositories.UserRepository;
public class AppUserDetails implements UserDetails {
private static final long serialVersionUID = 1L;
// properties
private User user;
private UserRepository userRepository;
// manufacturers
public AppUserDetails() {
}
public AppUserDetails(User user, UserRepository userRepository) {
this.user = user;
this.userRepository = userRepository;
}
// -------------------------interface
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
for (Role role : userRepository.getRoles(user.getId())) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getLogin();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
// getters and setters
...
}
- 第 14 行:[AppUserDetails] 类实现了 [UserDetails] 接口;
- 第 19–20 行:该类封装了一个用户(第 19 行)以及提供该用户详细信息的存储库(第 20 行);
- 第 26–29 行:构造函数,用于通过用户及其存储库实例化该类;
- 第 32–36 行:实现 [UserDetails] 接口的 [getAuthorities] 方法。该方法必须构建一个由 [GrantedAuthority] 类型或其派生类型元素组成的集合。此处我们使用派生类型 [SimpleGrantedAuthority](第 36 行),该类型封装了第 19 行中用户的某个角色的名称;
- 第 35–37 行:遍历第 19 行中用户的角色列表,以构建一个 [SimpleGrantedAuthority] 类型的元素列表;
- 第 42–44 行:实现 [UserDetails] 接口的 [getPassword] 方法。返回第 19 行用户的密码;
- 第 42–44 行:实现 [UserDetails] 接口的 [getUserName] 方法。返回第 19 行中用户的登录名;
- 第 51–54 行:用户的账户永不过期;
- 第 56–59 行:用户的账户永不过期;
- 第 61–64 行:用户的凭据永不过期;
- 第 66–69 行:用户的账户始终处于活动状态;
Spring Security 还要求存在一个实现 [AppUserDetailsService] 接口的类:
![]() |
该接口由以下 [AppUserDetailsService] 类实现:
package spring.security.dao;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import spring.security.entities.User;
import spring.security.repositories.UserRepository;
@Service
public class AppUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {
// search for user via login
User user = userRepository.findUserByLogin(login);
// found?
if (user == null) {
throw new UsernameNotFoundException(String.format("login [%s] inexistant", login));
}
// render user details
return new AppUserDetails(user, userRepository);
}
}
- 第 12 行:该类将成为 Spring 组件,因此可在其上下文中使用;
- 第 15–16 行:[UserRepository] 组件将在此处被注入;
- 第 19–28 行:实现 [UserDetailsService] 接口(第 10 行)的 [loadUserByUsername] 方法。参数是用户的登录名;
- 第 21 行:使用用户的登录名进行搜索;
- 第 23–25 行:若未找到用户,则抛出异常;
- 第 27 行:构造并返回一个 [AppUserDetails] 对象。它确实是 [UserDetails] 类型(第 19 行);
16.4.7. 项目配置
![]() |
![]() |
[DaoConfig] 类用于配置新项目引入的 [DAO] 层:
package spring.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@EnableJpaRepositories(basePackages = { "spring.security.repositories" })
@ComponentScan(basePackages = { "spring.security.dao" })
@Import({ spring.data.config.DaoConfig.class })
public class DaoConfig {
// constants
final static private String[] ENTITIES_PACKAGES = { "spring.data.entities", "spring.security.entities" };
@Bean
public String[] packagesToScan() {
return ENTITIES_PACKAGES;
}
}
- 第 10 行:我们从 [intro-spring-data-01] 项目导入配置类 [spring.data.config.DaoConfig],该类实现了产品和分类的 [DAO] 层;
- 第 8 行:我们指定当前项目中包含 Spring Data [repositories] 的文件夹;
- 第 9 行:我们指定当前项目中包含与 [DAO] 层相关的 Spring 组件的文件夹;
- 第 14 行:此处指定了包含 JPA 实体的目录。这些目录包括来自 [intro-spring-data-01] 项目和安全服务器项目的实体。相关信息在第 16–19 行的 Bean 中定义。该 Bean 覆盖了 [intro-spring-data-01] 项目中同名的 Bean:
final static private String[] ENTITIES_PACKAGES = { "spring.data.entities" };
// EntityManagerFactory
@Bean
public EntityManagerFactory entityManagerFactory(JpaVendorAdapter jpaVendorAdapter, DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setJpaVendorAdapter(jpaVendorAdapter);
factory.setPackagesToScan(packagesToScan());
factory.setDataSource(dataSource);
factory.afterPropertiesSet();
return factory.getObject();
}
@Bean
public String[] packagesToScan() {
return ENTITIES_PACKAGES;
}
在 [DAO] 层中,第 8 行会扫描第 1 行指定的目录。由于在 secure 项目中第 14–17 行(即第 16–19 行)重新定义了该 Bean,因此上文第 8 行现在将扫描目录 ["spring.data.entities", "spring.security.entities"]。 请注意,第 10 行从 [spring.security.config.DaoConfig] 类导入的类必须包含 [@Configuration] 注解;否则,上述行为将无法生效。
[SecurityConfig] 类用于配置项目的安全方面。我们之前已经遇到过一个 Spring Security 配置类:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
我们将遵循相同的步骤:
- 第 11 行:定义一个继承自 [WebSecurityConfigurerAdapter] 类的类;
- 第 13 行:定义一个方法 [configure(HttpSecurity http)],用于定义 Web 服务各 URL 的访问权限;
- 第 19 行:定义一个方法 [configure(AuthenticationManagerBuilder auth)],用于定义用户及其角色;
[SecurityConfig] 类将如下所示:
package spring.security.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import spring.security.dao.AppUserDetailsService;
@EnableWebSecurity
@ComponentScan(basePackages = { "spring.security.service" })
@Import({ spring.webjson.config.AppConfig.class, DaoConfig.class })
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AppUserDetailsService appUserDetailsService;
// security
private boolean activateSecurity = true;
@Override
protected void configure(AuthenticationManagerBuilder registry) throws Exception {
// authentication is performed by bean [appUserDetailsService]
// the password is encrypted using the BCrypt hash algorithm
registry.userDetailsService(appUserDetailsService).passwordEncoder(new BCryptPasswordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// CSRF
http.csrf().disable();
// secure application?
if (activateSecurity) {
// the password is transmitted by the header Authorization: Basic xxxx
http.httpBasic();
// the HTTP OPTIONS method must be authorized for all
http.authorizeRequests() //
.antMatchers(HttpMethod.OPTIONS, "/", "/**").permitAll();
// only the ADMIN role can use the application
http.authorizeRequests() //
.antMatchers("/", "/**") // all URL
.hasRole("ADMIN");
// no session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
}
- 第 16 行:启用 Spring Security 组件;
- 第 17 行:我们引入了 [spring.security.service] 包中的 Spring 组件;
- 第 18 行:导入我们刚刚引入的 [DAO] 层中的 Bean,以及来自未受保护的 Web 服务器/JSON 的 Bean;
- 第 21–22 行:注入 [AppUserDetails] 类,该类提供对应用程序用户的访问;
- 第 25 行:一个布尔值,用于控制 Web 应用程序是否启用安全保护(true)或不启用(false);
- 第 27–32 行:[configure(HttpSecurity http)] 方法定义用户及其角色。该方法接受 [AuthenticationManagerBuilder] 类型的参数。该参数被补充了两项信息(第 38 行):
- 来自第 22 行的 [appUserDetailsService] 引用,该服务提供对已注册用户的访问。请注意,此处并未明确说明用户存储在数据库中。因此,用户数据也可能存储在缓存中,或由 Web 服务提供等。
- 密码所使用的加密类型。回顾一下,我们使用了 BCrypt 算法;
- 第 34–52 行:[configure(HttpSecurity http)] 方法定义了对 Web 服务 URL 的访问权限;
- 第 37 行:我们在入门项目中看到,默认情况下 Spring Security 会管理一个 CSRF(跨站请求伪造)令牌,尝试认证的用户必须将该令牌发回给服务器。此处已禁用该机制。结合布尔值(isSecured=false),这使得 Web 应用程序可在无安全保护的情况下使用;
- 第 41 行:我们启用了通过 HTTP 头进行身份验证。客户端必须发送以下 HTTP 头:
其中 code 是登录名:密码字符串的 Base64 编码。例如,字符串 admin:admin 的 Base64 编码为 YWRtaW46YWRtaW4=。因此,登录名为 [admin]、密码为 [admin] 的用户将发送以下 HTTP 头进行身份验证:
- 第 46–48 行:指定具有 [ROLE_ADMIN] 角色的用户可以访问 Web 服务的所有 URL。这意味着没有此角色的用户无法访问该 Web 服务;
- 第 50 行:在 [session] 模式下,已通过身份验证的用户后续访问时无需再次验证。此处我们禁用该模式,因此用户每次访问服务时都必须进行身份验证;
16.4.8. [DAO] 层测试
![]() |
![]() |
首先,我们创建一个可执行类 [CreateUser],该类能够创建具有角色的用户:
package sprin.security.tests;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.security.crypto.bcrypt.BCrypt;
import spring.security.config.DaoConfig;
import spring.security.entities.Role;
import spring.security.entities.User;
import spring.security.entities.UserRole;
import spring.security.repositories.RoleRepository;
import spring.security.repositories.UserRepository;
import spring.security.repositories.UserRoleRepository;
public class CreateUser {
public static void main(String[] args) {
// syntax: login password roleName
// three parameters are required
if (args.length != 3) {
System.out.println("Syntaxe : [pg] user password role");
System.exit(0);
}
// parameters are retrieved
String login = args[0];
String password = args[1];
String roleName = String.format("ROLE_%s", args[2].toUpperCase());
// spring context
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DaoConfig.class);
UserRepository userRepository = context.getBean(UserRepository.class);
RoleRepository roleRepository = context.getBean(RoleRepository.class);
UserRoleRepository userRoleRepository = context.getBean(UserRoleRepository.class);
// does the role already exist?
Role role = roleRepository.findRoleByName(roleName);
// if it doesn't exist, we create it
if (role == null) {
role = roleRepository.save(new Role(roleName));
}
// does the user already exist?
User user = userRepository.findUserByLogin(login);
// if it doesn't exist, we create it
if (user == null) {
// hash the password with bcrypt
String crypt = BCrypt.hashpw(password, BCrypt.gensalt());
// save user
user = userRepository.save(new User(login, login, crypt));
// we create the relationship with the role
userRoleRepository.save(new UserRole(user, role));
} else {
// the user already exists - does he/she have the required role?
boolean trouvé = false;
for (Role r : userRepository.getRoles(user.getId())) {
if (r.getName().equals(roleName)) {
trouvé = true;
break;
}
}
// if not found, we create the relationship with the role
if (!trouvé) {
userRoleRepository.save(new UserRole(user, role));
}
}
// closing Spring context
context.close();
// end
System.out.println("Travail terminé...");
}
}
- 第 17 行:该类期望接收三个定义用户的参数:登录名、密码和角色;
- 第 25–27 行:获取这三个参数;
- 第 29 行:从 [AppConfig] 配置类构建 Spring 上下文;
- 第 30–32 行:获取三个 [Repository] 对象的引用,这些对象可能有助于创建用户;
- 第 34 行:检查该角色是否已存在;
- 第 36–38 行:如果不存在,则在数据库中创建该角色。其名称将采用 [ROLE_XX] 的格式;
- 第 40 行:检查登录名是否已存在;
- 第 42–49 行:如果用户名不存在,则在数据库中创建它;
- 第 44 行:对密码进行加密。此处使用 Spring Security 中的 [BCrypt] 类(第 4 行)。因此我们需要该框架的依赖包。[pom.xml] 文件中包含以下依赖项:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- 第 46 行:用户被持久化到数据库中;
- 第 48 行:以及将其与角色关联的关系;
- 第51–57行:如果登录用户已存在,则检查我们要分配给该用户的角色是否已在其角色列表中;
- 第 59–61 行:如果未找到要查找的角色,则在 [USERS_ROLES] 表中创建一行,将用户与其角色关联起来;
- 我们尚未对潜在的异常进行处理。这是一个用于快速创建带角色的用户的辅助类。
当该类以参数 [x x guest] 执行时,数据库中将生成以下结果:
表 [USERS]
![]() |
表 [ROLES]
![]() |
表 [USERS_ROLES]
![]() |
现在我们来看看第二个类 [UsersTest],这是一个 JUnit 测试:
![]() |
package spring.security.tests;
import java.util.List;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import spring.security.config.DaoConfig;
import spring.security.dao.AppUserDetails;
import spring.security.dao.AppUserDetailsService;
import spring.security.entities.Role;
import spring.security.entities.User;
import spring.security.repositories.UserRepository;
@SpringApplicationConfiguration(classes = DaoConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class UsersTest {
@Autowired
private UserRepository userRepository;
@Autowired
private AppUserDetailsService appUserDetailsService;
// mapper jSON
private ObjectMapper mapper = new ObjectMapper();
@Test
public void findAllUsersWithTheirRoles() throws JsonProcessingException {
Iterable<User> users = userRepository.findAll();
for (User user : users) {
System.out.println(String.format("\n----------Utilisateur [%s]",mapper.writeValueAsString(user)));
display("Roles :", userRepository.getRoles(user.getId()));
}
}
@Test
public void findUserByLogin() {
// user [admin] is retrieved
User user = userRepository.findUserByLogin("admin");
// we check that his password is [admin]
Assert.assertTrue(BCrypt.checkpw("admin", user.getPassword()));
// check admin / admin role
List<Role> roles = Lists.newArrayList(userRepository.getRoles("admin", user.getPassword()));
Assert.assertEquals(1L, roles.size());
Assert.assertEquals("ROLE_ADMIN", roles.get(0).getName());
}
@Test
public void loadUserByUsername() {
// user [admin] is retrieved
AppUserDetails userDetails = (AppUserDetails) appUserDetailsService.loadUserByUsername("admin");
// we check that his password is [admin]
Assert.assertTrue(BCrypt.checkpw("admin", userDetails.getPassword()));
// check admin / admin role
@SuppressWarnings("unchecked")
List<SimpleGrantedAuthority> authorities = (List<SimpleGrantedAuthority>) userDetails.getAuthorities();
Assert.assertEquals(1L, authorities.size());
Assert.assertEquals("ROLE_ADMIN", authorities.get(0).getAuthority());
}
// utility method - displays items in a collection
private void display(String message, Iterable<?> elements) throws JsonProcessingException {
System.out.println(message);
for (Object element : elements) {
System.out.println(mapper.writeValueAsString(element));
}
}
}
- 第 37–44 行:视觉测试。我们显示所有用户及其角色;
- 第 46–56 行:我们使用 [UserRepository] 验证用户 [admin] 的密码为 [admin] 且角色为 [ROLE_ADMIN];
- 第 51 行:[admin] 是明文密码。在数据库中,它使用 BCrypt 算法进行加密。[BCrypt.checkpw] 方法用于验证加密后的明文密码是否与数据库中的密码匹配;
- 第 58–69 行:我们使用 [appUserDetailsService] 验证用户 [admin] 的密码为 [admin] 且角色为 [ROLE_ADMIN];
测试运行成功,日志如下:
16.4.9. Web 服务测试
我们将使用 Chrome 客户端 [Advanced Rest Client] 测试 Web 服务。需要指定 HTTP 身份验证头:
其中 [code] 是 Base64 编码的字符串 [登录名:密码]。要生成此代码,您可以使用以下程序:
![]() |
package spring.security.helpers;
import org.springframework.security.crypto.codec.Base64;
public class Base64Encoder {
public static void main(String[] args) {
// we expect two arguments: login password
if (args.length != 2) {
System.out.println("Syntaxe : login password");
System.exit(0);
}
// we retrieve the two arguments
String chaîne = String.format("%s:%s", args[0], args[1]);
// encode the string
byte[] data = Base64.encode(chaîne.getBytes());
// displays its Base64 encoding
System.out.println(new String(data));
}
}
如果我们使用两个参数 [admin admin] 运行此程序:
![]() |
我们得到以下结果:
既然我们已经知道如何生成 HTTP 身份验证头,我们就启动安全 Web 服务,然后使用 Chrome 客户端 [Advanced Rest Client] 请求所有产品的列表:
![]() |
- 在 [1] 中,我们请求分类的 URL;
- 在 [2] 中,使用 GET 方法;
- 在 [3] 中,我们提供 HTTP 身份验证头。代码 [YWRtaW46YWRtaW4=] 是字符串 [admin:admin] 的 Base64 编码;
- 在 [4] 中,我们发送 HTTP 请求;
服务器的响应如下:
![]() |
- 在 [1] 中,HTTP 身份验证标头;
- 在[2]中,服务器返回一个JSON响应;
我们成功获取了分类列表:
![]() |
现在,让我们尝试发送一个包含错误身份验证标头的 HTTP 请求。此时响应如下:
![]() |
- 在 [1] 中:HTTP 身份验证标头;
我们收到以下响应:
![]() |
- 在 [2] 中:Web 服务响应;
现在,我们来试一下用户 / user。该用户存在,但无权访问该 Web 服务。如果我们使用两个参数 [user user] 运行 Base64 编码程序:
![]() |
我们得到以下结果:
![]() |
- 在 [1] 中:错误的 HTTP 身份验证标头;
![]() |
- 在 [2] 中:Web 服务响应。它与之前的 [401 未授权] 不同。这次,用户身份验证正确,但没有足够的权限访问该 URL;
我们的安全 Web 服务现已正常运行。
16.4.10. 一个身份验证 URL
![]() |
我们将创建一个 URL,用于判断用户是否具有访问 Web 服务的权限。为此,我们创建以下新的 MVC 控制器 [AuthenticateController]:
package spring.security.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import spring.webjson.models.Response;
@Controller
public class AuthenticateController {
// spring dependencies
@Autowired
private ApplicationContext context;
@RequestMapping(value = "/authenticate", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String authenticate() throws JsonProcessingException {
// answer jSON
ObjectMapper mapperResponse = context.getBean(ObjectMapper.class);
return mapperResponse.writeValueAsString(new Response<Void>(0, null, null));
}
}
- 第 15 行:[AuthenticateController] 类是一个 Spring 控制器。因此,它暴露了 URL;
- 第 22 行:公开了 URL [/authenticate];
- 第 23 行:该方法的结果将直接发送给客户端;
- 第 26–27 行:该方法仅返回一个空的 [Response] 对象,但其 [status] 属性设为 0,表示未发生错误;
这个 URL 用于什么?当我们仅需对用户进行身份验证时,就会调用它。我们已经看到,如果安全层不接受该用户,它会抛出异常。以下是一个示例;
使用用户 [admin:admin]:
![]() | ![]() |
我们收到一个空响应,但没有抛出异常。
用户 [user:user] 的情况:
![]() | ![]() |
我们遇到一个异常。
16.4.11. 结论
在未修改原始 web/JSON 项目的情况下,已成功添加了 Spring Security 所需的类。这种非常理想的场景源于一个事实:即添加到数据库中的三个表与现有表是相互独立的。我们甚至可以将它们放置在另一个数据库中。在其他情况下,新增的表可能与现有表存在关联。这种情况下,必须修改 JPA 实体,这通常会影响项目的所有层级。
16.5. 为安全 Web/JSON 服务编写的客户端
我们已经为未加密的 Web 服务 / JSON 编写了一个客户端:
![]() |
接下来,我们将编写一个用于安全 Web 服务的客户端:
![]() |
我们将现有项目 [intro-webjson-client] 复制为一个新项目 [intro-spring-security-client-01]:
![]() |
16.5.1. [AbstractDao] 类
[AbstractDao] 类负责处理与安全 Web/JSON 服务器的 HTTP 通信。正如我们刚才所见,在此 HTTP 通信中,客户端现在必须发送一个身份验证头,例如:
具体操作如下:
package spring.security.client.dao;
import java.net.URI;
...
public abstract class AbstractDao {
// data
@Autowired
protected RestTemplate restTemplate;
@Autowired
protected String urlServiceWebJson;
// generic request
protected String getResponse(User user, String url, String jsonPost) {
// url : URL to contact
- 第 15 行:泛型方法 [getResponse] 负责与安全 Web 服务进行 HTTP 通信,现在将请求 URL 的用户作为其第一个参数。 [User] 类定义如下:
该类定义如下:
![]() |
package spring.security.client.entities;
public class User {
// properties
private String login;
private String password;
// manufacturer
public User() {
}
public User(String login, String password) {
this.login = login;
this.password = password;
}
// getters and setters
...
}
[getResponse] 方法随后变为如下形式:
package spring.security.client.dao;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.RequestEntity.BodyBuilder;
import org.springframework.http.RequestEntity.HeadersBuilder;
import org.springframework.web.client.RestTemplate;
import spring.security.client.entities.User;
public abstract class AbstractDao {
// data
@Autowired
protected RestTemplate restTemplate;
@Autowired
protected String urlServiceWebJson;
private String getBase64(User user) {
// encodes user and password in base 64 - requires java 8
String chaîne = String.format("%s:%s", user.getLogin(), user.getPassword());
return String.format("Basic %s", new String(Base64.getEncoder().encode(chaîne.getBytes())));
}
// generic request
protected String getResponse(User user, String url, String jsonPost) {
// url : URL to contact
// jsonPost: the jSON value to be posted
try {
// request execution
RequestEntity<?> request;
if (jsonPost == null) {
HeadersBuilder<?> headersBuilder = RequestEntity.get(new URI(String.format("%s%s", urlServiceWebJson, url)))
.accept(MediaType.APPLICATION_JSON);
if (user != null) {
headersBuilder = headersBuilder.header("Authorization", getBase64(user));
}
request = headersBuilder.build();
} else {
BodyBuilder bodyBuilder = RequestEntity.post(new URI(String.format("%s%s", urlServiceWebJson, url)))
.header("Content-Type", "application/json").accept(MediaType.APPLICATION_JSON);
if (user != null) {
bodyBuilder = bodyBuilder.header("Authorization", getBase64(user));
}
request = bodyBuilder.body(jsonPost);
}
// execute the query
return restTemplate.exchange(request, new ParameterizedTypeReference<String>() {
}).getBody();
} catch (URISyntaxException e1) {
throw new DaoException(20, e1);
} catch (RuntimeException e2) {
throw new DaoException(21, e2);
}
}
}
- 第 42–44 行、第 49–51 行:如果用户不为空,则添加身份验证标头。用户及其密码的 Base64 编码由第 25–29 行中的 [getBase64] 方法处理。请注意,该方法使用了属于 JDK 1.8 的 [Base64] 类。
- 除上述行外,其余代码保持不变;
16.5.2. [IDao] 接口
[IDao] 接口的所有方法都会接收一个额外的参数 [User user]:
![]() |
package spring.security.client.dao;
import java.util.List;
import spring.security.client.entities.Categorie;
import spring.security.client.entities.Produit;
import spring.security.client.entities.User;
public interface IDaoClient {
// authentication
public void authenticate(User user);
// insert product list
public List<Produit> addProduits(User user, List<Produit> produits);
// removal of all products
public void deleteAllProduits(User user);
// product list update
public List<Produit> updateProduits(User user, List<Produit> produits);
// all products obtained
public List<Produit> getAllProduits(User user);
// inserting a list of categories
public List<Categorie> addCategories(User user, List<Categorie> categories);
// delete all categories
public void deleteAllCategories(User user);
// updating a list of categories
public List<Categorie> updateCategories(User user, List<Categorie> categories);
// obtaining all categories
public List<Categorie> getAllCategories(User user);
// a special product
public Produit getProduitByIdWithCategorie(User user, Long idProduit);
public Produit getProduitByIdWithoutCategorie(User user, Long idProduit);
public Produit getProduitByNameWithCategorie(User user, String nom);
public Produit getProduitByNameWithoutCategorie(User user, String nom);
// a special category
public Categorie getCategorieByIdWithProduits(User user, Long idCategorie);
public Categorie getCategorieByIdWithoutProduits(User user, Long idCategorie);
public Categorie getCategorieByNameWithProduits(User user, String nom);
public Categorie getCategorieByNameWithoutProduits(User user, String nom);
}
- 第 12 行:我们添加了 [authenticate(User user)] 方法来验证用户身份。如果用户无权访问 Web 服务的 [/authenticate] URL,该方法将抛出异常;
16.5.3. [Dao] 类
[Dao] 类中的所有方法都会接收一个额外的参数 [User user],并将该参数传递给 [AbstractDao] 类的泛型方法 [getResponse]。以下是两个示例:
// authentication
@Override
public void authenticate(User user) {
getResponse(user, "/authenticate", null);
}
@Override
public List<Produit> addProduits(User user, List<Produit> produits) {
// ----------- add products (without category)
try {
// mappers jSON
ObjectMapper mapperPost = context.getBean(ObjectMapper.class);
mapperPost.setFilters(jsonFilterProduitWithoutCategorie);
ObjectMapper mapperResponse = mapperPost;
// request
Response<List<Produit>> response = mapperResponse.readValue(
getResponse(user, "/addProduits", mapperPost.writeValueAsString(produits)),
new TypeReference<Response<List<Produit>>>() {
});
// mistake?
if (response.getStatus() != 0) {
// 1 exception is thrown
throw new DaoException(response.getStatus(), response.getMessages());
} else {
// render the core of the server response
return response.getBody();
}
} catch (DaoException e1) {
throw e1;
} catch (IOException | RuntimeException e2) {
throw new DaoException(100, e2);
}
}
16.5.4. [Dao]类的单元测试
用于对 [Dao] 类进行单元测试的 [Test01] 类修改如下:
![]() |
package client.tests.junit;
...
@SpringApplicationConfiguration(classes = DaoConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class Test01 {
// spring context
@Autowired
private ApplicationContext context;
// layer [DAO]
@Autowired
private IDaoClient dao;
// users
static private User admin;
static private User user;
static private User unknown;
@BeforeClass
public static void init() {
admin = new User("admin", "admin");
user = new User("user", "user");
unknown = new User("x", "y");
}
@Before
public void cleanAndFill() {
// the base is cleaned before each test
log("Vidage de la base de données", 1);
// table [CATEGORIES] is emptied - by cascade, table [PRODUITS] will be emptied
dao.deleteAllCategories(admin);
// --------------------------------------------------------------------------------------
log("Remplissage de la base", 1);
// fill the tables
List<Categorie> categories = new ArrayList<Categorie>();
for (int i = 0; i < 2; i++) {
Categorie categorie = new Categorie(String.format("categorie%d", i));
for (int j = 0; j < 5; j++) {
categorie.addProduit(new Produit(String.format("produit%d%d", i, j), 100 * (1 + (double) (i * 10 + j) / 100),
String.format("desc%d%d", i, j)));
}
categories.add(categorie);
}
// add the category - the products will be cascaded in as well
dao.addCategories(admin, categories);
}
@Test
public void showDataBase() throws BeansException, JsonProcessingException {
// list of categories
log("Liste des catégories", 2);
List<Categorie> categories = dao.getAllCategories(admin);
affiche(categories, context.getBean("jsonMapperCategorieWithoutProduits", ObjectMapper.class));
// product list
log("Liste des produits", 2);
List<Produit> produits = dao.getAllProduits(admin);
affiche(produits, context.getBean("jsonMapperProduitWithoutCategorie", ObjectMapper.class));
// a few checks
Assert.assertEquals(2, categories.size());
Assert.assertEquals(10, produits.size());
Categorie categorie = findCategorieByName("categorie0", categories);
Assert.assertNotNull(categorie);
Produit produit = findProduitByName("produit03", produits);
Assert.assertNotNull(produit);
Long idCategorie = produit.getIdCategorie();
Assert.assertEquals(categorie.getId(), idCategorie);
}
...
@Test()
public void checkUserUser() {
ServiceException se = null;
try {
dao.authenticate(user);
} catch (ServiceException e) {
se = e;
}
Assert.assertNotNull(se);
Assert.assertEquals("403 Forbidden", se.getMessages().get(0));
}
@Test()
public void checkUserUnknown() {
ServiceException se = null;
try {
dao.authenticate(unknown);
} catch (ServiceException e) {
se = e;
}
Assert.assertNotNull(se);
Assert.assertEquals("401 Unauthorized", se.getMessages().get(0));
}
@Test()
public void checkUserAdmin() {
ServiceException se = null;
try {
dao.authenticate(admin);
} catch (ServiceException e) {
se = e;
}
Assert.assertNull(se);
}
...
}
- 在测试类的初始化过程中(第 21–26 行),创建了三个用户:
- 用户 [admin] 具有访问 Web 服务 URL 的权限,请测试第 96–104 行;
- 用户 [user] 存在,但无权使用 Web 服务 URL,测试第 71–81 行;
- 用户 [unknown] 不存在,参见第 83–93 行;
- 测试方法与之前针对未受保护 Web 服务所见的方法相同,区别仅在于调用 [IDaoClient] 接口的方法时,将拥有 URL 使用权限的用户 [admin] 作为第一个参数传入;
测试通过,但我们可以看到其速度比未受保护的 Web 服务更慢。对应用程序进行安全加固会显著增加其响应时间。有一个重要因素影响了受保护 Web 服务的性能:在配置它的 [AppConfig] 类中,我们写道:
@Override
protected void configure(HttpSecurity http) throws Exception {
// CSRF
http.csrf().disable();
// secure application?
if (activateSecurity) {
// the password is transmitted by the header Authorization: Basic xxxx
http.httpBasic();
// the HTTP OPTIONS method must be authorized for all
http.authorizeRequests() //
.antMatchers(HttpMethod.OPTIONS, "/", "/**").permitAll();
// only the ADMIN role can use the application
http.authorizeRequests() //
.antMatchers("/", "/**") // all URL
.hasRole("ADMIN");
// no session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
第 17 行代码是有代价的。它强制用户在每次访问时都进行身份验证。如果我们将该行注释掉,之前的 JUnit 测试耗时将从 10.57 秒缩短至 4.21 秒,因为 [admin] 用户仅在首次测试时进行身份验证,后续测试则无需验证(尽管客户端发送了 HTTP 身份验证头,但服务器不会重新验证用户的密码)。 对于未受保护的 Web 服务,JUnit 测试耗时缩短至 2.33 秒。
























































