構(gòu)建用戶管理微服務(wù)

構(gòu)建用戶管理微服務(wù)
翻譯自:https://springuni.com


構(gòu)建用戶管理微服務(wù)(一):定義領(lǐng)域模型和 REST API

在《構(gòu)建用戶管理微服務(wù)》的第一部分中,我們會定義應(yīng)用的需求,初始的領(lǐng)域模型和供前端使用的 REST API。 我們首先定義用戶注冊和管理用戶的故事。

用戶故事

在設(shè)計(jì)新系統(tǒng)時(shí),值得考慮的是用戶希望實(shí)現(xiàn)的結(jié)果。 下面您可以找到用戶注冊系統(tǒng)應(yīng)具有的基本功能的列表。

  • 作為用戶,我想注冊,以便我可以訪問需要注冊的內(nèi)容
  • 作為用戶,我想在注冊后確認(rèn)我的電子郵件地址
  • 作為用戶,我想登錄并注銷
  • 作為用戶,我想更改我的密碼
  • 作為用戶,我想更改我的電子郵件地址
  • 作為用戶,我想要重置我的密碼,以便我忘記密碼后可以登錄
  • 作為用戶,我想更新我的個(gè)人資料,以便我可以提供我正確的聯(lián)絡(luò)資料
  • 作為用戶,我想關(guān)閉我的帳戶,以便我可以關(guān)閉我與我注冊的服務(wù)的關(guān)系
  • 作為管理員,我想手動管理(創(chuàng)建/刪除/更新)用戶,以便工作人員不必重新進(jìn)行注冊過程
  • 作為管理員,我想手動創(chuàng)建用戶,這樣工作人員就不用再過注冊過程了
  • 作為管理員,我想列出所有用戶,即使是那些曾經(jīng)關(guān)閉帳戶的用戶
  • 作為管理員,我希望能夠看到用戶的活動(登錄,注銷,密碼重置,確認(rèn),個(gè)人資料更新),以便我可以遵守外部審計(jì)要求

工作流程

我們來看看系統(tǒng)將要支持什么樣的工作流程。首先,人們應(yīng)該能夠注冊和登錄,這些是相當(dāng)明顯的功能。

但是,處理確認(rèn)令牌時(shí)需要謹(jǐn)慎。 由于它們可用于執(zhí)行特權(quán)操作,因此我們使用一次性隨機(jī)令牌來處理密碼重置和電子郵件確認(rèn)。

當(dāng)一個(gè)新的令牌由用戶生成,無論什么原因,所有以前的都是無效的。 當(dāng)有人記住他們的密碼時(shí),以前發(fā)出的和有效的密碼重置令牌必須過期。

非功能性需求

用戶故事通常不會定義非功能性要求,例如安全性,開發(fā)原理,技術(shù)棧等。所以我們在這里單獨(dú)列出。

  • 領(lǐng)域模型是使用域驅(qū)動的設(shè)計(jì)原則在純 Java 中實(shí)現(xiàn)的,并且獨(dú)立于要使用的底層技術(shù)棧
  • 當(dāng)用戶登錄時(shí),將為他們生成一個(gè) JWT 令牌,有效期是 24 小時(shí)。在后續(xù)請求中包含此令牌,用戶可以執(zhí)行需要身份驗(yàn)證的操作
  • 密碼重置令牌有效期為 10 分鐘,電子郵件地址確認(rèn)令牌為一天
  • 密碼用加密算法(Bcrypt)加密,并且每用戶加鹽
  • 提供了 RESTful API,用于與用戶注冊服務(wù)進(jìn)行交互
  • 應(yīng)用程序?qū)⒕哂心K化設(shè)計(jì),以便能夠?yàn)楦鞣N場景提供單獨(dú)的部署工件(例如,針對 Google App Engine 的 2.5 servlet 兼容 WAR 和其他用例的基于 Spring Boot 的自包含可執(zhí)行 JAR)
  • 實(shí)體標(biāo)識符以數(shù)據(jù)庫無關(guān)的方式生成,也就是說,不會使用數(shù)據(jù)庫特定機(jī)制(AUTO_INCREMENT 或序列)來獲取下一個(gè) ID 值。解決方案將類似于 Instagram genetes ID。

領(lǐng)域模型

對于第一輪實(shí)現(xiàn)中,我們只關(guān)注三個(gè)實(shí)體,即用戶,確認(rèn)令牌和用戶事件。

Rest api

訪問下面的大多數(shù) API 都需要認(rèn)證,否則返回一個(gè) UNAUTHORIZED 狀態(tài)碼。 如果用戶嘗試查詢屬于某個(gè)其他用戶的實(shí)體,則他們還會返回客戶端錯(cuò)誤(FORBIDDEN),除非他具有管理權(quán)限。 如果指定的實(shí)體不存在,則調(diào)用的端點(diǎn)返回 NOT_FOUND。

創(chuàng)建會話(POST /sessions)和注冊新用戶(POST / users)是公開的,它們不需要身份驗(yàn)證。

Session management

GET /session/{session_id}

如果沒有給定 ID 的會話或者會話已經(jīng)過期,則返回給定會話的詳細(xì)信息或 NOT_FOUND。

POST /session

創(chuàng)建新會話,前提是指定的電子郵件和密碼對屬于一個(gè)有效的用戶。

DELETE /session/{session_id}

刪除給定的會話(注銷)

User management

GET /users/{user_id}

根據(jù)一個(gè)指定的 ID 查找用戶。

GET /users

列舉系統(tǒng)中所有的用戶

POST /users

注冊一個(gè)新的用戶

DELETE /users/{user_id}

刪除指定的用戶

