Pular para o conteúdo

a

Login no front-end

Nas últimas duas partes, nós focamos principalmente no backend. O frontend que desenvolvemos na parte 2 ainda não suporta o gerenciamento de usuários que implementamos no backend na parte 4.

No momento, o frontend mostra as notas existentes e permite que os usuários mudem o estado de uma nota de importante para não importante e vice-versa. Novas notas não podem mais ser adicionadas por causa das mudanças feitas no backend na parte 4: o backend agora espera que um token contendo a identidade de um usuário seja enviado com a nova nota.

Agora nós iremos implementar uma parte da funcionalidade de gerenciamento de usuários necessária no frontend. Vamos começar com o login de usuário. Durante toda essa parte, nós iremos assumir que novos usuários não serão adicionados a partir do frontend.

Gerenciando o login

Um formulário de login foi agora adicionado ao topo da página:

navegador mostrando login do usuário para as notas

O código do componente App agora é o seguinte:

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

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

export default App

O código atual da aplicação pode ser encontrado no Github, branch part5-1. Se você clonar o repositório, não se esqueça de rodar npm install antes de tentar iniciar o frontend.

O frontend não irá renderizar nenhuma nota se não estiver conectado ao backend. Você pode iniciar o backend através do comando npm run dev em sua pasta da Parte 4. Isso iniciará o backend na porta 3001. Enquanto ele estiver ativo, você pode iniciar o frontend em uma janela de terminal separada com o comando npm start, e agora você pode ver as notas que foram salvas no seu banco de dados MongoDB da Parte 4.

Mantenha isso em mente de agora em diante.

O formulário de login é gerenciado da mesma forma que gerenciamos formulários na parte 2. O estado do app tem campos de username e password para armazenar os dados do formulário. Os campos do formulário possuem gerenciadores de eventos, que sincronizam as alterações no campo e enviam o estado para o componente App . Os gerenciadores de eventos são simples: Um objeto é passado como parâmetro, e eles desestruturam o campo target do objeto e salvam seu valor no estado.

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

O método handleLogin, que responsável por lidar com os dados no formulário, ainda será implementado.

O login é feito enviando uma requisição HTTP POST para o endereço do servidor api/login. Vamos separar o código responsável por essa requisição em seu próprio módulo, para o arquivo services/login.js.

Nós usaremos a sintaxe async/await ao invés de promises para a requisição 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 }

Se você instalou o plugin eslint no VS Code, poderá ver agora o seguinte aviso:

aviso do vs code - atribua o objeto a uma variável antes de exportá-la como um módulo padrão

Nós iremos retornar à configuração do eslint em breve. Você pode ignorar o erro por enquanto ou silenciá-lo adicionando a seguinte linha de código antes do aviso:

// eslint-disable-next-line import/no-anonymous-default-export
export default { login }

O método para gerenciar o login pode ser implementado da seguinte forma:

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 (exception) {      setErrorMessage('Wrong credentials')      setTimeout(() => {        setErrorMessage(null)      }, 5000)    }  }

  // ...
}

Se o login for um sucesso, os campos do formulário serão apagados e o e a resposta do servidor (incluindo um token e os detalhes do usuário) será salva no campo do usuário no estado (state) da aplicação.

Se o login falhar ou a função loginService.login resultar em erro, o usuário será notificado.

O usuário não é notificado sobre um login bem-sucedido de nenhuma forma. Vamos modificar a aplicação para mostrar o formulário de login apenas se o usuário não estiver logado, ou seja, user === null. O formulário para adicionar novas notas será mostrado apenas se o usuário estiver logado, ou seja, se user contiver os detalhes do usuário.

Vamos adicionar duas funções auxiliares ao componente App para gerar os formulários:

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

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

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

  return (
    // ...
  )
}

e condicionalmente renderizá-los:

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

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

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

  return (
    <div>
      <h1>Notes</h1>

      <Notification message={errorMessage} />

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

      <Footer />
    </div>
  )
}

Um truque um pouco estranho, mas comumente usado no React, é usado para renderizar os formulários condicionalmente:

{
  user === null && loginForm()
}

Se a primeira declaração for avaliada como falsa ou for falsy, a segunda declaração (gerando o formulário) não será executada.

Podemos tornar isso ainda mais simples usando o operador condicional:

return (
  <div>
    <h1>Notes</h1>

    <Notification message={errorMessage}/>

    {user === null ?
      loginForm() :
      noteForm()
    }

    <h2>Notes</h2>

    // ...

  </div>
)

Se user === null for igual a um valor truthy, loginForm() será executado. Caso contrário, noteForm() será executado.

Vamos fazer mais uma modificação. Se o usuário estiver logado, seu nome é exibido na tela:

return (
  <div>
    <h1>Notes</h1>

    <Notification message={errorMessage} />

    {!user && loginForm()} 
    {user && <div>
       <p>{user.name} logged in</p>
         {noteForm()}
      </div>
    }

    <h2>Notes</h2>

    // ...

  </div>
)

A solução não é perfeita, mas vamos deixar assim por enquanto.

Nosso componente principal App está muito grande no momento. As mudanças que fizemos agora são um sinal claro de que os formulários devem ser refatorados em seus próprios componentes. No entanto, deixaremos isso como um exercício opcional.

O código atual da aplicação pode ser encontrado no GitHub, branch part5-2.

Criando novas notas

O token retornado com um login bem-sucedido é salvo no estado (state) da aplicação - o campo token do usuário:

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

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

