将Auth0认证添加到Ktor HTTP API中

346 阅读10分钟

在上一篇文章中,我们创建了一个Ktor HTTP API,暴露了三个端点。所有这些端点都是可以公开访问的,在这篇文章中,我们将添加一个安全层,要求有一个有效的访问令牌来查询 protectedadmin 端点。

此外,我们将为我们的前端应用程序创建一个Auth0 API和应用程序,允许用户进行认证并提供传递给后端API的访问令牌。

在开始之前,我们需要了解两项基本技术,它们构成了我们的API的认证和授权层的核心。JSON Web Tokens(JWT)和JSON Web Keys(JWK)。

什么是JWT?

JWT.io网站将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将受到Auth0API应用程序的保护。

创建API

我们将从创建一个新的API开始。在Auth0管理控制台,选择左侧菜单中的应用程序->API选项,然后点击创建API按钮。创建一个新的API,名称和标识符为ktordemo

Creating the API 创建一个新的Auth0 API。

创建应用程序

接下来,我们创建应用程序。在左边的菜单中选择应用->应用选项,然后点击创建应用按钮。

创建应用程序对话框中,选择单页Web应用程序选项,给应用程序起一个名字(这个名字并不重要,所以使用一个对你有意义的名字),然后点击创建按钮。

Creating the application 创建一个新的应用程序。

记下应用程序的域名客户端ID,因为我们以后在配置前端Web应用程序的时候会需要这些。

我们需要将该URL http://localhost:4040允许的回调URLs允许的注销URLs允许的Web Origins字段。这些将允许我们的样本前端应用程序在后面的文章中连接到Auth0应用程序。

Creating the application 配置回调、注销和起源URL。

应用程序示例代码

要构建后端应用程序,你需要有JDK 11或更高版本,这可以从许多来源获得,包括OpenJDKAdoptOpenJDKAzulOracle

前端应用程序需要Node.js

Ktor应用程序的最终代码可以在这里找到。每个帖子中讨论的代码都有一个分支相匹配。

  • starter- 基本API,没有认证或授权。
  • add-authorization- 需要有效的访问令牌的API为 protectedadmin 端点。这个分支将在本帖中讨论。
  • add-rbac- 需要特殊权限才能访问admin 端点的API。

前端应用程序的代码可以在这里找到。

更新API

现在必须更新后端API,以确保 protectedadmin 端点需要一个有效的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) }
        }
    }

最后一步是更新路由功能,以确保对 protectedadmin 端点满足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端点。

The sample frontend application 前端应用程序的样本。

在CodeSandBox中运行前端程序

你也可以在CodeSandBox中运行前台程序。

打开URLgithubbox.com/auth0-sampl…。这将把前端代码导入到一个沙盒中。

然后在根目录下创建一个名为 .env的文件,内容如下,并确保替换掉 REACT_APP_AUTH0_DOMAINREACT_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 URLs added to the Auth0 application CodeSandBox的URL添加到Auth0应用程序中。

现在你可以从CodeSandBox的URL中浏览前端应用程序。

如果你在登录Auth0时遇到错误,请确保 .env文件是以 LF行结尾。这可以通过命令调色板中的 "改变行尾序列 "来完成。

CodeSandBox命令调色板

检查访问令牌

捕获前端发送的流量显示了正在向后端API发出的请求。下面的截图显示了对 api/messages/admin端点的请求。注意Authorization 头部包括以下值 Bearer <token>.其中的 <token>这个值的一部分是JWT。

Browser network traffic capture 浏览器网络流量捕获。

你可以把这个令牌复制到jwt.io来检查JWT的内容。注意PAYLOAD部分,它包括我们在博文中早先谈到的字段。

A parsed JWT token 一个经过解析的JWT令牌。

结论

在这篇文章中,我们扩展了上一篇文章中创建的HTTP API,以确保 protectedadmin 端点,并由Ktor提供本地JWT验证支持。

然后,我们创建了一个Auth0的API和应用程序,并通过与Auth0集成的前端示例应用程序访问我们的后端API。

在本系列的下一篇也是最后一篇文章中,我们将用基于角色的访问控制(RBAC)和特殊权限锁定admin 端点,称为 read:admin-messages.