構建用戶管理微服務
構建用戶管理微服務
翻譯自:https://springuni.com/user-management-microservice-part-1/
構建用戶管理微服務(一):定義領域模型和 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) 前端可以很容易地響應錯誤, 這是非常可取的。
@Datapublic 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 負責生成針對應用程序特定異常的正確響應。
@RestControllerAdvicepublic 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 來設置它。
@Controllerpublic class DefaultController { @RequestMapping public ResponseEntity<RestErrorResponse> handleUnmappedRequest(final HttpServletRequest request) {return ResponseEntity.status(NOT_FOUND).body(RestErrorResponse.of(NOT_FOUND)); } }
經過這樣的設置,RestConfiguration 在一定程度上擴展了 WebMvcConfigurationSupport, 這提供了用於調用 MVC 基礎結構的自定義鉤子。
@EnableWebMvc @Configurationpublic 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 @EnableWebMvcpublic 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=/;HttpOnlyX-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/524201457797040HTTP/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/524201457797040HTTP/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/524201457797040HTTP/1.1 200...Set-Cookie: remember-me=eyJhbGciOiJIUzUxMiJ9...;Max-Age=1209600;path=/;HttpOnlyX-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/logoutHTTP/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
幸運的是,我們在添加適當的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 {} doesnt 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大致相同,包裝器會在PersistentRememberMeToken和Session之間進行轉換 。唯一需要特別注意的是PersistentRememberMeToken中的日期欄位。在會話中,我分離了兩個日期欄位(即已發布的和lastUsedAt),後者在用戶首次使用remember-me令牌的幫助下登錄時獲取第一個值。因此有可能它是空的,而且是什麼時候使用publishedAt的值。
實現 RememberMeServices
在這一點上,我們重新使用PersistentTokenBasedRememberMeServices並為手頭的任務進行自定義,它取決於UserDetailsService和PersistentTokenRepository,而這些已經被考慮到了。
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周期。
將所有這些組合在一起
@Configurationpublic 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-ec50522ba06fHTTP/1.1 200
第二次登錄嘗試
curl -D- -XPOST http://localhost:5000/auth/login -d { "username":"test5", "password": "test" }HTTP/1.1 200X-Set-Authorization-Bearer: eyJhbGciOiJIUzUxMiJ9.eyJqdGkiOiI2MjA1OTkwNjIwMTQ4ODEiLCJzdWIiOiI2MjAzNjYxODQ0NDczNzciLCJleHAiOjE0OTcxMDQ3OTAsImlhdCI6MTQ5NzAxODM5MCwiYXV0aG9yaXRpZXMiOiIifQ.U-GfabsdYidg-Y9eSp2lyyh7DxxaI-zaTOZISlCf3RjKQUTmu0-vm6DH80xYWE69SmoGgm07qiYM32JBd9d5oQ
用戶的電子郵件地址確認後,即可登錄。
下一步是什麼?
正如我之前提到的,這個應用程序有很多工作要做。其中還有一些基本功能,也沒有UI。您可以按照以下步驟進行:springuni/springuni-particles
翻譯: 構建用戶管理微服務
推薦閱讀:
※如何解決微服務化中日誌收集不全的問題?
※一周IT博文精選TOP10(第六期)
※Spring Cloud(七)服務網關 Zuul Filter 使用
※2018微服務狂熱之死