在線演示https://" />

国产成人精品无码青草_亚洲国产美女精品久久久久∴_欧美人与鲁交大毛片免费_国产果冻豆传媒麻婆精东

15158846557 在線咨詢 在線咨詢
15158846557 在線咨詢
所在位置: 首頁(yè) > 營(yíng)銷資訊 > 網(wǎng)站運(yùn)營(yíng) > 請(qǐng)問如何微信掃碼登錄網(wǎng)站同時(shí)關(guān)注公眾號(hào)?

請(qǐng)問如何微信掃碼登錄網(wǎng)站同時(shí)關(guān)注公眾號(hào)?

時(shí)間:2023-11-23 03:36:01 | 來(lái)源:網(wǎng)站運(yùn)營(yíng)

時(shí)間:2023-11-23 03:36:01 來(lái)源:網(wǎng)站運(yùn)營(yíng)

請(qǐng)問如何微信掃碼登錄網(wǎng)站同時(shí)關(guān)注公眾號(hào)?:
我的(文章)很長(zhǎng),你忍一下?

太長(zhǎng)不看版本?

本文通過(guò)一個(gè)實(shí)際的具有一定商業(yè)價(jià)值的項(xiàng)目,展示了 API 優(yōu)先的開發(fā)方法。通過(guò)薅羊毛的方式,落地了 Free Arch 架構(gòu)。



在線演示

https://wechat-mp.herokuapp.com/login ?

源代碼

https://github.com/Jeff-Tian/securing-web-with-wechat-mp

背景和價(jià)值

?

通過(guò)微信公眾號(hào)積累粉絲并進(jìn)行商業(yè)活動(dòng)宣傳,是新媒體運(yùn)營(yíng)的常見方式。而系統(tǒng)對(duì)接微信登錄,既能給用戶帶來(lái)便利,同時(shí)也能夠給系統(tǒng)引流。但是傳統(tǒng)或者標(biāo)準(zhǔn)的 PC 網(wǎng)頁(yè)端微信掃碼登錄,用戶掃碼只需要做 OAuth 授權(quán),不必關(guān)注公眾號(hào)。但是對(duì)于運(yùn)營(yíng)者來(lái)說(shuō),更希望通過(guò)微信登錄的用戶,自動(dòng)成為微信粉絲,實(shí)現(xiàn)系統(tǒng)中微信用戶和公眾號(hào)粉絲的一一對(duì)應(yīng)。 ?

所以,關(guān)注公眾號(hào)即登錄,將系統(tǒng)的微信用戶和公眾號(hào)粉絲等同起來(lái),方便了運(yùn)營(yíng)同學(xué)。 ?

另外,傳統(tǒng)的微信掃碼登錄,需要在公眾平臺(tái)之外,額外再在開發(fā)平臺(tái)申請(qǐng)一個(gè)應(yīng)用,再和公眾號(hào)做綁定,多了一個(gè)賬號(hào),就多了一份維護(hù)工作,增加了管理者的心智負(fù)擔(dān),還要多花錢,畢竟兩個(gè)不同的賬號(hào)需要單獨(dú)繳費(fèi)和認(rèn)證。 ?

以及,開發(fā)平臺(tái)和公眾平臺(tái)的 openid 不一樣,還需要通過(guò) unionid 的機(jī)制做關(guān)聯(lián),增加了開發(fā)的心智負(fù)擔(dān)和開發(fā)成本。 ?

以上,通過(guò)關(guān)注公眾號(hào)即登錄的方案,都可以避免,和微信打交道的全程只需要 openid 即可。

Java Spring-Security

Spring Security 是一個(gè)專注在 Java 應(yīng)用中提供認(rèn)證和授權(quán)的框架。和所有 Spring 項(xiàng)目一樣,Spring Security 的真正威力在于其極易擴(kuò)展已滿足定制化的需求,為認(rèn)證和授權(quán)提供完整的和可擴(kuò)展的支持。

Open API

Open API 即開放 API,也成為開放平臺(tái)。它是服務(wù)型網(wǎng)站常見的一種應(yīng)用,網(wǎng)站的服務(wù)商將自己的網(wǎng)站服務(wù)封裝成一系列 API 開放出去,供第三方開發(fā)者使用,這種行為就叫做開放網(wǎng)站的 API,所開放的 API 就被稱作 Open API。

Open API 規(guī)范始于 Swagger 規(guī)范,經(jīng)過(guò) Reverb Technologies 和 SmartBear 等公司多年的發(fā)展,Open API 計(jì)劃擁有該規(guī)范。規(guī)范是一種與語(yǔ)言無(wú)關(guān)的格式,用于描述 Web 服務(wù),應(yīng)用程序可以解釋生成的文件,這樣才能生成代碼、生成文檔并根據(jù)其描述的服務(wù)創(chuàng)建模擬應(yīng)用。 ?

Swagger 的目標(biāo)是為 API 定義一個(gè)標(biāo)準(zhǔn)的,與語(yǔ)言無(wú)關(guān)的接口,使人和計(jì)算機(jī)在看不到源碼或者看不到文檔或者不能通過(guò)網(wǎng)絡(luò)流量檢測(cè)的情況下能夠發(fā)現(xiàn)和理解各種服務(wù)的功能。當(dāng)服務(wù)通過(guò) Swagger 定義,消費(fèi)者通過(guò)少量的實(shí)現(xiàn)邏輯就能與遠(yuǎn)程服務(wù)互動(dòng)。類似于低級(jí)編程接口,Swagger 去掉了調(diào)用服務(wù)時(shí)的很多猜測(cè)。 ?

關(guān)注公眾號(hào)即登錄的流程設(shè)計(jì)

