在上一篇文章中,我们创建了一个Ktor HTTP API,暴露了三个端点。所有这些端点都是可以公开访问的,在这篇文章中,我们将添加一个安全层,要求有一个有效的访问令牌来查询 protected
和admin
端点。
此外,我们将为我们的前端应用程序创建一个Auth0 API和应用程序,允许用户进行认证并提供传递给后端API的访问令牌。
在开始之前,我们需要了解两项基本技术,它们构成了我们的API的认证和授权层的核心。JSON Web Tokens(JWT)和JSON Web Keys(JWK)。
什么是JWT?
一个开放的标准(RFC 7519),它定义了一种紧凑和独立的方式,以JSON对象的形式在各方之间安全地传输信息。这种信息可以被验证和信任,因为它是经过数字签名的。
在认证和授权方面,JWTs提供了一套标准的JSON属性,这些属性包括
- 通过
iss
属性识别令牌发行者,在我们的例子中,这将是一个Auth0应用程序。 - 通过
aud
属性确定受众,这将是我们的Ktor API。 - 通过
exp
属性定义过期时间,确保令牌不能被永远使用。
我们将在文章后面看一下前端应用程序生成的JWT,但下面是一个Auth0 JWT访问令牌的JSON有效载荷的例子。
{
"sub": "auth0|60ab2d9aa43f230069c54f13",
"aud": [
"ktordemo",
"https://yourdomain.auth0.com/userinfo"
],
"iat": 1623731072,
"exp": 1623817472,
"azp": "KcFo4BTtPdYvoRdvZ5wKmTUqjT51F84y",
"scope": "openid profile email"
}
这个JWT告诉我们,yourdomain.auth0.com/,应用程序授予这个令牌的持有者调用ktordemo
服务的能力。
因为这个令牌是经过签名的,我们知道它没有被篡改过,我们可以相信令牌中的数据是有效的。
然而,我们不能仅仅从JWT中看出_是谁_创建了它。iss
字段告诉我们,一个Auth0应用程序发布了这个令牌,但任何人都可以用这个iss
字段创建一个有效的JWT。在我们对令牌的内容采取行动之前,我们需要确认它是由一个我们信任的服务创建的。这就是JWK的作用。
什么是JWK?
验证一个令牌的作者是通过JWK进行的。JWK持有一个或多个公钥,可用于验证JWT签名。如果来自受信任地点的公钥与JWT签名相符,我们就可以确定是受信任的服务创建了传递给我们的令牌。
一个JWK可以从 /.well-known/jwks.json
按照惯例,JWK是从发行人的URL所暴露的路径中获得的。所以在我们的例子中,JWK可以从 https://yourdomain.auth0.com/.well-known/jwks.json
.安全的HTTPS请求为我们提供了高度的信心,当我们访问这个JSON文件时,我们确实从相关的Auth0应用中收到了JWK。
下面是一个JWK文件的例子。在这种情况下,它包含两个密钥,这使发行者有机会在需要时轮换密钥。
{
"keys": [
{
"alg": "RS256",
"kty": "RSA",
"use": "sig",
"n": "ut8wiM3LtyWZROKo1rUnWrqApCV2idvmAvcOgbXmr_8bygzQtOaoCsjmC6Ra9KUVFZpH4ovFfSyCd8-hSfSspVP7WVhSUUNkPn96WcXChuxPQH7A2W7TiufCIBX-uIZyj8LCy5Sx9bjnRU7XhRpVuP_u610Eaud7ertcTCw6n7bG3zt-BM64VjjLQcjabDFFm3kACRRiP7-m4XOUIDu-ntBMiYt4Ay48LtzIeUI3zAsLwakILqOPrpiyKcpzZx9KTwPIuwr8Ocg3N0Q-XDGyDRBJmoddKRde2ryo2ggt8Yov0LClyVf_BGHqL2X2W5z2QyGFbwgZacTn8XRm2g6joQ",
"e": "AQAB",
"kid": "QEf9Ewnh8lGxtIB7GKHF5",
"x5t": "9XQPmPB7VX7KK73qArUPwRi8Nvk",
"x5c": [
"MIIDFTCCAf2gAwIBAgIJPedZR/pd9QahMA0GCSqGSIb3DQEBCwUAMCgxJjAkBgNVBAMTHW1hdHRoZXdjYXNwZXJzb24uYXUuYXV0aDAuY29tMB4XDTIxMDUyNDA0MzQ1NVoXDTM1MDEzMTA0MzQ1NVowKDEmMCQGA1UEAxMdbWF0dGhld2Nhc3BlcnNvbi5hdS5hdXRoMC5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC63zCIzcu3JZlE4qjWtSdauoCkJXaJ2+YC9w6Bteav/xvKDNC05qgKyOYLpFr0pRUVmkfii8V9LIJ3z6FJ9KylU/tZWFJRQ2Q+f3pZxcKG7E9AfsDZbtOK58IgFf64hnKPwsLLlLH1uOdFTteFGlW4/+7rXQRq53t6u1xMLDqftsbfO34EzrhWOMtByNpsMUWbeQAJFGI/v6bhc5QgO76e0EyJi3gDLjwu3Mh5QjfMCwvBqQguo4+umLIpynNnH0pPA8i7Cvw5yDc3RD5cMbINEEmah10pF17avKjaCC3xii/QsKXJV/8EYeovZfZbnPZDIYVvCBlpxOfxdGbaDqOhAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMp/i3n4GHWiXGdG3w6C77ulo+6nMA4GA1UdDwEB/wQEAwIChDANBgkqhkiG9w0BAQsFAAOCAQEAZj+TggYxBCiri4/rWTBq+lQo0bpn89uGZ1vfWdmxhurHEsanG1XgxUyh75jeuul+U3NGZU/d2wzPi9ttORsiSoYdsoJttZ5cVI8UYJZhfeUi/JWW8p2SlptOrSOCk/2/n5qy6PYb5npXxnrC2BDWqS/G6zfqfHCJXCyR1Wo2XpFC3PIhlFtMNvgDv5jHWVxuxrS0iwfsB3nKARcEZ34G6X26fijYMxB0ivjrzhtTcQCEvdCKs9dAcyd0+gnnxZvtbxK/ev+pXvnWoTAr9x0CpTTFBxIoozp8ClxY+D4fr1IIAz+akO1NMj1u3XycjqjKgG01xeCma+NS5+skfm1dow=="
]
},
{
"alg": "RS256",
"kty": "RSA",
"use": "sig",
"n": "wjdxVLuQw2aBc7swDtkL20V4nZw2NKlvBZZvl7sSmgsyAL4UoL_tE_GLNy5ASN3dafUfLsvYg_C5EPvd0-_A5LI7yxe0zbE8CMMHD6l-W3EnsOBLvmI-RgvXSpJXrmVh87_GOSVcHQw5n2GloEFqZfeSGDcbpTCJEMiNB5zU_MzYzATpTCgFW3Tvx7WX8oNQmHZhXAFEO9in4LZbg_3GDqZZg1LPy3dGnEvr_vw635sP6h8bmfOXAG3RNVGxfZuV0prV-m-VjyYm-qaVShA2XkDll1rCnHFBz1dI6KVEcbH-dyTWFLHrlBCpDEMG5ve3XanIJ_eExWIDc3YN_DsGJw",
"e": "AQAB",
"kid": "puUYU8zrbxliFfz0jn8St",
"x5t": "dXu90JUEVYhGTwQx7gZp5q4lmMM",
"x5c": [
"MIIDFTCCAf2gAwIBAgIJHphcpYJ9MY8TMA0GCSqGSIb3DQEBCwUAMCgxJjAkBgNVBAMTHW1hdHRoZXdjYXNwZXJzb24uYXUuYXV0aDAuY29tMB4XDTIxMDUyNDA0MzQ1NVoXDTM1MDEzMTA0MzQ1NVowKDEmMCQGA1UEAxMdbWF0dGhld2Nhc3BlcnNvbi5hdS5hdXRoMC5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDCN3FUu5DDZoFzuzAO2QvbRXidnDY0qW8Flm+XuxKaCzIAvhSgv+0T8Ys3LkBI3d1p9R8uy9iD8LkQ+93T78DksjvLF7TNsTwIwwcPqX5bcSew4Eu+Yj5GC9dKkleuZWHzv8Y5JVwdDDmfYaWgQWpl95IYNxulMIkQyI0HnNT8zNjMBOlMKAVbdO/HtZfyg1CYdmFcAUQ72KfgtluD/cYOplmDUs/Ld0acS+v+/Drfmw/qHxuZ85cAbdE1UbF9m5XSmtX6b5WPJib6ppVKEDZeQOWXWsKccUHPV0jopURxsf53JNYUseuUEKkMQwbm97ddqcgn94TFYgNzdg38OwYnAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFKpDxHQVMbLDzGBjlGeT66aWQqU+MA4GA1UdDwEB/wQEAwIChDANBgkqhkiG9w0BAQsFAAOCAQEAf89pRtcVC9TrMgBCC6xoZoV4HwA6Sr4MFQsJC1WO5g1rnFJlYgXLn2LGK6ZhxByEbZlQ+lf1ZcpZ1cJMGEqnIJPfexfiedB19TNrQ4Yu8vr9JlwHrrD2c8P1TJPlOUGBiodSBGxQP8WfzHCO61mX2XFpdXELDl8u/bA/QZ5Z1jyNui21y6LQDtJlb/vsbcXn8pd73Sd4H9+PHhIutRhuaJ5wFeD3iLB1bNpOoXXwVYl2Gvzmxg6lJCEBBG+AgIvbD0hiSujL2fQC9KfSYqXwwoXHm8qMKYii41V3lHwE/etfeoDjwPs32FrLrpSuZQ7xDA5RT7p2BkhE/rN64bZr9A=="
]
}
]
}
JWT和JWK的组合为我们提供了所需的细节,以授予令牌持有者必要的访问权,并验证令牌是由可信的来源生成的。
配置Auth0 API、应用程序、角色和用户
创建API
我们将从创建一个新的API开始。在Auth0管理控制台,选择左侧菜单中的应用程序->API选项,然后点击创建API按钮。创建一个新的API,名称和标识符为ktordemo。
创建一个新的Auth0 API。
创建应用程序
接下来,我们创建应用程序。在左边的菜单中选择应用->应用选项,然后点击创建应用按钮。
在创建应用程序对话框中,选择单页Web应用程序选项,给应用程序起一个名字(这个名字并不重要,所以使用一个对你有意义的名字),然后点击创建按钮。
创建一个新的应用程序。
记下应用程序的域名和客户端ID,因为我们以后在配置前端Web应用程序的时候会需要这些。
我们需要将该URL http://localhost:4040
到允许的回调URLs、允许的注销URLs和允许的Web Origins字段。这些将允许我们的样本前端应用程序在后面的文章中连接到Auth0应用程序。
配置回调、注销和起源URL。
应用程序示例代码
要构建后端应用程序,你需要有JDK 11或更高版本,这可以从许多来源获得,包括OpenJDK、AdoptOpenJDK、Azul或Oracle。
前端应用程序需要Node.js。
Ktor应用程序的最终代码可以在这里找到。每个帖子中讨论的代码都有一个分支相匹配。
- starter- 基本API,没有认证或授权。
- add-authorization- 需要有效的访问令牌的API为
protected
和admin
端点。这个分支将在本帖中讨论。 - add-rbac- 需要特殊权限才能访问
admin
端点的API。
前端应用程序的代码可以在这里找到。
更新API
现在必须更新后端API,以确保 protected
和admin
端点需要一个有效的JWT访问令牌。该文件的完整代码如下 src/Application.kt
文件的完整代码如下所示。
package com.matthewcasperson
import com.auth0.jwk.JwkProviderBuilder
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.auth.jwt.*
import io.ktor.features.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import java.util.concurrent.TimeUnit
fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)
fun validateCreds(credential: JWTCredential): JWTPrincipal? {
val containsAudience = credential.payload.audience.contains(System.getenv("AUDIENCE"))
if (containsAudience) {
return JWTPrincipal(credential.payload)
}
return null
}
fun Application.module() {
val jwkProvider = JwkProviderBuilder(System.getenv("ISSUER"))
.cached(10, 24, TimeUnit.HOURS)
.rateLimited(10, 1, TimeUnit.MINUTES)
.build()
install(Authentication) {
jwt("auth0") {
verifier(jwkProvider, System.getenv("ISSUER"))
validate { credential -> validateCreds(credential) }
}
}
install(CORS) {
anyHost()
method(HttpMethod.Options)
method(HttpMethod.Get)
header("authorization")
allowCredentials = true
allowNonSimpleContentTypes = true
}
routing {
get("/api/messages/public") {
call.respondText(
"""{"message": "The API doesn't require an access token to share this message."}""",
contentType = ContentType.Application.Json
)
}
}
routing {
authenticate("auth0") {
get("/api/messages/protected") {
call.respondText(
"""{"message": "The API successfully validated your access token."}""",
contentType = ContentType.Application.Json
)
}
}
}
routing {
authenticate("auth0") {
get("/api/messages/admin") {
call.respondText(
"""{"message": "The API successfully recognized you as an admin."}""",
contentType = ContentType.Application.Json
)
}
}
}
}
让我们强调一下上一篇博文中介绍的代码所需的变化。
validateCreds
函数包含确定一个请求是否被授权访问一个给定端点的逻辑。它确保提供的JWT有一个预定义的受众,定义在 AUDIENCE
环境变量中定义的受众。这验证了我们收到的令牌是用于我们的API的。
fun validateCreds(credential: JWTCredential): JWTPrincipal? {
val containsAudience = credential.payload.audience.contains(System.getenv("AUDIENCE"))
if (containsAudience) {
return JWTPrincipal(credential.payload)
}
return null
}
我们利用JwkProviderBuilder
,建立一个验证我们JWT令牌的对象。这个对象理解JWK文件的标准位置(即 ${issuer}/.well-known/jwks.json
),并将访问它以验证传递给应用程序的任何JWT的签名。
val jwkProvider = JwkProviderBuilder(System.getenv("ISSUER"))
.cached(10, 24, TimeUnit.HOURS)
.rateLimited(10, 1, TimeUnit.MINUTES)
.build()
我们现在安装并配置了Authentication
功能,有一个名为auth0
的单一jwt
认证机制,它验证JWT令牌中的签名,并验证受众。
install(Authentication) {
jwt("auth0") {
verifier(jwkProvider, System.getenv("ISSUER"))
validate { credential -> validateCreds(credential) }
}
}
最后一步是更新路由功能,以确保对 protected
和admin
端点满足auth0
的认证机制。
routing {
authenticate("auth0") {
get("/api/messages/protected") {
call.respondText(
"""{"message": "The API successfully validated your access token."}""",
contentType = ContentType.Application.Json
)
}
}
}
routing {
authenticate("auth0") {
get("/api/messages/admin") {
call.respondText(
"""{"message": "The API successfully recognized you as an admin."}""",
contentType = ContentType.Application.Json
)
}
}
}
我们的后端应用程序现在已经准备好接受认证的请求了。现在我们已经创建了Auth0 API和应用程序,我们可以从示例的前端应用程序中提出这些请求。
前端应用程序
前端是由GitHub上的一个JavaScript React应用程序提供的。这个应用程序了解如何调用暴露了端点的API /api/messages/public
, /api/messages/protected
和 /api/messages/admin
,允许它被集成到我们的Ktor后端。
查看代码并安装依赖项。
npm install
然后设置连接到Auth0应用程序所需的环境变量,该应用程序将对我们的用户进行认证。
export REACT_APP_AUTH0_DOMAIN=yourdomain.auth0.com
export REACT_APP_AUTH0_CLIENT_ID=abcdefghigklmnop
接下来,定义与后端API相关的环境变量。
export REACT_APP_AUTH0_AUDIENCE=ktordemo
export REACT_APP_API_SERVER_URL=http://localhost:6060
最后,运行前端应用程序。
npm start
在http://localhost:4040,打开应用程序,完成登录,点击外部API选项卡,并点击公共、保护和管理员链接,查询我们的Ktor应用程序所暴露的相关HTTP API端点。
前端应用程序的样本。
在CodeSandBox中运行前端程序
你也可以在CodeSandBox中运行前台程序。
打开URLgithubbox.com/auth0-sampl…。这将把前端代码导入到一个沙盒中。
然后在根目录下创建一个名为 .env
的文件,内容如下,并确保替换掉 REACT_APP_AUTH0_DOMAIN
和 REACT_APP_AUTH0_CLIENT_ID
值替换为你的Auth0应用程序的值。
REACT_APP_AUTH0_DOMAIN=yourdomain.auth0.com
REACT_APP_AUTH0_CLIENT_ID=abcdefghigklmnop
REACT_APP_AUTH0_AUDIENCE=ktordemo
REACT_APP_API_SERVER_URL=http://localhost:6060
内置浏览器将显示前端应用程序的URL,如 https://[code].csb.app/
.这个URL必须被添加到你的Auth0应用程序的回调、注销和起源URL中。
CodeSandBox的URL添加到Auth0应用程序中。
现在你可以从CodeSandBox的URL中浏览前端应用程序。
如果你在登录Auth0时遇到错误,请确保 .env
文件是以 LF
行结尾。这可以通过命令调色板中的 "改变行尾序列 "来完成。
CodeSandBox命令调色板
检查访问令牌
捕获前端发送的流量显示了正在向后端API发出的请求。下面的截图显示了对 api/messages/admin
端点的请求。注意Authorization
头部包括以下值 Bearer <token>
.其中的 <token>
这个值的一部分是JWT。
浏览器网络流量捕获。
你可以把这个令牌复制到jwt.io来检查JWT的内容。注意PAYLOAD部分,它包括我们在博文中早先谈到的字段。
一个经过解析的JWT令牌。
结论
在这篇文章中,我们扩展了上一篇文章中创建的HTTP API,以确保 protected
和admin
端点,并由Ktor提供本地JWT验证支持。
然后,我们创建了一个Auth0的API和应用程序,并通过与Auth0集成的前端示例应用程序访问我们的后端API。
在本系列的下一篇也是最后一篇文章中,我们将用基于角色的访问控制(RBAC)和特殊权限锁定admin
端点,称为 read:admin-messages
.