构建用户管理微服务

构建用户管理微服务

翻译自:https://springuni.com/user-management-microservice-part-1/

构建用户管理微服务(一):定义领域模型和 REST API

在《构建用户管理微服务》的第一部分中,我们会定义应用的需求,初始的领域模型和供前端使用的 REST API。 我们首先定义用户注册管理用户的故事。

用户故事

在设计新系统时,值得考虑的是用户希望实现的结果。 下面您可以找到用户注册系统应具有的基本功能的列表。

  • 作为用户,我想注册,以便我可以访问需要注册的内容
  • 作为用户,我想在注册后确认我的电子邮件地址
  • 作为用户,我想登录并注销
  • 作为用户,我想更改我的密码
  • 作为用户,我想更改我的电子邮件地址
  • 作为用户,我想要重置我的密码,以便我忘记密码后可以登录
  • 作为用户,我想更新我的个人资料,以便我可以提供我正确的联络资料
  • 作为用户,我想关闭我的帐户,以便我可以关闭我与我注册的服务的关系
  • 作为管理员,我想手动管理(创建/删除/更新)用户,以便工作人员不必重新进行注册过程
  • 作为管理员,我想手动创建用户,这样工作人员就不用再过注册过程了
  • 作为管理员,我想列出所有用户,即使是那些曾经关闭帐户的用户
  • 作为管理员,我希望能够看到用户的活动(登录,注销,密码重置,确认,个人资料更新),以便我可以遵守外部审计要求

工作流程

我们来看看系统将要支持什么样的工作流程。首先,人们应该能够注册和登录,这些是相当明显的功能。

1

但是,处理确认令牌时需要谨慎。 由于它们可用于执行特权操作,因此我们使用一次性随机令牌来处理密码重置和电子邮件确认。

当一个新的令牌由用户生成,无论什么原因,所有以前的都是无效的。 当有人记住他们的密码时,以前发出的和有效的密码重置令牌必须过期。

非功能性需求

用户故事通常不会定义非功能性要求,例如安全性,开发原理,技术栈等。所以我们在这里单独列出。

  • 领域模型是使用域驱动的设计原则在纯 Java 中实现的,并且独立于要使用的底层技术栈
  • 当用户登录时,将为他们生成一个 JWT 令牌,有效期是 24 小时。在后续请求中包含此令牌,用户可以执行需要身份验证的操作
  • 密码重置令牌有效期为 10 分钟,电子邮件地址确认令牌为一天
  • 密码用加密算法(Bcrypt)加密,并且每用户加盐
  • 提供了 RESTful API,用于与用户注册服务进行交互
  • 应用程序将具有模块化设计,以便能够为各种场景提供单独的部署工件(例如,针对 Google App Engine 的 2.5 servlet 兼容 WAR 和其他用例的基于 Spring Boot 的自包含可执行 JAR)
  • 实体标识符以数据库无关的方式生成,也就是说,不会使用数据库特定机制(AUTO_INCREMENT 或序列)来获取下一个 ID 值。解决方案将类似于 Instagram genetes ID。

领域模型

对于第一轮实现中,我们只关注三个实体,即用户,确认令牌和用户事件。

0

rest api

访问下面的大多数 API 都需要认证,否则返回一个 UNAUTHORIZED 状态码。 如果用户尝试查询属于某个其他用户的实体,则他们还会返回客户端错误(FORBIDDEN),除非他具有管理权限。 如果指定的实体不存在,则调用的端点返回 NOT_FOUND。

创建会话(POST /sessions)和注册新用户(POST / users)是公开的,它们不需要身份验证。

Session management

1 GET /session/{session_id}

如果没有给定 ID 的会话或者会话已经过期,则返回给定会话的详细信息或 NOT_FOUND。

1 POST /session

创建新会话,前提是指定的电子邮件和密码对属于一个有效的用户。

1 DELETE /session/{session_id}

删除给定的会话(注销)。

User management

1 GET /users/{user_id}

根据一个指定的 ID 查找用户。

1 GET /users

列举系统中所有的用户。

1 POST /users

注册一个新的用户。

1 DELETE /users/{user_id}

删除指定的用户。

1 PUT /users/{user_id}

更新指定用户的个人信息。

1 PUT /users/{user_id}/tokens/{token_id}

使用给定用户的令牌执行与令牌类型相关的操作。

构建用户管理微服务(二):实现领域模型

在第二部分,将详细介绍如何实现领域模型,在代码之外做了哪些决定。

使用领域驱动设计

在第一部分中,作者提到了将使用领域驱动设计原则,这意味着,该模型可以不依赖于任何框架或基础设施类。在多次应用实现过程中,作者把领域模型和框架的具体注释(如 JPA 或 Hibernate )混在一起,就如同和 Java POJO 一起工作(贫血模型)。在设计领域模型中,唯一使用的库是Lombok,用于减少定义的 getter 和 setter 方法以避免冗余。

当设计 DDD 的模型,第一步是对类进行分类。在埃里克·埃文斯书中的第二部分专注于模型驱动设计的构建模块。考虑到这一点,我们的模型分为以下几类。

实体类

实体有明确的标识和生命周期需要被管理。从这个角度来看,用户肯定是一个实体。

ConfirmationToken 就是一个边缘的例子,因为在没有用户上下文的情况下,逻辑上它就不存在,而另一方面,它可以通过令牌的值来标识并且它有自己的生命周期。

同样的方法也适用于 Session ,这也可能是一个值对象,由于其不可改变的性质,但它仍然有一个 ID 和一个生命周期(会话过期)。

值对象

相对于实体类,值对象没有一个明确的 ID ,那就是,他们只是将一系列属性组合,并且,如果这些属性和另外一个相同类型的值对象的属性相同,那么我们就可以认为这两个值对象是相同的。

当设计领域模型,值对象提供了一种方便的方式来描述携带有一定的信息片段属性的集合。 AddressData,AuditData,ContactData 和 Password 因此可以认为是值对象。

虽然将所有这些属性实现为不可改变的是不切实际的,他们的某些属性可以单独被修改, Password 是一个很好的例子。当我们创建 Password 的实例,它的盐和哈希创建只有一次。在改变密码时,一个全新的实例与新的盐和散列将会被创建。

聚合

聚合代表一组结合在一起,并通过访问所谓的聚合根的对象。

这儿有两个聚合对象:用户和会话。前者包含了所有与用户相关的实体和值对象,而后者只包含一个单一的实体 Session 。

显然,用户聚合根是用户实体。通过一个实例用户实体,我们可以管理确认令牌,用户事件和用户的密码。

聚合 Session 成为一个独立的实体——尽管被捆绑到一个用户的上下文——部分原因是由于其一次性性质,部分是因为当我们查找一个会话时我们不知道用户是谁。 Session 被创建之后,要么过期,要么按需删除。

领域事件

当需要由系统的另外组件处理的事件发生时,领域事件就会被触发。

用户管理应用程序有一个领域事件,这是 UserEvent ,它有以下类型:

  • DELETED
  • EMAIL_CHANGED
  • EMAIL_CHANGE_REQUESTED
  • EMAIL_CONFIRMED
  • PASSWORD_CHANGED
  • PASSWORD_RESET_CONFIRMED
  • PASSWORD_RESET_REQUESTED
  • SCREEN_NAME_CHANGED
  • SIGNIN_SUCCEEDED
  • SIGNIN_FAILED
  • SIGNUP_REQUESTED