PC 網(wǎng)頁(yè)站點(diǎn)實(shí)現(xiàn)微信登錄時(shí),需要通過(guò)用戶使用微信掃描網(wǎng)頁(yè)上展示的二維碼,然后在手機(jī)上的微信授權(quán)登錄。如何實(shí)現(xiàn)二維碼的展示,并“感知”用戶掃描事件,是需要解決的關(guān)鍵問題。傳統(tǒng)的 OAuth 微信掃碼登錄,由于本質(zhì)上是打開了一個(gè)微信官方的頁(yè)面,因此不需要關(guān)注這其中的細(xì)節(jié),但是不通過(guò)打開微信官方的頁(yè)面,就需要自行設(shè)計(jì)這個(gè)展示和感知的能力了。 ?

常規(guī) PC 網(wǎng)頁(yè)端微信掃碼登錄,PC 打開了微信官方的頁(yè)面,由微信官方展示二維碼,而手機(jī)掃描后,會(huì)在微信端跳出授權(quán)頁(yè)面,用戶確認(rèn)后,微信官方二維碼頁(yè)面會(huì)重定向至開發(fā)者在開放平臺(tái)設(shè)置好的回調(diào)頁(yè)面,并將臨時(shí)授權(quán)碼作為查詢字符串;而關(guān)注公眾號(hào)即登錄,則利用了微信的帶參二維碼功能,用戶掃描這種二維碼后,手機(jī)微信會(huì)展示開發(fā)者的微信公眾號(hào),同時(shí)將用戶信息(主要是 openid)通過(guò)服務(wù)器端 API 調(diào)用,發(fā)送給開發(fā)者服務(wù)器。該過(guò)程沒有二次確認(rèn),對(duì)于已關(guān)注過(guò)公眾號(hào)的用戶,直接發(fā)送掃描事件;對(duì)于新用戶,需要新用戶點(diǎn)擊關(guān)注,才會(huì)發(fā)送該事件。這里的難點(diǎn)在于如何感知用戶的掃描事件,以及保證服務(wù)器端 API 調(diào)用的安全(主要是確認(rèn)調(diào)用者真的是來(lái)自微信而不是偽造的請(qǐng)求)。 ?

下面通過(guò)闡述利用微信的帶參二維碼,通過(guò)接收微信服務(wù)器發(fā)送的消息來(lái)“感知”用戶的掃描事件。首先是帶參二維碼的生成,它是通過(guò)調(diào)用微信官方的接口完成的。 微信公眾平臺(tái)提供了兩種生成帶參數(shù)二維碼的接口,分別是一、臨時(shí)二維碼,有 過(guò)期時(shí)間,數(shù)量沒有明確的上限;二、永久二維碼,沒有過(guò)期時(shí)間,但是最多只能生成 10 萬(wàn)個(gè)。顯然,對(duì)于登錄場(chǎng)景,適合采用臨時(shí)二維碼。本文的方案里, 過(guò)期時(shí)間設(shè)置為 1 分鐘。如果用戶在打開登錄頁(yè)面的 1 分鐘內(nèi),都沒有掃碼,或者因?yàn)榫W(wǎng)絡(luò)等原因掃碼失敗,那么就展示二維碼過(guò)期,提示用戶刷新二維碼,這個(gè)體驗(yàn)和用戶登錄電腦版微信相似。其中調(diào)用生成二維碼接口的關(guān)鍵是需要傳遞場(chǎng)景值,每個(gè)場(chǎng)景值會(huì)和一個(gè)嘗試登錄的請(qǐng)求相關(guān),因此必須做到唯一。本文選擇使用 UUID(或者稱為 GUID)。UUID 由 128 位數(shù)字組成,其生成算法保證了 其極低的重復(fù)率,具體地說(shuō),如果以一秒鐘生成十億個(gè) UUID 的速度連續(xù)生成一年,才會(huì)有 50%的機(jī)會(huì)產(chǎn)生一個(gè)重復(fù) ID。下圖為未登錄用戶成功掃碼登錄系統(tǒng)的流程圖。

由上圖可以看出,開發(fā)者服務(wù)為嘗試登錄請(qǐng)求生成場(chǎng)景值后,會(huì)同時(shí)傳遞給微信服務(wù)和瀏覽器。這個(gè)場(chǎng)景值還會(huì)被后續(xù)查詢用戶掃描狀態(tài)時(shí)被使用。如果用戶不掃描,致使二維碼過(guò)期,那么這個(gè)場(chǎng)景值將會(huì)被丟棄,被認(rèn)為該嘗試登錄失敗。從上圖還可以看出,當(dāng)用戶掃描后,微信會(huì)自動(dòng)進(jìn)入開發(fā)者公眾號(hào)頁(yè)面,這為運(yùn)營(yíng)提供了很大的好處,因?yàn)楣娞?hào)頁(yè)面會(huì)展示歷史圖文信息,相比傳統(tǒng)的用 戶掃碼后,展示的信息要豐富得多。另外可以看到,無(wú)論用戶是否關(guān)注過(guò)公眾號(hào), 掃碼后,微信服務(wù)都會(huì)向開發(fā)者服務(wù)推送用戶的 openid 以及場(chǎng)景值。而且對(duì)于沒有關(guān)注過(guò)公眾號(hào)的新用戶,還會(huì)自動(dòng)關(guān)注,成為公眾號(hào)新粉絲。這樣就把系統(tǒng) 的微信用戶賬號(hào)和微信公眾號(hào)分析的屬性關(guān)聯(lián)了起來(lái)。場(chǎng)景值被系統(tǒng)用來(lái)更新掃碼狀態(tài),而 openid 用來(lái)關(guān)聯(lián)或者創(chuàng)建賬號(hào)。這一系列動(dòng)作完成后,系統(tǒng)還可以通 過(guò)微信渠道向用戶發(fā)送自定義的歡迎信息,這是傳統(tǒng)微信登錄方式很難做到的 (需要實(shí)現(xiàn)模板消息功能,但是模板消息的使用是受到嚴(yán)格監(jiān)控和限制的,而在掃碼后的消息回復(fù)則不受此限)。 ?

