Saltar al contenido

a

Iniciar sesión en la interfaz

En las dos últimas partes, nos hemos concentrado principalmente en el backend. El frontend, que desarrollamos en la parte 2 aún no es compatible con la administración de usuarios que implementamos en el backend en la parte 4.

Por el momento, el frontend muestra las notas existentes y permite a los usuarios cambiar el estado de una nota de importante a no importante y viceversa. Ya no se pueden agregar nuevas notas debido a los cambios realizados en el backend en la parte 4: el backend ahora espera que se envíe un token que verifique la identidad de un usuario con la nueva nota.

Ahora implementaremos una parte de la funcionalidad de administración de usuarios requerida en el frontend. Comencemos con el inicio de sesión del usuario. A lo largo de esta parte, asumiremos que no se agregarán nuevos usuarios desde el frontend.

Controlando el inicio de sesión

Ahora se ha agregado un formulario de inicio de sesión en la parte superior de la página.

navegador mostrando login de usuario para app de notas

El código del componente App ahora tiene el siguiente aspecto:

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

El código de aplicación actual se puede encontrar en GitHub, en la rama part5-1. Si clonas el repositorio, no olvides ejecutar el comando npm install antes de intentar ejecutar el frontend.

El frontend no mostrara ninguna nota si no se conecta al backend. Puedes iniciar el backend con el comando npm run dev en su directorio de la Parte 4. Esto ejecutara el backend en el puerto 3001. Mientras esté activo, en una ventana diferente del terminal puedes ejecutar el frontend con npm start, y ahora veras las notas que están guardadas en tu base de datos MongoDB de la Parte 4.

Recuerda esto de ahora en más.

El formulario de inicio de sesión se maneja de la misma manera que manejamos los formularios en la parte 2. El estado de la aplicación tiene los campos username y password para almacenar los datos del formulario. Los campos de formulario tienen controladores de eventos, que sincronizan los cambios en el campo con el estado del componente App. Los controladores de eventos son simples: se les da un objeto como parámetro, y desestructuran el campo target del objeto y guardan su valor en el estado.

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

El método handleLogin, que se encarga de manejar los datos en el formulario, aún no se ha implementado.

El inicio de sesión se realiza enviando una solicitud HTTP POST a la dirección del servidor api/login. Separemos el código responsable de esta solicitud en su propio módulo, en el archivo services/login.js.

Usaremos la sintaxis async/await en lugar de promesas para la solicitud 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 }

El método para manejar el inicio de sesión se puede implementar de la siguiente manera:

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

  // ...
}

Si la conexión es exitosa, los campos de formulario se vacían y la respuesta del servidor (incluyendo un token y los datos del usuario) se guardan en el campo user del estado de la aplicación.

Si el inicio de sesión falla, o la ejecución de la función loginService.login da como resultado un error, se notifica al usuario.

No se notifica al usuario acerca de un inicio de sesión exitoso de ninguna manera. Modifiquemos la aplicación para que muestre el formulario de inicio de sesión solo si el usuario no ha iniciado sesión, cuando user === null. El formulario para agregar nuevas notas se muestra solo si el usuario ha iniciado sesión, por lo que user contiene los detalles del usuario.

Agreguemos dos funciones auxiliares al componente App para generar los formularios:

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

y renderizarlos condicionalmente:

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

Un truco de React ligeramente extraño, pero de uso común, se usa para renderizar los formularios de forma condicional:

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

Si la primera declaración se evalúa como falsa, o es falsy, la segunda declaración (que genera el formulario) no se ejecuta en absoluto.

Podemos hacer esto aún más sencillo usando el operador condicional:

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

    <Notification message={errorMessage}/>

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

    <h2>Notes</h2>

    // ...

  </div>
)

Si user === null es truthy (verdadero), se ejecuta loginForm(). Si no es así, se ejecuta noteForm().

Hagamos una modificación más. Si el usuario ha iniciado sesión, su nombre se muestra en la pantalla:

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

    <Notification message={errorMessage} />

    {user === null ?
      loginForm() :
      <div>
        <p>{user.name} logged-in</p>
        {noteForm()}
      </div>
    }

    <h2>Notes</h2>

    // ...

  </div>
)

La solución no es perfecta, pero la dejaremos así por ahora.

Nuestro componente principal App es demasiado grande en este momento. Los cambios que hicimos ahora son una clara señal de que los formularios deben ser refactorizados en sus propios componentes. Sin embargo, lo dejaremos para un ejercicio opcional.

El código de la aplicación actual se puede encontrar en GitHub, en la rama part5-2.

Creando nuevas notas

El token devuelto con un inicio de sesión exitoso se guarda en el estado de la aplicación, en el campo token de user:

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

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

Arreglemos la creación de nuevas notas para que funcione con el backend. Esto significa agregar el token del usuario que inició sesión en el encabezado de Autorización de la solicitud HTTP.

El módulo noteService cambia así:

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 }

El módulo noteService contiene una variable privada llamada token. Su valor se puede cambiar con la función setToken, que es exportada por el módulo. create, ahora con la sintaxis async/await, establece el token en el encabezado Authorization. El header se le da a axios como el tercer parámetro del método post.

