一个成功的App背后肯定有一堆后端服务提供支撑,认证授权服务(Authentication and Authorization Service,以下称AAS)就是其中之一,它是约束App、保障资源安全的必备组件。现在也有第三方平台提供此类服务,但万事不求人,自己撸才是我们的风格。

本文假设读者有一定的OAuth2知识,若没有可先阅读博主以前写的一篇博文,或其它资料。

Why PKCE?

当我们开发一个App(本文指的是Native App),选择何种AAS协议或模式是必须要谨慎考虑的问题。这就需要决策者对目前主流的AA协议有较深理解,若是随手套用、流于形式,就可能埋下严重的安全隐患。我们以OAuth2为例进行说明。

对于Client和AAS同属一个机构的情况,一般最简单能想到的就是在App中画个登录框,直连登录接口成功后返回AccessToken,这便是Username&Password Flow。其实,这种模式在Web领域流传已久(返回的cookie或JWT可以认为就是AccessToken),但是App却不适用。Why?有两个问题需要解决:

  1. 用户如何鉴别client是否合法。
  2. AAS如何鉴别登录请求是否来自于合法client。

针对这两个问题,Web应用程序依靠多年来各大厂大V对Web安全的重视和发展,已经有相当完备的应对手段。比如在用户侧,浏览器地址栏上的域名和CA(浏览器常以锁头图标显示)可以给到用户提醒;同时依靠数据隔离(浏览器沙盒、cookie等)能让AAS判断请求是否来自同域。

而对于App来说,用户侧没有一个标准识别信息能给到用户,这要求用户须要凭借自身经验判断App是否为钓鱼App;而本文所涉及的各类授权模式也无法保证百分之百的安全,只能做一些简单的防范(其实用户侧App防伪本身就不属于AAS的职责范围)。所幸各大应用商店的审核机制、手机自带的扫毒进程、App自身的后端防范(e.g.双因素认证two-factor authentication)等等手段让做一个钓鱼App相当困难。当然,对于小App来说,刚说的这些手段可能都没法覆盖到,特别是Android系统Apk随意下载安装,用户其实是很容易中招的,只是仿制一款没多少人用的App更加没意义。

我们把关注点放到后端,看看OAuth2的几种模式是否可行。

Username&Password Flow:适用于AAS高度信任请求的场景,既AAS认为所有请求肯定来自于合法的客户端———这种情况一般会出现在请求来自于信任域或内网系统———否则别人很容易通过外网系统抓包构建非法请求,就算每次请求的用户名密码错误也能让AAS浪费大量资源;或者以正确的用户名密码拿到token后,去Resource端呼风唤雨。所以,此模式不适用于App应用。

Client Credentials Flow:对于外部请求的身份识别,现在较广泛的解决方案是AAS给App颁发一个事先约定的身份证明(如client_idclient_secret对),App将它们织入请求(如签名),AAS以此确定请求合法。然而新的问题又出现了,就是如何保证该身份证明本身的安全性。这个问题在移动端并没有完美的解决方案。以Android为例,存储在Sharedpreferences需要在运行时写入,这就给了不法分子可趁之机;即使存储在最安全的KeyStore里,使用时还要传入外部密钥获取,那这个外部密钥的安全如何保障呢:)。

既然如此,那干脆不开放接口,而是要求App嵌入AAS自己的登录界面,类似于Authorization Code FlowImplicit Flow。因为Implicit Flow可看作Authorization Code Flow的“低配版”,我们先来论证Authorization Code Flow的可行性。

Authorization Code Flow中关键的一步是client获取code,是AAS发送重定向状态码(30X)到浏览器告知浏览器重定向到预先指定的URI,并将code附加到query。对Web应用程序来说,重定向的URI是属于client域的URL,而浏览器的安全级别也防止了其它进程对code的截获,且就算截获了,由于恶意进程不知道Client Credentials(存于client后端),也无法拿去交换AccessToken。而对于App,情况又变得错漏百出,如前所述,Client Credentials无法得到安全保障,而且code被拦截的风险也比Web端高了很多——

首先,重定向的URI的作用改变了,它起到一个类似“唤起”的作用,系统根据URI的scheme唤起相应App,如果恶意App注册了相同的scheme就能轻易拿到code;其次,URI从系统浏览器传递到App的过程走的基本都是不安全通道,这也增加了传递过程中被截获的风险。

于是Authorization Code Flow也被我们否了,那Implicit Flow自然更不用说,而且Implicit Flow无法提供RefreshToken,这对App来说可不能忍。