PUT /users/{user_id}

更新指定用戶的個(gè)人信息

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

使用給定用戶的令牌執(zhí)行與令牌類型相關(guān)的操作


構(gòu)建用戶管理微服務(wù)(二):實(shí)現(xiàn)領(lǐng)域模型

在第二部分,將詳細(xì)介紹如何實(shí)現(xiàn)領(lǐng)域模型,在代碼之外做了哪些決定。

使用領(lǐng)域驅(qū)動設(shè)計(jì)

在第一部分中,作者提到了將使用領(lǐng)域驅(qū)動設(shè)計(jì)原則,這意味著,該模型可以不依賴于任何框架或基礎(chǔ)設(shè)施類。在多次應(yīng)用實(shí)現(xiàn)過程中,作者把領(lǐng)域模型和框架的具體注釋(如 JPA 或 Hibernate )混在一起,就如同和 Java POJO 一起工作(貧血模型)。在設(shè)計(jì)領(lǐng)域模型中,唯一使用的庫是Lombok,用于減少定義的 getter 和 setter 方法以避免冗余。

當(dāng)設(shè)計(jì) DDD 的模型,第一步是對類進(jìn)行分類。在埃里克·埃文斯書中的第二部分專注于模型驅(qū)動設(shè)計(jì)的構(gòu)建模塊??紤]到這一點(diǎn),我們的模型分為以下幾類。

實(shí)體類

實(shí)體有明確的標(biāo)識和生命周期需要被管理。從這個(gè)角度來看,用戶肯定是一個(gè)實(shí)體。

ConfirmationToken 就是一個(gè)邊緣的例子,因?yàn)樵跊]有用戶上下文的情況下,邏輯上它就不存在,而另一方面,它可以通過令牌的值來標(biāo)識并且它有自己的生命周期。

同樣的方法也適用于 Session ,這也可能是一個(gè)值對象,由于其不可改變的性質(zhì),但它仍然有一個(gè) ID 和一個(gè)生命周期(會話過期)。

值對象

相對于實(shí)體類,值對象沒有一個(gè)明確的 ID ,那就是,他們只是將一系列屬性組合,并且,如果這些屬性和另外一個(gè)相同類型的值對象的屬性相同,那么我們就可以認(rèn)為這兩個(gè)值對象是相同的。

當(dāng)設(shè)計(jì)領(lǐng)域模型,值對象提供了一種方便的方式來描述攜帶有一定的信息片段屬性的集合。 AddressData,AuditData,ContactData 和 Password 因此可以認(rèn)為是值對象。

雖然將所有這些屬性實(shí)現(xiàn)為不可改變的是不切實(shí)際的,他們的某些屬性可以單獨(dú)被修改, Password 是一個(gè)很好的例子。當(dāng)我們創(chuàng)建 Password 的實(shí)例,它的鹽和哈希創(chuàng)建只有一次。在改變密碼時(shí),一個(gè)全新的實(shí)例與新的鹽和散列將會被創(chuàng)建。

聚合

聚合代表一組結(jié)合在一起,并通過訪問所謂的聚合根的對象。

這兒有兩個(gè)聚合對象:用戶和會話。前者包含了所有與用戶相關(guān)的實(shí)體和值對象,而后者只包含一個(gè)單一的實(shí)體 Session 。

顯然,用戶聚合根是用戶實(shí)體。通過一個(gè)實(shí)例用戶實(shí)體,我們可以管理確認(rèn)令牌,用戶事件和用戶的密碼。

聚合 Session 成為一個(gè)獨(dú)立的實(shí)體——盡管被捆綁到一個(gè)用戶的上下文——部分原因是由于其一次性性質(zhì),部分是因?yàn)楫?dāng)我們查找一個(gè)會話時(shí)我們不知道用戶是誰。 Session 被創(chuàng)建之后,要么過期,要么按需刪除。

領(lǐng)域事件

當(dāng)需要由系統(tǒng)的另外組件處理的事件發(fā)生時(shí),領(lǐng)域事件就會被觸發(fā)。

用戶管理應(yīng)用程序有一個(gè)領(lǐng)域事件,這是 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

服務(wù)

服務(wù)包含了能夠操作一組領(lǐng)域模型的類的業(yè)務(wù)邏輯。在本應(yīng)用中, UserService 管理用戶的生命周期,并發(fā)出合適的 UserEvent 。SessionService 是用于創(chuàng)建和銷毀用戶會話。

存儲庫

存儲庫旨在代表一個(gè)實(shí)體對象的概念集合,但是有時(shí)他們只是作為數(shù)據(jù)訪問對象。有兩種實(shí)現(xiàn)方法,一種方法是列出所有的抽象存儲庫類或超接口可能的數(shù)據(jù)訪問方法,例如 Spring Data ,或者創(chuàng)建專門存儲庫接口。

對于用戶管理應(yīng)用程序,作者選擇了第二種方法。UserRepository 和 SessionRepository 只列出那些絕對必要的處理他們實(shí)體的方法。

項(xiàng)目結(jié)構(gòu)

你可能已經(jīng)注意到,這里有一個(gè) GitHub 上的庫: springuni ,它包含用戶管理應(yīng)用程序的一部分,但它不包含應(yīng)用程序本身的可執(zhí)行版本。

究其原因,我為什么不提供單一只包含 Spring Boot 少量 @Enable* 注解的庫,是為了可重用性。大多數(shù)我碰到的項(xiàng)目第一眼看起來是可以模塊化的,但實(shí)際上他們只是沒有良好分解職責(zé)的巨大單體應(yīng)用。當(dāng)你試圖重用這樣一個(gè)項(xiàng)目的模塊,你很快意識到,它依賴于許多其他模塊和/或過多的外部庫。

