跳转至

第22章:谷歌认证

在前几章中,你学会了如何在TIL网站上添加认证。然而,有时用户并不想为一个应用程序创建额外的账户,而是希望使用他们现有的账户。

在这一章中,你将学习如何使用OAuth 2.0将认证委托给Google,这样用户就可以用他们的Google账户登录了。

OAuth 2.0

OAuth 2.0是一个授权框架,允许第三方应用程序代表用户访问资源。每当你用你的谷歌账户登录到一个网站,你就在使用OAuth

当你点击用Login with Google时,谷歌是认证你的网站。然后,你授权应用程序可以访问你的谷歌数据,如你的电子邮件。一旦你允许应用程序访问,谷歌会给应用程序一个令牌。该应用程序使用这个令牌来验证对谷歌API的请求。你将在本章中实现这一技术。

Note

你必须有一个谷歌账户来完成这一章。如果你没有,请访问https://accounts.google.com/SignUp,创建一个。

Imperial

编写所有必要的脚手架来与GoogleOAuth系统交互并获得一个令牌是一件很耗时的工作!有一个叫做Imperial的社区包,它可以完成繁重的工作。

有一个叫做Imperial的社区包,https://github.com/vapor-community/Imperial,它为你完成了繁重的工作。它有谷歌、FacebookGitHub的集成,还有一些其他的。

添加到你的项目中

Xcode中打开Package.swift,添加新的依赖关系。替换:

.package(
  url: "https://github.com/vapor/leaf.git",
  from: "4.0.0")

为以下内容:

.package(
  url: "https://github.com/vapor/leaf.git", 
  from: "4.0.0"),
.package(
  url: "https://github.com/vapor-community/Imperial.git",
  from: "1.0.0")

接下来,将该依赖关系添加到你的App目标的依赖关系数组中。替换:

.product(name: "Leaf", package: "leaf")

为以下内容:

.product(name: "Leaf", package: "leaf"),
.product(name: "ImperialGoogle", package: "Imperial")

接下来,为一个新的控制器创建一个文件来管理Imperial的路线。在Sources/App/Controllers中创建一个名为ImperialController.swift的文件。打开这个新文件,创建一个新的空控制器:

import ImperialGoogle
import Vapor
import Fluent

struct ImperialController: RouteCollection {
  func boot(routes: RoutesBuilder) throws {
  }
}

这将创建一个新的类型,ImperialController,它符合RouteCollection,实现所需的boot(routes:)

最后,打开routes.swift,在routes(_:)的底部添加控制器到你的应用程序。

let imperialController = ImperialController()
try app.register(collection: imperialController)

在谷歌设置你的应用程序

为了能够在你的应用程序中使用Google OAuth,你必须首先向Google注册应用程序。在你的浏览器中,进入https://console.developers.google.com/apis/credentials

如果这是你第一次使用谷歌的凭证,网站会提示你创建一个项目:

img

点击Create Project,为TIL应用程序创建一个项目。在表格中填写一个适当的名称,例如Vapor TIL

img

创建项目后,该网站会带你回到新创建项目的谷歌凭证页面。这一次,点击Create Credentials,为TIL应用创建凭证,并选择OAuth client ID

img

接下来,点击Configure consent screen来设置谷歌呈现给用户的页面,这样他们可以允许你的应用程序访问他们的详细信息。

为用户类型选择External,然后点击Create

img

添加一个应用程序名称并选择用户支持电子邮件。

img

在页面的底部,添加你的开发者联系信息。点击Save and Continue

img

在下一个屏幕上,你为你的应用程序配置作用域。这些是你想向用户请求的权限,比如他们的电子邮件地址。点击Add or remove scopes,选择/auth/userinfo.email/auth/userinfo.profile。这样你就可以访问用户的电子邮件和个人资料,你需要在TIL应用程序中创建一个账户。

img

一旦你选择了范围,点击Update,然后Save and continue。接下来,你需要选择你将用于测试的用户。点击Save and continue,添加任何你希望能够登录的用户。如果你发布了你的应用程序,你可以验证你的域名和应用程序以消除这一限制。点击Save and continue

img

你已经完成了OAuth同意界面,所以点击Back to dashboard。再次点击Credentials页面,再次点击Create Credentials,选择OAuth客户端ID。创建客户端ID时,选择Web application。为你的应用程序添加一个重定向URI进行测试 - http://localhost:8080/oauth/google。这是用户允许你的应用程序访问他们的数据后,谷歌重定向回的URL

如果你想把你的应用程序部署到互联网上,例如用AWSHeroku,为该网站的URL添加另一个重定向--例如,https://rw-til-vapor.herokuapp.com/oauth/google

img

点击Create,网站会给你提供你的客户ID和客户秘密:

img

Note