其次是掃碼狀態(tài)的更新,當(dāng)開發(fā)者服務(wù)器收到微信服務(wù)生成的二維碼后,就 處于等待用戶掃碼的階段,當(dāng)收到微信服務(wù)通知用戶掃碼成功或者超時(shí),開發(fā)者服務(wù)器應(yīng)該通知用戶端。因此這里需要一個(gè)即時(shí)消息服務(wù)。其狀態(tài)轉(zhuǎn)移如下圖所示,一共有三種狀態(tài):一、掃碼成功,收到微信服務(wù)通知的用戶 openid,場(chǎng)景值;二、掃碼失敗,收到微信服務(wù)通知的失敗原因;三、掃碼超時(shí),一段時(shí) 間沒有收到微信服務(wù)的通知。

開發(fā)者服務(wù)器端接收到微信服務(wù)通知或者超時(shí)后,需要通知客戶端,一般有 三種技術(shù)方案,即輪詢、長(zhǎng)連接以及 Socket IO。由于普通輪詢?yōu)榱吮3謱?shí)時(shí)性, 會(huì)在短時(shí)間內(nèi)發(fā)送大量的 HTTP 請(qǐng)求,不可取。而 Socket IO 實(shí)現(xiàn)較復(fù)雜,并且對(duì)服務(wù)器資源消耗過(guò)大,因此長(zhǎng)連接方案是最適合的。在這種方案下,客戶端向服務(wù)器端發(fā)送請(qǐng)求詢問掃碼狀態(tài),服務(wù)器只在掃碼成功或者超時(shí)的情況下給予回應(yīng),其他情況會(huì)掛起連接。因此在超時(shí)前,一個(gè)客戶端只會(huì)向服務(wù)器端發(fā)送一個(gè)查詢請(qǐng)求,有效地減輕了服務(wù)器端連接壓力。這里開發(fā)者服務(wù)會(huì)接受到客戶端查詢掃碼狀態(tài)和微信服務(wù)通知掃碼結(jié)果的 HTTP 請(qǐng)求,兩個(gè)請(qǐng)求到達(dá)的次序有可能 不同,在實(shí)現(xiàn)時(shí)需要注意。下面給出時(shí)序圖: ?




從上面的時(shí)序圖可以看出,只需要對(duì)情況一進(jìn)行查詢請(qǐng)求的掛起。另外,向客戶端返回掃碼結(jié)果后,一定要將緩存記錄清除,一方面更加安全,對(duì)重放的請(qǐng)求,因?yàn)椴樵儾坏綊叽a記錄會(huì)直接回復(fù)超時(shí);另一方面,可以及時(shí)釋放內(nèi)存,節(jié)省不必要的資源開銷。由于生產(chǎn)環(huán)境往往不止單個(gè)應(yīng)用實(shí)例,掃碼狀態(tài)需要緩存在獨(dú)立于應(yīng)用的緩存服務(wù)中,后續(xù)查詢請(qǐng)求即使被另外的應(yīng)用實(shí)例處理,也能返回正確的狀態(tài)。當(dāng)開發(fā)者服務(wù)收到掃碼成功的結(jié)果后,就可以將微信的 openid 作 為第三方登錄的 id,與自己的用戶數(shù)據(jù)庫(kù)中的用戶做關(guān)聯(lián)了。由于采用了關(guān)注公 眾號(hào)即登錄的流程,不再額外需要申請(qǐng)和維護(hù)微信開放平臺(tái)賬號(hào),也不再需要處理 unionid 的映射。 ?

本節(jié)通過(guò)仔細(xì)研究微信公眾號(hào)的開放 API 能力,梳理了一套非常規(guī)的微信掃碼登錄方案,通過(guò)嚴(yán)謹(jǐn)分析客戶端、開發(fā)者服務(wù)和微信服務(wù)三者間的 HTTP 溝通時(shí)序,實(shí)現(xiàn)了對(duì)用戶微信掃碼的感知能力,為最終實(shí)現(xiàn)關(guān)注公眾號(hào)即登錄打下了可行性基礎(chǔ)。 ?

應(yīng)用架構(gòu)設(shè)計(jì)