springuni-particles (它可能已被也稱為 springuni 模塊)提供了多個(gè)模塊的可重復(fù)使用的只為某些明確定義的功能。用戶和會話管理是很好的例子。

模塊

springuni-auth-model 包含了所有的領(lǐng)域模型類和用于管理用戶生命周期的業(yè)務(wù)邏輯,它是完全與框架無關(guān)的。它的存儲庫,并且可以使用任何數(shù)據(jù)存儲機(jī)制,對于手頭的實(shí)際任務(wù)最符合。還有,PasswordChecker 和 PasswordEncryptor 可基于任何強(qiáng)大的密碼散列技術(shù)實(shí)現(xiàn)。

springuni-commons 包含了通用的工具庫。有很多著名的第三方庫(如 Apache Commons Lang,Guava 等),這外延了 JDK 的標(biāo)準(zhǔn)庫。在另一方面,我發(fā)現(xiàn)自己很多時(shí)候僅僅只用這些非常可擴(kuò)展庫的少量類。我特別喜歡的 Apache Commons Lang 中的 StringUtils 的和 Apache 共同集合的 CollectionUtils 類,但是,我寧愿為當(dāng)前項(xiàng)目提供一個(gè)高度定制化的 StringUtils 和 CollectionUtils,這樣就不需要添加外部依賴。

sprinuni-crm-model 定義了通用的值對象,用于處理聯(lián)系人數(shù)據(jù),如地址,國家等。雖然微服務(wù)架構(gòu)的倡導(dǎo)者將投票反對使用共享庫,但我認(rèn)為這個(gè)特定點(diǎn)可能需要不時(shí)修訂手頭的任務(wù)。我最近參與了一些 CRM 集成項(xiàng)目,不得不重新實(shí)現(xiàn)了幾乎同樣的領(lǐng)域模型在不同的限界上下文(即用戶,客戶,聯(lián)系人),這樣一遍又一遍的操作是乏味的。也就是說,我認(rèn)為使用聯(lián)系人數(shù)據(jù)領(lǐng)域模型的小型通用庫是值得嘗試的。


構(gòu)建用戶管理微服務(wù)(三):實(shí)現(xiàn)和測試存儲庫

詳細(xì)介紹一個(gè)完整的基于 JPA 的用戶存儲庫實(shí)現(xiàn),一個(gè) JPA 的支撐模型和一些測試用例。

使用 XML 來映射簡單的 JAVA 對象

僅看到用戶存儲庫,也許你就能想到在對它添加基于 JPA 的實(shí)現(xiàn)時(shí)會遇到什么困難。

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 (域驅(qū)動設(shè)計(jì)), 因此, 在模型中就不能使用特定框架的依賴關(guān)系云 (包括 JPA 的注解) ,剩下的唯一可行性方法是用 XML 進(jìn)行映射。如果我沒有記錯(cuò)的話,自2010年以來,我再也沒有接觸過任何一個(gè) 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>

域驅(qū)動設(shè)計(jì)是一種持久化無關(guān)的方法,因此堅(jiān)持設(shè)計(jì)一個(gè)沒有具體目標(biāo)數(shù)據(jù)結(jié)構(gòu)的模型可能很有挑戰(zhàn)性。當(dāng)然, 它也存在優(yōu)勢, 即可對現(xiàn)實(shí)世界中的問題直接進(jìn)行建模, 而不存在只能以某種方式使用某種技術(shù)棧之類的副作用。

public class User implements Entity<Long, User> {  
 
private Long id;  
private String screenName;  ...  
 
private Set<String> authorities = new LinkedHashSet<>();
 
}

一般來說,一組簡單的字符串或枚舉值就能對用戶的權(quán)限(或特權(quán))進(jìn)行建模了。

使用像 MongoDB 這樣的文檔數(shù)據(jù)庫能夠輕松自然地維護(hù)這個(gè)模型,如下所示。(順便一提, 我還計(jì)劃在本系列的后續(xù)內(nèi)容中添加一個(gè)基于 Mongo 的存儲庫實(shí)現(xiàn))

{   "id":123456789,   
"screenName":"test",   ...   
"authorities":[      
             "USER",      
             "ADMIN"   
] 
}

然而, 在關(guān)系模型中, 權(quán)限的概念必須作為用戶的子關(guān)系進(jìn)行處理。但是在現(xiàn)實(shí)世界中, 這僅僅只是一套權(quán)限規(guī)則。我們需要如何彌合這樣的差距呢?

在 JPA 2.0 中可以引入 ElementCollection 來進(jìn)行操作,它的用法類似于 OneToMany。在這種情況下, 已經(jīng)配置好的 JPA 提供的程序 (Hibernate) 將自動生成必要的子關(guān)系。

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

項(xiàng)目中的新模塊

我一直在討論的 springuni-auth-user-jpa 包含了一個(gè)完整的基于 JPA 的 UserRepository 實(shí)現(xiàn)。其目標(biāo)是, 每個(gè)模塊都應(yīng)該只擁有那些對它們的操作來說絕對必要的依賴關(guān)系,而這些關(guān)系只需要依賴 JPA API 便可以實(shí)現(xiàn)。

springuni-commons-jpa 是一個(gè)支撐模塊, 它能夠使用預(yù)先配置好的 HikariCP 和 Hibernate 的組合作為實(shí)體管理器, 而不必關(guān)心其他細(xì)節(jié)。 它的特色是 AbstractJpaConfiguration, 類似于 Spring Boot 的 HibernateJpaAutoConfiguration。