###

服务

服务包含了能够操作一组领域模型的类的业务逻辑。在本应用中, UserService 管理用户的生命周期,并发出合适的 UserEvent 。SessionService 是用于创建和销毁用户会话。

###

存储库

存储库旨在代表一个实体对象的概念集合,但是有时他们只是作为数据访问对象。有两种实现方法,一种方法是列出所有的抽象存储库类或超接口可能的数据访问方法,例如 Spring Data ,或者创建专门存储库接口。

对于用户管理应用程序,作者选择了第二种方法。UserRepository 和 SessionRepository 只列出那些绝对必要的处理他们实体的方法。

###

项目结构

你可能已经注意到,这里有一个 GitHub 上的库: springuni ,它包含用户管理应用程序的一部分,但它不包含应用程序本身的可执行版本。

究其原因,我为什么不提供单一只包含 Spring Boot 少量 @Enable* 注解的库,是为了可重用性。大多数我碰到的项目第一眼看起来是可以模块化的,但实际上他们只是没有良好分解职责的巨大单体应用。当你试图重用这样一个项目的模块,你很快意识到,它依赖于许多其他模块和/或过多的外部库。

springuni-particles (它可能已被也称为 springuni 模块)提供了多个模块的可重复使用的只为某些明确定义的功能。用户和会话管理是很好的例子。

###

模块

springuni-auth-model 包含了所有的领域模型类和用于管理用户生命周期的业务逻辑,它是完全与框架无关的。它的存储库,并且可以使用任何数据存储机制,对于手头的实际任务最符合。还有,PasswordChecker 和 PasswordEncryptor 可基于任何强大的密码散列技术实现。

springuni-commons 包含了通用的工具库。有很多著名的第三方库(如 Apache Commons Lang,Guava 等),这外延了 JDK 的标准库。在另一方面,我发现自己很多时候仅仅只用这些非常可扩展库的少量类。我特别喜欢的 Apache Commons Lang 中的 StringUtils 的和 Apache 共同集合的 CollectionUtils 类,但是,我宁愿为当前项目提供一个高度定制化的 StringUtils 和 CollectionUtils,这样就不需要添加外部依赖。

sprinuni-crm-model 定义了通用的值对象,用于处理联系人数据,如地址,国家等。虽然微服务架构的倡导者将投票反对使用共享库,但我认为这个特定点可能需要不时修订手头的任务。我最近参与了一些 CRM 集成项目,不得不重新实现了几乎同样的领域模型在不同的限界上下文(即用户,客户,联系人),这样一遍又一遍的操作是乏味的。也就是说,我认为使用联系人数据领域模型的小型通用库是值得尝试的。

构建用户管理微服务(三):实现和测试存储库

详细介绍一个完整的基于 JPA 的用户存储库实现,一个 JPA 的支撑模型和一些测试用例。

使用 XML 来映射简单的 JAVA 对象

仅看到用户存储库,也许你就能想到在对它添加基于 JPA 的实现时会遇到什么困难。

1
2
3
4
5
6
7
8
9
public interface UserRepository {  

void delete(Long userId) throws NoSuchUserException;
Optional<User> findById(Long id);
Optional<User> findByEmail(String email);
Optional<User> findByScreenName(String screenName);
User save(User user);

}

但是, 正如我在第一部分提到的, 我们将使用 DDD (域驱动设计), 因此, 在模型中就不能使用特定框架的依赖关系云 (包括 JPA 的注解) ,剩下的唯一可行性方法是用 XML 进行映射。如果我没有记错的话,自2010年以来,我再也没有接触过任何一个 orm.xml 的文件 , 这也就是我为什么开始怀念它的原因。

接下来我们看看XML文件中User的映射情况,以下是 user-orm.xml 的部分摘录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<entity class="com.springuni.auth.domain.model.user.User" cacheable="true" metadata-complete="true">
<table name="user_"/>
<named-query name="findByIdQuery"> <query>
<![CDATA[
select u from User u
where u.id = :userId
and u.deleted = false
]]> </query>
</named-query>
<named-query name="findByEmailQuery"> <query>
<![CDATA[
select u from User u
where u.contactData.email = :email
and u.deleted = false
]]> </query>
</named-query>
<named-query name="findByScreenNameQuery"> <query>
<![CDATA[
select u from User u
where u.screenName = :screenName
and u.deleted = false
]]> </query>
</named-query>
<entity-listeners>
<entity-listener class="com.springuni.commons.jpa.IdentityGeneratorListener"/>
</entity-listeners>
<attributes>
<id name="id"/>
<basic name="timezone">
<enumerated>STRING</enumerated>
</basic> <basic name="locale"/>
<basic name="confirmed"/>
<basic name="locked"/>
<basic name="deleted"/>
<one-to-many name="confirmationTokens" fetch="LAZY" mapped-by="owner" orphan-removal="true">
<cascade>
<cascade-persist/>
<cascade-merge/>
</cascade>
</one-to-many>
<element-collection name="authorities">
<collection-table name="authority">
<join-column name="user_id"/>
</collection-table>
</element-collection>
<embedded name="auditData"/>
<embedded name="contactData"/>
<embedded name="password"/>
<!-- Do not map email directly through its getter/setter --> <transient name="email"/>

</attributes>

</entity>

域驱动设计是一种持久化无关的方法,因此坚持设计一个没有具体目标数据结构的模型可能很有挑战性。当然, 它也存在优势, 即可对现实世界中的问题直接进行建模, 而不存在只能以某种方式使用某种技术栈之类的副作用。

1
2
3
4
5
6
7
8
public class User implements Entity<Long, User> {  

private Long id;
private String screenName; ...

private Set<String> authorities = new LinkedHashSet<>();

}

一般来说,一组简单的字符串或枚举值就能对用户的权限(或特权)进行建模了。

使用像 MongoDB 这样的文档数据库能够轻松自然地维护这个模型,如下所示。(顺便一提, 我还计划在本系列的后续内容中添加一个基于 Mongo 的存储库实现)

1
2
3
4
5
6
7
{   "id":123456789,   
"screenName":"test", ...
"authorities":[
"USER",
"ADMIN"
]
}

然而, 在关系模型中, 权限的概念必须作为用户的子关系进行处理。但是在现实世界中, 这仅仅只是一套权限规则。我们需要如何弥合这样的差距呢?

在 JPA 2.0 中可以引入 ElementCollection 来进行操作,它的用法类似于 OneToMany。在这种情况下, 已经配置好的 JPA 提供的程序 (Hibernate) 将自动生成必要的子关系。

alter table authority add constraint FKoia3663r5o44m6knaplucgsxn foreign key (userid) references user

##

项目中的新模块

我一直在讨论的 springuni-auth-user-jpa 包含了一个完整的基于 JPA 的 UserRepository 实现。其目标是, 每个模块都应该只拥有那些对它们的操作来说绝对必要的依赖关系,而这些关系只需要依赖 JPA API 便可以实现。

springuni-commons-jpa 是一个支撑模块, 它能够使用预先配置好的 HikariCP 和 Hibernate 的组合作为实体管理器, 而不必关心其他细节。 它的特色是 AbstractJpaConfiguration, 类似于 Spring Boot 的 HibernateJpaAutoConfiguration。