演示應(yīng)用(https://wechat-mp.herokuapp.com/login)的架構(gòu)圖大致如下,基本遵從 Free Arch(杜撰,免費(fèi)架構(gòu)),在保證工作的前提下節(jié)省成本。 ?







即通過(guò) Cloudflare,使用免費(fèi)的網(wǎng)絡(luò)防火墻服務(wù)。對(duì)于要實(shí)現(xiàn)的 Java Spring-Security 應(yīng)用,部署在 Heroku 這個(gè) PaaS 平臺(tái)上,也是免費(fèi)的。對(duì)于微信服務(wù),我們使用微信官方提供的測(cè)試號(hào),也是免費(fèi)的。但是對(duì)于測(cè)試公眾號(hào),有個(gè)限制是只能有 100 個(gè)關(guān)注者。但是對(duì)于演示來(lái)說(shuō)足夠用了,相信本文的閱讀量不會(huì)超過(guò) 100,如果超過(guò),甚至還有打賞,產(chǎn)生了收入,那我就去注冊(cè)一個(gè)真正的公眾號(hào)! ?

在流程設(shè)計(jì)上提到對(duì)于掃碼狀態(tài)的查詢,需要長(zhǎng)鏈接以等待微信消息通知,至于微信發(fā)過(guò)來(lái)的消息存儲(chǔ)方面,可以采用 Redis,也可以使用消息隊(duì)列。對(duì)于 Redis 方案,也有對(duì)應(yīng)的免費(fèi)服務(wù),但是本文采用了消息隊(duì)列來(lái)實(shí)現(xiàn),消息隊(duì)列使用了 Pulsar,跟上時(shí)代。Pulsar 號(hào)稱是下一代的消息隊(duì)列方案,比 Kafka 有過(guò)之而無(wú)不及。為了節(jié)省成本,采用了免費(fèi)的 Pulsar as a service:https://kesque.com/ ?

關(guān)于使用 Redis 的方案,詳見這篇文章:《基于 keycloak 的關(guān)注公眾號(hào)即登錄功能的設(shè)計(jì)與實(shí)現(xiàn)》。 ?

API first 開發(fā)方式

應(yīng)用程序向云環(huán)境這一演變趨勢(shì)為更好地集成服務(wù)和增加代碼重用提供了機(jī)會(huì),只要擁有一個(gè)接口,然后通過(guò)該接口,其他服務(wù)的應(yīng)用程序就可以與你的應(yīng)用程序進(jìn)行交互,這是向其他人公開你的功能,但是,開發(fā) API 不應(yīng)該是在開發(fā)后才公開功能。 ?

API 文檔應(yīng)該是構(gòu)建應(yīng)用程序的基礎(chǔ),這個(gè)原則正式 API first 開發(fā)的全部?jī)?nèi)容。你需要設(shè)計(jì)和創(chuàng)建描述新服務(wù)與外部世界之間交互的文檔,一旦建立了這些交互,就可以開發(fā)代碼邏輯來(lái)支持這些交互。它的好處是: ?

?

基于 Java Spring Security 的關(guān)注公眾號(hào)即登錄的實(shí)現(xiàn)及其關(guān)鍵代碼

促銷服務(wù)企業(yè)基本都會(huì)通過(guò)微信公眾平臺(tái)與用戶互動(dòng),但是微信公眾平臺(tái)的限制在于,公眾號(hào)不能主動(dòng)找到用戶和向用戶主動(dòng)發(fā)消息的,而只有用戶主動(dòng)先 關(guān)注公眾號(hào)成為其粉絲,才能有互動(dòng)的可能。用戶掃碼并關(guān)注微信公眾號(hào),是在手機(jī)端完成的。如何從應(yīng)用層面“感知”到用戶的操作,是實(shí)現(xiàn)中的主要難點(diǎn)。 同時(shí),開發(fā)者服務(wù)層本身是無(wú)狀態(tài)的,但是掃碼流程又是有狀態(tài)流轉(zhuǎn)的,所以需要解決狀態(tài)存儲(chǔ)的問題。另外,開發(fā)者服務(wù)層需要同時(shí)與微信服務(wù)和前端頁(yè)面打交道,這個(gè)過(guò)程中會(huì)有設(shè)計(jì)用戶敏感信息的發(fā)送與接收,如何保證和驗(yàn)證數(shù)據(jù)來(lái)源的可信性和安全性就成了必須要考慮的問題。在具體實(shí)現(xiàn)前,需要申請(qǐng)微信公眾號(hào),并配置好相關(guān)參數(shù)。由于存在開發(fā)者服務(wù)和微信服務(wù)之間的消息傳送,所以還需要在公眾號(hào)后臺(tái)配置好開發(fā)者服務(wù)接收消息的 URL,并同時(shí)配置好密鑰字符串,微信服務(wù)發(fā)送消息是會(huì)使用這個(gè)密鑰字符串對(duì)消息加密,并且只發(fā)送到開發(fā)者配置的 URL,同時(shí)這個(gè) URL 必須為 https 協(xié)議的,這樣即使數(shù)據(jù)包被第 三方截獲,也不能做任何改動(dòng),如果將數(shù)據(jù)包轉(zhuǎn)發(fā),則接收方可以識(shí)別出消息已被篡改拒絕接收,由于采用了開發(fā)者配置的密鑰加密了消息,因此第三方基本無(wú)法破譯,從而保證了消息的安全。同時(shí),還需要將從公眾號(hào)后臺(tái)獲取的 AppID 和 AppSecret 配置到開發(fā)者服務(wù)(即本系統(tǒng))中。 ?

整體上看,要實(shí)現(xiàn)微信登錄就需要拿到用戶在微信端的唯一標(biāo)志符 openid, 查找用戶數(shù)據(jù)庫(kù)看是否存在該用戶,有的話直接登錄,否則注冊(cè)后登錄。而要拿到用戶的 openid,一般做法是通過(guò)微信網(wǎng)頁(yè)的 OAuth 授權(quán),但是缺點(diǎn)是不能給公眾號(hào)引流。關(guān)注公眾號(hào)即登錄功能在統(tǒng)一移動(dòng)端和桌面端的微信登錄用戶體驗(yàn)、 便利用戶運(yùn)營(yíng)都起了非常重要的作用,可以增加微信粉絲、發(fā)送登錄后消息等等。 要實(shí)現(xiàn)的功能目標(biāo)是去除對(duì)微信開放平臺(tái)的依賴,減少用戶二次點(diǎn)擊。因?yàn)橐呀?jīng)有微信公眾平臺(tái),所以系統(tǒng)應(yīng)該盡量利用公眾平臺(tái)完成一切和微信相關(guān)的交互, 而用戶主動(dòng)掃碼,已經(jīng)是一個(gè)確認(rèn)的行為,減少一次額外的點(diǎn)擊,使得登錄行為更加流暢。有上述功能目標(biāo)分析,再結(jié)合流程設(shè)計(jì)中介紹的瀏覽器、開發(fā)者服務(wù)以及微信服務(wù)的交互流程可知,要拿到用戶的 openid,只需用戶掃碼帶參二維碼, 用戶掃碼后會(huì)被導(dǎo)流到公眾號(hào)。同時(shí),如果用戶關(guān)注公眾號(hào),或者已經(jīng)關(guān)注過(guò)該 公眾號(hào),那么微信服務(wù)層會(huì)給開發(fā)這服務(wù)層發(fā)送用戶的 openid 消息。所以要實(shí)現(xiàn)關(guān)注公眾即登錄,就要實(shí)現(xiàn)參數(shù)場(chǎng)景值的生成掃碼狀態(tài)存儲(chǔ)、狀態(tài)查詢、消息收發(fā)的安全性等這幾個(gè)關(guān)鍵點(diǎn)。 ?