然而我沒有使用后者的原因是 Spring Boot 的自動配置需要一定的初始化。因?yàn)楣雀钁?yīng)用引擎標(biāo)準(zhǔn)環(huán)境是我的目標(biāo)平臺之一,因此能否快速地啟動是至關(guān)重要的。

單元測試存儲庫

雖然有人可能會說, 對于存儲庫沒必要進(jìn)行過多的測試, 尤其是在使用 Spring Data 的 存儲庫接口的時(shí)候。但是我認(rèn)為測試代碼可以避免運(yùn)行時(shí)存在的一些問題,例如錯(cuò)誤的實(shí)體映射或錯(cuò)誤的 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())
  }
 
  ... 
}

這個(gè)測試用例啟動了一個(gè)具有嵌入式 H2 數(shù)據(jù)庫的實(shí)體管理器。H2 非常適合于測試, 因?yàn)樗С衷S多眾所周知的數(shù)據(jù)庫 (如 MySQL) 的兼容模式,可以模擬你的真實(shí)數(shù)據(jù)庫。


構(gòu)建用戶管理微服務(wù)(四):實(shí)現(xiàn) REST 控制器

將 REST 控制器添加到領(lǐng)域控制模型的頂端

有關(guān) REST

REST, 全稱是 Resource Representational State Transfer(Resource 被省略掉了)。通俗來講就是:資源在網(wǎng)絡(luò)中以某種表現(xiàn)形式進(jìn)行狀態(tài)轉(zhuǎn)移。在 web 平臺上,REST 就是選擇通過使用 http 協(xié)議和 uri,利用 client/server model 對資源進(jìn)行 CRUD (Create/Read/Update/Delete) 增刪改查操作。

使用 REST 結(jié)構(gòu)風(fēng)格是因?yàn)?,隨著時(shí)代的發(fā)展,傳統(tǒng)前后端融為一體的網(wǎng)頁模式無法滿足需求,而 RESTful 可以通過一套統(tǒng)一的接口為 Web,iOS 和 Android 提供服務(wù)。另外對于廣大平臺來說,比如 Facebook platform,微博開放平臺,微信公共平臺等,他們需要一套提供服務(wù)的接口,于是 RESTful 更是它們最好的選擇。

REST 端點(diǎn)的支撐模塊

我經(jīng)手的大多數(shù)項(xiàng)目,都需要對控制器層面正確地進(jìn)行 Spring MVC 的配置。隨著近幾年單頁應(yīng)用程序的廣泛應(yīng)用,越來越不需要在 Spring mvc 應(yīng)用程序中配置和開發(fā)視圖層 (使用 jsp 或模板引擎)。

現(xiàn)在,創(chuàng)建完整的 REST 后端的消耗并生成了 JSON 是相當(dāng)?shù)湫偷? 然后通過 SPA 或移動應(yīng)用程序直接使用?;谝陨纤v, 我收集了 Spring MVC 常見配置,這能實(shí)現(xiàn)對后端的開發(fā)。

  • Jackson 用于生成和消解 JSON
  • application/json 是默認(rèn)的內(nèi)容類型
  • ObjectMapper 知道如何處理 Joda 和 JSR-310 日期/時(shí)間 api, 它在 iso 格式中對日期進(jìn)行序列化, 并且不將缺省的值序列化 (NON_ABSENT)
  • ModelMapper 用于轉(zhuǎn)換為 DTO 和模型類
  • 存在一個(gè)自定義異常處理程序, 用于處理 - EntityNotFoundException 和其他常見應(yīng)用程序級別的異常
  • 捕獲未映射的請求并使用以前定義的錯(cuò)誤響應(yīng)來處理它們

能被重新使用的常見 REST 配置項(xiàng)目

該代碼在 github, 有一個(gè)新的模塊 springuni-commons-rest , 它包含實(shí)現(xiàn) REST 控制器所需的所有常用的實(shí)用程序。 專有的 RestConfiguration 可以通過模塊進(jìn)行擴(kuò)展, 它們可以進(jìn)一步細(xì)化默認(rèn)配置。

錯(cuò)誤處理

正常的 web 應(yīng)用程序向最終用戶提供易于使用的錯(cuò)誤頁。但是,對于一個(gè)純粹的 JSON-based REST 后端, 這不是一個(gè)需求, 因?yàn)樗目蛻羰?SPA 或移動應(yīng)用。

因此, 最好的方法是用一個(gè)明確定義的 JSON 結(jié)構(gòu) (RestErrorResponse) 前端可以很容易地響應(yīng)錯(cuò)誤, 這是非??扇〉?。

@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 錯(cuò)誤代碼,包括 HTTP 錯(cuò)誤的文本表示和對客戶端的詳細(xì)信息,RestErrorHandler 負(fù)責(zé)生成針對應(yīng)用程序特定異常的正確響應(yīng)。

@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);  } }



處理未響應(yīng)請求

為了處理未映射的請求, 首先我們需要定義一個(gè)默認(rèn)處理程序, 然后用 RequestMappingHandlerMapping 來設(shè)置它。

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

經(jīng)過這樣的設(shè)置,RestConfiguration 在一定程度上擴(kuò)展了 WebMvcConfigurationSupport, 這提供了用于調(diào)用 MVC 基礎(chǔ)結(jié)構(gòu)的自定義鉤子。

@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 端點(diǎn)

