PKCE
一個成功的App背后肯定有一堆后端服務(wù)提供支撐,認證授權(quán)服務(wù)(Authentication and Authorization Service,以下稱AAS)就是其中之一,它是約束App、保障資源安全的必備組件?,F(xiàn)在也有第三方平臺提供此類服務(wù)。如果自己開發(fā)AAS,針對不同場景選擇何種模式是必須要謹慎考慮的問題。這就需要決策者對目前主流的AA協(xié)議有較深理解,若是隨手套用、流于形式,就可能埋下嚴重的安全隱患。我們以OAuth2為例進行說明。
本文假設(shè)讀者有一定的OAuth2知識,若沒有可先閱讀博主以前寫的一篇博文,或其它資料。
先說明:以下大部分討論針對的是第三方授權(quán)場景。對于同源系統(tǒng)(Client與AAS同屬一個開發(fā)實體),Username&Password Flow模式和嵌入式(AAS登錄頁面嵌入Client中)PKCE模式都能呈現(xiàn)相同且授權(quán)無感的用戶體驗,相對來說前者簡單,后者能在Client端做一些安全處理,更推薦。
Why PKCE?
當我們開發(fā)一個App(本文指的是Native App),若使用的AAS同屬自己開發(fā),一般最簡單能想到的就是在App中畫個登錄框,直連登錄接口成功后返回AccessToken,這便是Username&Password Flow。其實,這種模式在Web領(lǐng)域流傳已久(返回的cookie或JWT可以認為就是AccessToken),但是App卻需要考量兩個問題:
- 用戶如何鑒別client是否合法。
- AAS如何鑒別登錄請求是否來自于合法client。
針對這兩個問題,Web應(yīng)用程序依靠多年來各大廠大V對Web安全的重視和發(fā)展,已經(jīng)有相當完備的應(yīng)對手段。比如在用戶側(cè),瀏覽器地址欄上的域名和CA(瀏覽器常以鎖頭圖標顯示)可以給到用戶提醒;同時依靠數(shù)據(jù)隔離(瀏覽器沙盒、cookie等)能讓AAS判斷請求是否來自同域。
而對于App來說,用戶側(cè)沒有一個標準識別信息能給到用戶,這要求用戶須要憑借自身經(jīng)驗判斷App是否為釣魚App;而本文所涉及的各類授權(quán)模式也無法保證百分之百的安全,只能做一些簡單的防范(其實用戶側(cè)App防偽本身就不屬于AAS的職責(zé)范圍)。所幸各大應(yīng)用商店的審核機制、手機自帶的掃毒進程、App自身的后端防范(e.g.雙因素認證two-factor authentication)等等手段讓做一個釣魚App相當困難。當然,對于小App來說,剛說的這些手段可能都沒法覆蓋到,特別是Android系統(tǒng)Apk隨意下載安裝,用戶其實是很容易中招的,只是仿制一款沒多少人用的App更加沒意義。
我們把關(guān)注點放到后端,看看OAuth2的幾種模式是否可行。
Username&Password Flow:適用于AAS高度信任請求的場景,既AAS認為所有請求肯定來自于合法的客戶端———這種情況一般會出現(xiàn)在請求來自于信任域或內(nèi)網(wǎng)系統(tǒng)———即AAS不介意client可以擁有用戶名和密碼。自然該模式不適用于第三方client的場景。另外由于該模式請求一般使用接口形式,惡意方較容易構(gòu)建非法請求,就算每次請求的用戶名密碼錯誤也能讓AAS浪費大量資源(此時后端需要網(wǎng)關(guān)對請求頻率進行限制,并屏蔽惡意請求)。有人會說結(jié)合Client Credentials Flow就能解決client合法性校驗的問題,如果client本身是后端的話確實如此,但在App場景下仍然不夠嚴密。
Client Credentials Flow:對于外部請求的身份識別,現(xiàn)在較廣泛的解決方案是AAS給App頒發(fā)一個事先約定的身份證明(如client_id和client_secret對),App將它們織入請求(如簽名),AAS以此確定請求合法。然而新的問題又出現(xiàn)了,就是如何保證該身份證明本身的安全性。這個問題在移動端并沒有完美的解決方案。以Android為例,存儲在Sharedpreferences需要在運行時寫入,這就給了不法分子可趁之機;即使存儲在最安全的KeyStore里,使用時還要傳入外部密鑰獲取,那這個外部密鑰的安全如何保障呢:)。
純粹的Client Credentials Flow是不依賴用戶的,AAS直接向合法client開放指定資源(可能是與所有用戶相關(guān)的資源)。所以在用戶授權(quán)場景下,一般將此模式作為其它模式的輔助。
既然如此,那干脆不開放接口,而是要求App嵌入AAS自己的登錄界面(與開放接口相比,AAS可借助界面實現(xiàn)一些安全策略比如在客戶端判斷請求頻率是否超出閾值,請求是否由該頁面發(fā)出/cookie判斷),類似于Authorization Code Flow和Implicit Flow。因為Implicit Flow可看作Authorization Code Flow的“低配版”,我們先來論證Authorization Code Flow的可行性。
Authorization Code Flow中關(guān)鍵的一步是client獲取code,是AAS發(fā)送重定向狀態(tài)碼(30X)到瀏覽器告知瀏覽器重定向到預(yù)先指定的URI,并將code附加到query。對Web應(yīng)用程序來說,重定向的URI是屬于client域的URL,而瀏覽器的安全級別也防止了其它進程對code的截獲,且就算截獲了,由于惡意進程不知道Client Credentials(存于client后端),也無法拿去交換AccessToken。而對于App,情況又變得錯漏百出,如前所述,Client Credentials無法得到安全保障,而且code被攔截的風(fēng)險也比Web端高了很多——
首先,重定向的URI的作用改變了,它起到一個類似“喚起”的作用,系統(tǒng)根據(jù)URI的scheme喚起相應(yīng)App,如果惡意App注冊了相同的scheme就能輕易拿到code;其次,URI從系統(tǒng)瀏覽器傳遞到App的過程走的基本都是不安全通道,這也增加了傳遞過程中被截獲的風(fēng)險。
于是Authorization Code Flow也被我們否了,那Implicit Flow自然更不用說,而且Implicit Flow無法提供RefreshToken,這對App來說可不能忍。
另外,用戶側(cè)也不推薦在App中嵌入Web頁面,一個是增加用戶識別的難度,另一個給了App訪問Web隱私數(shù)據(jù)的機會?,F(xiàn)在很多三方登錄并不會提供嵌入式的頁面,都是直接打開它們自己的App的單獨登錄界面,應(yīng)該就是主要避免這個問題。
看來在App場景下,OAuth2的其中四個授權(quán)模式多少都有安全隱患,那還有什么協(xié)議能支持App的認證授權(quán)呢?難道這么多年的移動互聯(lián)網(wǎng),大家都是在里面裸奔嗎?現(xiàn)實情況雖然沒有如此糟糕,但筆者所接觸到的項目和開發(fā)人員,普遍對所使用的協(xié)議是否真的安全缺少認知,多年運行良好很可能是因為你的App還不起眼:)
偉大的萊布尼茨·牛頓說過,地球毀滅與普通人無關(guān),因為總會有其他人去解決。有一部分人注意到了App的授權(quán)安全問題,這其中又有一小撮人各種改造了自己的項目,最后有幾個人提出了一個規(guī)范并收錄在RFC文件中,這個規(guī)范就是Proof Key for Code Exchange(即PKCE)。官方說法PKCE是Authorization Code Flow的擴展[和增強],It is primarily used by mobile and JavaScript apps, but the technique can be applied to any client as well.
之后我們走完一遍授權(quán)流程就會清楚,PKCE和Authorization Code Flow確實無本質(zhì)不同,差別在對CSRF的防范手段上。因為App沒有瀏覽器那樣的cookie支持,AAS沒有session這樣的東西來保存state參數(shù)從而防止CSRF,所以轉(zhuǎn)而使用PKCE的code_verifier和code_challenge,順便解決了無client_secret驗證身份的問題。其實PKCE并沒有解決本節(jié)開頭的那兩個問題,而且它的設(shè)計目標也不是它們(雖然我們可以保證請求是從AAS頁面發(fā)出,但無法保證嵌入AAS頁面的Client是合法的,也許可以通過某些方式檢測?)。任何人只要知道了client_id,就能無障礙地和AAS交互PKCE流程,除非AAS強制要求client_secret(但如前所述在App端這也沒有意義)。它只是彌補了Authorization Code Flow在移動端的一個缺陷,避免請求結(jié)果被惡意App截獲。
PKCE擴展不會添加任何新響應(yīng),因此即使授權(quán)服務(wù)器不支持,客戶端也可以始終使用PKCE擴展。
確定好授權(quán)模式,接下來就是要實現(xiàn)或者尋找一個合適的框架,以前用過的DotNetOpenAuth已經(jīng)很久沒更新了,是否支持PKCE尚不清楚。本著與時俱進的想法,為ASP.NET Core量身定制的IdentityServer4進入了視線。它是一款基于OpenID Connect的AAS框架。
Java開發(fā)的Keycloak在7.0版本也加入了對PKCE的支持
OIDC(OpenID Connect)
OpenID Connect是OpenID發(fā)展到第三代的產(chǎn)物,它其實是原來OpenID和OAuth的集合體。本人當年在研究OAuth的時候就被OpenID搞了一頭霧水,總感覺OpenID和AccessToken雖然語義不同,但兩者完全可以歸為一個。多年之后OpenID Connect姍姍來遲,但細究之后發(fā)現(xiàn)還是換湯不換藥,只不過原本的OpenID現(xiàn)在變成了IDToken(一般使用JWT格式來包裝。得益于JWT的自包含性,緊湊性以及防篡改機制,使得ID-Token可以安全的傳遞給第三方客戶端程序并且容易被驗證。),區(qū)別于AccessToken。為什么不干脆合并成一個Token呢?其實從名稱上就能看出來它們各自的職責(zé)不同,從職責(zé)及歷史角度看,如此這般也是合理。
IDToken只是用于標識用戶,側(cè)重于用戶信息,可用于單點登錄等會話相關(guān)場景,Client也可以把用戶信息保存下來便于用戶行為統(tǒng)計分析。它的標準流程中用戶參與是必須的。AccessToken解決的問題是授權(quán)[給Client],而授權(quán)可能是用戶授權(quán)也可能是AAS直接授權(quán),用戶認證并不是不可或缺的條件,所以授權(quán)過程中并不一定需要用戶參與。在任何模式下,Client也不需要知道用戶信息就能無障礙訪問授權(quán)過的API?,F(xiàn)在看來,AccessToken當然可以在某些模式下包含IDToken,但若沒有IDToken,若只是為了標識用戶使用AccessToken就顯得不那么恰當了。另外,就算有用戶參與,IDToken就一個,但對于不同的Client,AccessToken肯定是不一樣的。
實戰(zhàn)
Talk is cheap, show you the code. 我們的目標很簡單,就是實現(xiàn)App的登錄授權(quán),通過后可以拿著token調(diào)用資源Api。用戶對背后的邏輯是無感知的,對他們來說就和普通的登錄一樣。為了達到這個目的,我們還需要把登錄界面嵌入到App中——細心的讀者會發(fā)現(xiàn)這似乎違背了上一節(jié)的提醒,但是上面的建議主要面向的場景是AAS作為第三方登錄平臺,服務(wù)的App不受完全信任——由于我們這個App也是自己從頭開始擼的,所以不用考慮這個問題。
下面分別就AAS、App、Resource Server列出關(guān)鍵步驟和代碼。
AAS
為簡單起見,我們直接使用ASP.NET Core Identity作為用戶體系。
# 1.創(chuàng)建一個空的mvc項目
dotnet new mvc -o AAS
# 2.添加ASP.NET Core Identity相關(guān)依賴包
dotnet add package Microsoft.Extensions.Identity.Stores
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
# 3.添加IdentityServer4依賴包
dotnet add package IdentityServer4
dotnet add package IdentityServer4.EntityFramework
dotnet add package IdentityServer4.AspNetIdentity
- 定義一個繼承自
IdentityDbContext<ApplicationUser>的DbContext——ApplicationDbContext,其中ApplicationUser繼承須自IdentityUser。 Startup.ConfigureServices,主要是將一些服務(wù)注入到IOC容器中。
public void ConfigureServices(IServiceCollection services)
{
//Other code...
//DbContext
services.AddDbContext<ApplicationDbContext>(options =>
options.UseMySql(Configuration.GetConnectionString("DefaultConnection")));
//ASP.NET Core Identity
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
//IdentityServer
string migrationsAssembly = typeof(Startup).Assembly.GetName().Name;
var builder = services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
// see https://identityserver4.readthedocs.io/en/latest/topics/resources.html
options.EmitStaticAudienceClaim = true;
})
.AddConfigurationStore(options =>
{
options.ConfigureDbContext = builder =>
builder.UseMySql(Configuration.GetConnectionString("DefaultConnection"),
sql => sql.MigrationsAssembly(migrationsAssembly));
})
.AddOperationalStore(options =>
{
options.ConfigureDbContext = builder =>
builder.UseMySql(Configuration.GetConnectionString("DefaultConnection"),
sql => sql.MigrationsAssembly(migrationsAssembly));
})
.AddAspNetIdentity<ApplicationUser>()
.AddProfileService<ApplicationProfileService>();
builder.AddDeveloperSigningCredential();
}
其中AddDefaultTokenProviders和本文所指的Token沒關(guān)系,它主要提供了手機號驗證、郵箱驗證等Api,如何使用可參看ASP.NET CORE IDENTITY TOKEN PROVIDERS – UNDER THE HOOD。
options.Events.RaiseXXXXEvents表示什么級別的事件會被監(jiān)聽到,可以自定義事件和監(jiān)聽器,可參看官方文檔Events。這種模式比到處log應(yīng)該要稍好一點。
我們使用MySql持久化數(shù)據(jù),AddConfigurationStore表示存儲一些預(yù)配置數(shù)據(jù)如clients、resources等等,AddOperationalStore存儲授權(quán)過程中產(chǎn)生的數(shù)據(jù)如codes、tokens等等。
AddProfileService<T>用于添加自定義claim,泛型參數(shù)類需要實現(xiàn)IProfileService。
- 配置IdentityServer到請求處理管道,在
Startup.Configure中增加app.UseIdentityServer()(它間接調(diào)用了app.UseAuthentication()),注意它在管道中和其它處理器的順序。 - 在數(shù)據(jù)庫中添加client。由于數(shù)據(jù)表中非關(guān)鍵字段太多,我們一般使用管理后臺或編寫CRUD接口進行維護。以PKCE客戶端為例,我們只需構(gòu)建帶必要屬性的如下client:
// interactive client using code flow + pkce
new Client
{
ClientId = "interactive",
RequireClientSecret = false,
AllowedGrantTypes = GrantTypes.Code,
RedirectUris = { "https://localhost:44300/signin-oidc" },
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "scope2" }
},
其中RequireClientSecret設(shè)為false表示交互時不需要client_secret參與;AllowOfflineAccess設(shè)為true表示需要獲取Refresh token;RedirectUris是與client約定好的重定向地址;is4默認開啟對client的PKCE交互的支持(RequirePkce),當然首先要AllowedGrantTypes = GrantTypes.Code。
-
畫一個登錄頁面

