第20章:网络认证、Cookies
和会话¶
在前几章中,你学到了如何在TIL
应用的API
中实现认证。在本章中,你将看到如何为TIL
网站实现认证。你将学习如何在网络上进行认证,以及Vapor
的认证模块如何提供所有必要的支持。然后你将看到如何保护网站上的不同路线。最后,你将学习如何使用cookie
和会话来发挥你的优势。
网络认证¶
它是如何工作的¶
早些时候,你学会了如何使用HTTP
基本认证和承载认证来保护API
。你会记得,这是通过在请求头中发送令牌和凭证来实现的。然而,这在网络浏览器中是不可能的。没有办法在你的浏览器用普通的HTML
发出的请求中添加头信息。
为了解决这个问题,浏览器和网站使用cookies
。Cookie
是你的应用程序发送给浏览器的一小部分数据,用于存储在用户的计算机上。然后,当用户向你的应用程序发出请求时,浏览器就会为你的网站附加上cookie
。
你把它与sessions
结合起来,以验证用户。会话允许你在不同的请求中持续保持状态。在Vapor
,当你启用会话时,应用程序向用户提供一个具有唯一ID的cookie
。这个ID
标识了用户的会话。当用户登录时,Vapor
将用户保存在会话中。当你需要确保一个用户已经登录或获得当前的认证用户时,你可以查询会话。
实现会话¶
Vapor
使用一个中间件SessionsMiddleware
来管理会话。在Xcode
中打开该项目,并打开configure.swift
。在中间件配置部分,添加以下内容 app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
:
app.middleware.use(app.sessions.middleware)
这就把会话中间件注册为你的应用程序的一个全局中间件。它还为所有请求启用了会话。接下来,打开User.swift
,在文件的底部添加以下内容:
// 1
extension User: ModelSessionAuthenticatable {}
// 2
extension User: ModelCredentialsAuthenticatable {}
下面是这个的作用:
- 使
User
符合ModelSessionAuthenticatable
。这允许应用程序保存和检索你的用户作为会话的一部分。 - 将
User
与ModelCredentialsAuthenticatable
相匹配。这允许Vapor
在用户登录时用用户名和密码进行认证。由于你已经在ModelAuthenticatable
中实现了ModelCredentialsAuthenticatable
的必要属性和功能,这里没有什么可做的。
登录¶
为了登录用户,你需要两条路由--一条用于显示登录页面,一条用于接受该页面的POST
请求。打开WebsiteController.swift
,在文件的底部添加以下内容,为登录页面创建一个上下文:
struct LoginContext: Encodable {
let title = "Log In"
let loginError: Bool
init(loginError: Bool = false) {
self.loginError = loginError
}
}
这提供了页面的标题和一个指示登录错误的标志。接下来,在WebsiteController
的底部,为该页面添加一个路由处理程序:
// 1
func loginHandler(_ req: Request)
-> EventLoopFuture<View> {
let context: LoginContext
// 2
if let error = req.query[Bool.self, at: "error"], error {
context = LoginContext(loginError: true)
} else {
context = LoginContext()
}
// 3
return req.view.render("login", context)
}
下面是这个的作用:
- 为登录页面定义一个路由处理程序,返回一个未来的
View
。 - 如果请求中包含
error
参数,并且为真,则创建一个loginError
设置为true
的上下文。 - 渲染
login.leaf
模板,传入上下文。
在Resources/Views
中创建新的模板login.leaf
,并打开该文件。用以下内容替换该文件的内容:
<!-- 1 -->
#extend("base"):
#export("content"):
<!-- 2 -->
<h1>#(title)</h1>
<!-- 3 -->
#if(loginError):
<div class="alert alert-danger" role="alert">
User authentication error. Either your username or
password was invalid.
</div>
#endif
<!-- 4 -->
<form method="post">
<!-- 5 -->
<div class="form-group">
<label for="username">Username</label>
<input type="text" name="username" class="form-control"
id="username"/>
</div>
<!-- 6 -->
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password"
class="form-control" id="password"/>
</div>
<!-- 7 -->
<button type="submit" class="btn btn-primary">
Log In
</button>
</form>
#endexport
#endextend
以下是模板中的情况:
- 扩展
base.leaf
并按要求导出content
。 - 使用上下文中提供的
title
来设置页面的标题。 - 如果
loginError
的上下文值为true
,显示一个合适的信息。 - 定义一个
<form>
,在提交时向同一URL
发送POST
请求。 - 为用户的用户名添加一个输入。输入的名称与
ModelCredentialsAuthenticatable
要求的名称一致。 - 为用户的密码添加一个输入。注意
type="password"
--这告诉浏览器将该输入作为一个密码字段。这使用了ModelCredentialsAuthenticatable
要求的密码名称。 - 为表单添加一个提交按钮。
接下来,打开WebsiteController.swift
,在loginHandler(_:)
下面,为这个请求添加以下路由处理器:
// 1
func loginPostHandler(
_ req: Request
) -> EventLoopFuture<Response> {
// 2
if req.auth.has(User.self) {
// 3
return req.eventLoop.future(req.redirect(to: "/"))
} else {
// 4
let context = LoginContext(loginError: true)
return req
.view
.render("login", context)
.encodeResponse(for: req)
}
}
下面是这个的作用:
- 定义一个返回
EventLoopFuture<Response>
的路由处理程序。 - 验证该请求是否有一个经过认证的
User
。你使用中间件来执行验证。 - 登录成功后重定向到主页。
- 如果登录失败,重定向回登录页面,显示错误。
最后,在boot(route:)
的底部,注册两个路由:
// 1
routes.get("login", use: loginHandler)
// 2
let credentialsAuthRoutes =
routes.grouped(User.credentialsAuthenticator())
// 3
credentialsAuthRoutes.post("login", use: loginPostHandler)
下面是这个的作用:
- 将
/login
的GET
请求路由到loginHandler(_:)
。 - 使用
ModelCredentialsAuthenticator
创建一个路由组。这个中间件检查提交表单的请求。然后,它验证证书,如果成功,就对请求进行认证。 - 通过
credentialsAuthRoutes
将/login
的POST
请求路由到loginPostHandler(_:userData:)
。
建立并运行该应用程序。在你的浏览器中,访问http://localhost:8080/login。在不输入数据的情况下点击Log In
,以查看错误处理情况。
接下来,输入你的证书并再次点击Log In
。在应用程序验证你的证书后,它会将你重定向到主缩略语列表。
保护路由¶
在API
中,你使用GuardAuthenticationMiddleware
来断言请求中包含一个经过认证的用户。如果没有用户,这个中间件会抛出一个认证错误,导致客户端收到一个401 Unauthorized响应。
在网络上,这并不是最好的用户体验。相反,你可以使用RedirectMiddleware
,当用户试图访问一个受保护的路由而不先登录时,将其重定向到登录页面。在你使用这个重定向之前,你必须先把浏览器发送的会话cookie
翻译成一个认证用户。
在WebsiteController
中,将boot(routes:)
的全部内容,包括你刚刚添加的新路由替换为以下内容:
let authSessionsRoutes =
routes.grouped(User.sessionAuthenticator())
这将创建一个路由组,在路由处理程序之前运行DatabaseSessionAuthenticator
。这个中间件从请求中读取cookie
并在应用程序的会话列表中查找会话ID
。如果会话包含一个用户,DatabaseSessionAuthenticator
将其添加到请求的认证缓存中,使用户在以后的过程中可用。
接下来,在这个路由组中注册所有的公共路由,包括新的登录路由:
authSessionsRoutes.get("login", use: loginHandler)
let credentialsAuthRoutes =
authSessionsRoutes.grouped(User.credentialsAuthenticator())
credentialsAuthRoutes.post("login", use: loginPostHandler)
authSessionsRoutes.get(use: indexHandler)
authSessionsRoutes.get(
"acronyms",
":acronymID",
use: acronymHandler)
authSessionsRoutes.get("users", ":userID", use: userHandler)
authSessionsRoutes.get("users", use: allUsersHandler)
authSessionsRoutes.get("categories", use: allCategoriesHandler)
authSessionsRoutes.get(
"categories",
":categoryID",
use: categoryHandler)
这使得User
在这些页面上可用,尽管它不是必需的。这对于在任何你想要的页面上显示用户特定的内容,如个人资料链接,是很有用的。在这些路由下面,添加以下内容:
let protectedRoutes = authSessionsRoutes
.grouped(User.redirectMiddleware(path: "/login"))
这创建了一个新的路由组,从authSessionsRoutes
延伸出来,包括User
的RedirectMiddleware
。应用程序在到达路由处理程序之前通过RedirectMiddleware
运行一个请求,但在DatabaseSessionAuthenticator
之后。这允许RedirectMiddleware
检查认证的用户。RedirectMiddleware
需要你指定重定向未认证用户的路径。
最后,将需要保护的路由--创建、编辑和删除缩略语--注册到这个路由组:
protectedRoutes.get(
"acronyms",
"create",
use: createAcronymHandler)
protectedRoutes.post(
"acronyms",
"create",
use: createAcronymPostHandler)
protectedRoutes.get(
"acronyms",
":acronymID",
"edit",
use: editAcronymHandler)
protectedRoutes.post(
"acronyms",
":acronymID",
"edit",
use: editAcronymPostHandler)
protectedRoutes.post(
"acronyms",
":acronymID",
"delete",
use: deleteAcronymHandler)
记住这包括GET
请求和POST
请求。建立并运行,然后在你的浏览器中访问http://localhost:8080。
点击导航栏中的创建首字母缩写,这一次,应用程序会将你重定向到登录页面:
输入种子管理员用户的凭证,并点击Log In
。应用程序会将你重定向到主缩略语列表。如果你再次点击创建首字母缩写,应用程序会让你进入该页面。
更新网站¶
就像API
一样,现在用户必须登录,应用程序知道哪个用户正在创建或编辑首字母缩写。还是在WebsiteController.swift
中,找到CreateAcronymFormData
并删除用户ID
:
let userID: UUID
这不再是必需的,因为你可以从认证的用户那里得到它。接下来,找到createAcronymPostHandler(_:data:)
并替换:
let acronym = Acronym(
short: data.short,
long: data.long,
userID: data.userID)
为以下内容:
let user = try req.auth.require(User.self)
let acronym = try Acronym(
short: data.short,
long: data.long,
userID: user.requireID())
这就像API
中那样,使用require(_:)
从请求中获取用户。接下来,在editAcronymPostHandler(_:)
中,在方法的顶部添加以下内容:
let user = try req.auth.require(User.self)
let userID = try user.requireID()
同样,这是从请求中获得认证的用户,然后获得相关的ID
。在这里做很有用,因为你可以在editAcronymPostHandler(_:)
的主体中抛出错误。最后,将acronym.$user.id = updateData.userID
替换为以下内容:
acronym.$user.id = userID
这就为更新的首字母缩写使用了认证用户的ID
。现在,创建和编辑缩略语都使用认证用户。因此,你不再需要在表单中显示用户。打开createAcronym.leaf
,删除以下代码:
<div class="form-group">
<label for="userID">User</label>
<select name="userID" class="form-control" id="userID">
#for(user in users):
<option value="#(user.id)"
#if(editing):
#if(acronym.user.id == user.id): selected #endif
#endif>
#(user.name)
</option>
#endfor
</select>
</div>
由于你使用相同的模板来创建和编辑缩写,你只需要从一个地方删除这个模板即可 接下来,打开WebsiteController.swift
,从CreateAcronymContext
中删除以下内容:
let users: [User]
这不再需要,因为模板不再使用users
了。在createAcronymHandler(_:)
中,通过替换方法的主体来解决这个变化:
let context = CreateAcronymContext()
return req.view.render("createAcronym", context)
接下来,从EditAcronymContext
中删除以下内容:
let users: [User]
接下来,将editAcronymHandler(_:)
,改为以下内容:
func editAcronymHandler(_ req: Request)
-> EventLoopFuture<View> {
return Acronym
.find(req.parameters.get("acronymID"), on: req.db)
.unwrap(or: Abort(.notFound))
.flatMap { acronym in
acronym.$categories.get(on: req.db)
.flatMap { categories in
let context = EditAcronymContext(
acronym: acronym,
categories: categories)
return req.view.render("createAcronym", context)
}
}
}
这就删除了获取所有用户的查询和由此产生的额外的未来。建立并运行,然后在浏览器中访问http://localhost:8080/。点击创建一个首字母缩写并再次登录。
Note
你需要在重启后再次登录,因为应用程序在内存中保留会话。对于生产应用,你可以使用Redis
或数据库来保存这些信息,并在不同的服务器实例中共享。
回到创建一个缩写,表格中不再包括用户的列表:
创建一个首字母缩写。当应用程序将你重定向到首字母缩写的页面时,你会看到Vapor
已经使用认证的用户作为首字母缩写的用户:
注销¶
当你允许用户登录到你的网站时,你也应该允许他们注销。还是在WebsiteController.swift
中,在loginPostHandler(_:)
之后添加以下内容:
// 1
func logoutHandler(_ req: Request) -> Response {
// 2
req.auth.logout(User.self)
// 3
return req.redirect(to: "/")
}
下面是这个的作用:
- 定义一个简单返回
Response
的路由处理程序。这个方法中没有异步工作,所以它不需要返回一个未来。 - 在请求中调用
logout(_:)
。这将从会话中删除用户,所以它不能被用来验证未来的请求。 - 返回一个重定向到索引页。
在credentialsAuthRoutes.post("login", use: loginPostHandler)
之后,在boot(routes:)
内注册该路由:
authSessionsRoutes.post("logout", use: logoutHandler)
这将/logout
的POST
请求连接到logoutHandler()
。对于任何改变应用程序状态的请求,你都应该使用POST
请求。现代浏览器会预先获取GET
请求,如果你不使用POST
,可能会导致你的用户被意外地注销!
打开base.leaf
,在导航栏的</ul>
后面添加以下内容:
<!-- 1 -->
#if(userLoggedIn):
<!-- 2 -->
<form class="form-inline" action="/logout" method="POST">
<!-- 3 -->
<input class="nav-link btn btn-secondary mr-sm-2"
type="submit" value="Log out">
</form>
#endif
下面是这个的作用:
- 检查
userLoggedIn
是否被设置,以便你只在用户登录时显示注销选项。 - 创建一个新的表单,向
/logout
发送一个POST
请求。 - 在表单中添加一个提交按钮,其值为
Log out
,并使其具有按钮的样式,将其向右对齐。
保存该文件。接下来,打开WebsiteController.swift
,在IndexContext
的底部,添加以下内容:
let userLoggedIn: Bool
这是你设置的标志,用来告诉模板该请求包含一个已登录的用户。最后,在indexHandler(_:)
中,将let context = IndexContext(title: "Home page", acronyms: acronyms)
改为以下内容:
// 1
let userLoggedIn = req.auth.has(User.self)
// 2
let context = IndexContext(
title: "Home page",
acronyms: acronyms,
userLoggedIn: userLoggedIn)
下面是这个的作用:
- 检查该请求是否包含一个已认证的用户。
- 将结果传递给
IndexContext
中的新标志。
建立并运行,然后到你的浏览器。点击创建一个首字母缩写并登录。当应用程序将你重定向到主页时,你会在右上方看到一个新的Log out
选项:
如果你点击这个,然后再次点击创建一个缩写,你需要登录,因为应用程序已经把你登录出去。
Cookies
¶
Cookies
在网络上被广泛使用。每个人都见过当你第一次访问时网站上弹出的cookie
同意信息。你已经用cookie
来实现认证,但有时你想手动设置和读取cookie
。
处理cookie
同意信息的一个常见方法是在用户接受通知时添加一个cookie
(讽刺啊!)。
打开base.leaf
,在jQuery
的脚本标签上面,添加以下内容。
<!-- 1 -->
#if(showCookieMessage):
<!-- 2 -->
<footer id="cookie-footer">
<div id="cookieMessage" class="container">
<span class="muted">
<!-- 3 -->
This site uses cookies! To accept this, click
<a href="#" onclick="cookiesConfirmed()">OK</a>
</span>
</div>
</footer>
<!-- 4 -->
<script src="/scripts/cookies.js"></script>
#endif
以下是代码的作用:
- 检查是否为模板设置了
showCookieMessage
标志。 - 如果是,为
cookie
信息添加一个<footer>
,使用Bootstrap
的风格。 - 添加一个
OK
链接供用户点击。这将调用cookiesConfirmed()
,这个JavaScript
函数可以撤销cookie
信息。 - 添加用于
cookie
的JavaScript
文件。
接下来,在base.leaf
上面<title>#(title) | Acronyms</title>
,添加以下内容:
<link rel="stylesheet" href="/styles/style.css">
这包括网站的一个新的样式表。你将用它来为你的网站添加自定义样式。保存该文件。
要创建这个样式表,在终端输入以下内容:
mkdir Public/styles
touch Public/styles/style.css
接下来,打开style.css
并添加以下内容:
undefined
这个样式将cookie
信息固定在页面的底部。保存样式表。接下来,在终端输入以下内容,在Public/scripts
创建一个新文件,名为cookies.js
:
touch Public/scripts/cookies.js
接下来,打开cookies.js
,添加以下内容:
// 1
function cookiesConfirmed() {
// 2
$('#cookie-footer').hide();
// 3
var d = new Date();
d.setTime(d.getTime() + (365*24*60*60*1000));
var expires = "expires="+ d.toUTCString();
// 4
document.cookie = "cookies-accepted=true;" + expires;
}
下面是JavaScript
的作用:
- 定义一个函数,
cookiesConfirmed()
,当用户点击cookie
信息中的OK
链接时,浏览器会调用这个函数。 - 隐藏
cookie
信息。 - 创建一个未来一年的日期。然后,创建
cookie
所需的expires
字符串。默认情况下,cookie
在浏览器会话中有效--当用户关闭浏览器窗口或标签时,浏览器会删除cookie
。添加日期可确保浏览器将该cookie
保留一年。 - 使用
JavaScript
在页面上添加一个名为cookies-accepted
的cookie
。在研究是否显示cookie
同意信息时,你将检查这个cookie
是否存在。
保存该文件。在Xcode
中打开WebsiteController.swift
,在IndexContext
的底部添加以下内容:
let showCookieMessage: Bool
这个标志向模板表明它是否应该显示cookie
同意信息。在indexHandler(_:)
中,将let context = IndexContext...
替换为以下内容:
// 1
let showCookieMessage =
req.cookies["cookies-accepted"] == nil
// 2
let context = IndexContext(
title: "Home page",
acronyms: acronyms,
userLoggedIn: userLoggedIn,
showCookieMessage: showCookieMessage)
下面是这个的作用:
- 看看是否存在一个叫做
cookies-accepted
的cookie
。如果不存在,将showCookieMessage
标志设为true
。你可以从请求中读取cookie
并在响应中设置它们。 - 将该标志传递给
IndexContext
,以便模板知道是否显示信息。
建立并运行,然后在浏览器中转到http://localhost:8080。该网站在页面上显示cookie
同意信息:
在cookie
同意信息中点击OK
,你的JavaScript
代码就会隐藏它。刷新页面,网站就不会再显示该信息。
会话¶
除了使用cookie
进行网络认证外,你还利用了会话。会话在很多情况下都很有用,包括认证。
另一种情况是防止跨站请求伪造(CSRF
)。CSRF
是指攻击者欺骗用户发送一个意外的或非预期的POST
请求,例如向银行转账的请求。如果用户已经登录,网站处理该请求就没有任何问题。
在TIL
网站上创建首字母缩写也有可能。如果有人欺骗已经认证的用户向/acronyms/create
发送POST
请求,应用程序就会创建首字母缩略词
解决这个问题的一个常见方法是在表单中加入一个CSRF
令牌。当应用程序收到POST
请求时,它会验证CSRF
令牌是否与发给表单的令牌匹配。如果令牌匹配,应用程序将处理该请求;否则,它将拒绝该请求。
要添加CSRF
令牌支持,请打开WebsiteController.swift
,在CreateAcronymContext
的底部添加以下内容:
let csrfToken: String
这是你要传入模板的CSRF
令牌。在createAcronymHandler(_:)
中,将let context = CreateAcronymContext()
改为以下内容:
// 1
let token = [UInt8].random(count: 16).base64
// 2
let context = CreateAcronymContext(csrfToken: token)
// 3
req.session.data["CSRF_TOKEN"] = token
以下是新代码的作用:
- 使用
16
字节的随机生成的数据创建一个令牌,Base64
编码。 - 用创建的标记初始化
CreateAcronymContext
。 - 在
CSRF_TOKEN
键下将令牌保存到请求的会话数据中。
Vapor
在不同的请求中持续保存会话中的令牌。当用户提出一个新的请求并提供识别会话的cookie
时,所有的会话数据都可以使用。打开createAcronym.leaf
,在<form method="post">
下面,添加以下内容:
#if(csrfToken):
<input type="hidden" name="csrfToken" value="#(csrfToken)">
#endif
这将检查上下文是否包含一个token
。如果是,模板会在表单中添加一个新的输入元素,并将令牌作为其值。因为这个元素是隐藏的,所以浏览器不会向用户显示这个标记。
保存该文件。回到WebsiteController.swift
中,在CreateAcronymFormData
的底部添加以下内容:
let csrfToken: String?
这是表单使用隐藏输入发送的CSRF
令牌。这个令牌是可选的,因为编辑首字母的页面暂时不需要它。最后,在createAcronymPostHandler(_:data:)
后面let user = try req.auth.require(User.self)
,添加以下内容:
// 1
let expectedToken = req.session.data["CSRF_TOKEN"]
// 2
req.session.data["CSRF_TOKEN"] = nil
// 3
guard
let csrfToken = data.csrfToken,
expectedToken == csrfToken
else {
throw Abort(.badRequest)
}
下面是这个的作用:
- 从请求的会话数据中获取预期的令牌。这是你在
createAcronymHandler(_:)
中保存的令牌。 - 清除
CSRF
令牌,因为你已经使用了它。你在每个表单中都会生成一个新的令牌。 - 确保提供的令牌不为零,并与预期的令牌相匹配;否则,抛出一个
400 Bad Request
错误。
建立并运行,然后在你的浏览器中访问http://localhost:8080。登录后进入创建首字母缩写页面,创建一个新的首字母缩写。该应用程序创建的首字母缩写是表单提供了正确的CSRF令牌。如果你在没有令牌的情况下发送请求,无论是从你的页面中删除它还是使用RESTed
,你都会得到一个400 Bad Request
的响应。
接下来去哪?¶
在本章中,你学到了如何在应用程序的网站上添加认证。你还学会了如何利用会话和cookies
。你可能想看看在其他POST
路由中添加CSRF
令牌,比如删除和编辑缩写。在下一章,你将学习如何使用Vapor
的验证库来自动验证对象、请求数据和输入。