在第一部分中,我定義了一堆用于和用戶管理服務(wù)進(jìn)行交互的 REST 風(fēng)格的端點(diǎn)。而實(shí)際上, 他們與用 Spring MVC 創(chuàng)建 REST 風(fēng)格的端點(diǎn)相比,并沒有什么特別的。但是,我有一些最近意識到的小細(xì)節(jié)想要補(bǔ)充。

  • 正如 Spring 4.3 有一堆用于定義請求處理程序的速記注解,@GetMapping 是一個(gè)組合的注解, 它為 @RequestMapping (method = RequestMethod. GET) 作為其對應(yīng)的 @PostMapping、@PutMapping 等的快捷方式。
  • 我找到了一個(gè)用于處理從/到模型類轉(zhuǎn)換的 DTO 的模塊映射庫 。在此之前,我用的是 Apache Commons Beanutils。
  • 手動注冊控制器來加快應(yīng)用程序初始化的速度。正如我在第三部分中提到的, 這個(gè)應(yīng)用程序?qū)⑼泄茉诠雀钁?yīng)用引擎標(biāo)準(zhǔn)環(huán)境中,而開啟一個(gè)新的實(shí)例是至關(guān)重要的。
@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 在查找匹配屬性時(shí)是相當(dāng)自動的, 但在某些情況下需要進(jìn)行手動調(diào)整。比如說,用戶的密碼。這是我們絕對不想暴露的內(nèi)容。

通過定義自定義屬性的映射, 可以很容易地避免這一點(diǎn)。

import org.modelmapper.PropertyMap;
public class UserMap extends PropertyMap<User, UserDto> {
  
@Override  
 protected void configure() {
    
skip().setPassword(null);  
 
} 
}

當(dāng) ModelMapper 的實(shí)例被創(chuàng)建時(shí), 我們可以自定義屬性映射、轉(zhuǎn)換器、目標(biāo)值提供程序和一些其他的內(nèi)容

@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 一起被搭建。 一個(gè)是通過 web 應(yīng)用程序上下文 (如本例中) 來完成, 另一種方法是向 standaloneSetup () 提供具體的控制器實(shí)例。我使用的是前者,當(dāng) Spring Security得到配置的時(shí)候,測試控制器顯得更為合適。


構(gòu)建用戶管理微服務(wù)(五):使用 JWT 令牌和 Spring Security 來實(shí)現(xiàn)身份驗(yàn)證

我們已經(jīng)建立了業(yè)務(wù)邏輯、數(shù)據(jù)訪問層和前端控制器, 但是忽略了對身份進(jìn)行驗(yàn)證。隨著 Spring Security 成為實(shí)際意義上的標(biāo)準(zhǔn), 將會在在構(gòu)建 Java web 應(yīng)用程序的身份驗(yàn)證和授權(quán)時(shí)使用到它。在構(gòu)建用戶管理微服務(wù)系列的第五部分中, 將帶您探索 Spring Security 是如何同 JWT 令牌一起使用的。

有關(guān) Token

諸如 Facebook,Github,Twitter 等大型網(wǎng)站都在使用基于 Token 的身份驗(yàn)證。相比傳統(tǒng)的身份驗(yàn)證方法,Token 的擴(kuò)展性更強(qiáng),也更安全,非常適合用在 Web 應(yīng)用或者移動應(yīng)用上。我們將 Token 翻譯成令牌,也就意味著,你能依靠這個(gè)令牌去通過一些關(guān)卡,來實(shí)現(xiàn)驗(yàn)證。實(shí)施 Token 驗(yàn)證的方法很多,JWT 就是相關(guān)標(biāo)準(zhǔn)方法中的一種。

關(guān)于 JWT 令牌

JSON Web TOKEN(JWT)是一個(gè)開放的標(biāo)準(zhǔn) (RFC 7519), 它定義了一種簡潔且獨(dú)立的方式, 讓在各方之間的 JSON 對象安全地傳輸信息。而經(jīng)過數(shù)字簽名的信息也可以被驗(yàn)證和信任。

JWT 的應(yīng)用越來越廣泛, 而因?yàn)樗禽p量級的,你也不需要有一個(gè)用來驗(yàn)證令牌的認(rèn)證服務(wù)器。與 OAuth 相比, 這有利有弊。如果 JWT 令牌被截獲,它可以用來模擬用戶, 也無法防范使用這個(gè)被截獲的令牌繼續(xù)進(jìn)行身份驗(yàn)證。

真正的 JWT 令牌看起來像下面這樣:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJsYXN6bG9fQVRfc3ByaW5ndW5pX0RPVF9jb20iLCJuYW1lIjoiTMOhc3psw7MgQ3NvbnRvcyIsImFkbWluIjp0cnVlfQ.
XEfFHwFGK0daC80EFZBB5ki2CwrOb7clGRGlzchAD84

JWT 令牌的第一部分是令牌的 header , 用于標(biāo)識令牌的類型和對令牌進(jìn)行簽名的算法。

{ 
 "alg": "HS256", "typ": "JWT"
}

第二部分是 JWT 令牌的 payload 或它的聲明。這兩者是有區(qū)別的。Payload 可以是任意一組數(shù)據(jù), 它甚至可以是明文或其他 (嵌入 JWT)的數(shù)據(jù)。而聲明則是一組標(biāo)準(zhǔn)的字段。

{ 
 "sub": "laszlo_AT_springuni_DOT_com", "name": "László Csontos", "admin": true
}

第三部分是由算法產(chǎn)生的、由 JWT 的 header 表示的簽名。

創(chuàng)建和驗(yàn)證 JWT 令牌

有相當(dāng)多的第三方庫可用于操作 JWT 令牌。而在本文中, 我使用了 JJWT。

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.7.0</version>
</dependency>

采用 JwtTokenService 使 JWT 令牌從身份驗(yàn)證實(shí)例中創(chuàng)建, 并將 JWTs 解析回身份驗(yàn)證實(shí)例。

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);
    }
  }
 
}