然而我没有使用后者的原因是 Spring Boot 的自动配置需要一定的初始化。因为谷歌应用引擎标准环境是我的目标平台之一,因此能否快速地启动是至关重要的。

单元测试存储库

虽然有人可能会说, 对于存储库没必要进行过多的测试, 尤其是在使用 Spring Data 的 存储库接口的时候。但是我认为测试代码可以避免运行时存在的一些问题,例如错误的实体映射或错误的 JPQL 查询。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@RunWith(SpringJUnit4ClassRunner)
@ContextConfiguration(classes = [UserJpaTestConfiguration])
@Transactional
@Rollbackclass UserJpaRepositoryTest {

@Autowired
UserRepository userRepository

User user

@Before void before() {
user = new User(1, "test", "test@springuni.com")
user.addConfirmationToken(ConfirmationTokenType.EMAIL, 10)
userRepository.save(user)
}

...

@Test void testFindById() {
Optional<User> userOptional = userRepository.findById(user.id)
assertTrue(userOptional.isPresent())
}

...

}

这个测试用例启动了一个具有嵌入式 H2 数据库的实体管理器。H2 非常适合于测试, 因为它支持许多众所周知的数据库 (如 MySQL) 的兼容模式,可以模拟你的真实数据库。

构建用户管理微服务(四):实现 REST 控制器

将 REST 控制器添加到领域控制模型的顶端

有关 REST

REST, 全称是 Resource Representational State Transfer(Resource 被省略掉了)。通俗来讲就是:资源在网络中以某种表现形式进行状态转移。在 web 平台上,REST 就是选择通过使用 http 协议和 uri,利用 client/server model 对资源进行 CRUD (Create/Read/Update/Delete) 增删改查操作。

使用 REST 结构风格是因为,随着时代的发展,传统前后端融为一体的网页模式无法满足需求,而 RESTful 可以通过一套统一的接口为 Web,iOS 和 Android 提供服务。另外对于广大平台来说,比如 Facebook platform,微博开放平台,微信公共平台等,他们需要一套提供服务的接口,于是 RESTful 更是它们最好的选择。

REST 端点的支撑模块

我经手的大多数项目,都需要对控制器层面正确地进行 Spring MVC 的配置。随着近几年单页应用程序的广泛应用,越来越不需要在 Spring mvc 应用程序中配置和开发视图层 (使用 jsp 或模板引擎)。

现在,创建完整的 REST 后端的消耗并生成了 JSON 是相当典型的, 然后通过 SPA 或移动应用程序直接使用。基于以上所讲, 我收集了 Spring MVC 常见配置,这能实现对后端的开发。

  • Jackson 用于生成和消解 JSON
  • application/json 是默认的内容类型
  • ObjectMapper 知道如何处理 Joda 和 JSR-310 日期/时间 api, 它在 iso 格式中对日期进行序列化, 并且不将缺省的值序列化 (NON_ABSENT)
  • ModelMapper 用于转换为 DTO 和模型类
  • 存在一个自定义异常处理程序, 用于处理 EntityNotFoundException 和其他常见应用程序级别的异常
  • 捕获未映射的请求并使用以前定义的错误响应来处理它们

####

能被重新使用的常见 REST 配置项目

该代码在 github, 有一个新的模块 springuni-commons-rest , 它包含实现 REST 控制器所需的所有常用的实用程序。 专有的 RestConfiguration 可以通过模块进行扩展, 它们可以进一步细化默认配置。

####

错误处理

正常的 web 应用程序向最终用户提供易于使用的错误页。但是,对于一个纯粹的 JSON-based REST 后端, 这不是一个需求, 因为它的客户是 SPA 或移动应用。

因此, 最好的方法是用一个明确定义的 JSON 结构 (RestErrorResponse) 前端可以很容易地响应错误, 这是非常可取的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Data
public class RestErrorResponse {
private final int statusCode;
private final String reasonPhrase;
private final String detailMessage;

protected RestErrorResponse(HttpStatus status, String detailMessage) {
statusCode = status.value();
reasonPhrase = status.getReasonPhrase();
this.detailMessage = detailMessage; }

public static RestErrorResponse of(HttpStatus status) {
return of(status, null); }

public static RestErrorResponse of(HttpStatus status, Exception ex) {
return new RestErrorResponse(status, ex.getMessage()); } }

以上代码将返回 HTTP 错误代码,包括 HTTP 错误的文本表示和对客户端的详细信息,RestErrorHandler 负责生成针对应用程序特定异常的正确响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@RestControllerAdvice
public class RestErrorHandler extends ResponseEntityExceptionHandler {

@ExceptionHandler(ApplicationException.class)
public ResponseEntity<Object> handleApplicationException(final ApplicationException ex) {
return handleExceptionInternal(ex, BAD_REQUEST); }

@ExceptionHandler(EntityAlreadyExistsException.class)
public ResponseEntity<Object> handleEntityExistsException(final EntityAlreadyExistsException ex) {
return handleExceptionInternal(ex, BAD_REQUEST); }

@ExceptionHandler(EntityConflictsException.class)
public ResponseEntity<Object> handleEntityConflictsException(final EntityConflictsException ex) {
return handleExceptionInternal(ex, CONFLICT); }

@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<Object> handleEntityNotFoundException(final EntityNotFoundException ex) {
return handleExceptionInternal(ex, NOT_FOUND); }

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Object> handleRuntimeException(final RuntimeException ex) {
return handleExceptionInternal(ex, INTERNAL_SERVER_ERROR); }

@ExceptionHandler(UnsupportedOperationException.class)
public ResponseEntity<Object> handleUnsupportedOperationException(
final UnsupportedOperationException ex) {
return handleExceptionInternal(ex, NOT_IMPLEMENTED); }

@Override
protected ResponseEntity<Object> handleExceptionInternal(
Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {
RestErrorResponse restErrorResponse = RestErrorResponse.of(status, ex);
return super.handleExceptionInternal(ex, restErrorResponse, headers, status, request); }

private ResponseEntity<Object> handleExceptionInternal(Exception ex, HttpStatus status) {
return handleExceptionInternal(ex, null, null, status, null); } }

处理未响应请求

为了处理未映射的请求, 首先我们需要定义一个默认处理程序, 然后用 RequestMappingHandlerMapping 来设置它。

1
2
3
4
5
6
7
8
@Controller
public class DefaultController {

@RequestMapping
public ResponseEntity<RestErrorResponse> handleUnmappedRequest(final HttpServletRequest request) {
return ResponseEntity.status(NOT_FOUND).body(RestErrorResponse.of(NOT_FOUND));
}
}

经过这样的设置,RestConfiguration 在一定程度上扩展了 WebMvcConfigurationSupport, 这提供了用于调用 MVC 基础结构的自定义钩子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@EnableWebMvc 
@Configuration
public class RestConfiguration extends WebMvcConfigurationSupport {
...

protected Object createDefaultHandler() {
return new DefaultController(); }
...
@Override
protected RequestMappingHandlerMapping createRequestMappingHandlerMapping() {
RequestMappingHandlerMapping handlerMapping = super.createRequestMappingHandlerMapping();
Object defaultHandler = createDefaultHandler();
handlerMapping.setDefaultHandler(defaultHandler);
return handlerMapping; }
}

用于管理用户的 REST 端点

在第一部分中,我定义了一堆用于和用户管理服务进行交互的 REST 风格的端点。而实际上, 他们与用 Spring MVC 创建 REST 风格的端点相比,并没有什么特别的。但是,我有一些最近意识到的小细节想要补充。