-
用戶點擊登錄按鈕時執(zhí)行:
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginInput model)
{
// 關(guān)鍵,check if we are in the context of an authorization request
var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl);
if (ModelState.IsValid)
{
//toetb isPersistent設(shè)為false,應(yīng)該跟AccessToken的有效時間不相干
var result = await _signInManager.PasswordSignInAsync(model.PhoneNumber, model.Password, false, lockoutOnFailure: true);
if (result.Succeeded)
{
var user = await _userManager.FindByNameAsync(model.PhoneNumber);
await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId));
if (context != null)
{
// we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
return Redirect(model.ReturnUrl);
}
}
await _events.RaiseAsync(new UserLoginFailureEvent(model.Password, "invalid credentials", clientId: context?.Client.ClientId));
ModelState.AddModelError(string.Empty, _localizer["Invalid username or password"]);
}
// something went wrong, show form with error
return View(model);
}
其中_interaction是IIdentityServerInteractionService類型的對象,調(diào)用它的GetAuthorizationContextAsync能區(qū)分用戶操作是處于授權(quán)過程還是普通登錄。
App
- LoginActivity

class LoginActivity : BaseActivity() {
private val vm by lazy {
this.getViewModel(LoginViewModel::class.java)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
login_page.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
val url = request?.url
if (OIDC.REDIRECT_URL.indexOf(url?.host!!) > -1) {
val code = url!!.getQueryParameter("code")
vm.getToken(code!!)
return true
}
return super.shouldOverrideUrlLoading(view, request)
}
}
login_page.loadUrl(vm.loginUrl)
}
}
使用WebViewClient嵌入AAS登錄頁面,并重寫shouldOverrideUrlLoading方法,當發(fā)現(xiàn)瀏覽器redirect約定的URL時即知用戶已登錄成功,此時攔截跳轉(zhuǎn),并發(fā)起token請求。
- 交互的流程主要在
LoginViewModel中
class LoginViewModel @Inject constructor() : ViewModel() {
@Inject
lateinit var authService: AuthorizationService
var code_verifier: String = getRandomStr(
64,
Alphabet.PUREDIG.plus(Alphabet.LOWERCASE).plus(Alphabet.CAPITAL).plus(Alphabet.SPECIAL)
)
val redirectUrl = URLEncoder.encode(OIDC.REDIRECT_URL, "UTF-8")
lateinit var code_challenge: String
lateinit var loginUrl: String
init {
code_challenge = getChallengeCode(code_verifier)
loginUrl = """
${OIDC.AUTHORIZATION_ENDPOINT}
?response_type=code&client_id=${OIDC.CLIENT_ID}&code_challenge=$code_challenge&code_challenge_method=S256
&redirect_uri=$redirectUrl&scope=openid%20profile%20offline_access
""".trimIndent()
}
fun getToken(code: String) {
viewModelScope.launch(Dispatchers.IO) {
//取access_token
val rsp = authService.authorize(
mapOf(
"code_verifier" to code_verifier,
"code" to code,
"redirect_uri" to OIDC.REDIRECT_URL,
"client_id" to OIDC.CLIENT_ID,
"grant_type" to "authorization_code"
)
)
rsp.apply {
val token = Token(
idToken,
accessToken,
refreshToken,
getCurrentTimeStamp() + expiresIn,
scope
)
Session.storeToken(token)
}
}
}
private fun getChallengeCode(code_verifier: String): String {
val bytes = code_verifier.toByteArray(Charset.forName("US-ASCII"))
val md = MessageDigest.getInstance("SHA-256")
md.update(bytes, 0, bytes.size)
val digest = md.digest()
val challenge =
Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
return challenge
}
override fun onCleared() {
viewModelScope.coroutineContext.cancelChildren()
super.onCleared()
}
}
loginUrl查詢字符串各參數(shù)的意義可參看RFC文檔,重點關(guān)注code_challenge。
用戶登錄成功后,就可以用code換token了,重點關(guān)注code_verifier,AAS會拿著這個和上一步的code_challenge進行比對校驗。這里使用Retrofit2封裝了Http請求,注意獲取token時,content-type要設(shè)置為application/x-www-form-urlencoded。
@POST(TOKEN_ENDPOINT)
@FormUrlEncoded
suspend fun authorize(@FieldMap maps: Map<String, String>): TokenRsp
- 獲取了token之后,就可以訪問Resource Server的資源了。在每次請求時添加請求頭如下:
reqBuilder.addHeader(
"Authorization",
"Bearer ${Session.getAccessToken()}"
)
Resource Server
依舊以ASP.NET Core為例。
- 添加依賴包
Microsoft.AspNetCore.Authentication.JwtBearer - 在Startup.ConfigureServices注冊token校驗服務(wù):
services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://localhost:5001";
});
options.Authority設(shè)置為AAS地址。
由于AAS頒發(fā)的AccessToken默認是JWT格式,所以這里我們使用JWT校驗。AccessToken還可設(shè)置為Reference Token格式,具體可參看IdentityServer4之JWT簽名(RSA加密證書)及驗簽
- 保證Startup.Configure中有
app.UseAuthentication();
app.UseAuthorization();
- 在Api上增加
[Authorize]特性。
That's all ! 如果想要更精細的控制,比如控制Api只開放給符合特定要求的AccessToken,那么就要聲明Policy了,如:
services.AddAuthorization(options =>
{
options.AddPolicy("ApiScope", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "api1");
});
});
可查閱其它資料獲取更多信息,這里不再贅述。
參考資料
OAuth 2.0 for Native Apps
Asp.Net Core 中IdentityServer4 實戰(zhàn)之 Claim詳解
淺談SAML, OAuth, OpenID和SSO, JWT和Session

浙公網(wǎng)安備 33010602011771號