根據(jù)實(shí)際的驗(yàn)證,parseClaimsJws () 會引發(fā)各種異常。在 parseJwtToken () 中, 引發(fā)的異常被轉(zhuǎn)換回 AuthenticationExceptions。雖然 JwtAuthenticationEntryPoint 能將這些異常轉(zhuǎn)換為各種 HTTP 的響應(yīng)代碼, 但它也只是重復(fù) DefaultAuthenticationFailureHandler 來以 http 401 (未經(jīng)授權(quán)) 響應(yīng)。

登錄和身份驗(yàn)證過程

基本上, 認(rèn)證過程有兩個(gè)短語, 讓后端將服務(wù)用于單頁面 web 應(yīng)用程序。

登錄時(shí)創(chuàng)建 JWT 令牌

第一次登錄變完成啟動, 且在這一過程中, 將創(chuàng)建一個(gè) JWT 令牌并將其發(fā)送回客戶端。這些是通過以下請求完成的:

POST /session
{   
  "username": "laszlo_AT_sprimguni_DOT_com",
   "password": "secret"
}

成功登錄后, 客戶端會像往常一樣向其他端點(diǎn)發(fā)送后續(xù)請求, 并在授權(quán)的 header 中提供本地緩存的 JWT 令牌。

Authorization: Bearer <JWT token>

正如上面的步驟所講, LoginFilter 開始進(jìn)行登錄過程。而Spring Security 的內(nèi)置 UsernamePasswordAuthenticationFilter 被延長, 來讓這種情況發(fā)生。這兩者之間的唯一的區(qū)別是, UsernamePasswordAuthenticationFilter 使用表單參數(shù)來捕獲用戶名和密碼, 相比之下, 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);
  }
}

處理登陸過程的結(jié)果將在之后分派給一個(gè) AuthenticationSuccessHandler 和 AuthenticationFailureHandler。

兩者都相當(dāng)簡單。DefaultAuthenticationSuccessHandler 調(diào)用 JwtTokenService 發(fā)出一個(gè)新的令牌, 然后將其發(fā)送回客戶端。

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);
  }

}

以下是它的對應(yīng), DefaultAuthenticationFailureHandler, 只是發(fā)送回一個(gè) http 401 錯(cuò)誤消息。

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);
  }
 
}



處理后續(xù)請求

在客戶端登陸后, 它將在本地緩存 JWT 令牌, 并在前面討論的后續(xù)請求中發(fā)送反回。

對于每個(gè)請求, JwtAuthenticationFilter 通過 JwtTokenService 驗(yàn)證接收到的 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;
    }
  }
 
}

如果令牌是有效的, 則會實(shí)例化 JwtAuthenticationToken, 并執(zhí)行線程的 SecurityContext。而由于恢復(fù)的 JWT 令牌包含唯一的 ID 和經(jīng)過身份驗(yàn)證的用戶的權(quán)限, 因此無需與數(shù)據(jù)庫聯(lián)系以再次獲取此信息。

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 世界中有競爭者嗎?

雖然這不是這篇文章的主題, 但我想花一分鐘的時(shí)間來談?wù)?。如果我不得不在一個(gè) JAVA EE 應(yīng)用程序中完成所有這些?Spring Security 真的是在 JAVA 中實(shí)現(xiàn)身份驗(yàn)證和授權(quán)的黃金標(biāo)準(zhǔn)嗎?

讓我們做個(gè)小小的研究!

JAVA EE 8 指日可待,他將在 2017 年年底發(fā)布,我想看看它是否會是 Spring Security 一個(gè)強(qiáng)大的競爭者。我發(fā)現(xiàn) JAVA EE 8 將提供 JSR-375 , 這應(yīng)該會緩解 JAVA EE 應(yīng)用程序的安全措施的發(fā)展。它的參考實(shí)施被稱為 Soteira, 是一個(gè)相對新的 github 項(xiàng)目。那就是說, 現(xiàn)在的答案是真的沒有這樣的一個(gè)競爭者。

但這項(xiàng)研究是不完整的,并沒有提到 Apache Shiro。雖然我從未使用過,但我聽說這算是更為簡單的 Spring Security。讓它更 JWT 令牌 一起使用也不是不可能。從這個(gè)角度來看,Apache Shiro 是算 Spring Security 的一個(gè)的有可比性的替代品


構(gòu)建用戶管理微服務(wù)(六):添加并記住我使用持久JWT令牌的身份驗(yàn)證

于用戶名和密碼的身份驗(yàn)證。如果你錯(cuò)過了這一點(diǎn),我在這里注意到,JWT令牌是在成功登錄后發(fā)出的,并驗(yàn)證后續(xù)請求。創(chuàng)造長壽的JWT是不實(shí)際的,因?yàn)樗鼈兪仟?dú)立的,沒有辦法撤銷它們。如果令牌被盜,所有賭注都會關(guān)閉。因此,我想添加經(jīng)典的remember-me風(fēng)格認(rèn)證與持久令牌。記住,我的令牌存儲在Cookie中作為JWT作為第一道防線,但是它們也保留在數(shù)據(jù)庫中,并且跟蹤其生命周期。
這次我想從演示運(yùn)行中的用戶管理應(yīng)用程序的工作原理開始,然后再深入細(xì)節(jié)。

這次我想從演示運(yùn)行中的用戶管理應(yīng)用程序的工作原理開始,然后再深入細(xì)節(jié)。

驗(yàn)證流程

基本上,用戶使用用戶名/密碼對進(jìn)行身份驗(yàn)證會發(fā)生什么,他們可能會表示他們希望應(yīng)用程序記住他們(持續(xù)會話)的意圖。大多數(shù)時(shí)候,UI上還有一個(gè)復(fù)選框來實(shí)現(xiàn)。由于應(yīng)用程序還沒有開發(fā)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...

