構(gòu)建用戶管理微服務(wù)
翻譯自:https://springuni.com
構(gòu)建用戶管理微服務(wù)(一):定義領(lǐng)域模型和 REST API
在《構(gòu)建用戶管理微服務(wù)》的第一部分中佳簸,我們會(huì)定義應(yīng)用的需求,初始的領(lǐng)域模型和供前端使用的 REST API乒疏。 我們首先定義用戶注冊(cè)和管理用戶的故事法牲。
用戶故事
在設(shè)計(jì)新系統(tǒng)時(shí),值得考慮的是用戶希望實(shí)現(xiàn)的結(jié)果琼掠。 下面您可以找到用戶注冊(cè)系統(tǒng)應(yīng)具有的基本功能的列表拒垃。
- 作為用戶,我想注冊(cè)瓷蛙,以便我可以訪問需要注冊(cè)的內(nèi)容
- 作為用戶悼瓮,我想在注冊(cè)后確認(rèn)我的電子郵件地址
- 作為用戶,我想登錄并注銷
- 作為用戶艰猬,我想更改我的密碼
- 作為用戶横堡,我想更改我的電子郵件地址
- 作為用戶,我想要重置我的密碼冠桃,以便我忘記密碼后可以登錄
- 作為用戶命贴,我想更新我的個(gè)人資料,以便我可以提供我正確的聯(lián)絡(luò)資料
- 作為用戶食听,我想關(guān)閉我的帳戶胸蛛,以便我可以關(guān)閉我與我注冊(cè)的服務(wù)的關(guān)系
- 作為管理員,我想手動(dòng)管理(創(chuàng)建/刪除/更新)用戶樱报,以便工作人員不必重新進(jìn)行注冊(cè)過程
- 作為管理員葬项,我想手動(dòng)創(chuàng)建用戶,這樣工作人員就不用再過注冊(cè)過程了
- 作為管理員迹蛤,我想列出所有用戶民珍,即使是那些曾經(jīng)關(guān)閉帳戶的用戶
- 作為管理員襟士,我希望能夠看到用戶的活動(dòng)(登錄,注銷嚷量,密碼重置陋桂,確認(rèn),個(gè)人資料更新)津肛,以便我可以遵守外部審計(jì)要求
工作流程
我們來看看系統(tǒng)將要支持什么樣的工作流程章喉。首先,人們應(yīng)該能夠注冊(cè)和登錄身坐,這些是相當(dāng)明顯的功能秸脱。
但是,處理確認(rèn)令牌時(shí)需要謹(jǐn)慎部蛇。 由于它們可用于執(zhí)行特權(quán)操作摊唇,因此我們使用一次性隨機(jī)令牌來處理密碼重置和電子郵件確認(rèn)。
當(dāng)一個(gè)新的令牌由用戶生成涯鲁,無論什么原因巷查,所有以前的都是無效的。 當(dāng)有人記住他們的密碼時(shí)抹腿,以前發(fā)出的和有效的密碼重置令牌必須過期岛请。
非功能性需求
用戶故事通常不會(huì)定義非功能性要求,例如安全性警绩,開發(fā)原理崇败,技術(shù)棧等。所以我們?cè)谶@里單獨(dú)列出肩祥。
- 領(lǐng)域模型是使用域驅(qū)動(dòng)的設(shè)計(jì)原則在純 Java 中實(shí)現(xiàn)的后室,并且獨(dú)立于要使用的底層技術(shù)棧
- 當(dāng)用戶登錄時(shí),將為他們生成一個(gè) JWT 令牌混狠,有效期是 24 小時(shí)岸霹。在后續(xù)請(qǐng)求中包含此令牌,用戶可以執(zhí)行需要身份驗(yàn)證的操作
- 密碼重置令牌有效期為 10 分鐘将饺,電子郵件地址確認(rèn)令牌為一天
- 密碼用加密算法(Bcrypt)加密贡避,并且每用戶加鹽
- 提供了 RESTful API,用于與用戶注冊(cè)服務(wù)進(jìn)行交互
- 應(yīng)用程序?qū)⒕哂心K化設(shè)計(jì)俯逾,以便能夠?yàn)楦鞣N場(chǎng)景提供單獨(dú)的部署工件(例如贸桶,針對(duì) Google App Engine 的 2.5 servlet 兼容 WAR 和其他用例的基于 Spring Boot 的自包含可執(zhí)行 JAR)
- 實(shí)體標(biāo)識(shí)符以數(shù)據(jù)庫(kù)無關(guān)的方式生成,也就是說桌肴,不會(huì)使用數(shù)據(jù)庫(kù)特定機(jī)制(AUTO_INCREMENT 或序列)來獲取下一個(gè) ID 值皇筛。解決方案將類似于 Instagram genetes ID。
領(lǐng)域模型
對(duì)于第一輪實(shí)現(xiàn)中坠七,我們只關(guān)注三個(gè)實(shí)體水醋,即用戶旗笔,確認(rèn)令牌和用戶事件。
Rest api
訪問下面的大多數(shù) API 都需要認(rèn)證拄踪,否則返回一個(gè) UNAUTHORIZED 狀態(tài)碼蝇恶。 如果用戶嘗試查詢屬于某個(gè)其他用戶的實(shí)體,則他們還會(huì)返回客戶端錯(cuò)誤(FORBIDDEN)惶桐,除非他具有管理權(quán)限撮弧。 如果指定的實(shí)體不存在,則調(diào)用的端點(diǎn)返回 NOT_FOUND姚糊。
創(chuàng)建會(huì)話(POST /sessions)和注冊(cè)新用戶(POST / users)是公開的贿衍,它們不需要身份驗(yàn)證。
Session management
GET /session/{session_id}
如果沒有給定 ID 的會(huì)話或者會(huì)話已經(jīng)過期救恨,則返回給定會(huì)話的詳細(xì)信息或 NOT_FOUND贸辈。
POST /session
創(chuàng)建新會(huì)話,前提是指定的電子郵件和密碼對(duì)屬于一個(gè)有效的用戶肠槽。
DELETE /session/{session_id}
刪除給定的會(huì)話(注銷)
User management
GET /users/{user_id}
根據(jù)一個(gè)指定的 ID 查找用戶擎淤。
GET /users
列舉系統(tǒng)中所有的用戶
POST /users
注冊(cè)一個(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ū)動(dòng)設(shè)計(jì)
在第一部分中,作者提到了將使用領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)原則寂纪,這意味著炊汤,該模型可以不依賴于任何框架或基礎(chǔ)設(shè)施類。在多次應(yīng)用實(shí)現(xiàn)過程中弊攘,作者把領(lǐng)域模型和框架的具體注釋(如 JPA 或 Hibernate )混在一起,就如同和 Java POJO 一起工作(貧血模型)姑曙。在設(shè)計(jì)領(lǐng)域模型中襟交,唯一使用的庫(kù)是Lombok,用于減少定義的 getter 和 setter 方法以避免冗余伤靠。
當(dāng)設(shè)計(jì) DDD 的模型捣域,第一步是對(duì)類進(jìn)行分類。在埃里克·埃文斯書中的第二部分專注于模型驅(qū)動(dòng)設(shè)計(jì)的構(gòu)建模塊宴合』烂罚考慮到這一點(diǎn),我們的模型分為以下幾類卦洽。
實(shí)體類
實(shí)體有明確的標(biāo)識(shí)和生命周期需要被管理贞言。從這個(gè)角度來看,用戶肯定是一個(gè)實(shí)體阀蒂。
ConfirmationToken 就是一個(gè)邊緣的例子该窗,因?yàn)樵跊]有用戶上下文的情況下弟蚀,邏輯上它就不存在,而另一方面酗失,它可以通過令牌的值來標(biāo)識(shí)并且它有自己的生命周期义钉。
同樣的方法也適用于 Session ,這也可能是一個(gè)值對(duì)象规肴,由于其不可改變的性質(zhì)捶闸,但它仍然有一個(gè) ID 和一個(gè)生命周期(會(huì)話過期)。
值對(duì)象
相對(duì)于實(shí)體類拖刃,值對(duì)象沒有一個(gè)明確的 ID 删壮,那就是,他們只是將一系列屬性組合序调,并且醉锅,如果這些屬性和另外一個(gè)相同類型的值對(duì)象的屬性相同,那么我們就可以認(rèn)為這兩個(gè)值對(duì)象是相同的发绢。
當(dāng)設(shè)計(jì)領(lǐng)域模型硬耍,值對(duì)象提供了一種方便的方式來描述攜帶有一定的信息片段屬性的集合。 AddressData边酒,AuditData经柴,ContactData 和 Password 因此可以認(rèn)為是值對(duì)象。
雖然將所有這些屬性實(shí)現(xiàn)為不可改變的是不切實(shí)際的墩朦,他們的某些屬性可以單獨(dú)被修改坯认, Password 是一個(gè)很好的例子。當(dāng)我們創(chuàng)建 Password 的實(shí)例氓涣,它的鹽和哈希創(chuàng)建只有一次牛哺。在改變密碼時(shí),一個(gè)全新的實(shí)例與新的鹽和散列將會(huì)被創(chuàng)建劳吠。
聚合
聚合代表一組結(jié)合在一起引润,并通過訪問所謂的聚合根的對(duì)象。
這兒有兩個(gè)聚合對(duì)象:用戶和會(huì)話痒玩。前者包含了所有與用戶相關(guān)的實(shí)體和值對(duì)象淳附,而后者只包含一個(gè)單一的實(shí)體 Session 。
顯然蠢古,用戶聚合根是用戶實(shí)體奴曙。通過一個(gè)實(shí)例用戶實(shí)體,我們可以管理確認(rèn)令牌草讶,用戶事件和用戶的密碼洽糟。
聚合 Session 成為一個(gè)獨(dú)立的實(shí)體——盡管被捆綁到一個(gè)用戶的上下文——部分原因是由于其一次性性質(zhì),部分是因?yàn)楫?dāng)我們查找一個(gè)會(huì)話時(shí)我們不知道用戶是誰。 Session 被創(chuàng)建之后脊框,要么過期颁督,要么按需刪除。
領(lǐng)域事件
當(dāng)需要由系統(tǒng)的另外組件處理的事件發(fā)生時(shí)浇雹,領(lǐng)域事件就會(huì)被觸發(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)建和銷毀用戶會(huì)話渺鹦。
存儲(chǔ)庫(kù)
存儲(chǔ)庫(kù)旨在代表一個(gè)實(shí)體對(duì)象的概念集合帕膜,但是有時(shí)他們只是作為數(shù)據(jù)訪問對(duì)象飞醉。有兩種實(shí)現(xiàn)方法嘱吗,一種方法是列出所有的抽象存儲(chǔ)庫(kù)類或超接口可能的數(shù)據(jù)訪問方法,例如 Spring Data 览绿,或者創(chuàng)建專門存儲(chǔ)庫(kù)接口萍丐。
對(duì)于用戶管理應(yīng)用程序相叁,作者選擇了第二種方法柄冲。UserRepository 和 SessionRepository 只列出那些絕對(duì)必要的處理他們實(shí)體的方法吻谋。
項(xiàng)目結(jié)構(gòu)
你可能已經(jīng)注意到,這里有一個(gè) GitHub 上的庫(kù): springuni 现横,它包含用戶管理應(yīng)用程序的一部分漓拾,但它不包含應(yīng)用程序本身的可執(zhí)行版本。
究其原因戒祠,我為什么不提供單一只包含 Spring Boot 少量 @Enable* 注解的庫(kù)骇两,是為了可重用性。大多數(shù)我碰到的項(xiàng)目第一眼看起來是可以模塊化的姜盈,但實(shí)際上他們只是沒有良好分解職責(zé)的巨大單體應(yīng)用脯颜。當(dāng)你試圖重用這樣一個(gè)項(xiàng)目的模塊,你很快意識(shí)到贩据,它依賴于許多其他模塊和/或過多的外部庫(kù)。
springuni-particles (它可能已被也稱為 springuni 模塊)提供了多個(gè)模塊的可重復(fù)使用的只為某些明確定義的功能闸餐。用戶和會(huì)話管理是很好的例子饱亮。
模塊
springuni-auth-model 包含了所有的領(lǐng)域模型類和用于管理用戶生命周期的業(yè)務(wù)邏輯,它是完全與框架無關(guān)的舍沙。它的存儲(chǔ)庫(kù)近上,并且可以使用任何數(shù)據(jù)存儲(chǔ)機(jī)制,對(duì)于手頭的實(shí)際任務(wù)最符合拂铡。還有壹无,PasswordChecker 和 PasswordEncryptor 可基于任何強(qiáng)大的密碼散列技術(shù)實(shí)現(xiàn)葱绒。
springuni-commons 包含了通用的工具庫(kù)。有很多著名的第三方庫(kù)(如 Apache Commons Lang斗锭,Guava 等)地淀,這外延了 JDK 的標(biāo)準(zhǔn)庫(kù)。在另一方面岖是,我發(fā)現(xiàn)自己很多時(shí)候僅僅只用這些非嘲锘伲可擴(kuò)展庫(kù)的少量類。我特別喜歡的 Apache Commons Lang 中的 StringUtils 的和 Apache 共同集合的 CollectionUtils 類豺撑,但是烈疚,我寧愿為當(dāng)前項(xiàng)目提供一個(gè)高度定制化的 StringUtils 和 CollectionUtils,這樣就不需要添加外部依賴聪轿。
sprinuni-crm-model 定義了通用的值對(duì)象爷肝,用于處理聯(lián)系人數(shù)據(jù),如地址陆错,國(guó)家等灯抛。雖然微服務(wù)架構(gòu)的倡導(dǎo)者將投票反對(duì)使用共享庫(kù),但我認(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)域模型的小型通用庫(kù)是值得嘗試的磨确。
構(gòu)建用戶管理微服務(wù)(三):實(shí)現(xiàn)和測(cè)試存儲(chǔ)庫(kù)
詳細(xì)介紹一個(gè)完整的基于 JPA 的用戶存儲(chǔ)庫(kù)實(shí)現(xiàn),一個(gè) JPA 的支撐模型和一些測(cè)試用例声邦。
使用 XML 來映射簡(jiǎn)單的 JAVA 對(duì)象
僅看到用戶存儲(chǔ)庫(kù)乏奥,也許你就能想到在對(duì)它添加基于 JPA 的實(shí)現(xiàn)時(shí)會(huì)遇到什么困難。
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ū)動(dòng)設(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ū)動(dòng)設(shè)計(jì)是一種持久化無關(guān)的方法句葵,因此堅(jiān)持設(shè)計(jì)一個(gè)沒有具體目標(biāo)數(shù)據(jù)結(jié)構(gòu)的模型可能很有挑戰(zhàn)性。當(dāng)然, 它也存在優(yōu)勢(shì), 即可對(duì)現(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<>();
}
一般來說乍丈,一組簡(jiǎn)單的字符串或枚舉值就能對(duì)用戶的權(quán)限(或特權(quán))進(jìn)行建模了剂碴。
使用像 MongoDB 這樣的文檔數(shù)據(jù)庫(kù)能夠輕松自然地維護(hù)這個(gè)模型,如下所示轻专。(順便一提, 我還計(jì)劃在本系列的后續(xù)內(nèi)容中添加一個(gè)基于 Mongo 的存儲(chǔ)庫(kù)實(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) 將自動(dòng)生成必要的子關(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)該只擁有那些對(duì)它們的操作來說絕對(duì)必要的依賴關(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 的自動(dòng)配置需要一定的初始化荚坞。因?yàn)楣雀钁?yīng)用引擎標(biāo)準(zhǔn)環(huán)境是我的目標(biāo)平臺(tái)之一挑宠,因此能否快速地啟動(dòng)是至關(guān)重要的。
單元測(cè)試存儲(chǔ)庫(kù)
雖然有人可能會(huì)說, 對(duì)于存儲(chǔ)庫(kù)沒必要進(jìn)行過多的測(cè)試, 尤其是在使用 Spring Data 的 存儲(chǔ)庫(kù)接口的時(shí)候颓影。但是我認(rèn)為測(cè)試代碼可以避免運(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è)測(cè)試用例啟動(dòng)了一個(gè)具有嵌入式 H2 數(shù)據(jù)庫(kù)的實(shí)體管理器诡挂。H2 非常適合于測(cè)試, 因?yàn)樗С衷S多眾所周知的數(shù)據(jù)庫(kù) (如 MySQL) 的兼容模式碎浇,可以模擬你的真實(shí)數(shù)據(jù)庫(kù)。
構(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 平臺(tái)上,REST 就是選擇通過使用 http 協(xié)議和 uri城豁,利用 client/server model 對(duì)資源進(jìn)行 CRUD (Create/Read/Update/Delete) 增刪改查操作苟穆。
使用 REST 結(jié)構(gòu)風(fēng)格是因?yàn)椋S著時(shí)代的發(fā)展唱星,傳統(tǒng)前后端融為一體的網(wǎng)頁模式無法滿足需求雳旅,而 RESTful 可以通過一套統(tǒng)一的接口為 Web,iOS 和 Android 提供服務(wù)间聊。另外對(duì)于廣大平臺(tái)來說岭辣,比如 Facebook platform,微博開放平臺(tái)甸饱,微信公共平臺(tái)等,他們需要一套提供服務(wù)的接口,于是 RESTful 更是它們最好的選擇叹话。
REST 端點(diǎn)的支撐模塊
我經(jīng)手的大多數(shù)項(xiàng)目偷遗,都需要對(duì)控制器層面正確地進(jìn)行 Spring MVC 的配置。隨著近幾年單頁應(yīng)用程序的廣泛應(yīng)用驼壶,越來越不需要在 Spring mvc 應(yīng)用程序中配置和開發(fā)視圖層 (使用 jsp 或模板引擎)氏豌。
現(xiàn)在,創(chuàng)建完整的 REST 后端的消耗并生成了 JSON 是相當(dāng)?shù)湫偷? 然后通過 SPA 或移動(dòng)應(yīng)用程序直接使用热凹”么基于以上所講, 我收集了 Spring MVC 常見配置,這能實(shí)現(xiàn)對(duì)后端的開發(fā)般妙。
- Jackson 用于生成和消解 JSON
- application/json 是默認(rèn)的內(nèi)容類型
- ObjectMapper 知道如何處理 Joda 和 JSR-310 日期/時(shí)間 api, 它在 iso 格式中對(duì)日期進(jìn)行序列化, 并且不將缺省的值序列化 (NON_ABSENT)
- ModelMapper 用于轉(zhuǎn)換為 DTO 和模型類
- 存在一個(gè)自定義異常處理程序, 用于處理 - EntityNotFoundException 和其他常見應(yīng)用程序級(jí)別的異常
- 捕獲未映射的請(qǐ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ò)誤頁碟渺。但是鲜锚,對(duì)于一個(gè)純粹的 JSON-based REST 后端, 這不是一個(gè)需求, 因?yàn)樗目蛻羰?SPA 或移動(dòng)應(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ò)誤的文本表示和對(duì)客戶端的詳細(xì)信息绒极,RestErrorHandler 負(fù)責(zé)生成針對(duì)應(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)請(qǐng)求
為了處理未映射的請(qǐ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)相比摘昌,并沒有什么特別的。但是,我有一些最近意識(shí)到的小細(xì)節(jié)想要補(bǔ)充高蜂。
- 正如 Spring 4.3 有一堆用于定義請(qǐng)求處理程序的速記注解聪黎,@GetMapping 是一個(gè)組合的注解, 它為 @RequestMapping (method = RequestMethod. GET) 作為其對(duì)應(yīng)的 @PostMapping、@PutMapping 等的快捷方式备恤。
- 我找到了一個(gè)用于處理從/到模型類轉(zhuǎn)換的 DTO 的模塊映射庫(kù) 稿饰。在此之前,我用的是 Apache Commons Beanutils露泊。
- 手動(dòng)注冊(cè)控制器來加快應(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)自動(dòng)的, 但在某些情況下需要進(jìn)行手動(dòng)調(diào)整侣姆。比如說生真,用戶的密碼。這是我們絕對(duì)不想暴露的內(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()); }
...
}
測(cè)試 REST 控制器 自 MockMvc 在 Spring 3.2 上推出以來, 使用 Spring mvc 測(cè)試 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í)候术瓮,測(cè)試控制器顯得更為合適。
構(gòu)建用戶管理微服務(wù)(五):使用 JWT 令牌和 Spring Security 來實(shí)現(xiàn)身份驗(yàn)證
我們已經(jīng)建立了業(yè)務(wù)邏輯贰健、數(shù)據(jù)訪問層和前端控制器, 但是忽略了對(duì)身份進(jìn)行驗(yàn)證胞四。隨著 Spring Security 成為實(shí)際意義上的標(biāo)準(zhǔn), 將會(huì)在在構(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)用或者移動(dò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), 它定義了一種簡(jiǎn)潔且獨(dú)立的方式, 讓在各方之間的 JSON 對(duì)象安全地傳輸信息。而經(jīng)過數(shù)字簽名的信息也可以被驗(yàn)證和信任茁计。
JWT 的應(yīng)用越來越廣泛, 而因?yàn)樗禽p量級(jí)的料皇,你也不需要有一個(gè)用來驗(yàn)證令牌的認(rèn)證服務(wù)器。與 OAuth 相比, 這有利有弊星压。如果 JWT 令牌被截獲践剂,它可以用來模擬用戶, 也無法防范使用這個(gè)被截獲的令牌繼續(xù)進(jìn)行身份驗(yàn)證。
真正的 JWT 令牌看起來像下面這樣:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJsYXN6bG9fQVRfc3ByaW5ndW5pX0RPVF9jb20iLCJuYW1lIjoiTMOhc3psw7MgQ3NvbnRvcyIsImFkbWluIjp0cnVlfQ.
XEfFHwFGK0daC80EFZBB5ki2CwrOb7clGRGlzchAD84
JWT 令牌的第一部分是令牌的 header , 用于標(biāo)識(shí)令牌的類型和對(duì)令牌進(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)多的第三方庫(kù)可用于操作 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 () 會(huì)引發(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 令牌
第一次登錄變完成啟動(dòng), 且在這一過程中, 將創(chuàng)建一個(gè) JWT 令牌并將其發(fā)送回客戶端盗胀。這些是通過以下請(qǐng)求完成的:
POST /session
{
"username": "laszlo_AT_sprimguni_DOT_com",
"password": "secret"
}
成功登錄后, 客戶端會(huì)像往常一樣向其他端點(diǎn)發(fā)送后續(xù)請(qǐng)求, 并在授權(quán)的 header 中提供本地緩存的 JWT 令牌艘蹋。
Authorization: Bearer <JWT token>
正如上面的步驟所講, LoginFilter 開始進(jìn)行登錄過程。而Spring Security 的內(nèi)置 UsernamePasswordAuthenticationFilter 被延長(zhǎng), 來讓這種情況發(fā)生票灰。這兩者之間的唯一的區(qū)別是, UsernamePasswordAuthenticationFilter 使用表單參數(shù)來捕獲用戶名和密碼, 相比之下, LoginFilter 將它們視做 JSON 對(duì)象女阀。
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)簡(jiǎn)單屑迂。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);
}
}
以下是它的對(duì)應(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ù)請(qǐng)求
在客戶端登陸后, 它將在本地緩存 JWT 令牌, 并在前面討論的后續(xù)請(qǐng)求中發(fā)送反回惹盼。
對(duì)于每個(gè)請(qǐng)求, 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;
}
}
}
如果令牌是有效的, 則會(huì)實(shí)例化 JwtAuthenticationToken, 并執(zhí)行線程的 SecurityContext。而由于恢復(fù)的 JWT 令牌包含唯一的 ID 和經(jīng)過身份驗(yàn)證的用戶的權(quán)限, 因此無需與數(shù)據(jù)庫(kù)聯(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;
}
}
在這之后, 它由安全框架決定是否允許或拒絕請(qǐng)求蚯舱。
Spring Security 在 Java EE 世界中有競(jìng)爭(zhēng)者嗎?
雖然這不是這篇文章的主題, 但我想花一分鐘的時(shí)間來談?wù)勓诟颉H绻也坏貌辉谝粋€(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ā)布,我想看看它是否會(huì)是 Spring Security 一個(gè)強(qiáng)大的競(jìng)爭(zhēng)者蜈亩。我發(fā)現(xiàn) JAVA EE 8 將提供 JSR-375 , 這應(yīng)該會(huì)緩解 JAVA EE 應(yīng)用程序的安全措施的發(fā)展懦窘。它的參考實(shí)施被稱為 Soteira, 是一個(gè)相對(duì)新的 github 項(xiàng)目。那就是說, 現(xiàn)在的答案是真的沒有這樣的一個(gè)競(jìng)爭(zhēng)者稚配。
但這項(xiàng)研究是不完整的畅涂,并沒有提到 Apache Shiro。雖然我從未使用過道川,但我聽說這算是更為簡(jiǎn)單的 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ù)請(qǐng)求灯帮。創(chuàng)造長(zhǎng)壽的JWT是不實(shí)際的,因?yàn)樗鼈兪仟?dú)立的逻住,沒有辦法撤銷它們钟哥。如果令牌被盜,所有賭注都會(huì)關(guān)閉瞎访。因此腻贰,我想添加經(jīng)典的remember-me風(fēng)格認(rèn)證與持久令牌。記住扒秸,我的令牌存儲(chǔ)在Cookie中作為JWT作為第一道防線播演,但是它們也保留在數(shù)據(jù)庫(kù)中,并且跟蹤其生命周期伴奥。
這次我想從演示運(yùn)行中的用戶管理應(yīng)用程序的工作原理開始写烤,然后再深入細(xì)節(jié)。
這次我想從演示運(yùn)行中的用戶管理應(yīng)用程序的工作原理開始渔伯,然后再深入細(xì)節(jié)顶霞。
驗(yàn)證流程
基本上,用戶使用用戶名/密碼對(duì)進(jìn)行身份驗(yàn)證會(huì)發(fā)生什么锣吼,他們可能會(huì)表示他們希望應(yīng)用程序記住他們(持續(xù)會(huì)話)的意圖选浑。大多數(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è)永久會(huì)話隧膘,將其保存到數(shù)據(jù)庫(kù)并將其轉(zhuǎn)換為JWT令牌。它負(fù)責(zé)將此持久會(huì)話存儲(chǔ)在客戶端的一個(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í)腔呜,它會(huì)在每個(gè)XHR請(qǐng)求的授權(quán)頭文件中發(fā)送暫時(shí)的JWT令牌叁温。然而,當(dāng)應(yīng)用程序重新加載時(shí)核畴,暫時(shí)令牌將丟失膝但。
為了簡(jiǎn)單起見,這里使用GET / users / {id}來演示正常的請(qǐng)求谤草。
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í)跟束,會(huì)發(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令牌氧腰。所有它可以做的是測(cè)試持久的remember-me cookie嘗試執(zhí)行一個(gè)正常的請(qǐng)求枫浙。
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)仍然有效,則會(huì)在上次使用數(shù)據(jù)庫(kù)時(shí)在數(shù)據(jù)庫(kù)中進(jìn)行更新古拴,并在瀏覽器中更新箩帚。還執(zhí)行另一個(gè)重要步驟,用戶將自動(dòng)重新進(jìn)行身份驗(yàn)證黄痪,而無需提供用戶名/密碼對(duì)紧帕,并創(chuàng)建新的臨時(shí)令牌。從現(xiàn)在開始桅打,只要運(yùn)行該應(yīng)用程序是嗜,該應(yīng)用程序?qū)⑹褂脮簳r(shí)令牌。
注銷
盡管注銷看起來很簡(jiǎn)單挺尾,有一些細(xì)節(jié)我們需要注意鹅搪。前端仍然發(fā)送無狀態(tài)的JWT令牌,只要用戶進(jìn)行身份驗(yàn)證遭铺,否則UI上的注銷按鈕甚至不會(huì)被提供丽柿,后臺(tái)也不會(huì)知道如何注銷。
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
在此請(qǐng)求之后魂挂,記住我的cookie被重置甫题,并且數(shù)據(jù)庫(kù)中的持久會(huì)話被標(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ù)非常簡(jiǎn)單赵辕,因?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è)簡(jiǎn)單的包裝器还惠,最終將返回的User模型對(duì)象轉(zhuǎn)換為框架特定的UserDetails實(shí)例。除此之外私杜,在這個(gè)項(xiàng)目中蚕键,我們不直接使用用戶的登錄名(電子郵件地址或屏幕名稱)。相反衰粹,他們的用戶的身份證遍及各地锣光。
實(shí)現(xiàn) PersistentTokenRepository
幸運(yùn)的是,我們?cè)谔砑舆m當(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中存儲(chǔ)記住我的令牌的物化形式。Spring Security的默認(rèn)格式也可以很好瓢捉,但JWT增加了一個(gè)額外的安全層频丘。默認(rèn)實(shí)現(xiàn)沒有簽名,每個(gè)請(qǐng)求最終都是數(shù)據(jù)庫(kù)中的一個(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注冊(cè)組件惊科,并啟用記住我的服務(wù)。有趣的是亮钦,我們需要一個(gè)在AbstractRememberMeServices 內(nèi)部使用的鍵(一個(gè)字符串)馆截。 AbstractRememberMeServices 也是此設(shè)置中的默認(rèn)注銷處理程序,并在注銷時(shí)將數(shù)據(jù)庫(kù)中的令牌標(biāo)記為已刪除。
陷阱 - 在POST請(qǐng)求的正文中接收用戶憑據(jù)和remember-me標(biāo)志作為JSON數(shù)據(jù)
默認(rèn)情況下蜡娶, UsernamePasswordAuthenticationFilter會(huì)將憑據(jù)作為POST請(qǐng)求的HTTP請(qǐng)求參數(shù)混卵,但是我們希望發(fā)送JSON文檔。進(jìn)一步下去窖张, AbstractRememberMeServices還會(huì)將remember-me標(biāo)志的存在檢查為請(qǐng)求參數(shù)幕随。為了解決這個(gè)問題,LoginFilter 將remember-me標(biāo)志設(shè)置為請(qǐng)求屬性宿接,并將決定委托給 PersistentTokenBasedRememberMeServices, 如果記住我的身份驗(yàn)證需要啟動(dòng)或不啟動(dòng)赘淮。
使用RememberMeServices處理登錄成功
RememberMeAuthenticationFilter不會(huì)繼續(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í)副女,該過濾器不會(huì)停止蛤高。
構(gòu)建用戶管理微服務(wù)器(七):將以上組合在一起
從絕對(duì)零開始,用戶管理應(yīng)用程序的構(gòu)建塊已被開發(fā)出來碑幅。在最后一篇中襟齿,我想向您展示如何組裝這些部分,以使應(yīng)用程序正常工作枕赵。一些功能仍然缺少,我仍然在第一個(gè)版本上工作位隶,使其功能完整拷窜,但現(xiàn)在基本上是可以使用的。
創(chuàng)建一個(gè)獨(dú)立的可執(zhí)行模塊
今天建立基于Spring的應(yīng)用程序最簡(jiǎn)單的方法是去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-web和spring-boot-starter-tomcat。為了能夠創(chuàng)建一個(gè)Web應(yīng)用程序域仇,我們需要它們刑然。
應(yīng)用程序的入口點(diǎn)
在沒有Spring Boot的情況下執(zhí)行此步驟將會(huì)非常費(fèi)力(必須在web.xml中注冊(cè)上下文監(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配置類集惋。
啟動(dòng)
Spring Boot附帶了一個(gè)非常有用的Maven插件,可以將整個(gè)項(xiàng)目重新打包成一個(gè)可執(zhí)行的überJAR佛南。它也能夠在本地啟動(dòng)項(xiàng)目绷雏。
mvn -pl springuni-auth-boot spring-boot:run
測(cè)試驅(qū)動(dòng)用戶管理應(yīng)用程序
第一部分定義了所有可用的REST端點(diǎn),現(xiàn)在已經(jīng)有一些現(xiàn)實(shí)世界的用例來測(cè)試它們蝠检。
注冊(cè)新用戶
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í)首次登錄嘗試不可避免地會(huì)失敗沐鼠,因?yàn)橛脩魩ぬ?hào)尚未確認(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)帳號(hào)
一般情況下,最終用戶將收到一封電子郵件中的確認(rèn)鏈接叹谁,點(diǎn)擊該鏈接會(huì)啟動(dòng)以下請(qǐng)求饲梭。
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