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 上注册应用
前往 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")