跳到内容

a

完成前端的登录功能

在过去的两部分中,我们主要集中在后端,而我们在第二章节中开发的前端还不支持我们在第四章节中对后端实现的用户管理。

目前,前端显示现有的笔记,并允许用户将一个笔记的状态从重要改为不重要,反之亦然。由于第四章节中对后端所做的修改,新的笔记不能再被添加:后端现在期望一个验证用户身份的令牌与新的笔记一起被发送。

我们现在要在前端实现一部分所需的用户管理功能。让我们从用户登录开始。在这一部分中,我们将假设新用户不会从前端添加。

Adding a Login Form

现在,一个登录表格已经被添加到页面的顶部。添加新笔记的表格也被移到了笔记列表的底部。

fullstack content

App组件的代码现在看起来如下。

const App = () => {
  const [notes, setNotes] = useState([]) 
  const [newNote, setNewNote] = useState('')
  const [showAll, setShowAll] = useState(true)
  const [errorMessage, setErrorMessage] = useState(null)
  const [username, setUsername] = useState('')   const [password, setPassword] = useState('') 
  useEffect(() => {
    noteService
      .getAll().then(initialNotes => {
        setNotes(initialNotes)
      })
  }, [])

  // ...

  const handleLogin = (event) => {    event.preventDefault()    console.log('logging in with', username, password)  }
  return (
    <div>
      <h1>Notes</h1>
      <Notification message={errorMessage} />
      
      <h2>Login</h2>      <form onSubmit={handleLogin}>        <div>          <label>            username            <input              type="text"              value={username}              onChange={({ target }) => setUsername(target.value)}            />          </label>        </div>        <div>          <label>            password            <input              type="password"              value={password}              onChange={({ target }) => setPassword(target.value)}            />          </label>        </div>        <button type="submit">login</button>      </form>
      // ...
    </div>
  )
}

export default App

当前的应用代码可以在Github的分支part5-1上找到。如果你克隆了这个 repo,在尝试运行前端之前,别忘了运行npm install

如果前端没有连接到后端,它将不会显示任何注释。你可以在第四章节的文件夹中用npm run dev来启动后端。这将在3001端口运行后端。当它处于激活状态时,在一个单独的终端窗口中,你可以用npm start启动前端,现在你可以看到第四章节中保存在MongoDB数据库中的注释。

从现在开始记住这一点。

登录表单的处理方式与我们在第二部分中处理表单的方式相同。应用的状态有usernamepassword字段来存储表单中的数据。表单字段有事件处理器,它们将字段中的变化同步到App组件的状态中。这些事件处理器很简单:给它们一个对象作为参数,它们从对象中解构出target字段,并将其值保存到状态中。

({ target }) => setUsername(target.value)

负责处理表单中数据的handleLogin方法还没有被实现。

Adding Logic to the Login Form

登录是通过向服务器地址api/login发送一个HTTP POST请求来完成。让我们把负责这个请求的代码分离到自己的模块中,放到services/login.js文件中。

我们将使用async/await语法而不是 promise 来处理HTTP请求。

import axios from 'axios'
const baseUrl = '/api/login'

const login = async credentials => {
  const response = await axios.post(baseUrl, credentials)
  return response.data
}

export default { login }

The method for handling the login can be implemented as follows:

import loginService from './services/login'
const App = () => {
  // ...
  const [username, setUsername] = useState('') 
  const [password, setPassword] = useState('') 
  const [user, setUser] = useState(null)
  // ...

  const handleLogin = async event => {    event.preventDefault()
    
    try {      const user = await loginService.login({ username, password })      setUser(user)      setUsername('')      setPassword('')    } catch {      setErrorMessage('wrong credentials')      setTimeout(() => {        setErrorMessage(null)      }, 5000)    }  }

  // ...
}

如果登录成功,表单字段被清空,并且服务器响应(包括一个token和用户详细信息)被保存到应用状态的user字段。

如果登录失败,或运行loginService.login函数导致错误,用户将被通知。

Conditional Rendering of the Login Form

用户不会以任何方式得到关于成功登录的通知。让我们修改应用,只有在用户没有登录的情况下才显示登录表单,所以当user == null。只有当用户登录时,才会显示添加新笔记的表单,所以用户包含用户的详细信息。

让我们在App组件中添加两个辅助函数来生成表单。

const App = () => {
  // ...

  const loginForm = () => (
    <form onSubmit={handleLogin}>
      <div>
        <label>
          username
          <input
            type="text"
            value={username}
            onChange={({ target }) => setUsername(target.value)}
          />
        </label>
      </div>
      <div>
        <label>
          password
          <input
            type="password"
            value={password}
            onChange={({ target }) => setPassword(target.value)}
          />
        </label>
      </div>
      <button type="submit">login</button>
    </form>
  )

  const noteForm = () => (
    <form onSubmit={addNote}>
      <input value={newNote} onChange={handleNoteChange} />
      <button type="submit">save</button>
    </form>
  )

  return (
    // ...
  )
}

