用户认证概述 封面
Vapor 大型教程

用户认证概述

认证是确认“你是谁”的过程。本文带你给 TIL 应用加上密码哈希、用户名唯一约束,并让接口在返回时不再泄露密码。

认证与授权的区别

认证(Authentication)是验证某个人是否合法的过程。它和授权(Authorization)不同:授权是赋予已登录用户执行某个动作的权力。TIL 应用也遵循常见做法,用“用户名 + 密码”的组合来做认证。

先在 User 模型里增加 password 字段,并修改初始化方法:

final class User: Codable {
    var id: UUID?
    var name: String
    var username: String
    var password: String

    init(name: String, username: String, password: String) {
        self.name = name
        self.username = username
        self.password = password
    }
}

用 BCrypt 哈希存储密码

存储密码有一条铁律:绝不明文存储。Vapor 提供了工业级的 BCrypt 算法来对密码做哈希。哈希是单向的:明文可以生成哈希值,但从哈希值几乎不可能反推出明文。BCrypt 在哈希时会自动加盐,让“撞库”复原密码代价大到不可接受;同时它还提供验证明文与哈希值是否匹配的功能。所以创建用户前,要先对明文密码做哈希再存储。加密相关能力都放在 Crypto 库中。

import Crypto

struct UsersController: RouteCollection {
    ...
    func createHandler(_ req: Request, user: User) throws -> Future<User> {
        user.password = try BCrypt.hash(user.password)
        return user.save(on: req)
    }
    ...
}
这样 user 的密码是以哈希值落库的,即便数据库被拖库,攻击者也很难还原出明文密码。

保证用户名唯一

现在对同名用户没有限制,但后续登录要靠“用户名 + 密码”校验,如果存在重名,登录时就不知道该验证哪一个用户了。因此要给用户名加上唯一约束。

extension User: Migration {
    static func prepare(on conn: PostgreSQLConnection) -> Future<Void> {
        return Database.create(self, on: conn, closure: { (builder) in
            try addProperties(to: builder)
            builder.unique(on: \.username)
        })
    }
}

这类似于添加外键约束:如果同名用户已经存在,新的同名用户就会创建失败。

因为改了表字段和约束,需要重置一次数据库让改动生效。在 Xcode 中按住 Option + Run 打开 Schema 设置,给运行时参数加上 revert --all --yes 并勾选运行一次,等价于执行 vapor run revert --all --yes。重置后再运行工程时,记得把该参数取消勾选,新的数据库表关系才会生效。

返回数据脱敏

从前面的请求返回里能看到密码的哈希值,这很不安全。解决办法是:返回用户数据前做一次转换,只暴露公开字段。新增一个 Public 嵌套类型,并在 User 上提供转换方法。

final class User: Codable {
    ...

    final class Public: Codable {
        var id: UUID?
        var name: String
        var username: String
        init(id: UUID?, name: String, username: String) {
            self.id = id
            self.name = name
            self.username = username
        }
    }
}

extension User.Public: Content {}

extension User {
    func convertToPublic() -> User.Public {
        return User.Public(id: self.id, name: self.name, username: self.username)
    }
}

extension Future where T: User {
    func convertToPublic() -> Future<User.Public> {
        return self.map(to: User.Public.self) { (user) -> User.Public in
            return user.convertToPublic()
        }
    }
}

然后让各个处理器都返回 User.Public,查询时也只解码公开字段:

import Vapor
import Crypto

struct UsersController: RouteCollection {
    ...
    func createHandler(_ req: Request, user: User) throws -> Future<User.Public> {
        user.password = try BCrypt.hash(user.password)
        return user.save(on: req).convertToPublic()
    }

    func getAllHandler(_ req: Request) throws -> Future<[User.Public]> {
        return User.query(on: req).decode(data: User.Public.self).all()
    }

    func getHandler(_ req: Request) throws -> Future<User.Public> {
        return try req.parameters.next(User.self).convertToPublic()
    }

    func updateHandler(_ req: Request) throws -> Future<User.Public> {
        return try flatMap(to: User.Public.self, req.parameters.next(User.self), req.content.decode(User.self)) { (user, updatedUser) -> Future<User.Public> in

            user.name = updatedUser.name
            user.username = updatedUser.username

            return user.save(on: req).convertToPublic()
        }
    }
    ...
}

现在所有用户相关的返回数据都不再包含密码相关信息了。


本系列其他文章