指南:如何通过 Google 实现 OAuth 登录

预计阅读时间: 7 分钟

在本指南中,我们会使用 OAuth 实现登录。

这是一篇高级教程,假定你已经对 Ktor 有一些基本的了解, 因此你应该先看关于制作网站的指南

目录:

创建一个指向 127.0.0.1 的主机名条目

Google 的 OAuth 要求重定向 URL 不能是 IP 地址或者 localhost。 因此,出于开发目的,我们需要一个合适的主机名指向 127.0.0.1。 由于并不需要从计算机外部访问该主机名,因此可以只为本地主机设置。 有一个公网域名 http://lvh.me/ 指向 localhost/127.0.0.1,但是出于安全原因考虑,你可能希望在本地提供自己的主机名。

为此,可以在计算机的 host 文件中添加一个条目。

对于本指南,我们会将 me.mydomain.com 关联到 127.0.0.1,不过你可以根据自己的需要进行更改, 只要它像公网顶级域名(.com、.org……)一样或者至少有两个组成部分即可。

127.0.0.1       me.mydomain.com

这个文件的结构很简单:# 字符用于注释, 每个非空且非注释行都应该包含一个 IP 地址,后跟几个由空格或者制表符(tab)分隔的主机名。

MacOS/Linux

在 MacOS 与 Linux(Unix)计算机中,可以在 /etc/hosts 处找到主机名文件。会需要 root 权限才能对其编辑。

sudo nano /etc/hosts 或者 sudo vi /etc/hosts

Windows

在 Windows 中,主机名文件保存在 %SystemRoot%\System32\drivers\etc\hosts。需要管理员权限才能编辑这个文件。例如,可以使用以管理员身份打开的 wxMEdit 来编辑。

还可以在 Windows 资源管理器中粘贴 %SystemRoot%\System32\drivers\etc 路径,然后右击 hosts 文件来编辑它。这个文件的结构与 MacOS/Linux 相同。

Google 开发者控制台

为了能够使用任何提供商的 OAuth,会需要一个公开的 clientId 以及一个私有的 clientSecret。 对于 Google 登录,可以使用 Google 开发者控制台创建它: https://console.developers.google.com/

首先,必须在开发者控制台中创建一个新项目:

API & ServicesCredentials 内部有一个带有 OAuth Client Id 选项的 Create Credentials 按钮:

不过首先,我们必须配置(Configure)OAuth consent screen:

现在,我们可以使用以下信息创建 OAuth 凭据:

  • Authorized JavaScript origins: http://me.mydomain.com:8080
  • Authorized redirect URIs: http://me.mydomain.com:8080/login

点击 Create 按钮。

可以稍后更改这些值,也可以通过编辑凭据添加其他授权的 URL。

会看到一个模式对话框,其中包含以下内容:

OAuth client

  • Here is your client ID: xxxxxxxxxxx.apps.googleusercontent.com
  • Here is your client secret: yyyyyyyyyyy

配置应用

首先,必须为 OAuth 提供商定义设置。必须用上一步获得的值替换 clientIdclientSecret 。根据我们对用户的需求,可以将 defaultScopes 列表调整为其他范围。profile 范围会访问用户 id、全名与图片,但不会访问电子邮件及其他任何内容:

val googleOauthProvider = OAuthServerSettings.OAuth2ServerSettings(
    name = "google",
    authorizeUrl = "https://accounts.google.com/o/oauth2/auth",
    accessTokenUrl = "https://www.googleapis.com/oauth2/v3/token",
    requestMethod = HttpMethod.Post,

    clientId = "xxxxxxxxxxx.apps.googleusercontent.com",
    clientSecret = "yyyyyyyyyyy",
    defaultScopes = listOf("profile") // 无电子邮件,但提供全名、图片与 id
)

请记住为了安全性、用户隐私与信任,请将 defaultScopes 调整为只请求你真正需要的内容。

我们还必须安装 OAuth 特性并进行配置。需要提供一个 HTTP 客户端实例、一个提供商查找程序—— 由该调用确定提供商(不需要在这里输入逻辑,因为本指南只支持 Google)以及一个给出重定向 URL 的 urlProvider,必须匹配在 Google 开发者控制台中指定为 authorized redirection 的 url——在本例中是 http://me.mydomain.com:8080/login

install(Authentication) {
    oauth("google-oauth") {
        client = HttpClient(Apache)
        providerLookup = { googleOauthProvider }
        urlProvider = { redirectUrl("/login") }
    }
}

private fun ApplicationCall.redirectUrl(path: String): String {
    val defaultPort = if (request.origin.scheme == "http") 80 else 443
    val hostPort = request.host()!! + request.port().let { port -> if (port == defaultPort) "" else ":$port" }
    val protocol = request.origin.scheme
    return "$protocol://$hostPort$path"
}