你必须保持这些安全和可靠。你的秘密允许你访问谷歌的API,你不应该分享或检查秘密到源代码控制。你应该把它当作一个密码。

设置集成

现在你已经在Google注册了你的应用程序,你可以开始整合Imperial。打开ImperialController.swift,在boot(routes:)下添加以下内容:

func processGoogleLogin(request: Request, token: String) 
  throws -> EventLoopFuture<ResponseEncodable> {
    request.eventLoop.future(request.redirect(to: "/"))
  }

这定义了一个处理谷歌登录的方法。这个处理方法只是将用户重定向到主页--与常规登录的方式相同。Imperial使用这个方法作为处理Google重定向后的最终回调。注意使用eventLoop.future(_:)request.redirect(to:)创建一个未来。这是因为Imperial使用的方法需要一个EventLoopFuture

接下来,通过在boot(routes:)中添加以下内容来设置Imperial路由:

guard let googleCallbackURL =
  Environment.get("GOOGLE_CALLBACK_URL") else {
    fatalError("Google callback URL not set")
}
try routes.oAuth(
  from: Google.self,
  authenticate: "login-google",
  callback: googleCallbackURL,
  scope: ["profile", "email"],
  completion: processGoogleLogin)

下面是这个的作用:

  • 从环境变量中获取Google的回调URL - 这是你在Google控制台中设置的URL。
  • ImperialGoogle OAuth路由器与你的应用程序的路由器注册。
  • 告诉Imperial使用谷歌的处理程序。
  • /login-google路由设置为触发OAuth流程的路由。这是应用程序用来允许用户通过谷歌登录的路由。
  • 提供回调URLImperial
  • Google请求profileemail作用域--这与你之前创建应用程序时设置的作用域一致。
  • 将完成处理程序设置为processGoogleLogin(request:token:)--你在上面创建的方法。

为了让Imperial工作,你需要向它提供谷歌给你的客户ID和客户秘密。你用环境变量把这些提供给Imperial。有许多方法可以做到这一点,但Vapor已经内置了对.env文件的支持。这允许你在一个文件中定义环境变量,让Vapor读取。这在命令行和Xcode中都适用。

Note

.env文件依赖于你在Xcode中运行时设置的自定义工作目录。如果你需要更多关于如何做的信息,请参阅第14章,"用Leaf制作模板"。在你的项目目录下创建一个名为.env的新文件,并在你喜欢的文本编辑器中打开它。插入以下内容。

GOOGLE_CALLBACK_URL=http://localhost:8080/oauth/google
GOOGLE_CLIENT_ID=<THE_CLIENT_ID_FROM_GOOGLE>
GOOGLE_CLIENT_SECRET=<THE_CLIENT_SECRET_FROM_GOOGLE>

插入你的客户ID和谷歌提供的客户秘密。

img

Note

.env文件添加到.gitignore中是一个很好的做法,这样你就不会将秘密检查到源代码控制中。

与网络认证整合

为用户提供一个无缝的体验,并与常规登录的体验相匹配,这一点很重要。要做到这一点,当用户第一次用谷歌登录时,你需要创建一个新用户。为了创建一个用户,你可以使用谷歌的API,使用OAuth令牌获得必要的细节。

向第三方API发送请求

ImperialController.swift的底部,添加一个新类型来解码来自GoogleAPI的数据:

struct GoogleUserInfo: Content {
  let email: String
  let name: String
}

对谷歌API的请求会返回许多字段。然而,你只关心电子邮件,它成为用户名,以及名字。

接下来,在GoogleUserInfo下,添加以下内容:

extension Google {
  // 1
  static func getUser(on request: Request)
    throws -> EventLoopFuture<GoogleUserInfo> {
      // 2
      var headers = HTTPHeaders()
      headers.bearerAuthorization =
        try BearerAuthorization(token: request.accessToken())

      // 3
      let googleAPIURL: URI =
        "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
      // 4
      return request
        .client
        .get(googleAPIURL, headers: headers)
        .flatMapThrowing { response in
        // 5
        guard response.status == .ok else {
          // 6
          if response.status == .unauthorized {
            throw Abort.redirect(to: "/login-google")
          } else {
            throw Abort(.internalServerError)
          }
        }
        // 7
        return try response.content
          .decode(GoogleUserInfo.self)
      }
  }
}

下面是这个的作用:

  1. ImperialGoogle服务添加一个新的方法,从Google API中获取用户的详细信息。
  2. 通过在授权头中添加OAuth令牌来设置请求的头信息。
  3. 设置请求的URL--这是谷歌的API,用于获取用户的信息。这使用了VaporURI类型,Client需要这个类型。
  4. 使用request.clientGoogle发送请求。get()向提供的URL发送HTTP GET请求。解除返回的未来响应。
  5. 确保响应状态是200 OK
  6. 否则,如果响应是401 Unauthorized,则返回到登录页面,或者返回一个错误。
  7. 将响应中的数据解码到GoogleUserInfo,并返回结果。

