Google 登录 封面
Vapor 大型教程

Google 登录

有时用户不想每到一个网站都注册新账号,本章用 Vapor 3 配合 Imperial 接入 Google 的 OAuth 2.0,让用户用 Google 账号一键登录。

OAuth 2.0

OAuth 2.0 是一个用户身份认证框架,允许第三方应用访问用户信息。通过它可以用 Google 账号登录我们之前做的网站:用自己的 Google 账号向 Google 发起认证请求,同意授权后,Google 会回传一个 Token,我们的应用再用这个 Token 去访问 Google 的 API(如邮箱、头像、昵称等)。进行 OAuth 2.0 认证前,你需要有一个 Google 账号。

使用 Imperial

按 OAuth 流程手写 Google 认证非常繁琐,好在社区已有成熟库 Imperial 帮我们完成这些步骤。

先在 package.swift 加入依赖:Auth 与 Imperial,并把 Imperial 加进 App 的 target 依赖。

        .package(url: "https://github.com/vapor/auth.git", from: "2.0.1"),
        .package(url: "https://github.com/vapor-community/Imperial.git", from: "0.7.0")
    ],
    targets: [
        .target(name: "App", dependencies: ["FluentPostgreSQL", "Authentication", "Vapor", "Leaf", "Imperial"]),
...

新建 ImperialController.swift,先搭一个空的 RouteCollection

import Vapor
import Authentication
import Imperial

struct ImperialController: RouteCollection {
    func boot(router: Router) throws {

    }
}

routes.swift 里把 ImperialController 注册进路由。

import Vapor

/// Register your application's routes here.
public func routes(_ router: Router) throws {

    ...

    let imperialController = ImperialController()
    try router.register(collection: imperialController)
}
注意:Google 在中国大陆处于被屏蔽状态,注册应用和调试时都需要连接 VPN 代理才能正常访问。

在 Google 上注册应用

前往 Google 开发者控制台 注册地址 注册好我们的 Web 应用后,就可以准备使用 OAuth 了。

实现 Google 登录回调

ImperialController 中,先定义一个 GoogleUserInfo 用于接收 Google 返回的用户信息,再扩展 Google 提供一个 getUser(on:) 方法:带上 accessToken 请求 Google 的用户信息接口,并把返回解码成 GoogleUserInfo

//
//  ImperialController.swift
//  App
//
//  Created by joker on 2018/12/16.
//

import Vapor
import Authentication
import Imperial

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

extension Google {
    static func getUser(on req: Request) throws -> Future<GoogleUserInfo> {
        var headers = HTTPHeaders()
        headers.bearerAuthorization = try BearerAuthorization(token: req.accessToken())

        let googleAPIURL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"

        return try req.client().get(googleAPIURL, headers: headers).map(to: GoogleUserInfo.self, { res in
            guard res.http.status == .ok else {
                if res.http.status == .unauthorized {
                    throw Abort.redirect(to: "/login-google")
                } else {
                    throw Abort(.internalServerError)
                }
            }
            return try res.content.syncDecode(GoogleUserInfo.self)
        })
    }
}

boot(router:) 中用 router.oAuth(from:authenticate:callback:scope:completion:) 注册 Google 登录路由,回调地址从环境变量 GOOGLE_CALLBACK_URL 读取。

struct ImperialController: RouteCollection {
    func boot(router: Router) throws {

        guard let callbackURL = Environment.get("GOOGLE_CALLBACK_URL") else {
            fatalError("Callback URL not set")
        }

        try router.oAuth(
            from: Google.self,
            authenticate: "login-google",
            callback: callbackURL,
            scope: ["profile", "email"],
            completion: processGoogleLogin)
    }

    func processGoogleLogin(_ req: Request, token: String) throws -> Future<ResponseEncodable> {
        return try Google.getUser(on: req).flatMap(to: ResponseEncodable.self, { userInfo in
            return User.query(on: req).filter(\.username == userInfo.email).first().flatMap(to: ResponseEncodable.self, { foundUser in
                guard let existingUser = foundUser else {
                    let user = User(name: userInfo.name, username: userInfo.email, password: "")
                    return user.save(on: req).map(to: ResponseEncodable.self, { user in
                        try req.authenticate(user)
                        return req.redirect(to: "/")
                    })
                }

                try req.authenticateSession(existingUser)
                return req.future(req.redirect(to: "/"))

            })
        })
    }
}

处理逻辑是:拿到用户信息后,按邮箱查找是否已有用户;没有就新建一个(以邮箱作用户名),并 authenticate 登录;已有则直接 authenticateSession 登录,最后都重定向回首页。

登录页加按钮

login.leaf 中放一个「使用 Google 登录」的图片按钮,链接到 /login-google

...
</form>
<a href="/login-google">
    <img class="mt-3" src="/images/sign-in-with-google.png" alt="Sign In With Google">
</a>
}
#embed("base")

本系列其他文章