Siirry sisältöön

a

Kirjautuminen frontendissä

Kaksi edellistä osaa keskittyivät lähinnä backendin toiminnallisuuteen. Edellisessä osassa backendiin toteutettua käyttäjänhallintaa ei ole tällä hetkellä tuettuna osassa 2  kehitetyssä frontendissa millään tavalla.

Frontend näyttää tällä hetkellä olemassaolevat muistiinpanot ja antaa muuttaa niiden tilaa. Uusia muistiinpanoja ei kuitenkaan voi lisätä, sillä osan 4 muutosten myötä backend edellyttää, että lisäyksen mukana on käyttäjän identiteetin varmistava token.

Toteutetaan nyt osa käyttäjienhallinnan edellyttämästä toiminnallisuudesta frontendiin. Aloitetaan käyttäjän kirjautumisesta. Oletetaan vielä tässä osassa, että käyttäjät luodaan suoraan backendiin.

Sovelluksen yläosaan on nyt lisätty kirjautumislomake:

Sovellus koostuu syötekentät username ja password koostuvasta kirjautumislomakkeesta, muistiinpanojen listasta, sekä lomakkeesta joka mahdollistaa uuden muistiinpanon luomisen (ainoastaan yksi syötekenttä muistiinpanon sisällölle). Jokaisen listalla olevan muistiinpanon kohdalla on nappi, jonka avulla muistiinpano voidaan merkata tärkeäksi/epätärkeäksi

Komponentin App koodi näyttää seuraavalta:

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

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part5-1.

Kirjautumislomakkeen käsittely noudattaa samaa periaatetta kuin osassa 2. Lomakkeen kenttiä varten on lisätty komponentin tilaan username ja password. Molemmille kentille on määritelty muutoksenkäsittelijä, joka synkronoi kenttään tehdyt muutokset komponentin App tilaan. Muutoksenkäsittelijä on yksinkertainen, se destrukturoi parametrina tulevasta oliosta kentän target ja asettaa sen arvon vastaavaan tilaan:

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

Kirjautumislomakkeen lähettämisestä vastaava metodi handleLogin ei tee vielä mitään.

Kirjautuminen tapahtuu tekemällä HTTP POST ‑pyyntö palvelimen osoitteeseen api/login. Eristetään pyynnön tekevä koodi omaan moduuliinsa, tiedostoon services/login.js.

Käytetään HTTP-pyynnön tekemiseen nyt promisejen sijaan async/await-syntaksia:

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 }

Kirjautumisen käsittelystä huolehtiva metodi voidaan toteuttaa seuraavasti:

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

  // ...
}

Kirjautumisen onnistuessa nollataan kirjautumislomakkeen kentät ja talletetaan palvelimen vastaus (joka sisältää tokenin sekä kirjautuneen käyttäjän tiedot) sovelluksen tilaan user.

Jos kirjautuminen epäonnistuu, eli funktion loginService.login suoritus aiheuttaa poikkeuksen, ilmoitetaan siitä käyttäjälle.

Onnistunut kirjautuminen ei nyt näy sovelluksen käyttäjälle mitenkään. Muokataan sovellusta vielä siten, että kirjautumislomake näkyy vain jos käyttäjä ei ole kirjautuneena eli user === null ja uuden muistiinpanon luomislomake vain jos käyttäjä on kirjautuneena, eli user sisältää kirjautuneen käyttäjän tiedot.

Määritellään ensin komponenttiin App apufunktiot lomakkeiden generointia varten:

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

Renderöidään funktiot ehdollisesti komponenttiin App:

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, i) => 
          <Note
            key={i}
            note={note} 
            toggleImportance={() => toggleImportanceOf(note.id)}
          />
        )}
      </ul>

      <Footer />
    </div>
  )
}

Lomakkeiden ehdolliseen renderöintiin käytetään hyväksi aluksi hieman erikoiselta näyttävää, mutta Reactin yhteydessä yleisesti käytettyä kikkaa:

{!user && loginForm()}

