教程:FullStackOpen2022/Part 4

为应用增加用户认证和鉴权的功能。User 应当存储在数据库中,并且每一个 Blog 应当关联创建它的 User,只有创建者才拥有删除和编辑它的权利。

本文中以 bloglist 应用为例。对应练习 4.15-4.22。

1. 添加用户

定义 User 并跨 Collection 引用 Blog

定义一个 model 来表示 User,并在 Mongoose validator 的帮助下验证用户。

models/user.js:

const mongoose = require("mongoose");
const uniqueValidator = require("mongoose-unique-validator");

const userSchema = new mongoose.Schema({
  username: {
    type: String,
    minLength: 3,
    required: true,
    unique: true,
  },
  name: String,
  passwordHash: {
    type: String,
    minLength: 3,
    required: true,
  },
  blogs: [
    {
      type: mongoose.Schema.Types.ObjectId,
      ref: "Blog",
    },
  ],
});

userSchema.set("toJSON", {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString();
    delete returnedObject._id;
    delete returnedObject.__v;
    // the passwordHash should not be revealed
    delete returnedObject.passwordHash;
  },
});

userSchema.plugin(uniqueValidator)

module.exports = mongoose.model("User", userSchema);

Blog 的 ID 以数组的形式存储在 User 当中,type 字段是 ObjectId,引用了 Blog 的文档类型。Mongo 本质上并不知道这是一个引用 Blog 的字段,这种语法完全是与 Mongoose 的定义有关。

修改 models/blog.js 文件中 Blog 的 schema,让 Blog 包含其创建者的信息:

const blogSchema = new mongoose.Schema({
  title: String,
  author: String,
  url: String,
  likes: Number,
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: "User",
  },
});

编写路由

User 拥有一个唯一的 username,一个 name,以及一个 passwordHash。password 的 hash 是一个 单向 Hash 函数的输出,用来存储 User 的密码。永远不要以明文的方式将密码存储在数据库中。

安装 bcrypt 用来生成密码的哈希值(有些 Windows 用户在 bcrypt 方面有问题。如果遇到问题,请使用命令删除该库,并安装bcryptjs来作为替代):

npm install bcrypt

实现一个创建 User 的路由。

controllers/users.js:

const bcrypt = require("bcrypt");
const usersRouter = require("express").Router();
const User = require("../models/user");

usersRouter.post("/", async (request, response) => {
  const body = request.body;

  if (!body.password) {
    return response.status(400).send(400, {
      error: "`password` is required",
    });
  }

  const saltRounds = 10;
  const passwordHash = await bcrypt.hash(body.password, saltRounds);

  const user = new User({
    username: body.username,
    name: body.name,
    passwordHash,
  });

  const savedUser = await user.save();

  response.json(savedUser);
});

module.exports = usersRouter;

populate()

我们希望当一个 HTTP GET 请求到 /api/users 路由时,User 对象同样包含其创建 Blog 的内容,而不仅仅是 Blog 的 id。在关系型数据库中,这个功能需求可以通过 join query 实现。

Mongoose 的 join 通过 populate 方法完成的。可以使用 populate 的参数来选择我们想要包含的文档 field。field 的选择遵循 Mongo 的语法。

数据库实际上并不知道 Blog 中 user field 中的 id 实际指向了 User Collection 中的 User,Mongoose 中 populate 方法的功能是基于已经用 ref 选项为 Mongoose Schema 中的引用定义了类型。

controllers/users.js:

//...
usersRouter.get("/", async (request, response) => {
  const users = await User.find({}).populate("blogs", {
    title: 1,
    author: 1,
    url: 1,
  });
  response.json(users);
});

自动化测试

创建 User 的测试用例。

tests/test_helpers.js:

const User = require("../models/user");

//...

const usersInDb = async () => {
  return await User.find({});
};

module.exports = {
  initialBlogs,
  blogsInDb,
  nonExistingBlogId,
  usersInDb,
};

tests/user_api.test.js:

const mongoose = require("mongoose");
const supertest = require("supertest");
const helper = require("./test_helper");
const app = require("../app");
const api = supertest(app);

const bcrypt = require("bcrypt");
const User = require("../models/user");