  • 正如 Spring 4.3 有一堆用于定义请求处理程序的速记注解,@GetMapping 是一个组合的注解, 它为 @RequestMapping (method = RequestMethod. GET) 作为其对应的 @PostMapping、@PutMapping 等的快捷方式。
  • 我找到了一个用于处理从/到模型类转换的 DTO 的模块映射库 。在此之前,我用的是 Apache Commons Beanutils。
  • 手动注册控制器来加快应用程序初始化的速度。正如我在第三部分中提到的, 这个应用程序将托管在谷歌应用引擎标准环境中,而开启一个新的实例是至关重要的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@RestController @RequestMapping("/users")
public class UserController {

private final UserService userService;
private final ModelMapper modelMapper;

public UserController(ModelMapper modelMapper, UserService userService) {
this.modelMapper = modelMapper;
this.userService = userService;
}
@GetMapping("/{userId}")
public UserDto getUser(@PathVariable long userId) throws ApplicationException {
User user = userService.getUser(userId);
return modelMapper.map(user, UserDto.class);
}

...

@PostMapping
public void createUser(@RequestBody @Validated UserDto userDto) throws ApplicationException {
User user = modelMapper.map(userDto, User.class);
userService.signup(user, userDto.getPassword());
}
...

}

将 DTO 映射到模型类

虽然 ModelMapper 在查找匹配属性时是相当自动的, 但在某些情况下需要进行手动调整。比如说,用户的密码。这是我们绝对不想暴露的内容。

通过定义自定义属性的映射, 可以很容易地避免这一点。

1
2
3
4
5
6
7
8
9
10
import org.modelmapper.PropertyMap;
public class UserMap extends PropertyMap<User, UserDto> {

@Override
protected void configure() {

skip().setPassword(null);

}
}

当 ModelMapper 的实例被创建时, 我们可以自定义属性映射、转换器、目标值提供程序和一些其他的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration 
@EnableWebMvc
public class AuthRestConfiguration extends RestConfiguration {
...
@Bean
public ModelMapper modelMapper() {
ModelMapper modelMapper = new ModelMapper();
customizeModelMapper(modelMapper);
modelMapper.validate();
return modelMapper; }
@Override
protected void customizeModelMapper(ModelMapper modelMapper) {
modelMapper.addMappings(new UserMap());
modelMapper.addMappings(new UserDtoMap()); }
...
}

测试 REST 控制器

自 MockMvc 在 Spring 3.2 上推出以来, 使用 Spring mvc 测试 REST 控制器变得非常容易。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RunWith(SpringJUnit4ClassRunner) 
@ContextConfiguration(classes = [AuthRestTestConfiguration])
@WebAppConfigurationclass UserControllerTest {
@Autowired WebApplicationContext context
@Autowired UserService userService MockMvc mockMvc
@Before
void before() {
mockMvc = MockMvcBuilders.webAppContextSetup(context).build()
reset(userService)
when(userService.getUser(0L)).thenThrow(NoSuchUserException)
when(userService.getUser(1L))
.thenReturn(new User(1L, "test", "test@springuni.com")) }
@Test
void testGetUser() {
mockMvc.perform(get("/users/1").contentType(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("id", is(1)))
.andExpect(jsonPath("screenName", is("test")))
.andExpect(jsonPath("contactData.email", is("test@springuni.com")))
.andDo(print())
verify(userService).getUser(1L)
verifyNoMoreInteractions(userService)
}
...
}

有两种方式能让 MockMvc 与 MockMvcBuilders 一起被搭建。 一个是通过 web 应用程序上下文 (如本例中) 来完成, 另一种方法是向 standaloneSetup () 提供具体的控制器实例。我使用的是前者,当 Spring Security得到配置的时候,测试控制器显得更为合适。

构建用户管理微服务(五):使用 JWT 令牌和 Spring Security 来实现身份验证

我们已经建立了业务逻辑、数据访问层和前端控制器, 但是忽略了对身份进行验证。随着 Spring Security 成为实际意义上的标准, 将会在在构建 Java web 应用程序的身份验证和授权时使用到它。在构建用户管理微服务系列的第五部分中, 将带您探索 Spring Security 是如何同 JWT 令牌一起使用的。

有关 Token

诸如 Facebook,Github,Twitter 等大型网站都在使用基于 Token 的身份验证。相比传统的身份验证方法,Token 的扩展性更强,也更安全,非常适合用在 Web 应用或者移动应用上。我们将 Token 翻译成令牌,也就意味着,你能依靠这个令牌去通过一些关卡,来实现验证。实施 Token 验证的方法很多,JWT 就是相关标准方法中的一种。

关于 JWT 令牌

JSON Web TOKEN(JWT)是一个开放的标准 (RFC 7519), 它定义了一种简洁且独立的方式, 让在各方之间的 JSON 对象安全地传输信息。而经过数字签名的信息也可以被验证和信任。

JWT 的应用越来越广泛, 而因为它是轻量级的,你也不需要有一个用来验证令牌的认证服务器。与 OAuth 相比, 这有利有弊。如果 JWT 令牌被截获,它可以用来模拟用户, 也无法防范使用这个被截获的令牌继续进行身份验证。

真正的 JWT 令牌看起来像下面这样:

1
2
3
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJsYXN6bG9fQVRfc3ByaW5ndW5pX0RPVF9jb20iLCJuYW1lIjoiTMOhc3psw7MgQ3NvbnRvcyIsImFkbWluIjp0cnVlfQ.
XEfFHwFGK0daC80EFZBB5ki2CwrOb7clGRGlzchAD84

JWT 令牌的第一部分是令牌的 header , 用于标识令牌的类型和对令牌进行签名的算法。

1
2
3
4
{ 
"alg": "HS256", "typ": "JWT"

}

第二部分是 JWT 令牌的 payload 或它的声明。这两者是有区别的。Payload 可以是任意一组数据, 它甚至可以是明文或其他 (嵌入 JWT)的数据。而声明则是一组标准的字段。

1
2
3
4
{ 
"sub": "laszlo_AT_springuni_DOT_com", "name": "László Csontos", "admin": true

}

第三部分是由算法产生的、由 JWT 的 header 表示的签名。

创建和验证 JWT 令牌

有相当多的第三方库可用于操作 JWT 令牌。而在本文中, 我使用了 JJWT。

1
2
3
4
5
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.7.0</version>
</dependency>

采用 JwtTokenService 使 JWT 令牌从身份验证实例中创建, 并将 JWTs 解析回身份验证实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class JwtTokenServiceImpl implements JwtTokenService {  

private static final String AUTHORITIES = "authorities";

static final String SECRET = "ThisIsASecret";

@Override
public String createJwtToken(Authentication authentication, int minutes) {
Claims claims = Jwts.claims()
.setId(String.valueOf(IdentityGenerator.generate()))
.setSubject(authentication.getName())
.setExpiration(new Date(currentTimeMillis() + minutes * 60 * 1000))
.setIssuedAt(new Date());

String authorities = authentication.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.map(String::toUpperCase)
.collect(Collectors.joining(","));

claims.put(AUTHORITIES, authorities);

return Jwts.builder()
.setClaims(claims)
.signWith(HS512, SECRET)
.compact();
}

@Override
public Authentication parseJwtToken(String jwtToken) throws AuthenticationException {
try {
Claims claims = Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(jwtToken)
.getBody();
return JwtAuthenticationToken.of(claims);
} catch (ExpiredJwtException | SignatureException e) {
throw new BadCredentialsException(e.getMessage(), e);
} catch (UnsupportedJwtException | MalformedJwtException e) {
throw new AuthenticationServiceException(e.getMessage(), e);
} catch (IllegalArgumentException e) {
throw new InternalAuthenticationServiceException(e.getMessage(), e);
}
}

}

根据实际的验证,parseClaimsJws () 会引发各种异常。在 parseJwtToken () 中, 引发的异常被转换回 AuthenticationExceptions。虽然 JwtAuthenticationEntryPoint 能将这些异常转换为各种 HTTP 的响应代码, 但它也只是重复 DefaultAuthenticationFailureHandler 来以 http 401 (未经授权) 响应。

登录和身份验证过程

基本上, 认证过程有两个短语, 让后端将服务用于单页面 web 应用程序。

登录时创建 JWT 令牌

第一次登录变完成启动, 且在这一过程中, 将创建一个 JWT 令牌并将其发送回客户端。这些是通过以下请求完成的:

1
2
3
4
5
POST /session
{
"username": "laszlo_AT_sprimguni_DOT_com",
"password": "secret"
}

成功登录后, 客户端会像往常一样向其他端点发送后续请求, 并在授权的 header 中提供本地缓存的 JWT 令牌。

1
Authorization: Bearer <JWT token>

img

正如上面的步骤所讲, LoginFilter 开始进行登录过程。而Spring Security 的内置 UsernamePasswordAuthenticationFilter 被延长, 来让这种情况发生。这两者之间的唯一的区别是, UsernamePasswordAuthenticationFilter 使用表单参数来捕获用户名和密码, 相比之下, LoginFilter 将它们视做 JSON 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import org.springframework.security.authentication.*;
import org.springframework.security.core.*;
import org.springframework.security.web.authentication.*;

public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private static final String LOGIN_REQUEST_ATTRIBUTE = "login_request";

...

@Override
public Authentication attemptAuthentication(
HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
LoginRequest loginRequest =
objectMapper.readValue(request.getInputStream(), LoginRequest.class);

request.setAttribute(LOGIN_REQUEST_ATTRIBUTE, loginRequest);
return super.attemptAuthentication(request, response);
} catch (IOException ioe) {
throw new InternalAuthenticationServiceException(ioe.getMessage(), ioe);
} finally {
request.removeAttribute(LOGIN_REQUEST_ATTRIBUTE);
}
}

@Override
protected String obtainUsername(HttpServletRequest request) {
return toLoginRequest(request).getUsername();
}

@Override
protected String obtainPassword(HttpServletRequest request) {
return toLoginRequest(request).getPassword();
}
private LoginRequest toLoginRequest(HttpServletRequest request) { return (LoginRequest)request.getAttribute(LOGIN_REQUEST_ATTRIBUTE);
}

}

