跳到内容

d

密钥认证

用户必须能够登录我们的应用,当用户登录后,他们的用户信息必须自动附加到他们创建的任何新笔记上。

我们现在将在后端实现对基于令牌的认证的支持。

基于令牌的认证的原则在下面的顺序图中得到描述。

sequence diagram of token-based authentication
  • 用户通过使用React实现的登录表单开始登录

  • 我们将在第五章节中把登录表单添加到前端。

  • 这使得React代码将用户名和密码作为HTTP POST请求发送到服务器地址/api/login

  • 如果用户名和密码正确,服务器会生成一个token,以某种方式识别登录的用户。

  • 令牌经过数字签名,使其不可能被伪造(用密码学手段)。

  • 后端以一个状态代码响应,表明操作成功,并将令牌与响应一起返回。

  • 浏览器保存令牌,例如保存到React应用的状态中。

  • 当用户创建一个新的笔记(或做一些其他需要识别的操作),React代码将令牌与请求一起发送到服务器。

  • 服务器使用该令牌来识别用户

让我们首先实现登录的功能。安装jsonwebtoken库,它允许我们生成JSON web tokens

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 { username, password } = request.body

  const user = await User.findOne({ username })
  const passwordCorrect = user === null
    ? false
    : await bcrypt.compare(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)

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

module.exports = loginRouter
  • 用户开始通过使用React实现的登录表单进行登录

    • 我们将在第5部分中将登录表单添加到前端
  • 这会导致React代码将用户名和密码作为HTTP POST请求发送到服务器地址/api/login
  • 如果用户名和密码正确,服务器会生成一个以某种方式识别已登录用户的token

    • 该token被数字签名,使其无法伪造(通过密码学手段)
  • 后端以状态码响应,表示操作成功,并在响应中返回token。
  • 浏览器保存token,例如保存到React应用的状态中。
  • 当用户创建新的笔记(或进行其他需要身份验证的操作)时,React代码将请求一起将token发送到服务器。
  • 服务器使用token来识别用户

代码首先通过请求中附带的username从数据库中搜索用户。

const user = await User.findOne({ username })

接下来,它检查附加到请求的password

const passwordCorrect = user === null
  ? false
  : await bcrypt.compare(password, user.passwordHash)

因为密码本身并未保存到数据库,而是从密码计算出的hashes,所以使用bcrypt.compare方法来检查密码是否正确:

await bcrypt.compare(password, user.passwordHash)

如果找不到用户,或者密码不正确,请求将以状态码401 unauthorized进行响应。失败的原因在响应体中解释。

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

如果密码正确,将使用方法 jwt.sign 创建一个token。该 token 以数字签名的形式包含用户名和用户id。

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

const token = jwt.sign(userForToken, process.env.SECRET)

该token已使用环境变量SECRET中的字符串作为secret进行数字签名。 数字签名确保只有知道秘密的方可以生成有效的token。 环境变量的值必须在.env文件中设置。

成功的请求将以状态码200 OK进行响应。生成的token和用户的用户名在响应体中返回。

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

现在只需要将登录代码添加到应用中,通过将新的路由器添加到app.js即可。

const loginRouter = require('./controllers/login')

//...

app.use('/api/login', loginRouter)

让我们试试用 VS Code REST-client 登录。

vscode rest post with username/password

它不工作。下面的内容被打印到控制台。

(node:32911) UnhandledPromiseRejectionWarning: Error: secretOrPrivateKey must have a value
    at Object.module.exports [as sign] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/sign.js:101:20)
    at loginRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/login.js:26:21)
(node:32911) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2)

命令 jwt.sign(userForToken, process.env.secret) 失败。我们忘记给环境变量SECRET设置一个值。它可以是任何字符串。当我们在文件.env中设置了这个值,登录就成功了。

一个成功的登录会返回用户的详细资料和令牌。

fullstack content

一个错误的用户名或密码会返回一个错误信息和正确的状态代码。

fullstack content

Limiting creating new notes to logged in users

让我们更改创建新的笔记,使其只有在post请求附带有效token时才可能。然后,将笔记保存到由token识别的用户的笔记列表中。

将token从浏览器发送到服务器有几种方法。我们将使用Authorization头。该头还告诉我们使用了哪种认证方案。如果服务器提供了多种认证方式,这可能是必要的。 识别方案告诉服务器应如何解释附加的凭据。

Bearer 方案适合我们的需求。

实际上,这意味着如果token是例如字符串 eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW ,那么Authorization头将具有以下值:


Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW

创建新的笔记将如此改变(controllers/notes.js):