El controlador de eventos responsable del inicio de sesión debe cambiarse para llamar al método noteService.setToken(user.token) con un inicio de sesión exitoso:

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

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

Y ahora, ¡agregar nuevas notas funciona otra vez!

Guardando el token en el local storage del navegador

Nuestra aplicación tiene un pequeño defecto: si el navegador es refrescado (por ejemplo, al apretar F5), la información de login del usuario desaparece.

Este problema se resuelve fácilmente guardando los datos de inicio de sesión en el local storage(almacenamiento local). Local Storage es una base de datos de clave-valor en el navegador.

Es muy fácil de usar. Un valor correspondiente a una determinada clave se guarda en la base de datos con el método setItem. Por ejemplo:

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

guarda el string dado como segundo parámetro como el valor de la clave name.

El valor de una clave se puede obtener con el método getItem:

window.localStorage.getItem('name')

mientras que removeItem elimina una clave.

Los valores del local storage se conservan incluso cuando se vuelve a renderizar la página. El almacenamiento es específico de origen, por lo que cada aplicación web tiene su propio almacenamiento.

Extendamos nuestra aplicación para que guarde los detalles de un usuario que inició sesión en local storage.

Los valores guardados en el storage son DOMstrings, por lo que no podemos guardar un objeto JavaScript tal cual. El objeto debe formatearse primero como JSON, con el método JSON.stringify. En consecuencia, cuando se lee un objeto JSON del almacenamiento local, debe formatearse de nuevo a JavaScript con JSON.parse.

Los cambios en el método de inicio de sesión son los siguientes:

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

Los detalles de un usuario que inició sesión ahora se guardan en local storage y se pueden ver en la consola (al escribir window.localStorage en ella):

consola del navegador mostrando datos de usuario guardados en local storage

También puedes inspeccionar el local storage con las herramientas de desarrollo. En Chrome, ve a la pestaña Application y selecciona Local Storage (más detalles aquí). En Firefox, ve a la pestaña Storage y selecciona Local Storage (detalles aquí).

Aún tenemos que modificar nuestra aplicación para que cuando ingresemos a la página, la aplicación verifique si los detalles de un usuario que inició sesión ya se pueden encontrar en el local storage. Si se encuentran allí, los detalles se guardan en el estado de la aplicación y en noteService.

La forma correcta de hacer esto es con un effect hook: un mecanismo que encontramos por primera vez en la parte 2 y que usamos para obtener notas desde el servidor.

Podemos tener múltiples effect hooks, así que creemos otro para manejar la primera carga de la 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)    }  }, [])
  // ...
}

El array vacío como parámetro del effect hook asegura que el hook se ejecute solo cuando el componente es renderizado por primera vez.

Ahora un usuario permanece conectado a la aplicación para siempre. Probablemente deberíamos agregar funcionalidad para cerrar sesión que elimine los detalles de inicio de sesión del almacenamiento local. Sin embargo, lo dejaremos para un ejercicio.

Es posible cerrar la sesión de un usuario usando la consola, y eso es suficiente por ahora. Puedes cerrar sesión con el comando:

window.localStorage.removeItem('loggedNoteappUser')

o con el comando que vacía el localstorage por completo:

window.localStorage.clear()

El código de la aplicación actual se puede encontrar en GitHub, en la rama part5-3.

Nota sobre el uso de local storage

Al final de la última parte mencionamos que el desafío de la autenticación basada en tokens es cómo afrontar la situación en la cual el acceso a la API del poseedor del token necesita ser revocado.

Hay dos soluciones para este problema. La primera es limitar el periodo de validez de un token. Esto fuerza al usuario a iniciar sesión nuevamente cuando el token ha expirado. El otro enfoque es guardar la información de validez de cada token en la base de datos del backend. Esta solución es llamada frecuentemente server side session.

No importa cómo la validez de los tokens es revisada y asegurada, guardar un token en el almacenamiento local puede significar un riesgo de seguridad si la aplicación tiene una vulnerabilidad que permite un ataque de Cross Site Scripting (XSS). Un ataque XSS es posible si la aplicación permite al usuario inyectar arbitrariamente código de JavaScript (ej. usando un formulario), que la aplicación luego puede ejecutar. Si usamos React correctamente, esto no debería ser posible, ya que React desinfecta todo el texto que renderiza, lo que significa que no está ejecutando el contenido renderizado como JavaScript.

Si uno quiere estar seguro, la mejor opción es no almacenar un token en el almacenamiento local. Esta puede ser una opción en situaciones en las que filtrar un token puede tener consecuencias trágicas.

Ha sido sugerido que la identidad de un usuario que ha iniciado sesión debería guardarse como httpOnly cookies, para que el código de JavaScript no pueda tener ningún acceso al token. El inconveniente de esta solución es que haría la implementación de aplicaciones SPA un poco mas compleja. Necesitaríamos implementar al menos una pagina separada para el inicio de sesión.

Sin embargo, es importante notar que incluso el uso de cookies httpOnly no garantiza nada. Incluso se ha sugerido que las cookies httpOnly no son mas seguras que el uso del almacenamiento local.

Al fin y al cabo, no importa la solución utilizada, lo mas importante es minimizar el riesgo de ataques XSS por completo.