处理登陆过程的结果将在之后分派给一个 AuthenticationSuccessHandler 和 AuthenticationFailureHandler。

两者都相当简单。DefaultAuthenticationSuccessHandler 调用 JwtTokenService 发出一个新的令牌, 然后将其发送回客户端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class DefaultAuthenticationSuccessHandler implements AuthenticationSuccessHandler {  

private static final int ONE_DAY_MINUTES = 24 * 60;

private final JwtTokenService jwtTokenService;
private final ObjectMapper objectMapper;

public DefaultAuthenticationSuccessHandler(
JwtTokenService jwtTokenService, ObjectMapper objectMapper) {
this.jwtTokenService = jwtTokenService;
this.objectMapper = objectMapper;
}

@Override
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {

response.setContentType(APPLICATION_JSON_VALUE);

String jwtToken = jwtTokenService.createJwtToken(authentication, ONE_DAY_MINUTES);
objectMapper.writeValue(response.getWriter(), jwtToken);
}

}

以下是它的对应, DefaultAuthenticationFailureHandler, 只是发送回一个 http 401 错误消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class DefaultAuthenticationFailureHandler implements AuthenticationFailureHandler {  

private static final Logger LOGGER =
LoggerFactory.getLogger(DefaultAuthenticationFailureHandler.class);

private final ObjectMapper objectMapper;

public DefaultAuthenticationFailureHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}

@Override
public void onAuthenticationFailure(
HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
throws IOException {

LOGGER.warn(exception.getMessage());

HttpStatus httpStatus = translateAuthenticationException(exception);

response.setStatus(httpStatus.value());
response.setContentType(APPLICATION_JSON_VALUE);

writeResponse(response.getWriter(), httpStatus, exception);
}
protected HttpStatus translateAuthenticationException(AuthenticationException exception) {
return UNAUTHORIZED;
}
protected void writeResponse(
Writer writer, HttpStatus httpStatus, AuthenticationException exception) throws IOException {

RestErrorResponse restErrorResponse = RestErrorResponse.of(httpStatus, exception);
objectMapper.writeValue(writer, restErrorResponse);
}

}

处理后续请求

在客户端登陆后, 它将在本地缓存 JWT 令牌, 并在前面讨论的后续请求中发送反回。

img

对于每个请求, JwtAuthenticationFilter 通过 JwtTokenService 验证接收到的 JWT令牌。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class JwtAuthenticationFilter extends OncePerRequestFilter {  

private static final Logger LOGGER =
LoggerFactory.getLogger(JwtAuthenticationFilter.class);

private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String TOKEN_PREFIX = "Bearer";

private final JwtTokenService jwtTokenService;

public JwtAuthenticationFilter(JwtTokenService jwtTokenService) {
this.jwtTokenService = jwtTokenService;
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {

Authentication authentication = getAuthentication(request);
if (authentication == null) {
SecurityContextHolder.clearContext();
filterChain.doFilter(request, response);
return;
}

try {
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
} finally {
SecurityContextHolder.clearContext();
}
} private Authentication getAuthentication(HttpServletRequest request) {
String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER); if (StringUtils.isEmpty(authorizationHeader)) {
LOGGER.debug("Authorization header is empty.");
return null;
} if (StringUtils.substringMatch(authorizationHeader, 0, TOKEN_PREFIX)) {
LOGGER.debug("Token prefix {} in Authorization header was not found.", TOKEN_PREFIX);
return null;
}

String jwtToken = authorizationHeader.substring(TOKEN_PREFIX.length() + 1); try {
return jwtTokenService.parseJwtToken(jwtToken);
} catch (AuthenticationException e) {
LOGGER.warn(e.getMessage());
return null;
}
}

}

如果令牌是有效的, 则会实例化 JwtAuthenticationToken, 并执行线程的 SecurityContext。而由于恢复的 JWT 令牌包含唯一的 ID 和经过身份验证的用户的权限, 因此无需与数据库联系以再次获取此信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class JwtAuthenticationToken extends AbstractAuthenticationToken {  

private static final String AUTHORITIES = "authorities";

private final long userId;

private JwtAuthenticationToken(long userId, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.userId = userId;
}

@Override
public Object getCredentials() {
return null;
}

@Override
public Long getPrincipal() {
return userId;
} /** * Factory method for creating a new {@code {@link JwtAuthenticationToken}}. * @param claims JWT claims * @return a JwtAuthenticationToken */

public static JwtAuthenticationToken of(Claims claims) {
long userId = Long.valueOf(claims.getSubject());

Collection<GrantedAuthority> authorities =
Arrays.stream(String.valueOf(claims.get(AUTHORITIES)).split(","))
.map(String::trim)
.map(String::toUpperCase)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet());

JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(userId, authorities);

Date now = new Date();
Date expiration = claims.getExpiration();
Date notBefore = claims.getNotBefore();
jwtAuthenticationToken.setAuthenticated(now.after(notBefore) && now.before(expiration)); return jwtAuthenticationToken;
}

}