Jos ensimmäinen osa evaluoituu epätodeksi eli on falsy (eli user ei ole määritelty), ei toista osaa eli lomakkeen generoivaa koodia suoriteta ollenkaan.

Tehdään vielä sellainen muutos, että jos käyttäjä on kirjautunut, renderöidään kirjautuneen käyttäjän nimi:

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

    <Notification message={errorMessage} />

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

    <h2>Notes</h2>

    // ...

  </div>
)

Ratkaisu näyttää hieman rumalta, mutta jätämme sen koodiin toistaiseksi.

Sovelluksemme pääkomponentti App on tällä hetkellä jo aivan liian laaja ja nyt tekemämme muutokset ovat ilmeinen signaali siitä, että lomakkeet olisi syytä refaktoroida omiksi komponenteikseen. Jätämme sen kuitenkin vapaaehtoiseksi harjoitustehtäväksi.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part5-2.

Muistiinpanojen luominen

Frontend on siis tallettanut onnistuneen kirjautumisen yhteydessä backendilta saamansa tokenin sovelluksen tilan user kenttään token:

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

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

Korjataan uusien muistiinpanojen luominen backendin edellyttämään muotoon, eli lisätään kirjautuneen käyttäjän token HTTP-pyynnön Authorization-headeriin.

noteService-moduuli muuttuu seuraavasti:

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 }

Moduulille on määritelty vain moduulin sisällä näkyvä muuttuja token, jolle voidaan asettaa arvo moduulin exporttaamalla funktiolla setToken. Async/await-syntaksiin muutettu create asettaa moduulin tallessa pitämän tokenin Authorization-headeriin, jonka se antaa Axiosille metodin post kolmantena parametrina.

Kirjautumisesta huolehtivaa tapahtumankäsittelijää pitää vielä viilata sen verran, että se kutsuu metodia noteService.setToken(user.token) onnistuneen kirjautumisen yhteydessä:

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

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

Uusien muistiinpanojen luominen onnistuu taas!

Tokenin tallettaminen selaimen local storageen

Sovelluksessamme on ikävä piirre: kun sivu uudelleenladataan, tieto käyttäjän kirjautumisesta katoaa. Tämä hidastaa melkoisesti myös sovelluskehitystä, sillä esim. testatessamme uuden muistiinpanon luomista, joudumme joka kerta kirjautumaan järjestelmään.

Ongelma korjaantuu helposti tallettamalla kirjautumistiedot local storageen eli selaimessa olevaan avain-arvo- eli key-value-periaatteella toimivaan tietokantaan.

Local storage on erittäin helppokäyttöinen. Metodilla setItem talletetaan tiettyä avainta vastaava arvo. Esim.

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

tallettaa avaimen name arvoksi toisena parametrina olevan merkkijonon.

Avaimen arvo selviää metodilla getItem:

window.localStorage.getItem('name')

removeItem poistaa avaimen.

Storageen talletetut arvot säilyvät vaikka sivu uudelleenladattaisiin. Storage on ns. origin-kohtainen, eli jokaisella selaimella käytettävällä web-sovelluksella on oma storagensa.

Laajennetaan sovellusta siten, että se asettaa kirjautuneen käyttäjän tiedot local storageen.

Koska storageen talletettavat arvot ovat merkkijonoja, emme voi tallettaa storageen suoraan JavaScript-oliota, vaan ne on muutettava ensin JSON-muotoon metodilla JSON.stringify. Vastaavasti kun JSON-muotoinen olio luetaan local storagesta, on se parsittava takaisin JavaScript-olioksi metodilla JSON.parse.

Kirjautumisen yhteyteen tehtävä muutos on seuraava:

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

Kirjautuneen käyttäjän tiedot tallentuvat nyt local storageen ja niitä voidaan tarkastella konsolista (kirjoittamalla konsoliin window.localStorage):

Selaimen konsoliin on evaluoitu window.localStorage-objektin arvo