另外,用户侧也不推荐在App中嵌入Web页面,一个是增加用户识别的难度,另一个给了App访问Web隐私数据的机会。现在很多三方登录并不会提供嵌入式的页面,都是直接打开它们自己的App的单独登录界面,应该就是主要避免这个问题。

看来在App场景下,OAuth2的其中四个授权模式多少都有安全隐患,那还有什么协议能支持App的认证授权呢?难道这么多年的移动互联网,大家都是在里面裸奔吗?现实情况虽然没有如此糟糕,但笔者所接触到的项目和开发人员,普遍对所使用的协议是否真的安全缺少认知,多年运行良好很可能是因为你的App还不起眼:)

伟大的莱布尼茨·牛顿说过,地球毁灭与普通人无关,因为总会有其他人去解决。有一部分人注意到了App的授权安全问题,这其中又有一小撮人各种改造了自己的项目,最后有几个人提出了一个规范并收录在RFC文件中,这个规范就是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.

之后我们走完一遍授权流程就会清楚,PKCE和Authorization Code Flow确实无本质不同,差别在对CSRF的防范手段上。因为App没有浏览器那样的cookie支持,AAS没有session这样的东西来保存state参数从而防止CSRF,所以转而使用PKCE的code_verifiercode_challenge,顺便解决了无client_secret验证身份的问题。

PKCE扩展不会添加任何新响应,因此即使授权服务器不支持,客户端也可以始终使用PKCE扩展。

确定好授权模式,接下来就是要实现或者寻找一个合适的框架,以前用过的DotNetOpenAuth已经很久没更新了,是否支持PKCE尚不清楚。本着与时俱进的想法,为ASP.NET Core量身定制的IdentityServer4进入了视线。它是一款基于OpenID Connect的AAS框架。

Java开发的Keycloak在7.0版本也加入了对PKCE的支持

OIDC(OpenID Connect)

OpenID Connect是OpenID发展到第三代的产物,它其实是原来OpenID和OAuth的集合体。本人当年在研究OAuth的时候就被OpenID搞了一头雾水,总感觉OpenID和AccessToken虽然语义不同,但两者完全可以归为一个。多年之后OpenID Connect姗姗来迟,但细究之后发现还是换汤不换药,只不过原本的OpenID现在变成了IDToken(一般使用JWT格式来包装。得益于JWT的自包含性,紧凑性以及防篡改机制,使得ID-Token可以安全的传递给第三方客户端程序并且容易被验证。),区别于AccessToken。为什么不干脆合并成一个Token呢?其实从名称上就能看出来它们各自的职责不同,从职责及历史角度看,如此这般也是合理。

IDToken只是用于标识用户,侧重于用户信息,可用于单点登录等会话相关场景,Client也可以把用户信息保存下来便于用户行为统计分析。它的标准流程中用户参与是必须的。AccessToken解决的问题是授权[给Client],而授权可能是用户授权也可能是AAS直接授权,用户认证并不是不可或缺的条件,所以授权过程中并不一定需要用户参与。在任何模式下,Client也不需要知道用户信息就能无障碍访问授权过的API。现在看来,AccessToken当然可以在某些模式下包含IDToken,但若没有IDToken,若只是为了标识用户使用AccessToken就显得不那么恰当了。另外,就算有用户参与,IDToken就一个,但对于不同的Client,AccessToken肯定是不一样的。


实战

Talk is cheap, show you the code. 我们的目标很简单,就是实现App的登录授权,通过后可以拿着token调用资源Api。用户对背后的逻辑是无感知的,对他们来说就和普通的登录一样。为了达到这个目的,我们还需要把登录界面嵌入到App中——细心的读者会发现这似乎违背了上一节的提醒,但是上面的建议主要面向的场景是AAS作为第三方登录平台,服务的App不受完全信任——由于我们这个App也是自己从头开始撸的,所以不用考虑这个问题。

下面分别就AAS、App、Resource Server列出关键步骤和代码。

AAS

为简单起见,我们直接使用ASP.NET Core Identity作为用户体系。

# 1.创建一个空的mvc项目
dotnet new mvc -o AAS
# 2.添加ASP.NET Core Identity相关依赖包
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
  1. 定义一个继承自IdentityDbContext<ApplicationUser>DbContext——ApplicationDbContext,其中ApplicationUser继承须自IdentityUser
  2. Startup.ConfigureServices,主要是将一些服务注入到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没关系,它主要提供了手机号验证、邮箱验证等Api,如何使用可参看ASP.NET CORE IDENTITY TOKEN PROVIDERS – UNDER THE HOOD

