構建用戶管理微服務
翻譯自:https://springuni.com
構建用戶管理微服務(一):定義領域模型和 REST API
在《構建用戶管理微服務》的第一部分中,我們會定義應用的需求,初始的領域模型和供前端使用的 REST API。 我們首先定義用戶注冊和管理用戶的故事。
用戶故事
在設計新系統時,值得考慮的是用戶希望實現的結果。 下面您可以找到用戶注冊系統應具有的基本功能的列表。
- 作為用戶,我想注冊,以便我可以訪問需要注冊的內容
- 作為用戶,我想在注冊后確認我的電子郵件地址
- 作為用戶,我想登錄并注銷
- 作為用戶,我想更改我的密碼
- 作為用戶,我想更改我的電子郵件地址
- 作為用戶,我想要重置我的密碼,以便我忘記密碼后可以登錄
- 作為用戶,我想更新我的個人資料,以便我可以提供我正確的聯絡資料
- 作為用戶,我想關閉我的帳戶,以便我可以關閉我與我注冊的服務的關系
- 作為管理員,我想手動管理(創建/刪除/更新)用戶,以便工作人員不必重新進行注冊過程
- 作為管理員,我想手動創建用戶,這樣工作人員就不用再過注冊過程了
- 作為管理員,我想列出所有用戶,即使是那些曾經關閉帳戶的用戶
- 作為管理員,我希望能夠看到用戶的活動(登錄,注銷,密碼重置,確認,個人資料更新),以便我可以遵守外部審計要求
工作流程
我們來看看系統將要支持什么樣的工作流程。首先,人們應該能夠注冊和登錄,這些是相當明顯的功能。
但是,處理確認令牌時需要謹慎。 由于它們可用于執行特權操作,因此我們使用一次性隨機令牌來處理密碼重置和電子郵件確認。
當一個新的令牌由用戶生成,無論什么原因,所有以前的都是無效的。 當有人記住他們的密碼時,以前發出的和有效的密碼重置令牌必須過期。
非功能性需求
用戶故事通常不會定義非功能性要求,例如安全性,開發原理,技術棧等。所以我們在這里單獨列出。
- 領域模型是使用域驅動的設計原則在純 Java 中實現的,并且獨立于要使用的底層技術棧
- 當用戶登錄時,將為他們生成一個 JWT 令牌,有效期是 24 小時。在后續請求中包含此令牌,用戶可以執行需要身份驗證的操作
- 密碼重置令牌有效期為 10 分鐘,電子郵件地址確認令牌為一天
- 密碼用加密算法(Bcrypt)加密,并且每用戶加鹽
- 提供了 RESTful API,用于與用戶注冊服務進行交互
- 應用程序將具有模塊化設計,以便能夠為各種場景提供單獨的部署工件(例如,針對 Google App Engine 的 2.5 servlet 兼容 WAR 和其他用例的基于 Spring Boot 的自包含可執行 JAR)
- 實體標識符以數據庫無關的方式生成,也就是說,不會使用數據庫特定機制(AUTO_INCREMENT 或序列)來獲取下一個 ID 值。解決方案將類似于 Instagram genetes ID。
領域模型
對于第一輪實現中,我們只關注三個實體,即用戶,確認令牌和用戶事件。
Rest api
訪問下面的大多數 API 都需要認證,否則返回一個 UNAUTHORIZED 狀態碼。 如果用戶嘗試查詢屬于某個其他用戶的實體,則他們還會返回客戶端錯誤(FORBIDDEN),除非他具有管理權限。 如果指定的實體不存在,則調用的端點返回 NOT_FOUND。
創建會話(POST /sessions)和注冊新用戶(POST / users)是公開的,它們不需要身份驗證。
Session management
GET /session/{session_id}
如果沒有給定 ID 的會話或者會話已經過期,則返回給定會話的詳細信息或 NOT_FOUND。
POST /session
創建新會話,前提是指定的電子郵件和密碼對屬于一個有效的用戶。
DELETE /session/{session_id}
刪除給定的會話(注銷)
User management
GET /users/{user_id}
根據一個指定的 ID 查找用戶。
GET /users
列舉系統中所有的用戶
POST /users
注冊一個新的用戶
DELETE /users/{user_id}
刪除指定的用戶
PUT /users/{user_id}
更新指定用戶的個人信息
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 的實現時會遇到什么困難。
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 的部分摘錄。
<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>
域驅動設計是一種持久化無關的方法,因此堅持設計一個沒有具體目標數據結構的模型可能很有挑戰性。當然, 它也存在優勢, 即可對現實世界中的問題直接進行建模, 而不存在只能以某種方式使用某種技術棧之類的副作用。
public class User implements 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 (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 查詢。
@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) 前端可以很容易地響應錯誤, 這是非常可取的。
@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 負責生成針對應用程序特定異常的正確響應。
@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 來設置它。
@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 基礎結構的自定義鉤子。
@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。
- 手動注冊控制器來加快應用程序初始化的速度。正如我在第三部分中提到的, 這個應用程序將托管在谷歌應用引擎標準環境中,而開啟一個新的實例是至關重要的。
@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 在查找匹配屬性時是相當自動的, 但在某些情況下需要進行手動調整。比如說,用戶的密碼。這是我們絕對不想暴露的內容。
通過定義自定義屬性的映射, 可以很容易地避免這一點。
import org.modelmapper.PropertyMap;
public class UserMap extends PropertyMap<User, UserDto> {
@Override
protected void configure() {
skip().setPassword(null);
}
}
當 ModelMapper 的實例被創建時, 我們可以自定義屬性映射、轉換器、目標值提供程序和一些其他的內容
@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 控制器變得非常容易。
@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 令牌看起來像下面這樣:
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 令牌
有相當多的第三方庫可用于操作 JWT 令牌。而在本文中, 我使用了 JJWT。
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.7.0</version>
</dependency>
采用 JwtTokenService 使 JWT 令牌從身份驗證實例中創建, 并將 JWTs 解析回身份驗證實例。
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 令牌并將其發送回客戶端。這些是通過以下請求完成的:
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 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 發出一個新的令牌, 然后將其發送回客戶端。
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 錯誤消息。
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 令牌, 并在前面討論的后續請求中發送反回。
對于每個請求, JwtAuthenticationFilter 通過 JwtTokenService 驗證接收到的 JWT令牌。
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 和經過身份驗證的用戶的權限, 因此無需與數據庫聯系以再次獲取此信息。
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做一切 。
登錄
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。
實現 UserDetailsService
在第一篇文章中,我決定使用DDD開發模型,因此它不能依賴于任何框架特定的類。實際上,它甚至不依賴于任何第三方框架或圖書館。大多數教程通常直接實現UserDetailsService,并且業務邏輯和用于構建應用程序的框架之間沒有額外的層。
UserServices在第二部分很久以前被添加到該項目中,因此我們的任務非常簡單,因為現在我們需要的是一個框架特定的組件,它將UserDetailsService的職責委托給現有的邏輯。
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實現方面同樣容易,因為域模型已經包含SessionService和Session。
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));
}
}
這個特定的實現使用JWT令牌作為在cookies中存儲記住我的令牌的物化形式。Spring Security的默認格式也可以很好,但JWT增加了一個額外的安全層。默認實現沒有簽名,每個請求最終都是數據庫中的一個查詢,用于檢查remember-me令牌。
JWT防止這種情況,盡管解析它并驗證其簽名需要更多的CPU周期。
將所有這些組合在一起
@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,它將停止其執行 。
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配置
<?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-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) throws Exception {
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。您可以按照以下步驟進行:springuni/springuni-particles
翻譯: 構建用戶管理微服務