在这之后, 它由安全框架决定是否允许或拒绝请求。

Spring Security 在 Java EE 世界中有竞争者吗?

虽然这不是这篇文章的主题, 但我想花一分钟的时间来谈谈。如果我不得不在一个 JAVA EE 应用程序中完成所有这些?Spring Security 真的是在 JAVA 中实现身份验证和授权的黄金标准吗?

让我们做个小小的研究!

JAVA EE 8 指日可待,他将在 2017 年年底发布,我想看看它是否会是 Spring Security 一个强大的竞争者。我发现 JAVA EE 8 将提供 JSR-375 , 这应该会缓解 JAVA EE 应用程序的安全措施的发展。它的参考实施被称为 Soteira, 是一个相对新的 github 项目。那就是说, 现在的答案是真的没有这样的一个竞争者。

但这项研究是不完整的,并没有提到 Apache Shiro。虽然我从未使用过,但我听说这算是更为简单的 Spring Security。让它更 JWT 令牌 一起使用也不是不可能。从这个角度来看,Apache Shiro 是算 Spring Security 的一个的有可比性的替代品

构建用户管理微服务(六):添加并记住我使用持久JWT令牌的身份验证

于用户名和密码的身份验证。如果你错过了这一点,我在这里注意到,JWT令牌是在成功登录后发出的,并验证后续请求。创造长寿的JWT是不实际的,因为它们是独立的,没有办法撤销它们。如果令牌被盗,所有赌注都会关闭。因此,我想添加经典的remember-me风格认证与持久令牌。记住,我的令牌存储在Cookie中作为 JWT作为第一道防线,但是它们也保留在数据库中,并且跟踪其生命周期。

这次我想从演示运行中的用户管理应用程序的工作原理开始,然后再深入细节。

验证流程

基本上,用户使用用户名/密码对进行身份验证会发生什么,他们可能会表示他们希望应用程序记住他们(持续会话)的意图。大多数时候,UI上还有一个复选框来实现。由于应用程序还没有开发UI,我们用cURL做一切 。

登录

1
2
3
4
5
6
7
8
curl -D- -c cookies.txt -b cookies.txt \
-XPOST http://localhost:5000/auth/login \
-d '{ "username":"test", "password": "test", "rememberMe": true }'

HTTP/1.1 200
...
Set-Cookie: remember-me=eyJhbGciOiJIUzUxMiJ9...;Max-Age=1209600;path=/;HttpOnly
X-Set-Authorization-Bearer: eyJhbGciOiJIUzUxMiJ9...

成功认证后, PersistentJwtTokenBasedRememberMeServices创建一个永久会话,将其保存到数据库并将其转换为JWT令牌。它负责将此持久会话存储在客户端的一个cookie(Set-Cookie)上,并且还发送新创建的瞬时令牌。后者旨在在单页前端的使用寿命内使用,并使用非标准HTTP头(X-Set-Authorization-Bearer)发送。

rememberMe标志为false时,只创建一个无状态的JWT令牌,并且完全绕过了remember-me基础架构。

在应用程序运行时仅使用瞬态令牌

当应用程序在浏览器中打开时,它会在每个XHR请求的授权头文件中发送暂时的JWT令牌。然而,当应用程序重新加载时,暂时令牌将丢失。

为了简单起见,这里使用GET / users / {id}来演示正常的请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
curl -D- -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...' \
-XGET http://localhost:5000/users/524201457797040

HTTP/1.1 200
...
{
"id" : 524201457797040,
"screenName" : "test",
"contactData" : {
"email" : "test@springuni.com",
"addresses" : [ ]
},
"timezone" : "AMERICA_LOS_ANGELES",
"locale" : "en_US"
}

使用瞬态令牌与持久性令牌结合使用

当用户在第一种情况下选择了remember-me认证时,会发生这种情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
curl -D- -c cookies.txt -b cookies.txt \
-H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...' \
-XGET http://localhost:5000/users/524201457797040

HTTP/1.1 200
...
{
"id" : 524201457797040,
"screenName" : "test",
"contactData" : {
"email" : "test@springuni.com",
"addresses" : [ ]
},
"timezone" : "AMERICA_LOS_ANGELES",
"locale" : "en_US"
}

在这种情况下,暂时的JWT令牌和一个有效的remember-me cookie都是同时发送的。只要单页应用程序正在运行,就使用暂时令牌。

初始化时使用持久令牌

当前端在浏览器中加载时,它不知道是否存在任何暂时的JWT令牌。所有它可以做的是测试持久的remember-me cookie尝试执行一个正常的请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
curl -D- -c cookies.txt -b cookies.txt \
-XGET http://localhost:5000/users/524201457797040

HTTP/1.1 200
...
Set-Cookie: remember-me=eyJhbGciOiJIUzUxMiJ9...;Max-Age=1209600;path=/;HttpOnly
X-Set-Authorization-Bearer: eyJhbGciOiJIUzUxMiJ9...

{
"id" : 524201457797040,
"screenName" : "test",
"contactData" : {
"email" : "test@springuni.com",
"addresses" : [ ]
},
"timezone" : "AMERICA_LOS_ANGELES",
"locale" : "en_US"
}

如果持久性令牌(cookie)仍然有效,则会在上次使用数据库时在数据库中进行更新,并在浏览器中更新。还执行另一个重要步骤,用户将自动重新进行身份验证,而无需提供用户名/密码对,并创建新的临时令牌。从现在开始,只要运行该应用程序,该应用程序将使用暂时令牌。

注销

尽管注销看起来很简单,有一些细节我们需要注意。前端仍然发送无状态的JWT令牌,只要用户进行身份验证,否则UI上的注销按钮甚至不会被提供,后台也不会知道如何注销。

1
2
3
4
5
6
7
curl -D- -c cookies.txt -b cookies.txt \
-H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...' \
-XPOST http://localhost:5000/auth/logout

HTTP/1.1 302
Set-Cookie: remember-me=;Max-Age=0;path=/
Location: http://localhost:5000/login?logout

在此请求之后,记住我的cookie被重置,并且数据库中的持久会话被标记为已删除。

实现记住我的身份验证

正如我在摘要中提到的,我们将使用持久性令牌来增加安全性,以便能够在任何时候撤销它们。有三个步骤,我们需要执行,以使适当的记住我处理与Spring Security。

实现 UserDetailsService

在第一篇文章中,我决定使用DDD开发模型,因此它不能依赖于任何框架特定的类。实际上,它甚至不依赖于任何第三方框架或图书馆。大多数教程通常直接实现UserDetailsService,并且业务逻辑和用于构建应用程序的框架之间没有额外的层。