options.Events.RaiseXXXXEvents表示什么级别的事件会被监听到,可以自定义事件和监听器,可参看官方文档Events。这种模式比到处log应该要稍好一点。

我们使用MySql持久化数据,AddConfigurationStore表示存储一些预配置数据如clients、resources等等,AddOperationalStore存储授权过程中产生的数据如codes、tokens等等。

AddProfileService<T>用于添加自定义claim,泛型参数类需要实现IProfileService

  1. 配置IdentityServer到请求处理管道,在Startup.Configure中增加app.UseIdentityServer()(它间接调用了app.UseAuthentication()),注意它在管道中和其它处理器的顺序。
  2. 在数据库中添加client。由于数据表中非关键字段太多,我们一般使用管理后台或编写CRUD接口进行维护。以PKCE客户端为例,我们只需构建带必要属性的如下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设为false表示交互时不需要client_secret参与;AllowOfflineAccess设为true表示需要获取Refresh token;RedirectUris是与client约定好的重定向地址;is4默认开启对client的PKCE交互的支持(RequirePkce),当然首先要AllowedGrantTypes = GrantTypes.Code

  1. 画一个登录页面

  2. 用户点击登录按钮时执行:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginInput model)
{
// 关键,check if we are in the context of an authorization request
var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); if (ModelState.IsValid)
{
//toetb isPersistent设为false,应该跟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类型的对象,调用它的GetAuthorizationContextAsync能区分用户操作是处于授权过程还是普通登录。

App

  1. 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方法,当发现浏览器redirect约定的URL时即知用户已登录成功,此时拦截跳转,并发起token请求。

  1. 交互的流程主要在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查询字符串各参数的意义可参看RFC文档,重点关注code_challenge

用户登录成功后,就可以用code换token了,重点关注code_verifier,AAS会拿着这个和上一步的code_challenge进行比对校验。这里使用Retrofit2封装了Http请求,注意获取token时,content-type要设置为application/x-www-form-urlencoded

    @POST(TOKEN_ENDPOINT)
@FormUrlEncoded
suspend fun authorize(@FieldMap maps: Map<String, String>): TokenRsp
  1. 获取了token之后,就可以访问Resource Server的资源了。在每次请求时添加请求头如下:
reqBuilder.addHeader(
"Authorization",
"Bearer ${Session.getAccessToken()}"
)

Resource Server

依旧以ASP.NET Core为例。

  1. 添加依赖包Microsoft.AspNetCore.Authentication.JwtBearer
  2. 在Startup.ConfigureServices注册token校验服务:
services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://localhost:5001";
});

options.Authority设置为AAS地址。

由于AAS颁发的AccessToken默认是JWT格式,所以这里我们使用JWT校验。AccessToken还可设置为Reference Token格式,具体可参看IdentityServer4之JWT签名(RSA加密证书)及验签

  1. 保证Startup.Configure中有
            app.UseAuthentication();
app.UseAuthorization();
  1. 在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 实战之 Claim详解

浅谈SAML, OAuth, OpenID和SSO, JWT和Session

