Cookie 与 Session 封面
Vapor 大型教程

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)">
        }
...
安全提示:服务端比对完 Token 后立刻把它清空(req.session()["CSRF_TOKEN"] = nil),这样同一个 Token 不能重复使用,能有效抵御重放攻击。

本系列其他文章