跳转至

第20章:网络认证、Cookies和会话

在前几章中,你学到了如何在TIL应用的API中实现认证。在本章中,你将看到如何为TIL网站实现认证。你将学习如何在网络上进行认证,以及Vapor的认证模块如何提供所有必要的支持。然后你将看到如何保护网站上的不同路线。最后,你将学习如何使用cookie和会话来发挥你的优势。

网络认证

它是如何工作的

早些时候,你学会了如何使用HTTP基本认证和承载认证来保护API。你会记得,这是通过在请求头中发送令牌和凭证来实现的。然而,这在网络浏览器中是不可能的。没有办法在你的浏览器用普通的HTML发出的请求中添加头信息。

为了解决这个问题,浏览器和网站使用cookiesCookie是你的应用程序发送给浏览器的一小部分数据,用于存储在用户的计算机上。然后,当用户向你的应用程序发出请求时,浏览器就会为你的网站附加上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 {}

下面是这个的作用:

  1. 使User符合ModelSessionAuthenticatable。这允许应用程序保存和检索你的用户作为会话的一部分。
  2. UserModelCredentialsAuthenticatable相匹配。这允许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)
}

下面是这个的作用:

  1. 为登录页面定义一个路由处理程序,返回一个未来的View
  2. 如果请求中包含error参数,并且为真,则创建一个loginError设置为true的上下文。
  3. 渲染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

以下是模板中的情况:

  1. 扩展base.leaf并按要求导出content
  2. 使用上下文中提供的title来设置页面的标题。
  3. 如果loginError的上下文值为true,显示一个合适的信息。
  4. 定义一个<form>,在提交时向同一URL发送POST请求。
  5. 为用户的用户名添加一个输入。输入的名称与ModelCredentialsAuthenticatable要求的名称一致。
  6. 为用户的密码添加一个输入。注意type="password"--这告诉浏览器将该输入作为一个密码字段。这使用了ModelCredentialsAuthenticatable要求的密码名称。
  7. 为表单添加一个提交按钮。

接下来,打开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)
  }
}

下面是这个的作用:

  1. 定义一个返回EventLoopFuture<Response>的路由处理程序。
  2. 验证该请求是否有一个经过认证的User。你使用中间件来执行验证。
  3. 登录成功后重定向到主页。
  4. 如果登录失败,重定向回登录页面,显示错误。

最后,在boot(route:)的底部,注册两个路由:

// 1
routes.get("login", use: loginHandler)
// 2
let credentialsAuthRoutes = 
  routes.grouped(User.credentialsAuthenticator())
// 3
credentialsAuthRoutes.post("login", use: loginPostHandler)

下面是这个的作用:

  1. /loginGET请求路由到loginHandler(_:)
  2. 使用ModelCredentialsAuthenticator创建一个路由组。这个中间件检查提交表单的请求。然后,它验证证书,如果成功,就对请求进行认证。
  3. 通过credentialsAuthRoutes/loginPOST请求路由到loginPostHandler(_:userData:)

建立并运行该应用程序。在你的浏览器中,访问http://localhost:8080/login。在不输入数据的情况下点击Log In,以查看错误处理情况。

img

接下来,输入你的证书并再次点击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延伸出来,包括UserRedirectMiddleware。应用程序在到达路由处理程序之前通过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

点击导航栏中的创建首字母缩写,这一次,应用程序会将你重定向到登录页面:

img

输入种子管理员用户的凭证,并点击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或数据库来保存这些信息,并在不同的服务器实例中共享。

回到创建一个缩写,表格中不再包括用户的列表:

img

创建一个首字母缩写。当应用程序将你重定向到首字母缩写的页面时,你会看到Vapor已经使用认证的用户作为首字母缩写的用户:

img

注销

当你允许用户登录到你的网站时,你也应该允许他们注销。还是在WebsiteController.swift中,在loginPostHandler(_:)之后添加以下内容:

// 1
func logoutHandler(_ req: Request) -> Response {
  // 2
  req.auth.logout(User.self)
  // 3
  return req.redirect(to: "/")
}

下面是这个的作用:

  1. 定义一个简单返回Response的路由处理程序。这个方法中没有异步工作,所以它不需要返回一个未来。
  2. 在请求中调用logout(_:)。这将从会话中删除用户,所以它不能被用来验证未来的请求。
  3. 返回一个重定向到索引页。

credentialsAuthRoutes.post("login", use: loginPostHandler)之后,在boot(routes:)内注册该路由:

authSessionsRoutes.post("logout", use: logoutHandler)

这将/logoutPOST请求连接到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

下面是这个的作用:

  1. 检查userLoggedIn是否被设置,以便你只在用户登录时显示注销选项。
  2. 创建一个新的表单,向/logout发送一个POST请求。
  3. 在表单中添加一个提交按钮,其值为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)

下面是这个的作用:

  1. 检查该请求是否包含一个已认证的用户。
  2. 将结果传递给IndexContext中的新标志。

建立并运行,然后到你的浏览器。点击创建一个首字母缩写并登录。当应用程序将你重定向到主页时,你会在右上方看到一个新的Log out选项:

img

如果你点击这个,然后再次点击创建一个缩写,你需要登录,因为应用程序已经把你登录出去。

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

以下是代码的作用:

  1. 检查是否为模板设置了showCookieMessage标志。
  2. 如果是,为cookie信息添加一个<footer>,使用Bootstrap的风格。
  3. 添加一个OK链接供用户点击。这将调用cookiesConfirmed(),这个JavaScript函数可以撤销cookie信息。
  4. 添加用于cookieJavaScript文件。

接下来,在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的作用:

  1. 定义一个函数,cookiesConfirmed(),当用户点击cookie信息中的OK链接时,浏览器会调用这个函数。
  2. 隐藏cookie信息。
  3. 创建一个未来一年的日期。然后,创建cookie所需的expires字符串。默认情况下,cookie在浏览器会话中有效--当用户关闭浏览器窗口或标签时,浏览器会删除cookie。添加日期可确保浏览器将该cookie保留一年。
  4. 使用JavaScript在页面上添加一个名为cookies-acceptedcookie。在研究是否显示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)

下面是这个的作用:

  1. 看看是否存在一个叫做cookies-acceptedcookie。如果不存在,将showCookieMessage标志设为true。你可以从请求中读取cookie并在响应中设置它们。
  2. 将该标志传递给IndexContext,以便模板知道是否显示信息。

建立并运行,然后在浏览器中转到http://localhost:8080。该网站在页面上显示cookie同意信息:

img

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

以下是新代码的作用:

  1. 使用16字节的随机生成的数据创建一个令牌,Base64编码。
  2. 用创建的标记初始化CreateAcronymContext
  3. 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)
}

下面是这个的作用:

  1. 从请求的会话数据中获取预期的令牌。这是你在createAcronymHandler(_:)中保存的令牌。
  2. 清除CSRF令牌,因为你已经使用了它。你在每个表单中都会生成一个新的令牌。
  3. 确保提供的令牌不为零,并与预期的令牌相匹配;否则,抛出一个400 Bad Request错误。

建立并运行,然后在你的浏览器中访问http://localhost:8080。登录后进入创建首字母缩写页面,创建一个新的首字母缩写。该应用程序创建的首字母缩写是表单提供了正确的CSRF令牌。如果你在没有令牌的情况下发送请求,无论是从你的页面中删除它还是使用RESTed,你都会得到一个400 Bad Request的响应。

接下来去哪?

在本章中,你学到了如何在应用程序的网站上添加认证。你还学会了如何利用会话和cookies。你可能想看看在其他POST路由中添加CSRF令牌,比如删除和编辑缩写。在下一章,你将学习如何使用Vapor的验证库来自动验证对象、请求数据和输入。