Vamos corrigir a criação de novas notas para que funcione com o backend. Isso significa adicionar o token do usuário logado ao cabeçalho Authorization da solicitação HTTP.

O módulo noteService muda da seguinte forma:

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

// eslint-disable-next-line import/no-anonymous-default-export
export default { getAll, create, update, setToken }

O módulo noteService contém uma variável privada token. Seu valor pode ser alterado com uma função setToken, que é exportada pelo módulo. A função create, agora com sintaxe async/await, define o token para o cabeçalho Authorization. O cabeçalho é fornecido ao axios como o terceiro parâmetro do método post.

O gerenciador de eventos responsável pelo login deve ser alterado para chamar o método noteService.setToken(user.token) com um login bem-sucedido:

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

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

E agora, a adição de novas notas funciona novamente!

Salvando o token no local storage do navegador

Nossa aplicação tem uma pequena falha: se o navegador for atualizado (por exemplo, pressionando F5), as informações de login do usuário desaparecem.

Esse problema é facilmente resolvido salvando os detalhes de login no local storage. O local storage é um banco de dados de chave-valor no navegador.

Ele é muito fácil de usar. Um valor correspondente a uma determinada chave é salvo no banco de dados com o método setItem. Por exemplo:

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

salva a string dada como segundo parâmetro como o valor da chave name.

O valor de uma chave pode ser encontrado com o método getItem:

window.localStorage.getItem('name')

e o método removeItem remove uma chave.

Valores no local storage são persistidos mesmo quando a página é re-renderizada. O armazenamento é específico para origem , então cada aplicação web tem seu próprio armazenamento.

Vamos estender nossa aplicação para que ela salve os detalhes de um usuário logado no local storage.

Valores salvos no armazenamento são DOMstrings, então não podemos salvar um objeto JavaScript da forma como ele é. O objeto deve ser convertido para JSON primeiro, com o método JSON.stringify. Da mesma forma, quando um objeto JSON é lido do local storage, ele deve ser convertido de volta para JavaScript com JSON.parse.

As mudanças no método login são as seguintes:

  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) {
      // ...
    }
  }

Os detalhes de um usuário logado agora são salvos no local storage e podem ser visualizados no console (digitando window.localStorage no console):

navegador mostrando alguém logado em notas

Você também pode inspecionar o local storage usando as ferramentas de desenvolvedor. No Chrome, vá para a guia Aplicativo e selecione local storage (mais detalhes aqui). No Firefox, vá para a guia Storage e selecione Local Storage (detalhes aqui).

Nós ainda temos que modificar nossa aplicação para que, quando entrarmos na página, a aplicação verifique se os detalhes de um usuário logado já podem ser encontrados no local storage. Se eles puderem, os detalhes são salvos no estado da aplicação e no noteService.

O jeito certo de fazer isso é com um effect hook: um mecanismo que conhecemos pela primeira vez na parte 2, e usamos para buscar notas do servidor.

Nós podemos ter vários hooks de efeitos, então vamos criar um para lidar com o primeiro carregamento da página:

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)    }  }, [])
  // ...
}

O array vazio como parâmetro do hook de efeito garante que o efeito seja executado apenas quando o componente for renderizado pela primeira vez.

Agora um usuário fica logado na aplicação para sempre. Provavelmente devemos adicionar uma funcionalidade de logout, que remove os detalhes de login do local storage. No entanto, deixaremos isso como um exercício.

É possível deslogar um usuário usando o console, e isso é o suficiente por enquanto. Você pode deslogar com o comando:

window.localStorage.removeItem('loggedNoteappUser')

ou com o comando que esvazia o localstorage completamente:

window.localStorage.clear()

O código da aplicação atual pode ser encontrado no GitHub, branch part5-3.

Uma nota sobre o uso do loca storagel

No fim da última parte, nós mencionamos que o desafio da autenticação baseada em token é como lidar com a situação em que o acesso da API do titular do token à API precisa ser revogado.

Existem duas soluções para o problema. A primeira é limitar o período de validade de um token. Isso obriga o usuário a fazer login novamente no aplicativo assim que o token expirar. A outra abordagem é salvar as informações de validade de cada token no banco de dados do backend. Essa solução geralmente é chamada de server-side session.

Não importa como a validade dos tokens é verificada e garantida, salvar um token no local storage pode conter um risco de segurança se o aplicativo tiver uma vulnerabilidade de segurança que permita ataques Cross Site Scripting (XSS). Um ataque XSS é possível se o aplicativo permitir que um usuário injete código JavaScript arbitrário (por exemplo, usando um formulário) que o aplicativo então execute. Ao usar o React com sensatez, não deve ser possível aplicar esse ataque, pois o React sanitiza todo o texto que ele renderiza, o que significa que não está executando o conteúdo renderizado como JavaScript.

Se você quiser mais segurança, a melhor opção é não armazenar um token no local storage. Essa pode ser uma opção em situações em que vazar um token pode ter consequências trágicas.

Foi sugerido que a identidade de um usuário logado deve ser salva como cookies httpOnly, para que o código JavaScript não tenha acesso ao token. A desvantagem dessa solução é que tornaria a implementação de aplicativos SPA um pouco mais complexa. Seria necessário implementar pelo menos uma página separada para fazer login.

No entanto, é bom notar que mesmo o uso de cookies httpOnly não garante nada. Até mesmo foi sugerido que os cookies httpOnly não são mais seguros do que o uso do local storage.

Então não importa a solução usada, o mais importante é minimizar o risco de ataques XSS.