定義 API

使用 API 優(yōu)先的方式開發(fā),那么先定義一下接口。采用 Swagger 的 Yaml 格式,去 https://app.swaggerhub.com/ 使用 Github 登錄,即可免費(fèi)使用 Swagger Hub 的服務(wù),既可以作為對(duì)外文檔,又可以直接使用現(xiàn)成的模擬服務(wù)。定義好的文檔見:https://app.swaggerhub.com/apis/UniHeart/wechat-mp/0.0.1 ?

從 paths 字段可以看到一共定義了 3 個(gè)接口:

?

使用 Swagger 定義開放 API 的好處之一是 Schema 支持,這個(gè)定義在 components 字段的 schemas 下,完整的 Swagger 文檔是: ?

openapi: "3.0.0"info: version: 0.0.1 title: Authenticate with Wechat MP!servers: # Added by API Auto Mocking Plugin - description: SwaggerHub API Auto Mocking url: https://virtserver.swaggerhub.com/UniHeart/wechat-mp/0.0.1 - url: http://localhost:8080paths: /mp-qr: get: summary: Gets a temporary qr code with parameter operationId: mp-qr-url tags: - mp-qr responses: '200': description: Got the temporary qr code image link content: application/json: schema: $ref: '#/components/schemas/MpQR' example: expire_seconds: 60 imageUrl: https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=gQGT7zwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAycnE3QWw3b3JmazMxb2FMQnh3Y1UAAgTOrmVgAwQ8AAAA sceneId: 66afab27-c8fa-417d-a28a-95d5a977e1d3 ticket: gQGT7zwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAycnE3QWw3b3JmazMxb2FMQnh3Y1UAAgTOrmVgAwQ8AAAA url: http://weixin.qq.com/q/02rq7Al7orfk31oaLBxwc /mp-qr-scan-status: get: summary: Get the scanning status of qr code operationId: mp-qr-scan-status tags: - mp-qr parameters: - in: query name: ticket required: true description: the ticket for the qr code to query scanning status schema: type: string example: gQE48DwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAyb2U4U2wwb3JmazMxcS1kQ3h3YzgAAgSCjWZgAwQ8AAAA responses: '200': description: The scanning stqtus of qr code content: application/json: schema: $ref: '#/components/schemas/MpQRScanStatus' example: openId: oWFvUw5ryWycy8XoDCy1pV0SiB58 status: SCANNED /mp-message: post: summary: Receive xml messages sent from wechat mp server operationId: mp-message tags: - mp-qr requestBody: description: wechat mp messages in xml format required: true content: application/xml: schema: $ref: '#/components/schemas/xml' responses: '200': description: the message was well receivedcomponents: schemas: MpQR: type: object properties: expire_seconds: type: integer format: int64 example: 60 imageUrl: type: string example: https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=gQGT7zwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAycnE3QWw3b3JmazMxb2FMQnh3Y1UAAgTOrmVgAwQ8AAAA sceneId: type: string example: 66afab27-c8fa-417d-a28a-95d5a977e1d3 ticket: type: string example: gQGT7zwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAycnE3QWw3b3JmazMxb2FMQnh3Y1UAAgTOrmVgAwQ8AAAA url: type: string example: http://weixin.qq.com/q/02rq7Al7orfk31oaLBxwc MpQRScanStatus: type: object properties: openId: type: string example: oWFvUw5ryWycy8XoDCy1pV0SiB58 status: type: string example: SCANNED xml: type: object properties: ToUserName: type: string example: oWfv FromUserName: type: string example: 1234 CreateTime: type: number example: 1357290913 MsgType: type: string example: Text Event: type: string example: subscribe EventKey: type: string example: qrscene_123123 Ticket: type: string example: TICKET?

創(chuàng)建工程

通過(guò)官網(wǎng)的指引,即可創(chuàng)建出一個(gè) Spring Security 模版工程,創(chuàng)建好后,在 build.gradle 文件中,增加一些依賴,主要有

題外話,關(guān)于這個(gè) 415 錯(cuò)誤,一定要往 Request Body 的解析上定位,否則你會(huì)浪費(fèi)不必要的時(shí)間去找原因,比如這位同學(xué):

- implementation 'org.apache.pulsar:pulsar-client:2.6.3' 用來(lái)和 pulsar 打交道

然后再在 build.gradle 文件里添加一個(gè)任務(wù),用來(lái)根據(jù)最新的 Swagger 文檔生成相關(guān)的類型代碼等: ?

// generates the spring controller interfaces from openapi spec in src/main/resources/service.yamlopenApiGenerate { generatorName = "spring" inputSpec = "$projectDir/swagger-output/swagger.yaml" outputDir = "$buildDir/generated" apiPackage = "com.uniheart.wechatmpservice.api" invokerPackage = "com.uniheart.wechatmpservice" modelPackage = "com.uniheart.wechatmpservice.models" configOptions = [ dateLibrary: "java8", interfaceOnly: "true", ]}這樣每次文檔有更新,就只需要在項(xiàng)目目錄下跑一下命令: ?