describe("when there is initially one user in db", () => {
  beforeEach(async () => {
    await User.deleteMany({});

    const passwordHash = await bcrypt.hash("sekret", 10);
    const user = new User({ username: "root", passwordHash });

    await user.save();
  });

  test("creation succeeds with a fresh username", async () => {
    const usersAtStart = await helper.usersInDb();

    const newUser = {
      username: "mluukkai",
      name: "Matti Luukkainen",
      password: "salainen",
    };

    await api
      .post("/api/users")
      .send(newUser)
      .expect(200)
      .expect("Content-Type", /application\/json/);

    const usersAtEnd = await helper.usersInDb();
    expect(usersAtEnd).toHaveLength(usersAtStart.length + 1);

    const usernames = usersAtEnd.map((u) => u.username);
    expect(usernames).toContain(newUser.username);
  });

  test("creation fails with proper statuscode and message if username already taken", async () => {
    const usersAtStart = await helper.usersInDb();

    const newUser = {
      username: "root",
      name: "Superuser",
      password: "salainen",
    };

    const result = await api
      .post("/api/users")
      .send(newUser)
      .expect(400)
      .expect("Content-Type", /application\/json/);

    expect(result.body.error).toContain("`username` to be unique");

    const usersAtEnd = await helper.usersInDb();
    expect(usersAtEnd).toHaveLength(usersAtStart.length);
  });

  test("creation fails with proper statuscode and message if username is missing", async () => {
    const usersAtStart = await helper.usersInDb();

    const newUser = {
      name: "Without Username",
      password: "salainen",
    };

    const result = await api
      .post("/api/users")
      .send(newUser)
      .expect(400)
      .expect("Content-Type", /application\/json/);

    expect(result.body.error).toContain("`username` is required");

    const usersAtEnd = await helper.usersInDb();
    expect(usersAtEnd).toHaveLength(usersAtStart.length);
  });

  test("creation fails with proper statuscode and message if password is missing", async () => {
    const usersAtStart = await helper.usersInDb();

    const newUser = {
      username: "withoutpassword",
      name: "Without Password",
    };

    const result = await api
      .post("/api/users")
      .send(newUser)
      .expect(400)
      .expect("Content-Type", /application\/json/);

    expect(result.body.error).toContain("`password` is required");

    const usersAtEnd = await helper.usersInDb();
    expect(usersAtEnd).toHaveLength(usersAtStart.length);
  });

});

afterAll(() => {
  mongoose.connection.close();
});

2. 密钥认证 Token-Based Authentication

用户必须能够登录应用,而当用户一旦登录,他们的用户信息必须能够自动地加到他们所创建的任何 Blog 中。

接下来将让后端支持基于令牌的认证。基于令牌认证的原理如下:

Token Auth

生成 Token

安装 jsonwebtoken 库, 它会生成 Json Web Token。

npm install jsonwebtoken

实现登录的功能,代码放在 controllers/login.js 中:

const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
const loginRouter = require("express").Router();
const User = require("../models/user");

loginRouter.post("/", async (request, response) => {
  const body = request.body;

  const user = await User.findOne({ username: body.username });
  const passwordCorrect =
    user === null
      ? false
      : await bcrypt.compare(body.password, user.passwordHash);

  if (!(user && passwordCorrect)) {
    return response.status(401).json({
      error: "invalid username or password",
    });
  }

  const userForToken = {
    username: user.username,
    id: user._id,
  };

  const token = jwt.sign(userForToken, process.env.SECRET, {
    expiresIn: 60 * 60,
  });
  //   const token = jwt.sign(userForToken, process.env.SECRET);

  response
    .status(200)
    .send({ token, username: user.username, name: user.name });
});

module.exports = loginRouter;

代码首先从数据库中根据 request 提供的 username 搜索用户。

然后检查 request 中的 password。由于 password 在数据库中并不是明文存储的,而是存储的通过 password 计算的 Hash 值,需用 bcrypt.compare 方法用来检查 password 是否正确。

如果用户没有找到, 或者是密码错误,response 返回 401 Unauthorized, 失败的原因会被放到 response 的 body 体中。

如果密码正确,通过 jwt.sign 方法创建一个 token。

这个 token 通过环境变量中的 SECRET 作为密钥来生成数字化签名,确保只有知道密钥的组织才能够生成合法的 token。SECRET 环境变量的值必须放到 .env文件中。

token 包含了数字签名表单中的用户名以及 ID,并限制 token 的有效时间,一旦token 过期,客户端程序需要重新获取一个新的 token。

一个成功的 request 会返回 200 OK 的状态码,生成的 token 以及用户名放到了 response 中返回。

服务端 token session

另一种方案是为每一个 token 在后台数据库中保存信息,并在每个API请求时都去后台查询该token 是否有对应的访问权限。通过这种方式,访问权限可以随意收回。