const jwt = require('jsonwebtoken')
// ...
const getTokenFrom = request => {  const authorization = request.get('authorization')  if (authorization && authorization.startsWith('Bearer ')) {    return authorization.replace('Bearer ', '')  }  return null}
notesRouter.post('/', async (request, response) => {
  const body = request.body
  const decodedToken = jwt.verify(getTokenFrom(request), process.env.SECRET)  if (!decodedToken.id) {    return response.status(401).json({ error: 'token invalid' })  }  const user = await User.findById(decodedToken.id)
  const note = new Note({
    content: body.content,
    important: body.important === undefined ? false : body.important,
    user: user._id
  })

  const savedNote = await note.save()
  user.notes = user.notes.concat(savedNote._id)
  await user.save()

  response.json(savedNote)
})

辅助函数getTokenFrom将token从authorization头部分离出来。使用jwt.verify检查token的有效性。该方法还解码token,或返回token基于的对象。

const decodedToken = jwt.verify(token, process.env.SECRET)

如果token丢失或无效,将引发异常JsonWebTokenError。我们需要扩展错误处理中间件以处理这种特殊情况:

const errorHandler = (error, request, response, next) => {
  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 === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) {
    return response.status(400).json({ error: 'expected `username` to be unique' })
  } else if (error.name ===  'JsonWebTokenError') {    return response.status(400).json({ error: 'token missing or invalid' })  }

  next(error)
}

从token解码的对象包含usernameid字段,这些字段告诉服务器是谁发出的请求。

如果从token解码的对象不包含用户的身份(decodedToken.id未定义),则返回错误状态码401 unauthorized,并在响应体中解释失败的原因。

if (!decodedToken.id) {
  return response.status(401).json({
    error: 'token invalid'
  })
}

当请求者的身份被解析后,执行将像以前一样继续。

现在,如果authorization头给出了正确的值,即字符串Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ,其中第二个值是login操作返回的token,那么就可以使用Postman创建新的笔记了。

在Postman中,这看起来如下:

postman添加bearer token

和在Visual Studio Code REST客户端中

vscode添加bearer token示例

当前应用程序代码可以在GitHub上找到,分支是part4-9

如果应用程序有多个接口需要身份验证,JWT的验证应该分离到自己的中间件中。也可以使用现有的库,如express-jwt

Problems of Token-based authentication

Token认证是很容易实现的,但它包含一个问题。一旦API用户,例如React应用得到一个令牌,API就会对令牌持有者产生盲目信任。如果令牌持有者的访问权被撤销了怎么办?

这个问题有两种解决方案。比较简单的是限制令牌的有效期。

loginRouter.post('/', async (request, response) => {
  const { username, password } = request.body

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

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

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

  // token expires in 60*60 seconds, that is, in one hour
  const token = jwt.sign(    userForToken,    process.env.SECRET,    { expiresIn: 60*60 }  )
  response
    .status(200)
    .send({ token, username: user.username, name: user.name })
})

令牌过期后,客户端应用需要获取新令牌。通常,这是通过强制用户重新登录应用程序来实现的。

应扩展错误处理中间件,以便在令牌过期的情况下给出适当的错误:

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)
}

过期时间越短,解决方案就越安全。所以如果令牌落入坏人之手,或者用户对系统的访问需要被撤销,令牌只能在有限的时间内使用。另一方面,过期时间短会给用户带来潜在的痛苦,用户必须更频繁地登录系统。

另一个解决方案是将每个令牌的信息保存在后端数据库中,并为每个API请求检查该令牌对应的访问权是否仍然有效。通过这种方案,访问权可以在任何时候被撤销。这种方案通常被称为服务器端会话

服务器端会话的消极方面是增加了后端的复杂性,也影响了性能,因为需要对每个API请求到数据库的token有效性进行检查数据库访问相比检查token本身的有效性要慢得多。这就是为什么将一个token对应的会话保存到一个键-值数据库(如Redis)是很常见的,与MongoDB或关系型数据库相比,其功能有限,但在某些使用场景下速度极快。

当使用服务器端会话时,令牌通常只是一个随机字符串,不包括关于用户的任何信息,因为在使用jwt令牌时通常是这样的。对于每个API请求,服务器从数据库中获取有关用户身份的相关信息。另外,通常不使用授权头,而是使用cookies作为客户端和服务器之间传输令牌的机制。

End notes

代码有很多变化,这对一个快节奏的软件项目来说,造成了一个典型的问题:大多数测试都坏了。由于课程的这一部分已经充斥着新信息,我们将把修复测试留作一个非强制性的练习。

使用token认证的用户名、密码和应用程序必须始终通过HTTPS使用。我们可以在我们的应用程序中使用Node HTTPS服务器(它需要更多的配置),而不是HTTP服务器。另一方面,我们应用程序的生产版本在Fly.io上,所以我们的应用程序保持安全:Fly.io将浏览器和Fly.io服务器之间的所有流量通过HTTPS路由。

我们将在下一部分中实现对前端的登录。

注意:在这个阶段,在部署的笔记应用中,预计创建笔记的功能将停止工作,因为后端登录功能尚未与前端链接。