接下来,将processGoogleLogin(request:token:)的内容替换为以下内容:

// 1
try Google
  .getUser(on: request)
  .flatMap { userInfo in
    // 2
    User
      .query(on: request.db)
      .filter(\.$username == userInfo.email)
      .first()
      .flatMap { foundUser in
        guard let existingUser = foundUser else {
          // 3
          let user = User(
            name: userInfo.name,
            username: userInfo.email,
            password: UUID().uuidString)
          // 4
          return user
            .save(on: request.db)
            .map {
              // 5
              request.session.authenticate(user)
              return request.redirect(to: "/")
            }
        }
        // 6
        request.session.authenticate(existingUser)
        return request.eventLoop
          .future(request.redirect(to: "/"))
      }
  }

以下是新代码的作用:

  1. 从谷歌获取用户信息。
  2. 通过查找电子邮件作为用户名,查看该用户是否存在于数据库中。
  3. 如果用户不存在,用Google提供的用户信息中的姓名和电子邮件创建一个新的User。将密码设置为UUID字符串,因为你不需要它。这可以确保没有人可以通过正常的密码登录到这个账户。
  4. 保存用户并解开返回的未来。
  5. 调用session.authenticate(_:)将创建的用户保存在会话中,以便网站允许访问。重定向回到主页。
  6. 如果用户已经存在,在会话中验证用户并重定向到主页。

Note

在实际应用中,你可能要考虑使用一个标志来区分在你的网站上注册的用户和用OAuth登录的用户。

最后要做的是在网站上添加一个按钮,让用户利用新的功能! 打开login.leaf,在</form>下,添加以下内容:

<a href="/login-google">
  <img class="mt-3" src="/images/sign-in-with-google.png"
   alt="Sign In With Google">
</a>

本章的示例项目包含一个新的、由Google提供的图片,sign-in-with-google.png,用于显示Sign in with Google按钮。这将图像添加为/login-google的链接--提供给Imperial开始登录的路线。

保存Leaf模板并在Xcode中建立和运行应用程序。记得在运行前设置自定义工作目录。在你的浏览器中访问http://localhost:8080

点击Create An Acronym,应用程序会带你到登录页面。你会看到新的Sign in with Google按钮:

img

点击新按钮,应用程序会带你到一个谷歌页面,允许TIL应用程序访问你的信息:

img

选择你要使用的账户,应用程序会将你重新引导到主页。进入所有用户屏幕,你会看到你的新用户账户。如果你创建了一个缩写,应用程序也会使用该新用户。

iOS集成

你已经将ImperialTIL网站整合,允许用户用Google登录。然而,你还有另一个客户端--iOS应用程序。你可以重用大部分现有的代码,让用户也能用Google登录到iOS应用中! 在ImperialController.swift中,在processGoogleLogin(_:)下面添加一个新的路由处理程序:

func iOSGoogleLogin(_ req: Request) -> Response {
  // 1
  req.session.data["oauth_login"] = "iOS"
  // 2
  return req.redirect(to: "/login-google")
}

以下是新路线的作用:

  1. 在请求的会话中添加一个条目,指出这个OAuth登录尝试来自iOS
  2. 重定向到你之前创建的URL,开始使用谷歌登录网站的OAuth流程。

boot(routes:)的底部注册新的路由:

routes.get("iOS", "login-google", use: iOSGoogleLogin)

这就把对/iOS/login-googleGET请求路由到iOSGoogleLogin(_:)'。然后,在iOSGoogleLogin(_:)`下面,添加一个新方法来创建登录的重定向:

// 1
func generateRedirect(on req: Request, for user: User) 
  -> EventLoopFuture<ResponseEncodable> {
    let redirectURL: EventLoopFuture<String>
    // 2
    if req.session.data["oauth_login"] == "iOS" {
      do {
        // 3
        let token = try Token.generate(for: user)
        // 4
        redirectURL = token.save(on: req.db).map {
          "tilapp://auth?token=\(token.value)"
        }
      // 5
      } catch {
        return req.eventLoop.future(error: error)
      }
    } else {
      // 6
      redirectURL = req.eventLoop.future("/")
    }
    // 7
    req.session.data["oauth_login"] = nil
    // 8
    return redirectURL.map { url in
      req.redirect(to: url)
    }
}