成功認(rèn)證后, PersistentJwtTokenBasedRememberMeServices創(chuàng)建一個(gè)永久會話,將其保存到數(shù)據(jù)庫并將其轉(zhuǎn)換為JWT令牌。它負(fù)責(zé)將此持久會話存儲在客戶端的一個(gè)cookie(Set-Cookie)上,并且還發(fā)送新創(chuàng)建的瞬時(shí)令牌。后者旨在在單頁前端的使用壽命內(nèi)使用,并使用非標(biāo)準(zhǔn)HTTP頭(X-Set-Authorization-Bearer)發(fā)送。
當(dāng)rememberMe標(biāo)志為false時(shí),只創(chuàng)建一個(gè)無狀態(tài)的JWT令牌,并且完全繞過了remember-me基礎(chǔ)架構(gòu)。

在應(yīng)用程序運(yùn)行時(shí)僅使用瞬態(tài)令牌

當(dāng)應(yīng)用程序在瀏覽器中打開時(shí),它會在每個(gè)XHR請求的授權(quán)頭文件中發(fā)送暫時(shí)的JWT令牌。然而,當(dāng)應(yīng)用程序重新加載時(shí),暫時(shí)令牌將丟失。
為了簡單起見,這里使用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"
}

使用瞬態(tài)令牌與持久性令牌結(jié)合使用

當(dāng)用戶在第一種情況下選擇了remember-me認(rèn)證時(shí),會發(fā)生這種情況。

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"
}

在這種情況下,暫時(shí)的JWT令牌和一個(gè)有效的remember-me cookie都是同時(shí)發(fā)送的。只要單頁應(yīng)用程序正在運(yùn)行,就使用暫時(shí)令牌。

初始化時(shí)使用持久令牌

當(dāng)前端在瀏覽器中加載時(shí),它不知道是否存在任何暫時(shí)的JWT令牌。所有它可以做的是測試持久的remember-me cookie嘗試執(zhí)行一個(gè)正常的請求。

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)仍然有效,則會在上次使用數(shù)據(jù)庫時(shí)在數(shù)據(jù)庫中進(jìn)行更新,并在瀏覽器中更新。還執(zhí)行另一個(gè)重要步驟,用戶將自動重新進(jìn)行身份驗(yàn)證,而無需提供用戶名/密碼對,并創(chuàng)建新的臨時(shí)令牌。從現(xiàn)在開始,只要運(yùn)行該應(yīng)用程序,該應(yīng)用程序?qū)⑹褂脮簳r(shí)令牌。

注銷

盡管注銷看起來很簡單,有一些細(xì)節(jié)我們需要注意。前端仍然發(fā)送無狀態(tài)的JWT令牌,只要用戶進(jìn)行身份驗(yàn)證,否則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被重置,并且數(shù)據(jù)庫中的持久會話被標(biāo)記為已刪除。

實(shí)現(xiàn)記住我的身份驗(yàn)證

正如我在摘要中提到的,我們將使用持久性令牌來增加安全性,以便能夠在任何時(shí)候撤銷它們。有三個(gè)步驟,我們需要執(zhí)行,以使適當(dāng)?shù)挠涀∥姨幚砼cSpring Security。

實(shí)現(xiàn) UserDetailsService

在第一篇文章中,我決定使用DDD開發(fā)模型,因此它不能依賴于任何框架特定的類。實(shí)際上,它甚至不依賴于任何第三方框架或圖書館。大多數(shù)教程通常直接實(shí)現(xiàn)UserDetailsService,并且業(yè)務(wù)邏輯和用于構(gòu)建應(yīng)用程序的框架之間沒有額外的層。

UserServices在第二部分很久以前被添加到該項(xiàng)目中,因此我們的任務(wù)非常簡單,因?yàn)楝F(xiàn)在我們需要的是一個(gè)框架特定的組件,它將UserDetailsService的職責(zé)委托給現(xiàn)有的邏輯。

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的一個(gè)簡單的包裝器,最終將返回的User模型對象轉(zhuǎn)換為框架特定的UserDetails實(shí)例。除此之外,在這個(gè)項(xiàng)目中,我們不直接使用用戶的登錄名(電子郵件地址或屏幕名稱)。相反,他們的用戶的身份證遍及各地。

實(shí)現(xiàn) PersistentTokenRepository

幸運(yùn)的是,我們在添加適當(dāng)?shù)?em>PersistentTokenRepository實(shí)現(xiàn)方面同樣容易,因?yàn)橛蚰P鸵呀?jīng)包含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));
  }
}

這個(gè)特定的實(shí)現(xiàn)使用JWT令牌作為在cookies中存儲記住我的令牌的物化形式。Spring Security的默認(rèn)格式也可以很好,但JWT增加了一個(gè)額外的安全層。默認(rèn)實(shí)現(xiàn)沒有簽名,每個(gè)請求最終都是數(shù)據(jù)庫中的一個(gè)查詢,用于檢查remember-me令牌。

JWT防止這種情況,盡管解析它并驗(yàn)證其簽名需要更多的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);
  }
  ...
}

令人感到神奇的結(jié)果在最后部分是顯而易見的?;旧希@是關(guān)于使用Spring Security注冊組件,并啟用記住我的服務(wù)。有趣的是,我們需要一個(gè)在AbstractRememberMeServices 內(nèi)部使用的鍵(一個(gè)字符串)。 AbstractRememberMeServices 也是此設(shè)置中的默認(rèn)注銷處理程序,并在注銷時(shí)將數(shù)據(jù)庫中的令牌標(biāo)記為已刪除。