【从零开始撸一个App】PKCE的更多相关文章

  1. 【从零开始撸一个App】Kotlin

    工欲善其事必先利其器.像我们从零开始撸一个App的话,选择最合适的语言是首要任务.如果你跟我一样对Java蹒跚的步态和僵硬的语法颇感无奈,那么Kotlin在很大程度上不会令你失望.虽然为了符合JVM规 ...

  2. 【从零开始撸一个App】Dagger2

    Dagger2是一个IOC框架,一般用于Android平台,第一次接触的朋友,一定会被搞得晕头转向.它延续了Java平台Spring框架代码碎片化,注解满天飞的传统.尝试将各处代码片段串联起来,理清思 ...

  3. 【从零开始撸一个App】RecyclerView的使用

    目标 前段时间打造了一款简单易用功能全面的图片上传组件,现在就来将上传的图片以图片集的形式展现到App上.出于用户体验考虑,加载新图片采用[无限]滚动模式,Android平台上我们优选Recycler ...

  4. 【从零开始撸一个App】Fragment和导航中的使用

    Fragment简介 Fragment自从Android 3.0引入开始,它所承担的角色就是显而易见的.它之于Activity就如html片段之于页面,好处无需赘述. Fragment的生命周期和Ac ...

  5. Android(4)—Mono For Android 第一个App应用程序

    0.前言 年前就计划着写这篇博客,总结一下自己做的第一个App,却一直被新项目所累,今天抽空把它写完,记录并回顾一下相关知识点,也为刚学习Mono的同学提供佐证->C#也是开发Android的! ...

  6. 深入浅出React Native 3: 从零开始写一个Hello World

    这是深入浅出React Native的第三篇文章. 1. 环境配置 2. 我的第一个应用 将index.ios.js中的代码全部删掉,为什么要删掉呢?因为我们准备从零开始写一个应用~学习技术最好的方式 ...

  7. Django1.8教程——从零开始搭建一个完整django博客(一)

    第一个Django项目将是一个完整的博客网站.它和我们博客园使用的博客别无二致,一样有分类.标签.归档.查询等功能.如果你对Django感兴趣的话,这是一个绝好的机会.该教程将和你一起,从零开始,搭建 ...

  8. 从零开始写一个武侠冒险游戏-6-用GPU提升性能(1)

    从零开始写一个武侠冒险游戏-6-用GPU提升性能(1) ----把帧动画的实现放在GPU上 作者:FreeBlues 修订记录 2016.06.19 初稿完成. 2016.08.05 增加对 XCod ...

  9. 从零开始构建一个的asp.net Core 项目

    最近突发奇想,想从零开始构建一个Core的MVC项目,于是开始了构建过程. 首先我们添加一个空的CORE下的MVC项目,创建完成之后我们运行一下(Ctrl +F5).我们会在页面上看到"He ...

随机推荐

  1. pandas | DataFrame基础运算以及空值填充

    本文始发于个人公众号:TechFlow,原创不易,求个关注 今天是pandas数据处理专题的第四篇文章,我们一起来聊聊DataFrame中的索引. 上一篇文章当中我们介绍了DataFrame数据结构当 ...

  2. Python os.pathconf() 方法

    概述 os.pathconf() 方法用于返回一个打开的文件的系统配置信息.高佣联盟 www.cgewang.com Unix 平台下可用. 语法 fpathconf()方法语法格式如下: os.fp ...

  3. 4.17 斐波那契数列 K维斐波那契数列 矩阵乘法 构造

    一道矩阵乘法的神题 早上的时候我开挂了 想了2h想出来了. 关于这道题我推了很多矩阵 最终推出两个核心矩阵 发现这两个矩阵放在一起做快速幂就行了. 当k==1时 显然的矩阵乘法 多开一个位置维护前缀和 ...

  4. 【FZYZOJ】细菌 题解(最短路)

    题目描述 为了研究一种新型细菌(称它为S型细菌)的性质,Q博士将S型细菌放在了一个犹如迷宫一般的通道面前,迷宫中N个站点,每个站点之间以一种单向通道的形式连接,当然,也有可能某两个站点之间是互不联通的 ...

  5. 【SCOI2005】互不侵犯 题解(状压DP)

    前言:一道状压DP的入门题(可惜我是个DP蒟蒻QAQ) ------------------ 题意简述:求在一个$n*n$的棋盘中放$k$个国王的方案数.注:当在一个格子中放入国王后,以此格为中心的九 ...

  6. 嵌入式Linux串口编程简介

    文章目录 简介 用到的API函数 代码 简介 嵌入式Linux下串口编程与Linux系统下的编程没有什么区别,系统API都是一样的.嵌入式设备中串口编程是很常用的,比如会对接一些传感器模块,这些模块大 ...

  7. 打开IDEA后tomcat不能用,Cannot load project of unknown project type,无法加载类或者项目

    这一问题在网络中有比较统一的解决方法,我这个也是按这个方法解决的. 问题出现的前提和原因: 一个运行正常项目,我关闭后第二天打开发现tomcat不能用了. 解决方法: 我查了一下,这是一个IDEA软件 ...

  8. 操作属性、操作样式 - DOM编程

    1. 操作属性 1.1 HTML 属性与 DOM 属性的对应 <div> <label for="username">User Name: </lab ...

  9. 蜻蜓点水说说Redis的ziplist的奥秘

    本篇博客参考: Redis 深度历险:核心原理与应用实践 Redis内部数据结构详解(4)--ziplist Redis的压缩列表ZipList 上篇博客中,我给大家蜻蜓点水般的介绍了Redis中SD ...

  10. Java常用类:包装类,String,日期类,Math,File,枚举类

    Java常用类:包装类,String,日期类,Math,File,枚举类