./gradlew openApiGenerate注意,我們采用了 Swagger Hub 來(lái)更新 API 文檔,它有個(gè) Sync 功能,可以在每次文檔改動(dòng)后點(diǎn)擊一下,就會(huì)自動(dòng)提交一個(gè)改動(dòng)推送到你的 git 倉(cāng)庫(kù)。 ?







由于使用了 swagger 相關(guān)的依賴,它自帶了 Swagger UI,所以即使在不能訪問 Swagger Hub 的情況下,也可以直接訪問項(xiàng)目本身 Host 的 Swagger UI:https://wechat-mp.herokuapp.com/swagger-ui 。通過(guò) swagger.json,你還可以將你的項(xiàng)目文檔直接同步到 YAPI 或者 Backstage 等 API 管理工具或者 dev portal 上。這個(gè) json 可以直接從項(xiàng)目中實(shí)時(shí)獲得:https://wechat-mp.herokuapp.com/v3/api-docs。 ?

配置路由

?

Spring-Security 項(xiàng)目模版默認(rèn)做了一些配置,我們需要額外添加幾個(gè),主要是放通我們的 API,以及 swagger 相關(guān)的路由,這在 WebSecurityConfig 里完成,主要代碼如下: ?

package com.uniheart.securing.web.wechat.mp;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.http.HttpMethod;import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;import org.springframework.security.core.userdetails.User;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.provisioning.InMemoryUserDetailsManager;@Configuration@EnableWebSecuritypublic class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/", "/home").permitAll() .antMatchers("/mp-qr", "/mp-qr").permitAll() .antMatchers("/mp-qr-scan-status", "/mp-qr-scan-status").permitAll() .antMatchers(HttpMethod.POST, "/mp-message").permitAll() .antMatchers("/v3/api-docs", "/v3/api-docs").permitAll() .antMatchers("/swagger-ui", "/swagger-ui").permitAll() .anyRequest().authenticated() .and() .formLogin() .loginPage("/login") .permitAll() .and() .logout() .permitAll(); http.csrf().disable(); }}

實(shí)現(xiàn)二維碼的展示

本示例應(yīng)用效果是登錄頁(yè)面除了可以輸入用戶名和密碼登錄外,還會(huì)顯示一個(gè)二維碼,掃碼后即登錄成功,并且在頁(yè)面上顯示一個(gè)歡迎信息: ?




掃碼登錄成功后,可以看到 Cookie 多了一個(gè) JSESSIONID 項(xiàng): ?




要實(shí)現(xiàn)二維碼的展示,由于采用了 Swagger 生成輪廓代碼,這里只需要添加一個(gè)新的 Controller 去實(shí)現(xiàn)預(yù)先定義好的 MpQrApi 即可: ?

package com.uniheart.securing.web.wechat.mp;import com.uniheart.securing.web.wechat.mp.services.MpServiceBean;import com.uniheart.wechatmpservice.api.MpQrApi;import com.uniheart.wechatmpservice.models.MpQR;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.RestController;@RestControllerpublic final class WechatMpApiController implements MpQrApi { @Autowired private MpServiceBean mpServiceBean; @Override public ResponseEntity<MpQR> mpQrUrl() { return new ResponseEntity<>(this.mpServiceBean.getMpQrCode(), HttpStatus.OK); }}可見核心業(yè)務(wù)邏輯在 MpServiceBean 中,代碼如下: ?

package com.uniheart.securing.web.wechat.mp.services;import com.google.gson.Gson;import com.google.gson.JsonObject;import com.uniheart.securing.web.wechat.mp.Constants;import com.uniheart.wechatmpservice.models.MpQR;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Component;import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.util.UnknownFormatConversionException;@Componentpublic class MpServiceBean { private final HttpClient httpClient; @Value("${weixin-qr-code-creation-endpoint:default-test-value}") private String qrCodeCreateUrl; @Value("${weixin-token-endpoint:default-test-value}") private String weixinAccessTokenEndpoint; public String getQrCodeCreateUrl() { return this.qrCodeCreateUrl; } public MpServiceBean() { this.httpClient = HttpClient.newHttpClient(); } public MpServiceBean(HttpClient client, String qrCodeCreateUrl, String tokenEndpoint) { this.httpClient = client; this.qrCodeCreateUrl = qrCodeCreateUrl; this.weixinAccessTokenEndpoint = tokenEndpoint; } public void setQrCodeCreateUrl(String url) { this.qrCodeCreateUrl = url; } public void setWeixinAccessTokenEndpoint(String url) { this.weixinAccessTokenEndpoint = url; } Logger logger = LoggerFactory.getLogger(MpServiceBean.class); public MpQR getMpQrCode() { var mpTokenManager = new MpTokenManager(this.weixinAccessTokenEndpoint); URI uri = URI.create(this.qrCodeCreateUrl + mpTokenManager.getAccessToken().accessToken); logger.info("Getting qr code with " + uri); var payload = WeixinQrCodeRequestPayload.getRandomInstance(); HttpRequest request = HttpRequest.newBuilder().POST(HttpRequest.BodyPublishers.ofString(payload.toJson())).uri(uri).build(); try { HttpResponse<String> response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString()); WeixinErrorResponse errorResponse = new Gson().fromJson(response.body(), WeixinErrorResponse.class); WeixinTicketResponse ticketResponse = new Gson().fromJson(response.body(), WeixinTicketResponse.class); if (ticketResponse.ticket != null) { return new MpQR().ticket(ticketResponse.ticket).imageUrl(ticketResponse.url).expireSeconds(ticketResponse.expiresInSeconds).url(ticketResponse.url).sceneId(String.valueOf(payload.action_info.scene.scene_id)); } if (errorResponse.errcode == (40001)) { return new MpQR().ticket("test").imageUrl(Constants.FALLBACK_QR_URL); } throw new UnknownFormatConversionException(response.body()); } catch (InterruptedException ie) { System.err.println("Exception = " + ie); ie.printStackTrace(); return new MpQR().ticket("interrupted").imageUrl(Constants.FALLBACK_QR_URL); } catch (Exception ex) { System.err.println("Exception = " + ex); ex.printStackTrace(); return new MpQR().ticket("error").imageUrl(Constants.FALLBACK_QR_URL); } }}?

