Cookie 与 Session
网站要记住「你是谁」,靠的就是 Cookie 和 Session。本文用 Vapor 3 的 SessionMiddleware 实现登录与会话认证,并用 Cookie 确认提示和 CSRF Token 加固 Web 应用。
一、Cookie 与 Session 是什么
Cookie 是服务器应用发送给浏览器、存放在用户计算机上的小块信息。因为浏览器在请求网页时不能设置自定义头部,所以访问服务端应用时,浏览器会在请求里带上服务端之前发给它的 Cookie。
Session 能在多次不同的请求之间保存状态信息。在用户验证过程中,服务端会给用户创建一个会话,并用一个唯一 ID 来标识它;发送给用户的 Cookie 里也包含这个标识会话的唯一 ID。当用户再次请求时,服务端通过 Cookie 里的 ID 就能认出是哪个会话。
在 Vapor 中,使用中间件来管理会话。先在 configure.swift 启用 SessionsMiddleware,并配置一个键值存储服务:
...
middlewares.use(SessionsMiddleware.self)
...
config.prefer(MemoryKeyedCache.self, for: KeyedCache.self)
再让 User 遵循密码验证与会话验证两个协议:
...
extension User: PasswordAuthenticatable {}
extension User: SessionAuthenticatable {}
二、登录页面
要实现登录,需要提供两个 API:一个显示登录页,一个接收登录信息。先定义登录页的上下文与 POST 数据:
struct LoginContext: Encodable {
let title = "Log In"
let loginError: Bool
init(loginError: Bool = false) {
self.loginError = loginError
}
}
...
func loginHandler(_ req: Request) throws -> Future<View> {
let context: LoginContext
if req.query[Bool.self, at: "error"] != nil {
context = LoginContext(loginError: true)
} else {
context = LoginContext()
}
return try req.view().render("login", context)
}
...
登录页模板 login.leaf(这里以纯文本片段展示核心结构):
#set("content") {
<h1>#(title)</h1>
#if(loginError) {
<div class="alert alert-danger" role="alert">
User authentication error. Either your username or password
was invalid.
</div>
}
<form method="post">
<div class="form-group">
<label for="username">Username</label>
<input type="text" name="username" class="form-control"
id="username"/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" class="form-control"
id="password"/>
</div>
<button type="submit" class="btn btn-primary">Log In</button>
</form>
}
#embed("base")
接收登录提交的处理器:用 User.authenticate(...) 校验账号密码,失败就重定向回登录页并带上 error;成功则 req.authenticateSession(user) 把用户写进会话:
...
import Authentication
...
struct LoginPostData: Content {
let username: String
let password: String
}
...
func boot(router: Router) throws {
...
router.get("login", use: loginHandler)
router.post(LoginPostData.self, at: "login", use: loginPostHandler)
}
func loginPostHandler(_ req: Request, userData: LoginPostData) throws -> Future<Response> {
return User.authenticate(username: userData.username, password: userData.password, using: BCryptDigest(), on: req).map(to: Response.self) {
user in
guard let user = user else {
return req.redirect(to: "/login?error")
}
try req.authenticateSession(user)
return req.redirect(to: "/")
}
}
三、用会话认证保护 API
登录页建好后,就要通过认证用户身份来保护 API,使用的是会话认证中间件:
func boot(router: Router) throws {
let authSessionRoutes = router.grouped(User.authSessionsMiddleware())
authSessionRoutes.get(use: indexHandler)
authSessionRoutes.get("acronyms", Acronym.parameter, use: acronymHandler)
authSessionRoutes.get("users", User.parameter, use: userHandler)
authSessionRoutes.get("users", use: allUsersHandler)
authSessionRoutes.get("categories", use: allCategoriesHandler)
authSessionRoutes.get("categories", Category.parameter, use: categoryHandler)
authSessionRoutes.get("login", use: loginHandler)
let protectedGroup = authSessionRoutes.grouped(RedirectMiddleware<User>(path: "/login"))
protectedGroup.get("acronyms", "create", use: createAcronymHandler)
protectedGroup.get("acronyms", Acronym.parameter, "edit", use: editAcronymHandler)
protectedGroup.post("acronyms", Acronym.parameter, "edit", use: editAcronymPostHandler)
protectedGroup.post("acronyms", Acronym.parameter, "delete", use: deleteAcronymHandler)
protectedGroup.post(CreateAcronymData.self, at: "acronyms", "create", use: createAcronymPostHandler)
router.post(LoginPostData.self, at: "login", use: loginPostHandler)
}
这里用 User.authSessionsMiddleware() 给一组路由加上会话认证;再用 RedirectMiddleware<User>(path: "/login") 把未登录用户重定向到登录页。
四、更新网站以利用已登录用户
有了认证信息,就能从会话里拿到当前用户的 ID,于是创建/编辑缩略词时不必再让用户手动选自己:
struct CreateAcronymData: Content {
let short: String
let long: String
let categories: [String]?
}
...
func createAcronymPostHandler(_ req: Request, data: CreateAcronymData) throws -> Future<Response> {
let user = try req.requireAuthenticated(User.self)
let acronym = try Acronym(short: data.short, long: data.long, userID: user.requireID())
...
}
func editAcronymPostHandler(_ req: Request) throws -> Future<Response> {
return try flatMap(to: Response.self,
req.parameters.next(Acronym.self),
req.content.decode(CreateAcronymData.self)
) { acronym, data in
...
let user = try req.requireAuthenticated(User.self)
acronym.userID = try user.requireID()
...
}
这样一来,createAcronym.leaf 里让用户选择创建者的下拉框就可以删掉。因为创建和编辑共用同一份模板,只需要改一下位置即可。对应的上下文也简化了:
struct CreateAcronymContext: Encodable {
let title = "Create An Acronym"
}
struct EditAcronymContext: Encodable {
let title = "Edit Acronym"
let acronym: Acronym
let editing = true
let categories: Future<[Category]>
}
...
func createAcronymHandler(_ req: Request) throws -> Future<View> {
let context = CreateAcronymContext()
return try req.view().render("createAcronym", context)
}
...
func editAcronymHandler(_ req: Request) throws -> Future<View> {
return try req.parameters.next(Acronym.self)
.flatMap(to: View.self) { acronym in
let categories = try acronym.categories.query(on: req).all()
let context = EditAcronymContext(acronym: acronym,
categories: categories)
return try req.view().render("createAcronym", context)
}
}
...
五、退出登录
能登录,自然也要能退出。加一个 logoutHandler,调用 req.unauthenticate 清除会话即可:
...
func boot(router: Router) throws {
....
authSessionRoutes.post("logout", use: logoutHandler)
...
func logoutHandler(_ req: Request) throws -> Response {
try req.unauthenticate(User.self)
return req.redirect(to: "/")
}
导航栏 base.leaf 里,已登录时才显示退出按钮:
...
</ul>
#if(userLoggedIn) {
<form class="form-inline" action="/logout" method="POST">
<input class="nav-link btn btn-link" type="submit"
value="Log out">
</form>
}
</div>
</nav>
...
首页上下文加入 userLoggedIn,由 req.isAuthenticated(User.self) 判断:
struct IndexContext: Encodable {
let title: String
let acronyms: [Acronym]?
let userLoggedIn: Bool
}
...
func indexHandler(_ req: Request) throws -> Future<View> {
return Acronym.query(on: req).all()
.flatMap(to: View.self) { acronyms in
let userLoggedIn = try req.isAuthenticated(User.self)
let acronymsData = acronyms.isEmpty ? nil : acronyms
let context = IndexContext(title: "Homepage", acronyms: acronymsData, userLoggedIn: userLoggedIn)
return try req.view().render("index", context)
}
}
六、Cookie
Web 上经常用到 Cookie。第一次访问网站时,通常会提示是否允许设置 Cookie,需要用户确认。我们已经用 Cookie 做用户认证,但有时还需要自己设置或读取 Cookie。下面实现一个让用户确认使用 Cookie 的提示。
在 base.leaf 里,根据 showCookieMessage 显示底部提示条,并引入相关脚本:
...
<link rel="stylesheet" href="/styles/style.css">
<title>#(title) | Acronyms</title>
...
#if(showCookieMessage) {
<footer id="cookie-footer">
<div id="cookieMessage" class="container">
<span class="muted">
This site uses cookies! To accept this, click
<a href="#" onclick="cookiesConfirmed()">OK</a>
</span>
</div>
</footer>
<script src="/scripts/cookies.js"></script>
}
...
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
...
</body>
样式 Public/styles/style.css:
#cookie-footer {
position: absolute;
bottom: 0;
width: 100%;
height: 60px;
line-height: 60px;
background-color: #f5f5f5;
}
脚本 Public/scripts/cookies.js,点击确认后写入一个有效期一年的 Cookie:
function cookiesConfirmed() {
$("#cookie-footer").hide();
var d = new Date();
d.setTime(d.getTime() + (365*24*60*60*1000));
var expires = "expires="+ d.toUTCString();
document.cookie = "cookies-accepted=true;" + expires;
}
控制器里通过 req.http.cookies 判断用户是否已经确认过,从而决定是否显示提示:
struct IndexContext: Encodable {
let title: String
let acronyms: [Acronym]?
let userLoggedIn: Bool
let showCookieMessage: Bool
}
...
func indexHandler(_ req: Request) throws -> Future<View> {
return Acronym.query(on: req).all()
.flatMap(to: View.self) { acronyms in
let userLoggedIn = try req.isAuthenticated(User.self)
let acronymsData = acronyms.isEmpty ? nil : acronyms
let showCookieMessage = req.http.cookies["cookies-accepted"] == nil
let context = IndexContext(title: "Homepage",
acronyms: acronymsData,
userLoggedIn: userLoggedIn,
showCookieMessage: showCookieMessage)
return try req.view().render("index", context)
}
}
七、Session 与 CSRF 防护
除了用 Cookie 做用户认证,还可以用会话。会话还有多种适用场景,其中之一就是 CSRF(跨站请求伪造)防护:攻击者伪装成一名用户发起破坏性请求。我们可以在表单里加入 CSRF Token,当 Web 应用收到请求时,验证它是否与服务端发出去的 Token 匹配,再决定是否处理。
生成 Token 并存入会话,创建缩略词时一并发给页面:
struct CreateAcronymContext: Encodable {
let title = "Create An Acronym"
let csrfToken: String
}
struct CreateAcronymData: Content {
let short: String
let long: String
let categories: [String]?
let csrfToken: String
}
...
func createAcronymHandler(_ req: Request) throws -> Future<View> {
let token = try CryptoRandom().generateData(count: 16).base64EncodedString()
let context = CreateAcronymContext(csrfToken: token)
try req.session()["CSRF_TOKEN"] = token
return try req.view().render("createAcronym", context)
}
...
func createAcronymPostHandler(_ req: Request, data: CreateAcronymData) throws -> Future<Response> {
let expectedToken = try req.session()["CSRF_TOKEN"]
try req.session()["CSRF_TOKEN"] = nil
guard expectedToken == data.csrfToken else {
throw Abort(.badRequest)
}
...
}
模板 createAcronym.leaf 里用一个隐藏域把 Token 带回去:
...
<form method = "post">
#if(csrfToken) {
<input type="hidden" name="csrfToken" value="#(csrfToken)">
}
...
req.session()["CSRF_TOKEN"] = nil),这样同一个 Token 不能重复使用,能有效抵御重放攻击。