UserServices在第二部分很久以前被添加到该项目中,因此我们的任务非常简单,因为现在我们需要的是一个框架特定的组件,它将UserDetailsService的职责委托给现有的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class DelegatingUserService implements UserDetailsService {

private final UserService userService;

public DelegatingUserService(UserService userService) {
this.userService = userService;
}

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Long userId = Long.valueOf(username);
UsernameNotFoundException usernameNotFoundException = new UsernameNotFoundException(username);
return userService.findUser(userId)
.map(DelegatingUser::new)
.orElseThrow(() -> usernameNotFoundException);
}

}

只是围绕UserService的一个简单的包装器,最终将返回的User模型对象转换为框架特定的UserDetails实例。除此之外,在这个项目中,我们不直接使用用户的登录名(电子邮件地址或屏幕名称)。相反,他们的用户的身份证遍及各地。

实现 PersistentTokenRepository

幸运的是,我们在添加适当的PersistentTokenRepository实现方面同样容易,因为域模型已经包含SessionServiceSession

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class DelegatingPersistentTokenRepository implements PersistentTokenRepository {

private static final Logger LOGGER =
LoggerFactory.getLogger(DelegatingPersistentTokenRepository.class);

private final SessionService sessionService;

public DelegatingPersistentTokenRepository(SessionService sessionService) {
this.sessionService = sessionService;
}

@Override
public void createNewToken(PersistentRememberMeToken token) {
Long sessionId = Long.valueOf(token.getSeries());
Long userId = Long.valueOf(token.getUsername());
sessionService.createSession(sessionId, userId, token.getTokenValue());
}

@Override
public void updateToken(String series, String tokenValue, Date lastUsed) {
Long sessionId = Long.valueOf(series);
try {
sessionService.useSession(sessionId, tokenValue, toLocalDateTime(lastUsed));
} catch (NoSuchSessionException e) {
LOGGER.warn("Session {} doesn't exists.", sessionId);
}
}

@Override
public PersistentRememberMeToken getTokenForSeries(String seriesId) {
Long sessionId = Long.valueOf(seriesId);
return sessionService
.findSession(sessionId)
.map(this::toPersistentRememberMeToken)
.orElse(null);
}

@Override
public void removeUserTokens(String username) {
Long userId = Long.valueOf(username);
sessionService.logoutUser(userId);
}

private PersistentRememberMeToken toPersistentRememberMeToken(Session session) {
String username = String.valueOf(session.getUserId());
String series = String.valueOf(session.getId());
LocalDateTime lastUsedAt =
Optional.ofNullable(session.getLastUsedAt()).orElseGet(session::getIssuedAt);
return new PersistentRememberMeToken(
username, series, session.getToken(), toDate(lastUsedAt));
}

}

情况与UserDetailsService大致相同,包装器会在PersistentRememberMeTokenSession之间进行转换 。唯一需要特别注意的是PersistentRememberMeToken中的日期字段。在会话中,我分离了两个日期字段(即已发布的lastUsedAt),后者在用户首次使用remember-me令牌的帮助下登录时获取第一个值。因此有可能它是空的,而且是什么时候使用publishedAt的值。

实现 RememberMeServices

在这一点上,我们重新使用PersistentTokenBasedRememberMeServices并为手头的任务进行自定义,它取决于UserDetailsServicePersistentTokenRepository,而这些已经被考虑到了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public class PersistentJwtTokenBasedRememberMeServices extends
PersistentTokenBasedRememberMeServices {

private static final Logger LOGGER =
LoggerFactory.getLogger(PersistentJwtTokenBasedRememberMeServices.class);

public static final int DEFAULT_TOKEN_LENGTH = 16;

public PersistentJwtTokenBasedRememberMeServices(
String key, UserDetailsService userDetailsService,
PersistentTokenRepository tokenRepository) {

super(key, userDetailsService, tokenRepository);
}

@Override
protected String[] decodeCookie(String cookieValue) throws InvalidCookieException {
try {
Claims claims = Jwts.parser()
.setSigningKey(getKey())
.parseClaimsJws(cookieValue)
.getBody();

return new String[] { claims.getId(), claims.getSubject() };
} catch (JwtException e) {
LOGGER.warn(e.getMessage());
throw new InvalidCookieException(e.getMessage());
}
}

@Override
protected String encodeCookie(String[] cookieTokens) {
Claims claims = Jwts.claims()
.setId(cookieTokens[0])
.setSubject(cookieTokens[1])
.setExpiration(new Date(currentTimeMillis() + getTokenValiditySeconds() * 1000L))
.setIssuedAt(new Date());

return Jwts.builder()
.setClaims(claims)
.signWith(HS512, getKey())
.compact();
}

@Override
protected String generateSeriesData() {
long seriesId = IdentityGenerator.generate();
return String.valueOf(seriesId);
}

@Override
protected String generateTokenData() {
return RandomUtil.ints(DEFAULT_TOKEN_LENGTH)
.mapToObj(i -> String.format("%04x", i))
.collect(Collectors.joining());
}

@Override
protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
return Optional.ofNullable((Boolean)request.getAttribute(REMEMBER_ME_ATTRIBUTE)).orElse(false);
}

}

这个特定的实现使用JWT令牌作为在cookies中存储记住我的令牌的物化形式。Spring Security的默认格式也可以很好,但JWT增加了一个额外的安全层。默认实现没有签名,每个请求最终都是数据库中的一个查询,用于检查remember-me令牌。

JWT防止这种情况,尽管解析它并验证其签名需要更多的CPU周期。

将所有这些组合在一起

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@Configuration
public class AuthSecurityConfiguration extends SecurityConfigurationSupport {

...

@Bean
public UserDetailsService userDetailsService(UserService userService) {
return new DelegatingUserService(userService);
}

@Bean
public PersistentTokenRepository persistentTokenRepository(SessionService sessionService) {
return new DelegatingPersistentTokenRepository(sessionService);
}

@Bean
public RememberMeAuthenticationFilter rememberMeAuthenticationFilter(
AuthenticationManager authenticationManager, RememberMeServices rememberMeServices,
AuthenticationSuccessHandler authenticationSuccessHandler) {

RememberMeAuthenticationFilter rememberMeAuthenticationFilter =
new ProceedingRememberMeAuthenticationFilter(authenticationManager, rememberMeServices);

rememberMeAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);

return rememberMeAuthenticationFilter;
}

@Bean
public RememberMeServices rememberMeServices(
UserDetailsService userDetailsService, PersistentTokenRepository persistentTokenRepository) {

String secretKey = getRememberMeTokenSecretKey().orElseThrow(IllegalStateException::new);

return new PersistentJwtTokenBasedRememberMeServices(
secretKey, userDetailsService, persistentTokenRepository);
}

...

@Override
protected void customizeRememberMe(HttpSecurity http) throws Exception {
UserDetailsService userDetailsService = lookup("userDetailsService");
PersistentTokenRepository persistentTokenRepository = lookup("persistentTokenRepository");
AbstractRememberMeServices rememberMeServices = lookup("rememberMeServices");
RememberMeAuthenticationFilter rememberMeAuthenticationFilter =
lookup("rememberMeAuthenticationFilter");

http.rememberMe()
.userDetailsService(userDetailsService)
.tokenRepository(persistentTokenRepository)
.rememberMeServices(rememberMeServices)
.key(rememberMeServices.getKey())
.and()
.logout()
.logoutUrl(LOGOUT_ENDPOINT)
.and()
.addFilterAt(rememberMeAuthenticationFilter, RememberMeAuthenticationFilter.class);
}