Sovellusta on vielä laajennettava siten, että kun sivulle tullaan uudelleen, esim. selaimen uudelleenlataamisen yhteydessä, tulee sovelluksen tarkistaa löytyykö local storagesta tiedot kirjautuneesta käyttäjästä. Jos löytyy, asetetaan ne sovelluksen tilaan ja noteServicelle.

Oikea paikka asian hoitamiselle on effect hook eli osasta 2 tuttu mekanismi, jonka avulla haemme palvelimelle talletetut muistiinpanot frontendiin.

Effect hookeja voi olla useita, joten tehdään oma hoitamaan kirjautuneen käyttäjän ensimmäinen sivun lataus:

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

Efektin parametrina oleva tyhjä taulukko varmistaa sen, että efekti suoritetaan ainoastaan kun komponentti renderöidään ensimmäistä kertaa.

Nyt käyttäjä pysyy kirjautuneena sovellukseen ikuisesti. Sovellukseen olisikin kenties syytä lisätä logout-toiminnallisuus, joka poistaisi kirjautumistiedot local storagesta. Jätämme kuitenkin uloskirjautumisen harjoitustehtäväksi.

Meille riittää se, että sovelluksesta on mahdollista kirjautua ulos kirjoittamalla konsoliin:

window.localStorage.removeItem('loggedNoteappUser')

Toinen tapa on käyttää local storagen tilan kokonaan nollaavaa komentoa:

window.localStorage.clear()

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part5-3.

Huomio local storagen käytöstä

Edellisen osan lopussa todettiin, että token-perustaisen kirjautumisen haasteena on se, miten toimia tilanteissa, joissa tokenin haltijalta pitäisi poistaa pääsy API:n tarjoamaan dataan.

Ratkaisuja ongelmaan on kaksi. Tokenille voidaan asettaa voimassaoloaika, jonka päätyttyä käyttäjä pakotetaan kirjautumaan järjestelmään uudelleen. Toinen ratkaisu on tallentaa tokeniin liittyvät tiedot palvelimen tietokantaan ja tarkastaa jokaisen API-kutsun yhteydessä, onko tokeniin liittyvä käyttöoikeus tai "sessio" edelleen voimassa. Jälkimmäistä tapaa kutsutaan usein palvelinpuolen sessioksi.

Riippumatta siitä miten palvelin hoitaa tokenin voimassaolon tarkastuksen, saattaa tokenin tallentaminen local storageen olla pienimuotoinen turvallisuusriski jos sovelluksessa on ns. Cross Site Scripting (XSS) ‑hyökkäyksen mahdollistava tietoturva-aukko. XSS-hyökkäys mahdollistuu, jos sovelluksen suoritettavaksi on mahdollista ujuttaa mielivaltaista JavaScript-koodia, minkä taas ei pitäisi olla "normaalisti" Reactia käyttäen mahdollista sillä React sanitoi renderöimänsä sisällön, eli ei suorita sitä koodina.

Toki jos haluaa pelata varman päälle, ei tokenia kannata tallettaa local storageen ainakaan niissä tapauksissa, joissa potentiaalisella tokenin vääriin käsiin joutumisella olisi traagisia seurauksia.

Erääksi turvallisemmaksi ratkaisuksi kirjautuneen käyttäjän muistamiseen on tarjottu httpOnly-evästeitä (engl. httpOnly cookies), joita käytettäessä JavaScript-koodi ei pääse ollenkaan käsiksi session muistavaan tunnisteeseen. Pelkästään yhden sivun renderöivien SPA-sovellusten toteuttaminen HttpOnly-evästeiden avulla ei kuitenkaan ole helppoa. Niiden käyttö edellyttäisi erillistä näkymää kirjautumista varten.

Täytyy kuitenkin huomata, että httpOnly-evästeisiinkään perustuva ratkaisu ei ole vedenpitävä. Joidenkin mukaan se on itse asiassa yhtä "turvaton" kuin local storage. Tärkeintä on siis joka tapauksessa ohjelmoida sovellukset tavoilla, jotka minimoivat XSS-hyökkäysten riskit.