翻译自: https://springuni.com/user-management-microservice-part-1/
在《构建用户管理微服务》的第一部分中,我们会定义应用的需求,初始的领域模型和供前端使用的 REST API。 我们首先定义 用户注册 和 管理用户 的故事。
在设计新系统时,值得考虑的是用户希望实现的结果。 下面您可以找到用户注册系统应具有的基本功能的列表。
我们来看看系统将要支持什么样的工作流程。首先,人们应该能够注册和登录,这些是相当明显的功能。
但是,处理确认令牌时需要谨慎。 由于它们可用于执行特权操作,因此我们使用一次性随机令牌来处理密码重置和电子邮件确认。
当一个新的令牌由用户生成,无论什么原因,所有以前的都是无效的。 当有人记住他们的密码时,以前发出的和有效的密码重置令牌必须过期。
用户故事通常不会定义非功能性要求,例如安全性,开发原理,技术栈等。所以我们在这里单独列出。
对于第一轮实现中,我们只关注三个实体,即用户,确认令牌和用户事件。
访问下面的大多数 API 都需要认证,否则返回一个 UNAUTHORIZED 状态码。 如果用户尝试查询属于某个其他用户的实体,则他们还会返回客户端错误(FORBIDDEN),除非他具有管理权限。 如果指定的实体不存在,则调用的端点返回 NOT_FOUND。
创建会话(POST /sessions)和注册新用户(POST / users)是公开的,它们不需要身份验证。
1 GET /session/{session_id}
如果没有给定 ID 的会话或者会话已经过期,则返回给定会话的详细信息或 NOT_FOUND。
1 POST /session
创建新会话,前提是指定的电子邮件和密码对属于一个有效的用户。
1 DELETE /session/{session_id}
删除给定的会话(注销)。
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 ,它有以下类型:
###
服务包含了能够操作一组领域模型的类的业务逻辑。在本应用中, 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 的支撑模型和一些测试用例。
仅看到用户存储库,也许你就能想到在对它添加基于 JPA 的实现时会遇到什么困难。
public interface UserRepository{ void delete(Long userId)throwsNoSuchUserException; 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 的部分摘录。
<entityclass="com.springuni.auth.domain.model.user.User"cacheable="true"metadata-complete="true"> <tablename="user_"/> <named-queryname="findByIdQuery"> <query> <![CDATA[ select u from User u where u.id = :userId and u.deleted = false ]]> </query> </named-query> <named-queryname="findByEmailQuery"> <query> <![CDATA[ select u from User u where u.contactData.email = :email and u.deleted = false ]]> </query> </named-query> <named-queryname="findByScreenNameQuery"> <query> <![CDATA[ select u from User u where u.screenName = :screenName and u.deleted = false ]]> </query> </named-query> <entity-listeners> <entity-listenerclass="com.springuni.commons.jpa.IdentityGeneratorListener"/> </entity-listeners> <attributes> <idname="id"/> <basicname="timezone"> <enumerated>STRING</enumerated> </basic> <basicname="locale"/> <basicname="confirmed"/> <basicname="locked"/> <basicname="deleted"/> <one-to-manyname="confirmationTokens"fetch="LAZY"mapped-by="owner"orphan-removal="true"> <cascade> <cascade-persist/> <cascade-merge/> </cascade> </one-to-many> <element-collectionname="authorities"> <collection-tablename="authority"> <join-columnname="user_id"/> </collection-table> </element-collection> <embeddedname="auditData"/> <embeddedname="contactData"/> <embeddedname="password"/> <!-- Do not map email directly through its getter/setter --> <transientname="email"/> </attributes> </entity>
域驱动设计是一种持久化无关的方法,因此坚持设计一个没有具体目标数据结构的模型可能很有挑战性。当然, 它也存在优势, 即可对现实世界中的问题直接进行建模, 而不存在只能以某种方式使用某种技术栈之类的副作用。
public class Userimplements Entity<Long,User>{ private Long id; private String screenName; ... private Set<String> authorities = new LinkedHashSet<>(); }
一般来说,一组简单的字符串或枚举值就能对用户的权限(或特权)进行建模了。
使用像 MongoDB 这样的文档数据库能够轻松自然地维护这个模型,如下所示。(顺便一提, 我还计划在本系列的后续内容中添加一个基于 Mongo 的存储库实现)
{ "id":123456789, "screenName":"test", ... "authorities":[ "USER", "ADMIN" ] }
然而, 在关系模型中, 权限的概念必须作为用户的子关系进行处理。但是在现实世界中, 这仅仅只是一套权限规则。我们需要如何弥合这样的差距呢?
在 JPA 2.0 中可以引入 ElementCollection 来进行操作,它的用法类似于 OneToMany。在这种情况下, 已经配置好的 JPA 提供的程序 (Hibernate) 将自动生成必要的子关系。
alter table authority add constraint FKoia3663r5o44m6knaplucgsxn foreign key (user id) references user
##
我一直在讨论的 springuni-auth-user-jpa 包含了一个完整的基于 JPA 的 UserRepository 实现。其目标是, 每个模块都应该只拥有那些对它们的操作来说绝对必要的依赖关系,而这些关系只需要依赖 JPA API 便可以实现。
springuni-commons-jpa 是一个支撑模块, 它能够使用预先配置好的 HikariCP 和 Hibernate 的组合作为实体管理器, 而不必关心其他细节。 它的特色是 AbstractJpaConfiguration, 类似于 Spring Boot 的 HibernateJpaAutoConfiguration。
然而我没有使用后者的原因是 Spring Boot 的自动配置需要一定的初始化。因为谷歌应用引擎标准环境是我的目标平台之一,因此能否快速地启动是至关重要的。
虽然有人可能会说, 对于存储库没必要进行过多的测试, 尤其是在使用 Spring Data 的 存储库接口的时候。但是我认为测试代码可以避免运行时存在的一些问题,例如错误的实体映射或错误的 JPQL 查询。
@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, 全称是 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 更是它们最好的选择。
我经手的大多数项目,都需要对控制器层面正确地进行 Spring MVC 的配置。随着近几年单页应用程序的广泛应用,越来越不需要在 Spring mvc 应用程序中配置和开发视图层 (使用 jsp 或模板引擎)。
现在,创建完整的 REST 后端的消耗并生成了 JSON 是相当典型的, 然后通过 SPA 或移动应用程序直接使用。基于以上所讲, 我收集了 Spring MVC 常见配置,这能实现对后端的开发。
####
该代码在 github, 有一个新的模块 springuni-commons-rest , 它包含实现 REST 控制器所需的所有常用的实用程序。 专有的 RestConfiguration 可以通过模块进行扩展, 它们可以进一步细化默认配置。
####
正常的 web 应用程序向最终用户提供易于使用的错误页。但是,对于一个纯粹的 JSON-based REST 后端, 这不是一个需求, 因为它的客户是 SPA 或移动应用。
因此, 最好的方法是用一个明确定义的 JSON 结构 (RestErrorResponse) 前端可以很容易地响应错误, 这是非常可取的。
@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 staticRestErrorResponseof(HttpStatus status){ return of(status, null); } public staticRestErrorResponseof(HttpStatus status, Exception ex){ return new RestErrorResponse(status, ex.getMessage()); } }
以上代码将返回 HTTP 错误代码,包括 HTTP 错误的文本表示和对客户端的详细信息,RestErrorHandler 负责生成针对应用程序特定异常的正确响应。
@RestControllerAdvice public class RestErrorHandlerextends ResponseEntityExceptionHandler{ @ExceptionHandler(ApplicationException.class) publicResponseEntity<Object>handleApplicationException(finalApplicationException ex){ return handleExceptionInternal(ex, BAD_REQUEST); } @ExceptionHandler(EntityAlreadyExistsException.class) publicResponseEntity<Object>handleEntityExistsException(finalEntityAlreadyExistsException ex){ return handleExceptionInternal(ex, BAD_REQUEST); } @ExceptionHandler(EntityConflictsException.class) publicResponseEntity<Object>handleEntityConflictsException(finalEntityConflictsException ex){ return handleExceptionInternal(ex, CONFLICT); } @ExceptionHandler(EntityNotFoundException.class) publicResponseEntity<Object>handleEntityNotFoundException(finalEntityNotFoundException ex){ return handleExceptionInternal(ex, NOT_FOUND); } @ExceptionHandler(RuntimeException.class) publicResponseEntity<Object>handleRuntimeException(finalRuntimeException ex){ return handleExceptionInternal(ex, INTERNAL_SERVER_ERROR); } @ExceptionHandler(UnsupportedOperationException.class) publicResponseEntity<Object>handleUnsupportedOperationException( final UnsupportedOperationException ex) { return handleExceptionInternal(ex, NOT_IMPLEMENTED); } @Override protectedResponseEntity<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); } privateResponseEntity<Object>handleExceptionInternal(Exception ex, HttpStatus status){ return handleExceptionInternal(ex, null, null, status, null); } }
为了处理未映射的请求, 首先我们需要定义一个默认处理程序, 然后用 RequestMappingHandlerMapping 来设置它。
@Controller public class DefaultController{ @RequestMapping publicResponseEntity<RestErrorResponse>handleUnmappedRequest(finalHttpServletRequest request){ return ResponseEntity.status(NOT_FOUND).body(RestErrorResponse.of(NOT_FOUND)); } }
经过这样的设置,RestConfiguration 在一定程度上扩展了 WebMvcConfigurationSupport, 这提供了用于调用 MVC 基础结构的自定义钩子。
@EnableWebMvc @Configuration public class RestConfigurationextends WebMvcConfigurationSupport{ ... protectedObjectcreateDefaultHandler(){ return new DefaultController(); } ... @Override protectedRequestMappingHandlerMappingcreateRequestMappingHandlerMapping(){ RequestMappingHandlerMapping handlerMapping = super.createRequestMappingHandlerMapping(); Object defaultHandler = createDefaultHandler(); handlerMapping.setDefaultHandler(defaultHandler); return handlerMapping; } }
在第一部分中,我定义了一堆用于和用户管理服务进行交互的 REST 风格的端点。而实际上, 他们与用 Spring MVC 创建 REST 风格的端点相比,并没有什么特别的。但是,我有一些最近意识到的小细节想要补充。
@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}") publicUserDtogetUser(@PathVariablelonguserId)throwsApplicationException{ User user = userService.getUser(userId); return modelMapper.map(user, UserDto.class); } ... @PostMapping public void createUser(@RequestBody @Validated UserDto userDto)throwsApplicationException{ User user = modelMapper.map(userDto, User.class); userService.signup(user, userDto.getPassword()); } ... }
虽然 ModelMapper 在查找匹配属性时是相当自动的, 但在某些情况下需要进行手动调整。比如说,用户的密码。这是我们绝对不想暴露的内容。
通过定义自定义属性的映射, 可以很容易地避免这一点。
import org.modelmapper.PropertyMap; public class UserMapextends PropertyMap<User,UserDto>{ @Override protected void configure(){ skip().setPassword(null); } }
当 ModelMapper 的实例被创建时, 我们可以自定义属性映射、转换器、目标值提供程序和一些其他的内容
@Configuration @EnableWebMvc public class AuthRestConfigurationextends RestConfiguration{ ... @Bean publicModelMappermodelMapper(){ ModelMapper modelMapper = new ModelMapper(); customizeModelMapper(modelMapper); modelMapper.validate(); return modelMapper; } @Override protected void customizeModelMapper(ModelMapper modelMapper){ modelMapper.addMappings(new UserMap()); modelMapper.addMappings(new UserDtoMap()); } ... }
自 MockMvc 在 Spring 3.2 上推出以来, 使用 Spring mvc 测试 REST 控制器变得非常容易。
@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得到配置的时候,测试控制器显得更为合适。
我们已经建立了业务逻辑、数据访问层和前端控制器, 但是忽略了对身份进行验证。随着 Spring Security 成为实际意义上的标准, 将会在在构建 Java web 应用程序的身份验证和授权时使用到它。在构建用户管理微服务系列的第五部分中, 将带您探索 Spring Security 是如何同 JWT 令牌一起使用的。
诸如 Facebook,Github,Twitter 等大型网站都在使用基于 Token 的身份验证。相比传统的身份验证方法,Token 的扩展性更强,也更安全,非常适合用在 Web 应用或者移动应用上。我们将 Token 翻译成令牌,也就意味着,你能依靠这个令牌去通过一些关卡,来实现验证。实施 Token 验证的方法很多,JWT 就是相关标准方法中的一种。
JSON Web TOKEN(JWT)是一个开放的标准 (RFC 7519), 它定义了一种简洁且独立的方式, 让在各方之间的 JSON 对象安全地传输信息。而经过数字签名的信息也可以被验证和信任。
JWT 的应用越来越广泛, 而因为它是轻量级的,你也不需要有一个用来验证令牌的认证服务器。与 OAuth 相比, 这有利有弊。如果 JWT 令牌被截获,它可以用来模拟用户, 也无法防范使用这个被截获的令牌继续进行身份验证。
真正的 JWT 令牌看起来像下面这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJzdWIiOiJsYXN6bG9fQVRfc3ByaW5ndW5pX0RPVF9jb20iLCJuYW1lIjoiTMOhc3psw7MgQ3NvbnRvcyIsImFkbWluIjp0cnVlfQ. XEfFHwFGK0daC80EFZBB5ki2CwrOb7clGRGlzchAD84
JWT 令牌的第一部分是令牌的 header , 用于标识令牌的类型和对令牌进行签名的算法。
{ "alg": "HS256", "typ": "JWT" }
第二部分是 JWT 令牌的 payload 或它的声明。这两者是有区别的。Payload 可以是任意一组数据, 它甚至可以是明文或其他 (嵌入 JWT)的数据。而声明则是一组标准的字段。
{ "sub": "laszlo_AT_springuni_DOT_com", "name": "László Csontos", "admin": true }
第三部分是由算法产生的、由 JWT 的 header 表示的签名。
有相当多的第三方库可用于操作 JWT 令牌。而在本文中, 我使用了 JJWT。
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.7.0</version> </dependency>
采用 JwtTokenService 使 JWT 令牌从身份验证实例中创建, 并将 JWTs 解析回身份验证实例。
public class JwtTokenServiceImplimplements JwtTokenService{ private static final String AUTHORITIES = "authorities"; static final String SECRET = "ThisIsASecret"; @Override publicStringcreateJwtToken(Authentication authentication,intminutes){ 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 publicAuthenticationparseJwtToken(String jwtToken)throwsAuthenticationException{ 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 令牌并将其发送回客户端。这些是通过以下请求完成的:
POST /session { "username": "laszlo_AT_sprimguni_DOT_com", "password": "secret" }
成功登录后, 客户端会像往常一样向其他端点发送后续请求, 并在授权的 header 中提供本地缓存的 JWT 令牌。
Authorization: Bearer <JWT token>
正如上面的步骤所讲, LoginFilter 开始进行登录过程。而Spring Security 的内置 UsernamePasswordAuthenticationFilter 被延长, 来让这种情况发生。这两者之间的唯一的区别是, UsernamePasswordAuthenticationFilter 使用表单参数来捕获用户名和密码, 相比之下, LoginFilter 将它们视做 JSON 对象。
import org.springframework.security.authentication.*; import org.springframework.security.core.*; import org.springframework.security.web.authentication.*; public class LoginFilterextends UsernamePasswordAuthenticationFilter{ private static final String LOGIN_REQUEST_ATTRIBUTE = "login_request"; ... @Override publicAuthenticationattemptAuthentication( 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 protectedStringobtainUsername(HttpServletRequest request){ return toLoginRequest(request).getUsername(); } @Override protectedStringobtainPassword(HttpServletRequest request){ return toLoginRequest(request).getPassword(); } privateLoginRequesttoLoginRequest(HttpServletRequest request){ return (LoginRequest)request.getAttribute(LOGIN_REQUEST_ATTRIBUTE); } }
处理登陆过程的结果将在之后分派给一个 AuthenticationSuccessHandler 和 AuthenticationFailureHandler。
两者都相当简单。DefaultAuthenticationSuccessHandler 调用 JwtTokenService 发出一个新的令牌, 然后将其发送回客户端。
public class DefaultAuthenticationSuccessHandlerimplements 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 错误消息。
public class DefaultAuthenticationFailureHandlerimplements 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); } protectedHttpStatustranslateAuthenticationException(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 令牌, 并在前面讨论的后续请求中发送反回。
对于每个请求, JwtAuthenticationFilter 通过 JwtTokenService 验证接收到的 JWT令牌。
public class JwtAuthenticationFilterextends 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(); } } privateAuthenticationgetAuthentication(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 和经过身份验证的用户的权限, 因此无需与数据库联系以再次获取此信息。
public class JwtAuthenticationTokenextends AbstractAuthenticationToken{ private static final String AUTHORITIES = "authorities"; private final long userId; private JwtAuthenticationToken(longuserId, Collection<? extends GrantedAuthority> authorities){ super(authorities); this.userId = userId; } @Override publicObjectgetCredentials(){ return null; } @Override publicLonggetPrincipal(){ return userId; } /** * Factory method for creating a new {@code{@linkJwtAuthenticationToken}}. *@paramclaims JWT claims *@returna JwtAuthenticationToken */ public staticJwtAuthenticationTokenof(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; } }
在这之后, 它由安全框架决定是否允许或拒绝请求。
虽然这不是这篇文章的主题, 但我想花一分钟的时间来谈谈。如果我不得不在一个 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 是不实际的,因为它们是独立的,没有办法撤销它们。如果令牌被盗,所有赌注都会关闭。因此,我想添加经典的 remember-me 风格认证与持久令牌。记住,我的令牌存储在Cookie中作为 JWT 作为第一道防线,但是它们也保留在数据库中,并且跟踪其生命周期。
这次我想从演示运行中的用户管理应用程序的工作原理开始,然后再深入细节。
基本上,用户使用用户名/密码对进行身份验证会发生什么,他们可能会表示他们希望应用程序记住他们(持续会话)的意图。大多数时候,UI上还有一个复选框来实现。由于应用程序还没有开发UI,我们用 cURL 做一切 。
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} 来演示正常的请求。
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认证时,会发生这种情况。
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尝试执行一个正常的请求。
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上的注销按钮甚至不会被提供,后台也不会知道如何注销。
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。
在第一篇文章中,我决定使用DDD开发模型,因此它不能依赖于任何框架特定的类。实际上,它甚至不依赖于任何第三方框架或图书馆。大多数教程通常直接实现 UserDetailsService ,并且业务逻辑和用于构建应用程序的框架之间没有额外的层。
UserServices 在第二部分很久以前被添加到该项目中,因此我们的任务非常简单,因为现在我们需要的是一个框架特定的组件,它将 UserDetailsService 的职责委托给现有的逻辑。
public class DelegatingUserServiceimplements UserDetailsService{ private final UserService userService; public DelegatingUserService(UserService userService){ this.userService = userService; } @Override publicUserDetailsloadUserByUsername(String username)throwsUsernameNotFoundException{ Long userId = Long.valueOf(username); UsernameNotFoundException usernameNotFoundException = new UsernameNotFoundException(username); return userService.findUser(userId) .map(DelegatingUser::new) .orElseThrow(() -> usernameNotFoundException); } }
只是围绕 UserService的 一个简单的包装器,最终将返回的 User 模型对象转换为框架特定的 UserDetails 实例。除此之外,在这个项目中,我们不直接使用用户的登录名(电子邮件地址或屏幕名称)。相反,他们的用户的身份证遍及各地。
幸运的是,我们在添加适当的 PersistentTokenRepository 实现方面同样容易,因为域模型已经包含 SessionService 和 Session 。
public class DelegatingPersistentTokenRepositoryimplements 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 publicPersistentRememberMeTokengetTokenForSeries(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); } privatePersistentRememberMeTokentoPersistentRememberMeToken(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 大致相同,包装器会在 PersistentRememberMeToken 和 Session 之间进行转换 。唯一需要特别注意的是 PersistentRememberMeToken中 的日期字段。在会话中,我分离了两个日期字段( 即已发布的 和 lastUsedAt ),后者在用户首次使用remember-me令牌的帮助下登录时获取第一个值。因此有可能它是空的,而且是什么时候使用 publishedAt 的值。
在这一点上,我们重新使用 PersistentTokenBasedRememberMeServices 并为手头的任务进行自定义,它取决于 UserDetailsService 和 PersistentTokenRepository, 而这些已经被考虑到了。
public class PersistentJwtTokenBasedRememberMeServicesextends 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 protectedStringencodeCookie(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 protectedStringgenerateSeriesData(){ long seriesId = IdentityGenerator.generate(); return String.valueOf(seriesId); } @Override protectedStringgenerateTokenData(){ 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周期。
@Configuration public class AuthSecurityConfigurationextends SecurityConfigurationSupport{ ... @Bean publicUserDetailsServiceuserDetailsService(UserService userService){ return new DelegatingUserService(userService); } @Bean publicPersistentTokenRepositorypersistentTokenRepository(SessionService sessionService){ return new DelegatingPersistentTokenRepository(sessionService); } @Bean publicRememberMeAuthenticationFilterrememberMeAuthenticationFilter( AuthenticationManager authenticationManager, RememberMeServices rememberMeServices, AuthenticationSuccessHandler authenticationSuccessHandler) { RememberMeAuthenticationFilter rememberMeAuthenticationFilter = new ProceedingRememberMeAuthenticationFilter(authenticationManager, rememberMeServices); rememberMeAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler); return rememberMeAuthenticationFilter; } @Bean publicRememberMeServicesrememberMeServices( UserDetailsService userDetailsService, PersistentTokenRepository persistentTokenRepository) { String secretKey = getRememberMeTokenSecretKey().orElseThrow(IllegalStateException::new); return new PersistentJwtTokenBasedRememberMeServices( secretKey, userDetailsService, persistentTokenRepository); } ... @Override protected void customizeRememberMe(HttpSecurity http)throwsException{ 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 也是此设置中的默认注销处理程序,并在注销时将数据库中的令牌标记为已删除。
默认情况下, UsernamePasswordAuthenticationFilter 会将凭据作为POST请求的HTTP请求参数,但是我们希望发送JSON文档。进一步下去, AbstractRememberMeServices 还会将remember-me标志的存在检查为请求参数。为了解决这个问题, LoginFilter 将remember-me标志设置为请求属性,并将决定委托给 PersistentTokenBasedRememberMeServices, 如果记住我的身份验证需要启动或不启动。
RememberMeAuthenticationFilter 不会继续进入过滤器链中的下一个过滤器,但如果设置了 AuthenticationSuccessHandler ,它将停止其执行 。
public class ProceedingRememberMeAuthenticationFilterextends 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 。
<?xml version="1.0" encoding="UTF-8"?> <projectxmlns="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-web 和 spring-boot-starter-tomcat 。为了能够创建一个Web应用程序,我们需要它们。
在没有Spring Boot的情况下执行此步骤将会非常费力(必须在web.xml中注册上下文监听器并为应用程序设置容器)。
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)throwsException{ SpringApplication.run(Application.class, args); } }
这几乎是一个虚拟模块,所有重要的举措都归结为不得不导入一些基于Java的Spring配置类。
Spring Boot附带了一个非常有用的Maven插件,可以将整个项目重新打包成一个可执行的überJAR。它也能够在本地启动项目。
mvn -pl springuni-auth-boot spring-boot:run
第一部分定义了所有可用的REST端点,现在已经有一些现实世界的用例来测试它们。
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
此时首次登录尝试不可避免地会失败,因为用户帐号尚未确认
curl -D- -XPOST http://localhost:5000/auth/login -d '{ "username":"test5", "password": "test" }' HTTP/1.1 401 { "statusCode" : 401, "reasonPhrase" : "Unauthorized" }
一般情况下,最终用户将收到一封电子邮件中的确认链接,点击该链接会启动以下请求。
curl -D- -XPUT http://localhost:5000/users/620366184447377/77fc990b-210c-4132-ac93-ec50522ba06f HTTP/1.1 200
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