以下是新代码的作用:

  1. 定义一个新的方法,同时接受RequestUser来生成一个重定向。这个新方法返回EventLoopFuture<ResponseEncodable>
  2. 检查请求的会话数据中的oauth_login标志,看它是否与iOSGoogleLogin(_:)中设置的标志相符。
  3. 如果请求来自iOS,为用户生成一个令牌。
  4. 保存令牌,解析返回的未来,并返回一个重定向。这使用tilapp方案,并返回令牌作为查询参数。你将在iOS应用程序中使用这个。
  5. 捕捉生成令牌时抛出的任何错误,并返回一个失败的未来。
  6. 如果请求不是来自iOS,为原始重定向URL创建一个未来字符串。
  7. 为下一个会话重置oauth_login标志。
  8. 解析future并使用返回的字符串返回重定向。

接下来,在processGoogleLogin(request:token:)中,替换:

return user.save(on: request.db).map {
  request.session.authenticate(user)
  return request.redirect(to: "/")
}

为以下内容:

return user.save(on: request.db).flatMap {
  request.session.authenticate(user)
  return generateRedirect(on: request, for: user)
}

这将为新用户返回一个生成的重定向,而不是硬编码的/。它还用flatMap替换了map,因为闭包现在返回一个future

最后,替换:

return request.eventLoop
  .future(request.redirect(to: "/"))

为以下内容:

return generateRedirect(on: request, for: existingUser)

这将返回一个为现有用户生成的重定向。构建并运行该应用,然后打开TILiOS的启动项目。这个iOS项目与第19章"API认证,第二部分"中的最终项目类似。现在的登录屏幕包含一个新的按钮,用于用Google登录。

打开LoginTableViewController.swift。点击Sign in with Google按钮时,会触发signInWithGoogleButtonTapped(_:)。这在目前没有任何作用。在文件的顶部,在import UIKit下面添加:

import AuthenticationServices

这将导入认证服务框架,你将使用它来登录。然后,在signInWithGoogleButtonTapped(_:)中,添加以下内容:

// 1
guard let googleAuthURL = URL(
  string: "http://localhost:8080/iOS/login-google") 
else {
  return
}
// 2
let scheme = "tilapp"
// 3
let session = ASWebAuthenticationSession(
  url: googleAuthURL, 
  callbackURLScheme: scheme) { callbackURL, error in
}

下面是这个的作用:

  1. 创建一个URL,与你之前在TILApp中为登录谷歌而创建的路线相匹配。
  2. 定义要使用的方案。这与你之前设置的TILApp的重定向方案相匹配。
  3. 创建一个ASWebAuthenticationSession的实例。这允许用户使用Safari浏览器的现有凭证来验证TIL应用程序。

接下来,在文件的底部,添加以下扩展:

extension LoginTableViewController: ASWebAuthenticationPresentationContextProviding {
  func presentationAnchor(
    for session: ASWebAuthenticationSession
  ) -> ASPresentationAnchor {
    guard let window = view.window else {
      fatalError("No window found in view")
    }
    return window
  }
}

这使视图控制器符合ASWebAuthenticationPresentationContextProviding,并根据协议要求实现presentationAnchor(for:)。然后,在ASWebAuthenticationSession(url:callbackURLScheme:)的回调中加入以下内容:

// 1
guard 
  error == nil, 
  let callbackURL = callbackURL 
else { 
  return 
}

// 2
let queryItems = 
  URLComponents(string: callbackURL.absoluteString)?.queryItems
// 3
let token = queryItems?.first { $0.name == "token" }?.value
// 4
Auth().token = token
// 5
DispatchQueue.main.async {
  let appDelegate = 
    UIApplication.shared.delegate as? AppDelegate
  appDelegate?.window?.rootViewController =
    UIStoryboard(name: "Main", bundle: Bundle.main)
      .instantiateInitialViewController()
}

以下是发生的情况:

  1. 确保没有错误,并且设置了一个回URL。
  2. 从回调URL中获取查询项目。
  3. URL中提取令牌。这是你之前设置的重定向中提供的令牌。
  4. Auth实例上设置令牌。
  5. 替换根视图控制器,完成登录过程。

最后,在ASWebAuthenticationSession(url:callbackURLScheme:)下面添加以下内容:

session.presentationContextProvider = self
session.start()

这将会话的presentationContextProvider设置为当前视图控制器。这允许iOS知道从哪里启动浏览器。然后,它启动会话,开始登录流程。

构建并运行应用程序,如有必要,在用户标签中注销。你会看到新的Sign in with Google按钮:

img

点击该按钮,你会得到一个提示,允许该应用程序访问TIL网站进行登录:

img

点击Continue,该应用程序会将你重定向到谷歌,让你登录或选择一个账户来使用。完成登录过程并选择一个账户,应用程序就会将你登录进去。

img

接下来去哪?

在本章中,你学到了如何使用ImperialOAuthGoogle登录整合到你的网站中。这使得用户可以用他们现有的谷歌账户登录!

下一章将向你展示如何整合另一个流行的OAuth提供商:GitHub