然后必须定义 /login 路由,该路由必须由我们的认证提供商进行认证。 当没有 get 参数传给该 URL 时,身份认证特性会 hook 住其处理程序,并会将我们重定向到 Google 的 OAuth Consent Screen,而它会重定向回我们的 /login 路由并带有 statuscode 参数——会用于由认证提供方回调 Google 以获取accessToken 并将 OAuthAccessTokenResponse.OAuth2 身份附加到我们的调用中。而这一次, 我们的处理程序会执行。

我们可以通过获取生成的 OAuthAccessTokenResponse.OAuth2 身份与 accessToken 来取得 accessToken。然后我们可以使用 https://www.googleapis.com/userinfo/v2/me URL 并传入 accessToken 作为 Authorization Bearer 来获取带有用户信息的 JSON。 可以使用 Google OAuth playground 检验该 JSON 的内容。

在本例中,一旦我们获取了用户 ID,我们就会将其存储在一个会话中,然后重定向到另一个地方。

class MySession(val userId: String)

authenticate("google-oauth") {
    route("/login") {
        handle {
            val principal = call.authentication.principal<OAuthAccessTokenResponse.OAuth2>()
                ?: error("No principal")

            val json = HttpClient(Apache).get<String>("https://www.googleapis.com/userinfo/v2/me") {
                header("Authorization", "Bearer ${principal.accessToken}")
            }

            val data = ObjectMapper().readValue<Map<String, Any?>>(json)
            val id = data["id"] as String?

            if (id != null) {
                call.sessions.set(MySession(id))
            }
            call.respondRedirect("/")
        }
    }
} 

必须先安装会话特性。详见完整示例

用户信息中的 ID 是一个看起来像数字的字符串。请记住 JSON 没有定义长整型, 并且对于 Twitter 或者 Google 来说,他们有大量的用户与实体,其 ID 可能会超过有符号整型的 31 比特,甚至超过标准 Double 的 51 比特精度。
一般来说,如果你不需要对其进行算术运算, 那么应该将 ID 以及其他类似数字的值始终视为字符串。

完整示例

一个简单的嵌入式应用如下所示:

OAuthApp.kt
val googleOauthProvider = OAuthServerSettings.OAuth2ServerSettings(
    name = "google",
    authorizeUrl = "https://accounts.google.com/o/oauth2/auth",
    accessTokenUrl = "https://www.googleapis.com/oauth2/v3/token",
    requestMethod = HttpMethod.Post,

    clientId = "xxxxxxxxxxx.apps.googleusercontent.com", // @TODO: 记得更改这个!
    clientSecret = "yyyyyyyyyyy", // @TODO: 记得更改这个!
    defaultScopes = listOf("profile") // no email, but gives full name, picture, and id
)

class MySession(val userId: String)

fun main(args: Array<String>) {
    embeddedServer(Netty, port = 8080) {
        install(WebSockets)
        install(Sessions) {
            cookie<MySession>("oauthSampleSessionId") {
                val secretSignKey = hex("000102030405060708090a0b0c0d0e0f") // @TODO: 记得更改这个!
                transform(SessionTransportTransformerMessageAuthentication(secretSignKey))
            }
        }
        install(Authentication) {
            oauth("google-oauth") {
                client = HttpClient(Apache)
                providerLookup = { googleOauthProvider }
                urlProvider = {
                    redirectUrl("/login")
                }
            }
        }
        routing {
            get("/") {
                val session = call.sessions.get<MySession>()
                call.respondText("HI ${session?.userId}")
            }
            authenticate("google-oauth") {
                route("/login") {
                    handle {
                        val principal = call.authentication.principal<OAuthAccessTokenResponse.OAuth2>()
                            ?: error("No principal")
            
                        val json = HttpClient(Apache).get<String>("https://www.googleapis.com/userinfo/v2/me") {
                            header("Authorization", "Bearer ${principal.accessToken}")
                        }
            
                        val data = ObjectMapper().readValue<Map<String, Any?>>(json)
                        val id = data["id"] as String?
            
                        if (id != null) {
                            call.sessions.set(MySession(id))
                        }
                        call.respondRedirect("/")
                    }
                }
            } 
        }
    }.start(wait = true)
}

private fun ApplicationCall.redirectUrl(path: String): String {
    val defaultPort = if (request.origin.scheme == "http") 80 else 443
    val hostPort = request.host()!! + request.port().let { port -> if (port == defaultPort) "" else ":$port" }
    val protocol = request.origin.scheme
    return "$protocol://$hostPort$path"
}

 

测试

可以提供一个测试 HttpClient 来测试 OAuth

补充资源