并有条件地渲染它们。

const App = () => {
  // ...

  const loginForm = () => (
    // ...
  )

  const noteForm = () => (
    // ...
  )

  return (
    <div>
      <h1>Notes</h1>
      <Notification message={errorMessage} />

      {!user && loginForm()}      {user && noteForm()}
      <div>
        <button onClick={() => setShowAll(!showAll)}>
          show {showAll ? 'important' : 'all'}
        </button>
      </div>
      <ul>
        {notesToShow.map(note => (
          <Note
            key={note.id}
            note={note}
            toggleImportance={() => toggleImportanceOf(note.id)}
          />
        ))}
      </ul>

      <Footer />
    </div>
  )
}

一个看起来有点奇怪,但常用的React技巧被用来有条件地渲染表单。

{!user && loginForm()}

如果第一条语句计算为false,或者是falsy,第二条语句(生成表单)根本就不会被执行。

我们再做一个修改。如果用户已经登录,他们的名字就会显示在屏幕上。

return (
  <div>
    <h1>Notes</h1>
    <Notification message={errorMessage} />

    {!user && loginForm()}
    {user && (      <div>        <p>{user.name} logged in</p>        {noteForm()}      </div>    )}
    <div>
      <button onClick={() => setShowAll(!showAll)}>
    // ...
)

解决方案并不完美,但我们暂时就这样吧。

我们的主要组件App目前太大。我们现在所做的改变清楚地表明,表单应该被重构为它们自己的组件。然而,我们将把这个问题留给一个可选的练习。

目前的应用代码可以在Github上找到,分支part5-2

Note on Using the Label Element

我们在登录表单的 input 字段中使用了 label 元素。用户名的 input 字段放置在相应的 label 元素内部:

<div>
  <label>
    username
    <input
      type="text"
      value={username}
      onChange={({ target }) => setUsername(target.value)}
    />
  </label>
</div>
// ...

我们为什么要以这种方式实现表单?从外观上看,使用更简单的代码,不使用单独的 label 元素也可以达到相同的效果:

<div>
  username
  <input
    type="text"
    value={username}
    onChange={({ target }) => setUsername(target.value)}
  />
</div>
// ...

label 元素用于表单中,用于描述和命名 input 字段。它为输入字段提供描述,帮助用户理解应向每个字段输入什么信息。这种描述与相应的输入字段程可编程地关联,提高了表单的可访问性。

这样,当输入字段被选中时,屏幕阅读器可以读出字段名称给用户听,点击标签的文本会自动聚焦到正确的输入字段。建议始终使用 label 元素与 input 字段配合使用,即使不使用它也能达到相同的外观效果。

几种方法可以将特定 labelinput 元素关联起来。最简单的方法是将 input 元素放置在相应的 label 元素内部,正如本材料所示。这会自动将 label 与正确的输入字段关联起来,无需额外配置。

Creating new notes

登录成功后返回的令牌被保存在应用的状态中--用户的字段token

const handleLogin = async (event) => {
  event.preventDefault()
  try {
    const user = await loginService.login({
      username, password,
    })

    setUser(user)    setUsername('')
    setPassword('')
  } catch (exception) {
    // ...
  }
}

让我们修复创建新的注释,使其与后端一起工作。这意味着在HTTP请求的授权头中添加登录用户的令牌。

noteService模块的变化是这样的。

import axios from 'axios'
const baseUrl = '/api/notes'

let token = null
const setToken = newToken => {  token = `Bearer ${newToken}`}
const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

const create = async newObject => {
  const config = {    headers: { Authorization: token }  }
  const response = await axios.post(baseUrl, newObject, config)  return response.data
}

const update = (id, newObject) => {
  const request = axios.put(`${ baseUrl }/${id}`, newObject)
  return request.then(response => response.data)
}

export default { getAll, create, update, setToken }

noteService模块包含一个私有变量token。它的值可以通过模块导出的函数setToken来改变。create,现在使用async/await语法,将token设置为Authorization头。这个头被作为post方法的第三个参数交给axios。

负责登录的事件处理程序必须改变,以便在登录成功后调用noteService.setToken(user.token)方法。

const handleLogin = async (event) => {
  event.preventDefault()

  try {
    const user = await loginService.login({ username, password })
    noteService.setToken(user.token)    setUser(user)
    setUsername('')
    setPassword('')
  } catch {
    // ...
  }
}

现在添加新的笔记又开始工作了!

Saving the token to the browser's local storage

我们的应用有一个缺陷:当页面被重新渲染时,用户的登录信息消失了。这也拖慢了开发速度。例如,当我们测试创建新的笔记时,我们每次都要重新登录。