...

}

令人感到神奇的结果在最后部分是显而易见的。基本上,这是关于使用Spring Security注册组件,并启用记住我的服务。有趣的是,我们需要一个在AbstractRememberMeServices 内部使用的键(一个字符串)。 AbstractRememberMeServices 也是此设置中的默认注销处理程序,并在注销时将数据库中的令牌标记为已删除。

陷阱

在POST请求的正文中接收用户凭据和remember-me标志作为JSON数据

默认情况下, UsernamePasswordAuthenticationFilter会将凭据作为POST请求的HTTP请求参数,但是我们希望发送JSON文档。进一步下去, AbstractRememberMeServices还会将remember-me标志的存在检查为请求参数。为了解决这个问题, LoginFilter 将remember-me标志设置为请求属性,并将决定委托给 PersistentTokenBasedRememberMeServices, 如果记住我的身份验证需要启动或不启动。

使用RememberMeServices处理登录成功

RememberMeAuthenticationFilter不会继续进入过滤器链中的下一个过滤器,但如果设置了AuthenticationSuccessHandler,它将停止其执行 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class ProceedingRememberMeAuthenticationFilter extends RememberMeAuthenticationFilter {

private static final Logger LOGGER =
LoggerFactory.getLogger(ProceedingRememberMeAuthenticationFilter.class);

private AuthenticationSuccessHandler successHandler;

public ProceedingRememberMeAuthenticationFilter(
AuthenticationManager authenticationManager, RememberMeServices rememberMeServices) {

super(authenticationManager, rememberMeServices);
}

@Override
public void setAuthenticationSuccessHandler(AuthenticationSuccessHandler successHandler) {
this.successHandler = successHandler;
}

@Override
protected void onSuccessfulAuthentication(
HttpServletRequest request, HttpServletResponse response, Authentication authResult) {

if (successHandler == null) {
return;
}

try {
successHandler.onAuthenticationSuccess(request, response, authResult);
} catch (Exception e) {
LOGGER.error(e.getMessage(), e);
}
}

}

ProceedingRememberMeAuthenticationFilter 是原始过滤器的自定义版本,当认证成功时,该过滤器不会停止。

构建用户管理微服务器(七):将以上组合在一起

从绝对零开始,用户管理应用程序的构建块已被开发出来。在最后一篇中,我想向您展示如何组装这些部分,以使应用程序正常工作。一些功能仍然缺少,我仍然在第一个版本上工作,使其功能完整,但现在基本上是可以使用的。

创建一个独立的可执行模块

今天建立基于Spring的应用程序最简单的方法是去Spring Boot。毫无疑问。由于一个原因,它正在获得大量采用,这就是使您的生活比使用裸弹更容易。之前我曾在各种情况下与Spring合作过,并在Servlet容器和完全成熟的Java EE应用服务器之上构建了应用程序,但能够将可执行软件包中的所有内容都打包成开发成本。

总而言之,第一步是为应用程序创建一个新的模块,它是springuni-auth-boot

Maven配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<?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>

<parent>
<artifactId>springuni-particles</artifactId>
<groupId>com.springuni</groupId>
<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>springuni-auth-boot</artifactId>

<name>SpringUni Auth User Boot</name>
<description>Example module for assembling user authentication modules</description>

<dependencies>
<dependency>
<groupId>com.springuni</groupId>
<artifactId>springuni-auth-rest</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

<dependency>
<groupId>com.springuni</groupId>
<artifactId>springuni-auth-user-jpa</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<!-- https://github.com/spring-projects/spring-boot/issues/6254#issuecomment-229600830 -->
<configuration>
<classifier>exec</classifier>
</configuration>
</plugin>
</plugins>
</build>

</project>

模块springuni-auth-rest提供用于用户管理的REST端点,它还将springuni-auth模型作为传递依赖。springuni-auth-user-jpa负责持久化的用户数据,并且将来可以替换其他持久性机制。

第三个依赖是MySQL连接器,也可以根据需要进行替换。

Spring Boot的角度来说,以下两个依赖关系是重要的:spring-boot-starter-webspring-boot-starter-tomcat。为了能够创建一个Web应用程序,我们需要它们。

应用程序的入口点

在没有Spring Boot的情况下执行此步骤将会非常费力(必须在web.xml中注册上下文监听器并为应用程序设置容器)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import com.springuni.auth.domain.model.AuthJpaRepositoryConfiguration;
import com.springuni.auth.domain.service.AuthServiceConfiguration;
import com.springuni.auth.rest.AuthRestConfiguration;
import com.springuni.auth.security.AuthSecurityConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

@SpringBootApplication
@Configuration
@Import({
AuthJpaRepositoryConfiguration.class,
AuthServiceConfiguration.class,
AuthRestConfiguration.class,
AuthSecurityConfiguration.class
})
public class Application {

public static void main(String[] args) throws Exception {
SpringApplication.run(Application.class, args);
}

}

这几乎是一个虚拟模块,所有重要的举措都归结为不得不导入一些基于Java的Spring配置类。

启动

Spring Boot附带了一个非常有用的Maven插件,可以将整个项目重新打包成一个可执行的überJAR。它也能够在本地启动项目。

1
mvn -pl springuni-auth-boot spring-boot:run

测试驱动用户管理应用程序

第一部分定义了所有可用的REST端点,现在已经有一些现实世界的用例来测试它们。

注册新用户

1
2
3
4
5
6
7
8
9
curl -H 'Content-Type: application/json' -XPOST http://localhost:5000/users -d \
'{
"screenName":"test2",
"contactData": {
"email": "test2@springuni.com"
},
"password": "test"
}'
HTTP/1.1 200

首次登录尝试

此时首次登录尝试不可避免地会失败,因为用户帐号尚未确认

1
2
3
4
5
6
curl -D- -XPOST http://localhost:5000/auth/login -d '{ "username":"test5", "password": "test" }' 
HTTP/1.1 401
{
"statusCode" : 401,
"reasonPhrase" : "Unauthorized"
}

确认帐号

一般情况下,最终用户将收到一封电子邮件中的确认链接,点击该链接会启动以下请求。

1
2
curl -D- -XPUT http://localhost:5000/users/620366184447377/77fc990b-210c-4132-ac93-ec50522ba06f
HTTP/1.1 200

第二次登录尝试

1
2
3
curl -D- -XPOST http://localhost:5000/auth/login -d '{ "username":"test5", "password": "test" }'
HTTP/1.1 200
X-Set-Authorization-Bearer: eyJhbGciOiJIUzUxMiJ9.eyJqdGkiOiI2MjA1OTkwNjIwMTQ4ODEiLCJzdWIiOiI2MjAzNjYxODQ0NDczNzciLCJleHAiOjE0OTcxMDQ3OTAsImlhdCI6MTQ5NzAxODM5MCwiYXV0aG9yaXRpZXMiOiIifQ.U-GfabsdYidg-Y9eSp2lyyh7DxxaI-zaTOZISlCf3RjKQUTmu0-vm6DH80xYWE69SmoGgm07qiYM32JBd9d5oQ

用户的电子邮件地址确认后,即可登录。

下一步是什么?

正如我之前提到的,这个应用程序有很多工作要做。其中还有一些基本功能,也没有UI。您可以按照以下步骤进行: https://github.com/springuni/springuni-particles/projects/1

您的支持将鼓励我继续创作!