陷阱 - 在POST請求的正文中接收用戶憑據(jù)和remember-me標(biāo)志作為JSON數(shù)據(jù)

默認(rèn)情況下, UsernamePasswordAuthenticationFilter會將憑據(jù)作為POST請求的HTTP請求參數(shù),但是我們希望發(fā)送JSON文檔。進(jìn)一步下去, AbstractRememberMeServices還會將remember-me標(biāo)志的存在檢查為請求參數(shù)。為了解決這個(gè)問題,LoginFilter 將remember-me標(biāo)志設(shè)置為請求屬性,并將決定委托給 PersistentTokenBasedRememberMeServices, 如果記住我的身份驗(yàn)證需要啟動或不啟動。

使用RememberMeServices處理登錄成功

RememberMeAuthenticationFilter不會繼續(xù)進(jìn)入過濾器鏈中的下一個(gè)過濾器,但如果設(shè)置了AuthenticationSuccessHandler,它將停止其執(zhí)行 。

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 是原始過濾器的自定義版本,當(dāng)認(rèn)證成功時(shí),該過濾器不會停止。

構(gòu)建用戶管理微服務(wù)器(七):將以上組合在一起

從絕對零開始,用戶管理應(yīng)用程序的構(gòu)建塊已被開發(fā)出來。在最后一篇中,我想向您展示如何組裝這些部分,以使應(yīng)用程序正常工作。一些功能仍然缺少,我仍然在第一個(gè)版本上工作,使其功能完整,但現(xiàn)在基本上是可以使用的。

創(chuàng)建一個(gè)獨(dú)立的可執(zhí)行模塊

今天建立基于Spring的應(yīng)用程序最簡單的方法是去Spring Boot。毫無疑問。由于一個(gè)原因,它正在獲得大量采用,這就是使您的生活比使用裸彈更容易。之前我曾在各種情況下與Spring合作過,并在Servlet容器和完全成熟的Java EE應(yīng)用服務(wù)器之上構(gòu)建了應(yīng)用程序,但能夠?qū)⒖蓤?zhí)行軟件包中的所有內(nèi)容都打包成開發(fā)成本。
總而言之,第一步是為應(yīng)用程序創(chuàng)建一個(gè)新的模塊,它是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端點(diǎn),它還將springuni-auth模型作為傳遞依賴。springuni-auth-user-jpa負(fù)責(zé)持久化的用戶數(shù)據(jù),并且將來可以替換其他持久性機(jī)制。

第三個(gè)依賴是MySQL連接器,也可以根據(jù)需要進(jìn)行替換。
Spring Boot的角度來說,以下兩個(gè)依賴關(guān)系是重要的:spring-boot-starter-webspring-boot-starter-tomcat。為了能夠創(chuàng)建一個(gè)Web應(yīng)用程序,我們需要它們。

應(yīng)用程序的入口點(diǎn)

在沒有Spring Boot的情況下執(zhí)行此步驟將會非常費(fèi)力(必須在web.xml中注冊上下文監(jiān)聽器并為應(yīng)用程序設(shè)置容器)。

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);
  }
}

這幾乎是一個(gè)虛擬模塊,所有重要的舉措都?xì)w結(jié)為不得不導(dǎo)入一些基于Java的Spring配置類。

啟動

Spring Boot附帶了一個(gè)非常有用的Maven插件,可以將整個(gè)項(xiàng)目重新打包成一個(gè)可執(zhí)行的überJAR。它也能夠在本地啟動項(xiàng)目。

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

測試驅(qū)動用戶管理應(yīng)用程序

第一部分定義了所有可用的REST端點(diǎn),現(xiàn)在已經(jīng)有一些現(xiàn)實(shí)世界的用例來測試它們。

注冊新用戶

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

首次登錄嘗試

此時(shí)首次登錄嘗試不可避免地會失敗,因?yàn)橛脩魩ぬ柹形创_認(rèn)

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

確認(rèn)帳號

一般情況下,最終用戶將收到一封電子郵件中的確認(rèn)鏈接,點(diǎn)擊該鏈接會啟動以下請求。

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

用戶的電子郵件地址確認(rèn)后,即可登錄。

下一步是什么?

正如我之前提到的,這個(gè)應(yīng)用程序有很多工作要做。其中還有一些基本功能,也沒有UI。您可以按照以下步驟進(jìn)行:springuni/springuni-particles

翻譯: 構(gòu)建用戶管理微服務(wù)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 136,695評論 19 139
  • Spring Boot 參考指南 介紹 轉(zhuǎn)載自:https://www.gitbook.com/book/qbgb...
    毛宇鵬閱讀 47,288評論 6 342
  • 1. 微服務(wù)架構(gòu)介紹 1.1 什么是微服務(wù)架構(gòu)? 形像一點(diǎn)來說,微服務(wù)架構(gòu)就像搭積木,每個(gè)微服務(wù)都是一個(gè)零件,并使...
    靜修佛緣閱讀 6,824評論 0 39
  • 001 制定目標(biāo)盡量具體,明確以及存在可操作性。 002 不僅要對成果進(jìn)行稱贊,在過程中適時(shí)適度的稱贊也是一門領(lǐng)導(dǎo)...
    小黃2333閱讀 204評論 0 0
  • 人的膚質(zhì)分為五種: 中性、干性、油性、混合型和敏感性。 不同的膚質(zhì)洗臉方式大有不同! 膚質(zhì)類別 >>>> 中 性 ...
    擺渡愛生活閱讀 330評論 0 1

友情鏈接更多精彩內(nèi)容