代碼比較長(zhǎng),不逐行解釋了,建議對(duì)照項(xiàng)目中的測(cè)試代碼一起看,主要是調(diào)用微信的 API,并根據(jù)響應(yīng)走到不同的邏輯分支。以上的關(guān)鍵在于 WeixinQrCodeRequestPayload.getRandomInstance(),會(huì)生成場(chǎng)景值。場(chǎng)景值以及帶參二維碼,因?yàn)槊總€(gè)登錄請(qǐng)求嘗試都是獨(dú)立發(fā)生的, 所以應(yīng)該是全局唯一;為了防止惡意者攻擊,這個(gè)場(chǎng)景值應(yīng)該具有不可猜性。前面介紹其可以使用 UUID 來(lái)滿足這兩點(diǎn),參見《基于 keycloak 的關(guān)注公眾號(hào)即登錄功能的設(shè)計(jì)與實(shí)現(xiàn)》的具體實(shí)現(xiàn),這里給出一種簡(jiǎn)便的實(shí)現(xiàn),即根據(jù)當(dāng)前時(shí)間來(lái)計(jì)算出一個(gè)場(chǎng)景值,由于精確到納秒,所以很難重復(fù)。 ?

package com.uniheart.securing.web.wechat.mp.services;import com.google.gson.Gson;import com.uniheart.securing.web.wechat.mp.Now;import org.joda.time.Instant;public class WeixinQrCodeRequestPayload { public String action_name; public ActionInfo action_info; public int expire_seconds; public String toJson() { return new Gson().toJson(this); } public static WeixinQrCodeRequestPayload getRandomInstance() { var timestamp = Now.instant(); var ret = new WeixinQrCodeRequestPayload(); ret.action_name = "QR_SCENE"; ret.expire_seconds = 604800; ret.action_info = new ActionInfo(); ret.action_info.scene = new Scene(); ret.action_info.scene.scene_id = timestamp.getEpochSecond() + timestamp.getNano(); return ret; }}class ActionInfo{ public Scene scene;}class Scene { public long scene_id;}?

實(shí)現(xiàn)微信消息的接收

?

消息接收后還需要存儲(chǔ)起來(lái),Redis 方案的實(shí)現(xiàn)詳見《基于 keycloak 的關(guān)注公眾號(hào)即登錄功能的設(shè)計(jì)與實(shí)現(xiàn)》,這里給出利用 pulsar 的具體實(shí)現(xiàn)。 ?

package com.uniheart.securing.web.wechat.mp.services;import com.google.gson.Gson;import com.uniheart.wechatmpservice.models.Xml;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.beans.factory.annotation.Value;import org.apache.pulsar.client.api.*;import org.springframework.stereotype.Component;import java.util.concurrent.TimeUnit;@Componentpublic class MpMessageService { Logger logger = LoggerFactory.getLogger(MpMessageService.class); private final String pulsarUrl; private final String pulsarToken; private final String pulsarTopic; public MpMessageService(@Value("${pulsar-service-url}") String pulsarUrl, @Value("${pulsar-auth-token}") String pulsarToken, @Value("${pulsar-producer-topic}") String pulsarTopic) { this.pulsarUrl = pulsarUrl; this.pulsarToken = pulsarToken; this.pulsarTopic = pulsarTopic; } public void saveMessageTo(Xml message) throws PulsarClientException { var client = PulsarClient.builder().serviceUrl(pulsarUrl).authentication(AuthenticationFactory.token(pulsarToken)).build(); var producer = client.newProducer().topic(pulsarTopic).create(); producer.send(new Gson().toJson(message).getBytes()); producer.close(); client.close(); } public synchronized Xml getMessageFor(String ticket) throws PulsarClientException { var client = PulsarClient.builder().serviceUrl(pulsarUrl).authentication(AuthenticationFactory.token(pulsarToken)).build(); var consumer = client.newConsumer().topic(pulsarTopic).subscriptionName("my-subscription").subscribe(); var xml = new Xml().fromUserName("empty"); var received = false; var count = 0; do { var msg = consumer.receive(1, TimeUnit.SECONDS); count++; if (msg != null) { var json = new String(msg.getData()); try { xml = new Gson().fromJson(json, Xml.class); received = xml.getTicket().equals(ticket); if(received){ consumer.acknowledge(msg); } } catch (Exception ex) { logger.error("Failed to parse json: " + json); xml.fromUserName(json); consumer.acknowledge(msg); } } } while (!received && count < 30); consumer.close(); client.close(); return xml; }}以上服務(wù)封裝了保存和獲取方法,消息接收的 Controller 調(diào)用起保存消息的方法: ?

