NIFTY Advent Calendar 2018 18日目の記事です。
今日は、「Vaporでセッションを利用した認証をつくる」です。
よろしくお願いします。
やること
- Tutorial: How to build Web Auth with Session
- こちらの記事をほぼ翻訳に近いかたちですすめます。
環境
- macOS HighSierra (10.13.6)
- Swift 4.1.2
- Vapor 3.1.7
はじめに
元記事の作者が作り終わったソースはここにあります。
1.新しいVaporプロジェクトをつくる
vaporにはgithubにあるテンプレートを利用できる機能があります。
以下のコマンドを叩くとテンプレートをCloneしてプエジェクトを作成してくれます。
vapor new vaporAuth --template=vaporberlin/my-first-controller
2.Xcodeプロジェクトをつくる
作ったプロジェクトの中を見るとPackage.swiftがあります。
Xcodeプロジェクトを生成する前に、Package.swift内のパッケージ名を変更する必要があります。
import PackageDescription
let package = Package(
name: "vaporAuth", // プロジェクト名
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "3.0.0-rc"),
.package(url: "https://github.com/vapor/leaf.git", from: "3.0.0-rc"),
.package(url: "https://github.com/vapor/fluent-sqlite.git", from: "3.0.0-rc"),
.package(url: "https://github.com/vapor/auth.git", from: "2.0.0-rc"), // added
.package(url: "https://github.com/vapor/crypto.git", from: "3.0.0") // added
],
targets: [
.target(name: "App", dependencies: ["Vapor", "Leaf", "FluentSQLite", "Authentication", "Crypto"]), // added
.target(name: "Run", dependencies: ["App"]),
.testTarget(name: "AppTests", dependencies: ["App"]),
]
)
パッケージをアップデートします。時間がかかるので気長に待ちましょう。
vapor update -y
完了すると、自動でXcodeでプロジェクトがたちあがります。
すでにテンプレートが適応されているためUser.swiftをはじめとした基本的なファイルが生成済の状態になっているはずです。
projectName/
├── Package.swift
├── Sources/
│ ├── App/
│ │ ├── Controllers/
│ │ │ └── UserController.swift
│ │ ├── Models/
│ │ │ └── User.swift
│ │ ├── app.swift
│ │ ├── boot.swift
│ │ ├── configure.swift
│ │ └── routes.swift
│ └── Run/
│ └── main.swift
├── Tests/
├── Resources/
│ └── Views/
│ └── userview.leaf
├── Public/
├── Dependencies/
└── Products/
一度VaperをローカルでRunしましょう。
Schemeの選択でRun>MyMacを選択し実行。
localhost:8080でUserlistを入力できる画面が確認できると思います!
3.Userモデルをつくる
すでにあるUser.swiftを変更していきます。
import FluentSQLite
import Vapor
import Authentication // added
final class User: SQLiteModel {
var id: Int?
var email: String // added
var password: String // added
init(id: Int? = nil, email: String, password: String) {
self.id = id
self.email = email
self.password = password
}
}
extension User: Content {}
extension User: Migration {}
// 以下を追加
extension User: PasswordAuthenticatable {
static var usernameKey: WritableKeyPath<User, String> {
return \User.email
}
static var passwordKey: WritableKeyPath<User, String> {
return \User.password
}
}
extension User: SessionAuthenticatable {}
4.登録用のビューをつくる
<!DOCTYPE html>
<html>
<head>
<title>Web Auth</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">
</head>
<body class="container">
<br />
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h3 class="card-title">Register</h3>
<form action="/register" method="POST">
<div class="form-group">
<label for="email">Email</label>
<input type="email" name="email" class="form-control" id="email" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" class="form-control" id="password" />
</div>
<div class="form-group">
<input type="submit" class="btn btn-block btn-primary" value="register" />
</div>
</form>
</div>
</div>
</div>
</div>
</body>
</html>
5.UserControllerを変更する(登録用routeの追加)
登録画面の遷移をつくっていきましょう。
import Vapor
final class UserController {
func renderRegister(_ req: Request) throws -> Future<View> {
return try req.view().render("register")
}
}
import Vapor
public func routes(_ router: Router) throws {
let userController = UserController()
router.get("register", use: userController.renderRegister)
}
Runしてみましょう。以下のような画面ができているはずです!
補足:Futureって何だ?
UserControllerにFuture<View>
といったような記述がでてきました。
VaporはVer3.0からSwiftNIOとよばれる非同期イベント駆動のフレームワークを利用するようになっています。
これによりControllerが返却する型やモデルから取得するデータはFuture型になっています。
Futureは非同期で処理を扱うための約束事のひとつです。重い処理などで将来取得するはずであろうオブジェクトの参照を可能にしてくれます。
Future<View>
は「Viewオブジェクトを返却するであろう将来を約束した型」といった表現になります。
くわしくはVaporの公式ドキュメントを見てください。
次に登録の仕組みをつくっていきます。
import Vapor
import FluentSQL // added
import Crypto // added
final class UserController {
func renderRegister(_ req: Request) throws -> Future<View> {
...
}
// 以下を追加
func register(_ req: Request) throws -> Future<Response> {
return try req.content.decode(User.self).flatMap { user in
return try User.query(on: req).filter(\User.email == user.email).first().flatMap { result in
if let _ = result {
return Future.map(on: req) { _ in
return req.redirect(to: "/register")
}
}
user.password = try BCryptDigest().hash(user.password)
return user.save(on: req).map { _ in
return req.redirect(to: "/login")
}
}
}
}
}
import Vapor
public func routes(_ router: Router) throws {
let userController = UserController()
router.get("register", use: userController.renderRegister)
router.post("register", use: userController.register) // add
}
Runして/registerにアクセスし、メアド・パスワード入力をしてみてください。
ユーザが新規追加に成功すれば/loginにリダイレクトし、すでに登録済のユーザであれば/loginにリダイレクトするはずです!
補足:map、flatMapって何だ?
基本的な仕組み
Swiftの配列が持つmapやflatMapと名前が同じですが、今回のものはVapor特有のFutureが持つメソッドになっています。
mapはFutureを別のFutureに変換してくれるFutureが持つメソッドです。
flatMapはmapに似ていて、Futureを別のFutureに変換してくれるFutureが持つメソッドで、Futureが入れ子になっていても入れ子にせずに返却してくれるものです。
たとえばFuture>となってしまうものをFutureで返却してくれるものです。
コードの説明
req.content.decode(User.self)
でFutureが取得されるのですが、それを非同期に処理するためには常にmapやflatMapのクロージャで閉じて処理をしてあげる必要があります。これがVaporでは随所に出てきます。
Future、mapを駆使することにより将来取得するであろう処理を非同期におこなうことができます。
最終的にreq.redirect(to: "/register")
などでFutureに変換してreturnしています。
6.ログイン用のビューを作る
<!DOCTYPE html>
<html>
<head>
<title>Web Auth</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">
</head>
<body class="container">
<br />
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h3 class="panel-title">Login</h3>
<form action="/login" method="POST">
<div class="form-group">
<label for="email">Email</label>
<input type="email" name="email" class="form-control" id="email" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" class="form-control" id="password" />
</div>
<div class="form-group">
<input type="submit" class="btn btn-block btn-success" value="login" />
</div>
</form>
</div>
</div>
</div>
</div>
</body>
</html>
7.UserControllerを変更する(ログイン遷移の追加)
ログインの遷移をつくっていきます。
import Vapor
import FluentSQL
import Crypto
final class UserController {
func renderRegister(_ req: Request) throws -> Future<View> {
...
}
func register(_ req: Request) throws -> Future<Response> {
...
}
//以下を追加
func renderLogin(_ req: Request) throws -> Future<View> {
return try req.view().render("login")
}
}
import Vapor
import Authentication // added
public func routes(_ router: Router) throws {
let userController = UserController()
router.get("register", use: userController.renderRegister)
router.post("register", use: userController.register)
router.get("login", use: userController.renderLogin) // add
let authSessionRouter = router.grouped(User.authSessionsMiddleware()) // added
authSessionRouter.post("login", use: userController.login) // added
}
import Vapor
import Leaf
import FluentSQLite
import Authentication // added
public func configure(
_ config: inout Config,
_ env: inout Environment,
_ services: inout Services
) throws {
...
try services.register(AuthenticationProvider()) // added
var middlewares = MiddlewareConfig.default() // added
middlewares.use(SessionsMiddleware.self) // added
services.register(middlewares) // added
config.prefer(MemoryKeyedCache.self, for: KeyedCache.self) // added
}
final class UserController {
...
// 以下を追加
func login(_ req: Request) throws -> Future<Response> {
return try req.content.decode(User.self).flatMap { user in
return User.authenticate(
username: user.email,
password: user.password,
using: BCryptDigest(),
on: req
).map { user in
guard let user = user else {
return req.redirect(to: "/login")
}
try req.authenticateSession(user)
return req.redirect(to: "/profile")
}
}
}
}
ここでセッションにユーザがいなければ/loginにリダイレクト。いれば/profileにリダイレクトとしています。
次に/profileをつくっていきましょう。
8.プロフィール用のビューをつくる
<!DOCTYPE html>
<html>
<head>
<title>Web Auth</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">
</head>
<body class="container">
<br />
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h3 class="card-title">Profile</h3>
<p>Email: #(user.email)</p>
</div>
</div>
</div>
</div>
</body>
</html>
9.UserControllerを変更する(プロフィール用routeの追加)
public func routes(_ router: Router) throws {
let userController = UserController()
router.get("register", use: userController.renderRegister)
router.post("register", use: userController.register)
router.get("login", use: userController.renderLogin)
let authSessionRouter = router.grouped(User.authSessionsMiddleware())
authSessionRouter.post("login", use: userController.login)
let protectedRouter = authSessionRouter.grouped(RedirectMiddleware<User>(path: "/login"))
protectedRouter.get("profile", use: userController.renderProfile)
}
final class UserController {
...
// 以下を追加
func renderProfile(_ req: Request) throws -> Future<View> {
let user = try req.requireAuthenticated(User.self)
return try req.view().render("profile", ["user": user])
}
}
10.UserControllerを変更する(ログアウト用routeの追加)
final class UserController {
...
// 以下を追加
func logout(_ req: Request) throws -> Future<Response> {
try req.unauthenticateSession(User.self)
return Future.map(on: req) { return req.redirect(to: "/login") }
}
}
public func routes(_ router: Router) throws {
...
router.get("logout", use: userController.logout) // added
}
最後にログアウトボタンをつけます。
<!DOCTYPE html>
<html>
<head>
<title>Web Auth</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">
</head>
<body class="container">
<br />
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h3 class="card-title">Profile</h3>
<p>Email: #(user.email)</p>
<a href="/logout" class="btn btn-block btn-danger">
logout
</a>
</div>
</div>
</div>
</div>
</body>
</html>
Runしてみてください!
ユーザ登録→ログイン→プロフィール確認→ログアウトがセッション管理で実現できていますよ!
これでVaporでのセッション認証はバッチリです!!
さいごに
実際、伝えたかったことは補足のFutureとmap、flatMapの部分でした
正直、今回の仕組みをつくって感じたことは「複雑だな」という印象です。
非同期の仕組みをFutureやmapで実現することは効率的な処理をする上で必要なのだとは思いますが、他のメジャーなWEBフレームワークと比較してしまうとどうしても面倒な記述が必要になりSwiftの簡潔な文法が生かされていないような気になります。
ただし今後async,awiitの仕組みがSwift5で提供されるため、Vaporがこの仕組みを導入してくれればかなりこの部分の複雑さは少なくなるのではないかと思います。そうなれば楽しいServerSideSwiftが実現するかもですね!
これを機にVaporやServerSideSwiftに興味を持っていただけると幸いです!!
長文にお付き合いいただきありがとうございました!!