这个问题可以通过将登录信息保存到本地存储来轻松解决。本地存储是浏览器中的一个键-值数据库。

它非常容易使用。通过setItem方法将与某个key对应的value保存到数据库中。例如。

window.localStorage.setItem('name', 'juha tauriainen')

将作为第二个参数的字符串保存为键name的值。

键的值可以通过方法getItem找到。

window.localStorage.getItem('name')

removeItem 删除一个键。

即使页面被重新渲染,本地存储中的值也会持续存在。这个存储是origin特定的,所以每个网络应用都有自己的存储。

让我们扩展我们的应用,使其将登录用户的详细信息保存在本地存储中。

保存到存储空间的值是DOMstrings,所以我们不能原封不动地保存一个JavaScript对象。该对象必须首先被解析为JSON,使用JSON.stringify方法。相应地,当一个JSON对象从本地存储中读取时,必须用JSON.parse将其解析为JavaScript。

登录方法的变化如下:

  const handleLogin = async (event) => {
    event.preventDefault()
    try {
      const user = await loginService.login({ username, password })

      window.localStorage.setItem(        'loggedNoteappUser', JSON.stringify(user)      )       noteService.setToken(user.token)
      setUser(user)
      setUsername('')
      setPassword('')
    } catch (exception) {
      // ...
    }
  }

登录用户的详细信息现在被保存到本地存储中,并且可以在控制台中查看(通过在控制台中输入window.localStorage)。

fullstack content

你也可以使用开发者工具检查本地存储。在Chrome上,进入Application标签,选择Local Storage(更多细节这里)。在Firefox上,进入Storage标签,并选择Local Storage(详细信息here)。

我们仍然需要修改我们的应用,以便当我们进入页面时,应用检查是否已经在本地存储中找到了登录用户的详细资料。如果可以,这些细节就会被保存到应用的状态和noteService

正确的方法是使用效果钩子:这是我们在第二章节中第一次遇到的机制,用来从服务器上获取笔记。

我们可以有多个效果钩子,所以让我们创建第二个效果钩子来处理页面的第一次加载。

const App = () => {
  const [notes, setNotes] = useState([])
  const [newNote, setNewNote] = useState('')
  const [showAll, setShowAll] = useState(true)
  const [errorMessage, setErrorMessage] = useState(null)
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [user, setUser] = useState(null)

  useEffect(() => {
    noteService.getAll().then(initialNotes => {
      setNotes(initialNotes)
    })
  }, [])
  
  useEffect(() => {    const loggedUserJSON = window.localStorage.getItem('loggedNoteappUser')    if (loggedUserJSON) {      const user = JSON.parse(loggedUserJSON)      setUser(user)      noteService.setToken(user.token)    }  }, [])
  // ...
}

空数组作为效果的参数,确保效果只在组件被渲染首次时执行。

现在,一个用户会在应用中永远保持登录状态。我们也许应该添加一个logout功能,从本地存储中删除登录的细节。然而,我们将把它作为一个练习。

我们可以使用控制台注销用户,目前这已经足够了。

你可以用命令注销。

window.localStorage.removeItem('loggedNoteappUser')

或者使用完全清空localstorage的命令。

window.localStorage.clear()

目前的应用代码可以在Github上找到,分支part5-3

A note on using local storage

在上一部分的结尾我们提到,基于令牌的认证的挑战是如何应对令牌持有者对API的访问需要被撤销的情况。

这个问题有两种解决方案。第一个是限制令牌的有效期限。这迫使用户在令牌过期后重新登录到应用。另一种方法是将每个令牌的有效期信息保存到后端数据库中。这种解决方案通常被称为服务器端会话

无论如何检查和确保令牌的有效性,如果应用有安全漏洞,允许跨站脚本(XSS)攻击,将令牌保存在本地存储中可能包含安全风险。如果应用允许用户注入任意的JavaScript代码(例如使用一个表单),然后由应用执行,那么XSS攻击就有可能。当以合理的方式使用React时,这应该是不可能的,因为React对其渲染的所有文本进行消毒,这意味着它不会将渲染的内容作为JavaScript执行。

如果想安全起见,最好的选择是不将令牌存储到本地存储。在泄漏令牌可能产生悲剧性后果的情况下,这可能是一个选择。

有人建议将登录用户的身份保存为httpOnly cookies,这样JavaScript代码就不能访问令牌了。这个解决方案的缺点是,它将使实施SPA-应用变得更加复杂。人们至少需要实现一个单独的页面来登录。

然而,注意到即使使用httpOnly cookies也不能保证任何事情是好的。甚至有人建议,只使用httpOnly cookies并不比使用本地存储更安全。

所以不管使用什么解决方案,最重要的是尽量减少XSS攻击的风险