package com.uniheart.securing.web.wechat.mp;import com.uniheart.securing.web.wechat.mp.services.MpMessageService;import com.uniheart.wechatmpservice.api.MpMessageApi;import com.uniheart.wechatmpservice.models.Xml;import io.swagger.annotations.ApiParam;import org.apache.pulsar.client.api.PulsarClientException;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RestController;import javax.validation.Valid;@RestControllerpublic class WechatMessageController implements MpMessageApi { Logger logger = LoggerFactory.getLogger(WechatMessageController.class); private final MpMessageService mpMessageService; public WechatMessageController(MpMessageService mpMessageService) { this.mpMessageService = mpMessageService; } @Override public ResponseEntity<Void> mpMessage(@ApiParam(value = "wechat mp messages in xml format", required = true) @Valid @RequestBody Xml xml) { try { this.mpMessageService.saveMessageTo(xml); logger.info("saved info: " + xml); return new ResponseEntity<>(HttpStatus.OK); } catch (PulsarClientException e) { e.printStackTrace(); return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } }}?

實(shí)現(xiàn)掃碼狀態(tài)查詢

從以上實(shí)現(xiàn)可以看出,接收到微信服務(wù)的消息通知后,會(huì)同時(shí)保存兩個(gè)信息,即保存被掃描的二維碼對(duì)應(yīng)的用戶標(biāo)識(shí) openid,以及更新該二維碼的掃碼狀態(tài)為已掃描。這個(gè)消息很重要,如前所述, 我們對(duì)客戶端的掃碼狀態(tài)查詢請(qǐng)求使用了長(zhǎng)連接方案。查詢掃碼狀態(tài)的部分比較復(fù)雜,因?yàn)檫@里把將用戶登錄的邏輯也放在這里了。即查詢到對(duì)應(yīng)的二維碼被掃描后,在返回掃碼成功前,新建一個(gè) HTTP 上下文,將登錄的用戶實(shí)例化出來(lái): ?

package com.uniheart.securing.web.wechat.mp;import com.uniheart.securing.web.wechat.mp.services.MpMessageService;import com.uniheart.wechatmpservice.api.MpQrScanStatusApi;import com.uniheart.wechatmpservice.models.MpQRScanStatus;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.core.Authentication;import org.springframework.security.core.GrantedAuthority;import org.springframework.security.core.authority.SimpleGrantedAuthority;import org.springframework.security.core.context.SecurityContextHolder;import org.springframework.web.bind.annotation.RestController;import java.security.Principal;import java.util.ArrayList;import java.util.List;@RestControllerpublic final class WechatMpQRScanStatusApiController implements MpQrScanStatusApi { private final MpMessageService mpMessageService; public WechatMpQRScanStatusApiController(MpMessageService mpMessageService) { this.mpMessageService = mpMessageService; } @Override public ResponseEntity<MpQRScanStatus> mpQrScanStatus(String ticket) { try { var xml = this.mpMessageService.getMessageFor(ticket); if(xml.getFromUserName().equals("empty")){ return new ResponseEntity<>(new MpQRScanStatus().openId(""), HttpStatus.REQUEST_TIMEOUT); } var user = new Object() {}; List<GrantedAuthority> authorities = new ArrayList<>(); authorities.add(new SimpleGrantedAuthority("WechatMP")); Authentication authentication = new UsernamePasswordAuthenticationToken(user, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); return new ResponseEntity<>(new MpQRScanStatus().openId(xml.getFromUserName()).status("SCANNED"), HttpStatus.OK); } catch (Exception ex) { ex.printStackTrace(); return new ResponseEntity<>(new MpQRScanStatus().openId(ex.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR); } }}?

這樣就實(shí)現(xiàn)了服務(wù)器端的三個(gè)開放 API。服務(wù)器端還有些邏輯,比如對(duì)微信的 Access Token 的管理等等,再次略過(guò),詳見 Github 倉(cāng)庫(kù):https://github.com/Jeff-Tian/securing-web-with-wechat-mp/tree/master/src/main/java/com/uniheart/securing/web/wechat/mp/services 。 ?

實(shí)現(xiàn)客戶端邏輯

服務(wù)器端的 API,最終要由客戶端來(lái)調(diào)用,這里的客戶端邏輯,為了實(shí)現(xiàn)最小代碼改動(dòng),直接寫了原生 JavaScript 添加在了模版項(xiàng)目的 html 文件里(login.html),沒有使用任何前端工程框架,直接手寫了兩個(gè) ajax,完成: ?

function queryScanStatus(ticket) { var req = new XMLHttpRequest(); req.onreadystatechange = function () { if(req.readyState === 4 && req.status === 200) { const json = JSON.parse(req.responseText); if (json.status === 'SCANNED') { location.href = '/hello'; }else{ alert('發(fā)生錯(cuò)誤(也許是超時(shí)了)!') } } }; req.open("GET", "/mp-qr-scan-status?ticket=" + ticket); req.send(); } function showQRCodeImage() { var req = new XMLHttpRequest(); req.onreadystatechange = function () { if (req.readyState === 4 && req.status === 200) { const json = JSON.parse(req.responseText); document.getElementById('wechat-mp-qr').setAttribute('src', 'https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=' + encodeURIComponent(json.ticket)); queryScanStatus(json.ticket); } }; req.open("GET", "/mp-qr", true); req.send(); } showQRCodeImage();

總結(jié)

本文通過(guò)一個(gè)實(shí)際的具有商業(yè)價(jià)值的項(xiàng)目,展示了 API 優(yōu)先的開發(fā)方法。通過(guò)薅羊毛的方式,落地了 Free Arch 架構(gòu)。



關(guān)鍵詞:同時(shí),關(guān)注,公眾,請(qǐng)問

74
73
25
news

版權(quán)所有? 億企邦 1997-2025 保留一切法律許可權(quán)利。

為了最佳展示效果,本站不支持IE9及以下版本的瀏覽器,建議您使用谷歌Chrome瀏覽器。 點(diǎn)擊下載Chrome瀏覽器
關(guān)閉