服务器端的session 的弊端是增加了后台的复杂性,并且会由于每个API都要向后台数据库的认证 token 的合法性而产生性能影响。与只是单纯验证 token 有效性相比,数据库的访问要慢许多。

使用服务器端的 session 时,token 通常是一个随机字符串,并不会像 jwt-token 那样包含任何用户信息,每个 API 请求向服务器从数据库中获取该用户相关的认证信息。

更常规的做法不是用认证头,而是用 cookies 作为客户端与服务端之间传输 token 的机制。

在 request 中添加 token

有几种方法可以将令牌 token 从浏览器发送到服务器中。

我们将使用 Authorization Header。Header 还包含了使用哪一种 Authentication Scheme,用来告诉服务器应当如何解析发来的认证信息。

使用 Bearer Schema:

Bearer [token]

使用 middleware 读取、解析 token

使用中间件从 Authorization Header 获取令牌,并将其放置到 request 的token 字段。

util/middleware.js:

const jwt = require("jsonwebtoken");

//...

const tokenExtractor = (request, response, next) => {
  const authorization = request.get("authorization");
  if (authorization && authorization.toLowerCase().startsWith("bearer ")) {
    request.token = jwt.verify(authorization.substring(7), process.env.SECRET);
  } else {
    request.token = null;
  }

  next();
};

module.exports = {
  requestLogger,
  unknownEndpoint,
  errorHandler,
  tokenExtractor,
};

中间件将 token 与 Authorization Header 分离。

token 的有效性通过 jwt.verify 进行检查。这个方法同样解码了 token,返回一个 token 所基于的对象。

再创建一个新的中间件 userExtractor,来找到对应用户,并将用户信息加入 request。

util/middleware.js:

const User = require("../models/user");

//...

const userExtractor = async (request, response, next) => {
  if (request.token) {
    request.user = await User.findById(request.token.id);
  } else {
    request.user = null;
  }

  next();
};

module.exports = {
  requestLogger,
  unknownEndpoint,
  errorHandler,
  tokenExtractor,
  userExtractor
};

在 app.js 中注册中间件:

app.use(middleware.tokenExtractor);

app.use("/api/blogs", middleware.userExtractor, blogsRouter);

可以为一系列特定的路由注册中间件,也可以仅为 Router 特定操作注册中间件。

Error Handling

Token 认证可能引起 JsonWebTokenError,应为错误处理中间件扩展来给出合适的错误提示。

util/middleware.js:

const errorHandler = (error, request, response, next) => {
  logger.error(error.message);

  if (error.name === "CastError") {
    return response.status(400).send({ error: "malformatted id" });
  } else if (error.name === "ValidationError") {
    return response.status(400).json({ error: error.message });
  } else if (error.name === "JsonWebTokenError") {
    return response.status(401).json({
      error: "invalid token",
    });
  } else if (error.name === "TokenExpiredError") {
    return response.status(401).json({
      error: "token expired",
    });
  }

  next(error);
};

3. 修改 Blog 路由

修改 Blog 的代码,以便 Blog 指向创建它的 User,并只有拥有合法 token 的 request 才能被通过。

controllers/blogs.js:

const User = require("../models/user");

//...

blogsRouter.post("/", async (request, response) => {
  const token = request.token;
  if (!token.id) {
    return response.status(401).json({ error: "token missing or invalid" });
  }

  if (!request.body.title || !request.body.url) {
    response.status(400).end();
    return;
  }

  const user = request.user;
  const blog = new Blog({
    title: request.body.title,
    author: request.body.author,
    url: request.body.url,
    likes: request.body.likes || 0,
    user: user._id,
  });

  const blogSaved = await blog.save();
  user.blogs = user.blogs.concat(blogSaved._id);
  await user.save({ validateModifiedOnly: true });

  response.status(201).json(blogSaved);
});

blogsRouter.delete("/:id", async (request, response) => {
  const blog = await Blog.findById(request.params.id).populate('user');
  const user = request.user;

  if (!user) {
    return response.status(401).json({ error: "user not login" });
  }

  if (!blog) {
    return response.status(404).json({ error: "blog not found" });
  }

  if (blog.user && blog.user.id.toString() != user.id.toString()) {
    return response.status(401).json({ error: "unauthorized" });
  }

  await blog.deleteOne();
  response.status(204).end();
});

添加新 Blog 时, 更新 User 的操作会导致 mongoose-unique-validator 报错。可以通过添加 { validateModifiedOnly: true } 避免报错(详见:Saves fail with “Error, expected _id to be unique”)。