« mooc.fi

Sisällysluettelo

Tehtävät

Osa 5

Viides osio alkaa kertaustehtävällä, jossa teemme viestien lisäämiseen ja hakemiseen tarkoitetun REST-rajapinnan. Tämän jälkeen tutustumme pikaisesti mediatyyppeihin sekä tiedostojen tallentamiseen ja lataamiseen, jota seuraa selaimessa tapahtuvaan toimintaan keskittyminen. Osio loppuu web-sovellusten tietoturvaan liittyvällä keskustelulla.

Toteuta REST-rajapinta valmiiksi annettujen Message-olioiden käsittelyyn. Rajapinnan tarjoamien metodien tulee palauttaa tietoa muodossa application/hal+json;charset=UTF-8, ja viestilista tulee löytyä polusta /messages. Pohdi sovellusta toteuttaessasi minkälaisia muita polkuja sovelluksessa tulee olla, sekä mieti myös niiden tarjoamia toiminnallisuuksia.

Mediatyypit

Pyyntöjä lähetettäessä ja vastaanottaessa palvelin vastaanottaa ja kertoo pyynnön tyypin otsakkeella Content-Type. Tätä tietoa lähetettävän tai vastaanotettavan datan muodosta kutsutaan mediatyypiksi, millä dataa käsittelevä ohjelmisto voi päättää mitä datalla tehdään. Mediatyyppi sisältää yleensä kaksi osaa; mediatyypin sekä tarkenteen (esim application/json). Kattava lista eri mediatyypeistä löytyy IANA-organisaation ylläpitämästä mediatyyppilistasta.

Tyypillisiä mediatyyppejä ovat erilaiset kuvat image/*, videot video/*, äänet audio/* sekä erilaiset tekstimuodot kuten JSON application/json.

Web-palvelut voivat tarjota käytännössä mitä tahansa näistä tiedostotyypeistä käyttäjälle; käyttäjän sovellusohjelmisto päättelee vastauksessa tulevan mediatyypin mukaan osaako se käsitellä tiedoston.

Yksinkertaisimmillaan mediatiedoston lähetys palvelimelta toimii Springillä seuraavasti. Oletetaan, että käytössämme on levypalvelin ja polussa /media/data/ oleva PNG-kuvatiedosto architecture.png.

@RequestMapping(method = RequestMethod.GET, produces = "image/png")
public void copyImage(OutputStream out) throws IOException {
    Files.copy(Paths.get("/media/data/architecture.png"), out);
}

Yllä olevassa esimerkissä kerromme että metodi tuottaa image/png-tyyppistä sisältöä. Spring asettaa kontrollerin metodin parametriksi automaattisesti OutputStream-olion, johon pyynnön vastaus voidaan kirjoittaa. Files-luokan tarjoama copy-metodi kopioi kuvan suoraan tiedostosta pyynnön vastaukseksi.

Ylläolevan kontrollerimetodin palauttaman kuvan voi näyttää osana sivua img-elementin avulla. Jos metodi kuuntelee osoitetta /media/image.png, HTML-elementti <img src="/media/image.png" /> hakee kuvan automaattisesti osoitteesta sivun latautuessa.

Huom! Jos kuvat ovat staattisia eikä niitä esimerkiksi lisäillä tai poisteta, tulee niiden olla esimerkiksi projektin kansiossa /src/main/resources/public/img -- niille ei tule määritellä kontrollerimetodia. Kansion public alla olevat tiedostot kopioidaan web-sovelluksen käyttöön, ja niihin pääsee käsiksi web-selaimella ilman tarvetta kontrollerille.

Tiedostojen tallentaminen ja lataaminen

Web-sivuilta voi lähettää tiedostoja palvelimelle. Alla oleva lomake HTML-koodi luo lomakkeen, joka voi sisältää myös binääridataa (kts. multipart/form-data).

<form method="POST" action="/files" enctype="multipart/form-data">
    <input type="file" name="file" />
    <input type="submit" value="Send!"/>
</form>

Lomake lähettää tiedot palvelimelle, jonka tulee käsitellä pyyntö. Pyynnön käsittely tapahtuu aivan kuten minkä tahansa muunkin pyynnön, mutta tässä tapauksessa pyynnön parametrin tyyppi on MultipartFile, joka sisältää lähetettävän tiedoston tiedot.

Alla oleva kontrollerimetodi vastaanottaa pyynnön, ja tulostaa pyynnössä lähetetyn tiedoston koon ja tyypin. Se ei kuitenkaan tee vielä muuta.

@RequestMapping(method = RequestMethod.POST)
public String save(@RequestParam("file") MultipartFile file) {
    System.out.println(file.getSize());
    System.out.println(file.getContentType());

    return "redirect:/files";
}

MultipartFile-olio sisältää myös tavutaulukon, missä pyynnössä lähetetty data sijaitsee.

Tavutaulukon -- eli tässä tapauksessa datan -- tallennus tietokantaan onnistuu seuraavasti. Entiteetti FileObject sisältää tavutaulukon siten, että sen voi tallentaa tietokantaan.

import javax.persistence.Entity;
import javax.persistence.Lob;
import org.springframework.data.jpa.domain.AbstractPersistable;

@Entity
public class FileObject extends AbstractPersistable<Long> {

    @Lob
    private byte[] content;

    // getterit ja setterit
}

Annotaatiolla @Lob kerrotaan että annotoitu muuttuja tulee tallentaa tietokantaan isona dataobjektina. Tietokantamoottorit tallentavat nämä tyypillisesti erilliseen isommille tiedostoille tarkoitettuun sijaintiin, jolloin tehokkuus ei juurikaan kärsi erikokoisten kenttien takia.

Kun entiteetille tekee repository-olion, voi sen ottaa käyttöön myös kontrollerissa. Tietokantaan tallentaminen tapahtuu tällöin seuraavasti:

@RequestMapping(method = RequestMethod.POST)
public String save(@RequestParam("file") MultipartFile file) throws IOException {
    FileObject fo = new FileObject();
    fo.setContent(file.getBytes());

    fileObjectRepository.save(fo);

    return "redirect:/files";
}

Tiedoston lähetys kontrollerista onnistuu vastaavasti. Tässä tapauksessa oletamme, että data on muotoa image/png; kontrolleri palauttaa tietokantaoliolta saatavan tavutaulukon pyynnön vastauksen rungossa.

@RequestMapping(value="{id}", method = RequestMethod.GET, produces = "image/png")
@ResponseBody
public byte[] get(@PathVariable Long id) {
    return fileObjectRepository.findOne(id).getContent();
}

Tässä tehtävässä toteutetaan sovellus gif-kuvien varastointiin ja selaamiseen.

Pääset toteuttamaan huomattavan osan sovelluksesta itse -- tarkista että suunnittelemasi domain-oliot sopivat yhteen annetun näkymän kanssa.

Tehtäväpohjassa olevassa gifs.html-sivussa on toiminnallisuus, minkä avulla kuvia näytetään käyttäjälle.

Toteuta toiminnallisuus, jonka avulla seuraavat toiminnot ovat käytössä.

  • Kun käyttäjä tekee GET-tyyppisen pyynnön osoitteeseen /gifs, hänet ohjataan osoitteeseen /gifs/1.
  • Kun käyttäjä tekee GET-tyyppisen pyynnön osoitteeseen /gifs/{id}, hänelle näytetään sivu gifs. Pyynnön modeliin tulee lisätä attribuutti count, joka sisältää tietokannassa olevien kuvien määrän. Tämän lisäksi, pyyntöön tulee lisätä attribuutti next, joka sisältää seuraavan kuvan tunnuksen -- jos sellainen on olemassa, attribuutti previous, joka sisältää edeltävän kuvan tunnuksen -- jos sellainen on olemassa, ja current, joka sisältää nykyisen kuvan tunnuksen -- jos sellainen on olemassa.
  • Kun käyttäjä tekee GET-tyyppisen pyynnön osoitteeseen /gifs/{id}/content, tulee hänelle palauttaa tunnukslla {id} tietokannassa oleva kuva -- vastauksen mediatyypiksi tulee asettaa myös image/gif.

HTML-sivulla on myös lomake, jonka avulla palvelimelle voi lähettää uusia kuvia. Toteuta palvelimelle toiminnallisuus, jonka avulla osoitteeseen /gifs tehdystä POST-pyynnöstä otetaan sisältö talteen ja tallennetaan se tietokantaa. Huom! Tallenna sisältö vain jos sen mediatyyppi on image/gif. Pyyntö uudelleenohjataan aina lopuksi osoitteeseen /gifs.

Kun isoja tiedostoja tallennetaan tietokantaan, kannattaa tietokanta suunnitella siten, että tiedostoja ei ladata tietokannasta sellaisten kutsujen yhteydessä, missä niitä ei tarvita. Voimme lisätä olioattribuuteille annotaatiolla @Basic lisämääreen fetch, minkä avulla hakeminen rajoitetaan eksplisiittisiin kutsuihin. Tarkasta tässä vaiheessa edellisen tehtävän mallivastaus -- huomaat että sielläkin -- vaikka annotaatio @Basic ei ollut käytössä -- konkreettinen kuva ladataan hyvin harvoin.

import javax.persistence.Basic;
import javax.persistence.Entity;
import javax.persistence.Lob;
import org.springframework.data.jpa.domain.AbstractPersistable;

@Entity
public class FileObject extends AbstractPersistable<Long> {

    @Lob
    @Basic(fetch = FetchType.LAZY)
    private byte[] content;

    // getterit ja setterit
}

Ylläoleva @Basic(fetch = FetchType.LAZY) annotaatio pyytää JPA-toteutusta (tapauksessamme Hibernate) luomaan annotoidun olioattribuutin get-metodiin ns. proxymetodin -- data haetaan tietokannasta vasta kun metodia getContent() kutsutaan.

Yleiskäyttöinen tiedoston tallennus ja lataaminen

Edellisessä esimerkissä määrittelimme kontrollerimetodin palauttaman mediatyypin osaksi @RequestMapping annotaatiota. Usein tiedostopalvelimet voivat kuitenkin palauttaa lähes minkätyyppisiä tiedostoja tahansa. Tutustutaan tässä yleisempään tiedoston tallentamiseen ja lataukseen.

Käytämme edellisessä esimerkissä käytettyä FileObject-entiteettiä toteutuksen pohjana.

Jotta voimme kertoa tiedoston mediatyypin, haluamme tallentaa sen tietokantaan. Tallennetaan tietokantaan mediatyypin lisäksi myös tiedoston alkuperäinen nimi sekä tiedoston pituus.

import javax.persistence.Basic;
import javax.persistence.Entity;
import javax.persistence.Lob;
import org.springframework.data.jpa.domain.AbstractPersistable;

@Entity
public class FileObject extends AbstractPersistable<Long> {

    private String name;
    private String mediaType;
    private Long size;

    @Lob
    @Basic(fetch = FetchType.LAZY)
    private byte[] content;

    // getterit ja setterit
}

Pääsemme kaikkiin kenttiin käsiksi MultipartFile-olion kautta; muokataan aiemmin näkemäämme kontrolleria siten, että otamme kaikki yllämääritellyt kentät tietokantaan tallennettavaan olioon.

@RequestMapping(method = RequestMethod.POST)
public String save(@RequestParam("file") MultipartFile file) throws IOException {
    FileObject fo = new FileObject();

    fo.setName(file.getOriginalName());
    fo.setMediaType(file.getContentType());
    fo.setSize(file.getSize());
    fo.setContent(file.getBytes());

    fileObjectRepository.save(fo);

    return "redirect:/files";
}

Nyt tietokantaan tallennettu olio tietää myös siihen liittyvän mediatyypin. Haluamme seuraavaksi pystyä myös kertomaan kyseisen mediatyypin tiedostoa hakevalle käyttäjälle.

ResponseEntity-oliota käytetään vastauksen paketointiin; voimme palauttaa kontrollerista ResponseEntity-olion, jonka pohjalta Spring luo vastauksen käyttäjälle. ResponseEntity-oliolle voidaan myös asettaa otsaketietoja, joihin saamme asetettua mediatyypin.

@RequestMapping(value = "/{id}", method = RequestMethod.GET)
public ResponseEntity<byte[]> viewFile(@PathVariable Long id) {
    FileObject fo = fileObjectRepository.findOne(id);

    final HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.parseMediaType(fo.getContentType()));
    headers.setContentLength(fo.getSize());

    return new ResponseEntity<>(fo.getContent(), headers, HttpStatus.CREATED);
}

Ylläolevassa esimerkissä vastaanotetaan pyyntö, minkä pohjalta tietokannasta haetaan FileObject-olio. Tämän jälkeen luodaan otsakeolio HttpHeaders ja asetetaan sille palautettavan datan mediatyyppi ja koko. Lopuksi palautetaan ResponseEntity-olio, mihin data, otsaketiedot ja pyyntöön liittyvä statusviesti (tässä tapauksessa CREATED) liitetään.

Edeltävä esimerkki ei ota kantaa tiedoston nimeen tai siihen, miten se ladataan. Voimme lisäksi vastaukseen Content-Disposition-otsakkeen, minkä avulla voidaan ehdottaa tiedoston tallennusnimeä sekä kertoa että tiedosto on liitetiedosto, jolloin se tulee tallentaa.

@RequestMapping(value = "/{id}", method = RequestMethod.GET)
public ResponseEntity<byte[]> viewFile(@PathVariable Long id) {
    FileObject fo = fileObjectRepository.findOne(id);

    final HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.parseMediaType(fo.getContentType()));
    headers.setContentLength(fo.getSize());
    headers.add("Content-Disposition", "attachment; filename=" + fo.getName());

    return new ResponseEntity<>(fo.getContent(), headers, HttpStatus.CREATED);
}

Tässä tehtävässä toteutetaan yleisempi tiedostojen varastointiin ja näyttämiseen käytettävä sovellus.

Kuten edellisessä tehtävässä, pääset toteuttamaan huomattavan osan sovelluksesta itse -- tarkista että suunnittelemasi domain-oliot sopivat yhteen annetun näkymän kanssa.

Toteuta toiminnallisuus, jonka avulla seuraavat toiminnot ovat käytössä.

  • Kun käyttäjä tekee GET-tyyppisen pyynnön osoitteeseen /files, pyyntöön lisätään tietokannasta löytyvät tiedostot ja käyttäjä ohjataan sivulle files.html.
  • Kun käyttäjä lähettää lomakkeella tiedoston osoitteeseen /files, pyynnöstä otetaan talteen kaikki tiedot mitä näkymässä halutaan näyttää, ja tallennetaan ne tietokantaan. Pyyntö ohjataan lopuksi uudelleen osoitteeseen /files.
  • Kun käyttäjä klikkaa yksittäiseen tiedostoon liittyvää delete-nappia, tulee tiedosto poistaa tietokannasta. Lopuksi pyyntö uudelleenohjataan osoitteeseen /files.
  • Kun käyttäjä klikkaa yksittäiseen tiedostoon liittyvää nimeä sen lataamista varten, tulee tiedosto lähettää käyttäjälle. Aseta pyyntöön datan lisäksi myös tiedoston mediatyyppi että ja ehdotus tiedoston tallennusnimestä.

Javascript-pohjaiset selainohjelmistot

Tutustutaan seuraavaksi selainpuolen toiminnallisuuden peruspalasiin.

Web-sivujen rakenne

Web-sivut määritellään HTML-kielen avulla. Yksittäinen HTML-dokumentti koostuu sisäkkäin ja peräkkäin olevista elementeistä, jotka määrittelevät sivun rakenteen sekä sivun sisältävän tekstin. Rakenteen määrittelevät elementit erotellaan pienempi kuin (<) ja suurempi kuin (>) -merkeillä. Elementti avataan elementin nimen sisältävällä pienempi kuin -merkillä alkavalla ja suurempi kuin -merkkiin loppuvalla merkkijonolla, esim. <html>, ja suljetaan merkkijonolla jossa elementin pienempi kuin -merkin jälkeen on vinoviiva, esim </html>. Yksittäisen elementin sisälle voi laittaa muita elementtejä.

Tyypillisen HTML-dokumentin runko näyttää seuraavalta. Kun klikkaat allaolevassa iframe-elementissä Result-tekstiä, näet HTML-sivun, ja kun painat HTML-tekstiä, näet HTML-koodin. Klikkaamalla elementin oikeassa ylälaidassa olevasta Edit in JSFiddle-linkistä, pääset muokkaamaan elementtiä suoraan JSFiddlessä.

Yllä olevassa HTML-dokumentissa on dokumentin tyypin kertova erikoiselementti <!DOCTYPE html>, joka kertoo dokumentin olevan HTML-sivu. Tätä seuraa elementti <html>, joka aloittaa HTML-dokumentin. Elementti <html> sisältää yleensä kaksi elementtiä, elementit <head> ja <body>. Elementti <head> sisältää sivun otsaketiedot, eli esimerkiksi sivun käyttämän merkistön <meta charset="utf-8" /> ja otsikon <title>. Elementti <body> sisältää selaimessa näytettävän sivun rungon. Ylläolevalla sivulla on ensimmäisen tason otsake-elementti h1 (header 1) ja tekstielementti p (paragraph).

Elementit voivat sisältää tekstisolmun. Esimerkiksi yllä olevat elementit title, h1 ja p kukin sisältävät tekstisolmun eli tekstiä. Tekstisolmulle ei ole erillistä elementtiä tai määrettä, vaan se näkyy tekstinä käyttäjälle sivulla olevana tekstinä.

Puhe tekstisolmuista antaa viitettä jonkinlaisesta puurakenteesta. HTML-dokumentit ovat rakenteellisia dokumentteja, joiden rakenne on usein helppo ymmärtää puumaisena kaaviona. Ylläolevan web-sivun voi esittää esimerkiksi seuraavanlaisena puuna.

                   html

               /          \

             /              \

          head              body

        /       \         /      \

     meta       title     h1      p

                 :        :       :

              tekstiä  tekstiä tekstiä

Koska HTML-dokumentti on rakenteellinen dokumentti, on elementtien sulkemisjärjestyksellä väliä. Elementit tulee sulkea samassa järjestyksessä kuin ne on avattu. Esimerkiksi, järjestys <body><p>whoa, minttutee!</body></p> on väärä, kun taas järjestys <body><p>whoa, minttutee!</p></body> on oikea.

Kaikki elementit eivät kuitenkaan sisällä tekstisolmua, eikä niitä suljeta erikseen. Yksi näistä poikkeuksista on link-elementti.

Kun selaimet lataavat HTML-dokumenttia ja muodostavat sen perusteella muistissa säilytettävää puuta, ne käyvät sen läpi ylhäältä alas, vasemmalta oikealle. Kun selain kohtaa elementin, se luo sille uuden solmun. Seuraavista elementeistä luodut solmut menevät aiemmin luodun solmun alle kunnes aiemmin kohdattu elementti suljetaan. Aina kun elementti suljetaan, puussa palataan ylöspäin edelliselle tasolle.

Elementit, attribuutit, nimet ja luokat

Elementit voivat sisältää attribuutteja, joilla voi olla yksi tai useampi arvo. Edellä nähdyssä HTML-dokumentissa elementille meta on määritelty erillinen attribuutti charset, joka kertoo dokumentissa käytettävän merkistön: "utf-8". Vastaavasti tiedon syöttämiseen käytettävien lomakkeiden input ym. kentissä käyttämämme attribuutti name määrittelee nimen, jota käytetään palvelimelle lähetettävän kentän sisällön tunnistamisessa.

Muita yleisesti käytettäviä attribuuttityyppejä ovat id, joka määrittelee elementille uniikin tunnisteen sekä class, jonka avulla elementille voidaan määritellä tyyppiluokitus. Uudehkossa HTML5-määritelmässä elementit voivat sisältää myös data-attribuutteja, joiden toiminnallisuutta ei ole ennalta määritelty, ja joita käytetään tyypillisesti sovelluksen toiminnallisuuden takaamiseksi.

Kun elementtejä haetaan id-attribuutin perusteella, vastaukseksi pitäisi tulla tyypillisesti vain yksi elementti, mutta class-attribuutin perusteella hakuvastauksia voi olla useampi.

Javascript-kieli

Siinä missä HTML on kuvauskieli web-sivujen rakenteen ja sisällön luomiseen, JavaScript on kieli dynaamisen toiminnan lisäämiselle. JavaScript on ohjelmakoodia, jota suoritetaan komento kerrallaan -- ylhäältä alas, vasemmalta oikealle. JavaScript-koodi suoritetaan käyttäjän omassa selaimessa.

JavaScript-tiedoston pääte on yleensä .js ja siihen viitataan elementillä script. Elementillä script on attribuutti src, jolla kerrotaan lähdekooditiedoston sijainti. Kun lisäämme Javascript-koodia web-projektiimme, lisätään se tyypillisesti kansion src/main/resources/public/javascript/ alle. Kansiossa public olevat tiedostot siirtyvät suoraan näkyville web-maailmaan, joten niitä ei tarvitse käsitellä erikseen esimerkiksi Thymeleaf-moottorin toimesta.

Jos lähdekoodi on kansiossa javascript olevassa tiedostossa code.js, käytetään script-elementtiä seuraavasti: <script th:src="@{/javascript/code.js}"></script>.

Yleinen käytänne JavaScript-lähdekoodien sivulle lisäämiseen on lisätä ne sivun loppuun juuri ennen body-elementin sulkemista. Tämä johtuu mm. siitä, että selain lähtee hakemaan JavaScript-tiedostoa kun se kohtaa sen määrittelyn HTML-dokumentissa, jolloin kaikki muut toiminnot odottavat latausta. Jos lähdekooditiedosto ladataan vasta sivun lopussa, käyttäjälle näytetään sivun sisältöä jo ennen Javascript-lähdekoodin latautumista, sillä selaimet usein näyttävät sivua käyttäjälle sitä mukaa kun se latautuu. Tällä luodaan tunne nopeammin reagoivista ja latautuvista sivuista.

Luodaan kansioon javascript lähdekooditiedosto code.js. Tiedostossa code.js on funktio sayHello. Funktio luo ponnahdusikkunan, missä on teksti "hello there".

function sayHello() {
    alert("hello there");
}

HTML-dokumentti, jossa lähdekooditiedosto ladataan, näyttää seuraavalta. Attribuutille onclick määritellään elementin klikkauksen yhteydessä suoritettava koodi.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" >
        <title>Sivun otsikko (näkyy selaimen palkissa)</title>
    </head>
    <body>
        <header>
            <h1>Sivulla näkyvä otsikko</h1>
        </header>

        <article>
            <p>Sivuilla näytettävä normaali teksti on p-elementin sisällä. Alla on nappi,
            jota painamalla kutsutaan funktiota "sayHello".</p>
            <input type="button" value="Tervehdi" onclick="sayHello();" />
        </article>

        <!-- ladataan JavaScript-koodit tiedoston lopussa! -->
        <script th:src="@{javascript/code.js}"></script>

    </body>
</html>

Alla sama JSFiddlessä -- siellä kuitenkin code.js samassa kansiossa HTML-tiedoston kanssa:

Web-sivujen rakenteen muokkaaminen Javascriptin avulla

JavaScriptiä käytetään ennenkaikkea dynaamisen toiminnallisuuden lisäämiseksi web-sivuille. Esimerkiksi web-sivuilla oleviin elementteihin tulee pystyä asettamaan arvoja, ja niitä tulee myös pystyä hakemaan. JavaScriptissä pääsee käsiksi dokumentissa oleviin elementteihin komennolla document.getElementById("tunnus"), joka palauttaa elementin, jonka id-attribuutti on "tunnus". Muita attribuutti- ja elementtityyppejä pääsee käsittelemään esimerkiksi querySelector-metodin avulla.

Alla on tekstikenttä, jonka HTML-koodi on <input type="text" id="tekstikentta"/>. Kentän tunnus on siis tekstikentta. Jos haluamme päästä käsiksi elementtiin, jonka tunnus on "tekstikentta", käytämme komentoa document.getElementById("tekstikentta"). Tekstikenttäelementillä on attribuutti value, joka voidaan tulostaa.

Tekstikentälle voidaan asettaa arvo kuten muillekin muuttujille. Alla olevassa esimerkissä haetaan edellisen esimerkin tekstikenttä, ja asetetaan sille arvo 5.

Tehdään vielä ohjelma, joka kysyy käyttäjältä syötettä, ja asettaa sen yllä olevan tekstikentän arvoksi.

Arvon asettaminen osaksi tekstiä

Yllä tekstikentälle asetettiin arvo sen value-attribuuttiin. Kaikilla elementeillä ei ole value-attribuuttia, vaan joillain näytetään niiden elementin sisällä oleva arvo. Elementin sisälle asetetaan arvo muuttujaan liittyvällä attribuutilla innerHTML.

Alla olevassa esimerkissä sivulla on tekstielementti, jossa ei ole lainkaan sisältöä. Jos tekstielementtiin lisätään sisältöä, tulee se näkyville.

Vastaavasti tekstin keskelle -- sisäelementtiin -- voi asettaa arvoja. Elementti span sopii tähän hyvin.

Case: Laskin

Luodaan laskin. Laskimella on kaksi toiminnallisuutta: pluslasku ja kertolasku. Luodaan ensin laskimelle javascriptkoodi, joka on tiedostossa laskin.js. Javascript-koodissa oletetaan, että on olemassa input-tyyppiset elementit tunnuksilla "eka" ja "toka" sekä span-tyyppinen elementti tunnuksella "tulos". Funktiossa plus haetaan elementtien "eka" ja "toka" arvot, ja asetetaan pluslaskun summa elementin "tulos" arvoksi. Kertolaskussa tehdään lähes sama, mutta tulokseen asetetaan kertolaskun tulos. Koodissa on myös apufunktio, jota käytetään sekä arvojen hakemiseen annetuilla tunnuksilla merkityistä kentistä että näiden haettujen arvojen muuttamiseen numeroiksi.

function haeNumero(tunnus) {
    return parseInt(document.getElementById(tunnus).value);
}

function asetaTulos(tulos) {
    document.getElementById("tulos").innerHTML = tulos;
}

function plus() {
    asetaTulos(haeNumero("eka") + haeNumero("toka"));
}

function kerto() {
    asetaTulos(haeNumero("eka") * haeNumero("toka"));
}

Laskimen käyttämä HTML-dokumentti näyttää seuraavalta:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" >
        <title>Laskin</title>
    </head>
    <body>
        <header>
            <h1>Plus- ja Kertolaskin</h1>
        </header>

        <section>
            <p>
                <input type="text" id="eka" value="0" />
                <input type="text" id="toka" value="0" />
            </p>

            <p>
                <input type="button" value="+" onclick="plus();" />
                <input type="button" value="*" onclick="kerto();" />
            </p>


            <p>Laskimen antama vastaus: <span id="tulos"></span></p>
        </section>

        <script src="javascript/laskin.js"></script>
    </body>
</html>

Kokonaisuudessaan laskin näyttää seuraavalta:

 

Toteuta edellisen esimerkin perusteella laskin, jossa on plus-, miinus-, kerto- ja jakolaskutoiminnallisuus. Keskity vain selainpuolen toiminnallisuuteen: älä muokkaa palvelinpuolen toiminnallisuutta. Varmista myös, että sivu on käytettävä ilman erillistä ohjetekstiä, eli että käyttämäsi napit ja tekstit kertovat käyttäjälle kaiken oleellisen.

Tehtävään ei ole TMC:ssä testejä -- kun sovellus toimii oikein, lähetä se palvelimelle.

Elementtien valinta

Käytimme getElementById-kutsua tietyn elementin hakemiseen. Kaikki sivun elementit voi taas hakea esimerkiksi getElementsByTagName("*")-kutsulla. Molemmat ovat kuitenkin hieman kömpelöjä jos tiedämme mitä haluamme hakea.

W3C DOM-määrittely sisältää myös paremman ohjelmointirajapinnan elementtien läpikäyntiin. Selectors API sisältää mm. querySelector-kutsun, jolla saadaan CSS-valitsinten kaltainen kyselytoiminnallisuus.

Selector APIn tarjoamien querySelector (yksittäisen osuman haku) ja querySelectorAll (kaikkien osumien haku) -komentojen avulla kyselyn rajoittaminen vain header-elementissä oleviin a-elementteihin on helppoa.

var linkit = document.querySelectorAll("nav a");
// linkit-muuttuja sisältää nyt kaikki a-elementit, jotka ovat nav-elementin sisällä

Vastaavasti header-elementin sisällä olevat linkit voi hakea seuraavanlaisella kyselyllä.

var linkit = document.querySelectorAll("header a");
// linkit-muuttuja sisältää nyt kaikki a-elementit, jotka ovat header-elementin sisällä

Elementtien lisääminen

HTML-dokumenttiin lisätään uusia elementtejä document-olion createElement-metodilla. Esimerkiksi alla luodaan p-elementti (tekstisolmu; createTextNode), joka asetetaan muuttujaan tekstiElementti. Tämän jälkeen luodaan tekstisolmu, joka sisältää tekstin "o-hai". Lopulta tekstisolmun lisätään tekstielementtiin.

var tekstiElementti = document.createElement("p");
var tekstiSolmu = document.createTextNode("o-hai");

tekstiElementti.appendChild(tekstiSolmu);

Ylläoleva esimerkki ei luonnollisesti muuta HTML-dokumentin rakennetta sillä uutta elementtiä ei lisätä osaksi HTML-dokumenttia. Olemassaoleviin elementteihin voidaan lisätä sisältöä elementin appendChild-metodilla. Alla olevan tekstialue sisältää article-elementin, jonka tunnus on dom-esim-3. Voimme lisätä siihen elementtejä elementin appendChild-metodilla.

var tekstiElementti = document.createElement("p");
var tekstiSolmu = document.createTextNode("o-noes!");

tekstiElementti.appendChild(tekstiSolmu);

var alue = document.getElementById("dom-esim-3");
alue.appendChild(tekstiElementti);

Artikkelielementin sekä sen sisältämien tekstielementtien lisääminen onnistuu vastaavasti. Alla olevassa esimerkissä käytössämme on seuraavanlainen section-elementti.

<!-- .. dokumentin alkuosa .. -->
    <section id="osio"></section>
<!-- .. dokumentin loppuosa .. -->

Uusien artikkelien lisääminen onnistuu helposti aiemmin näkemällämme createElement-metodilla.

var artikkeli = document.createElement("article");

var teksti1 = document.createElement("p");
teksti1.appendChild(document.createTextNode("Lorem ipsum... 1"));
artikkeli.appendChild(teksti1);

var teksti2 = document.createElement("p");
teksti2.appendChild(document.createTextNode("Lorem ipsum... 2"));
artikkeli.appendChild(teksti2);

document.getElementById("osio").appendChild(artikkeli);

Alla olevassa esimerkissä elementtejä lisätään yksitellen. Mukana on myös laskuri, joka pitää kirjaa elementtien lukumäärästä.

jQuery

jQuery on JavaScript-kirjasto, jonka tavoitteena on helpottaa selainohjelmistojen toteutusta. Se tarjoaa apuvälineitä mm. DOM-puun muokkaamiseen, tapahtumien käsittelyyn sekä palvelimelle tehtävien kyselyiden toteuttamiseen, ja sen avulla toteutettu toiminnallisuus toimii myös useimmissa selaimissa.

Uusimman jQuery-version saa ladattua täältä. Käytännössä jQuery on JavaScript-tiedosto, joka ladataan sivun latautuessa. Tiedoston voi asettaa esimerkiksi head-elementin sisään, tai ennen omia lähdekooditiedostoja.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>Selaimen palkissa ja suosikeissa näkyvä otsikko</title>
     </head>
    <body>

        <!-- sivun sisältö -->

        <script src="https://code.jquery.com/jquery-3.1.0.min.js"></script>
        <script src="javascript/koodi.js"></script>
    </body>
</html>

Valitsimet

Käytimme edellisissä osioissa valmiita JavaScriptin DOM-toiminnallisuuksia. Elementtien etsimiseen on käytetty mm. getElementById-kutsua. JQuery käyttää Sizzle-kirjastoa elementtien valinnan helpottamiseen. Esimerkiksi elementti, jonka attribuutin "id" arvo on "nimi", löytyy seuraavalla komennolla.

var elementti = $("#nimi");

Kyselyt ovat muotoa$("kysely"). Jos elementtia haetaan id-attribuutin perusteella, lisätään kyselyn alkuun risuaita. Jos elementtiä haetaan luokan (class) perusteella, lisätään kyselyn alkuun piste. Jos taas elementtiä halutaan hakea esimerkiksi nimen perusteella, muodostetaan kysely sekä elementin että attribuutin kautta, esim. $("input[name=nimi]") palauttaa kaikki input-tyyppiset elementit, joissa name-attribuutin arvo on nimi.

Elementtien lisääminen

JQuery tekee elementtien lisäämisestä hieman suoraviivaisempaa. Voimme kutsun document.createElement sijaan määritellä elementin tyypin sanomalla $("<article />");. Myös tekstielementin luominen on hieman helpompaa: $("<p/>").text("test");. Aiempi koodimme:

var artikkeli = document.createElement("article");

var teksti1 = document.createElement("p");
teksti1.appendChild(document.createTextNode("Lorem ipsum... 1"));
artikkeli.appendChild(teksti1);

var teksti2 = document.createElement("p");
teksti2.appendChild(document.createTextNode("Lorem ipsum... 2"));
artikkeli.appendChild(teksti2);

document.getElementById("osio").appendChild(artikkeli);

Voidaan kirjoittaa myös hieman suoraviivaisemmin:

var artikkeli = $("<article/>");

var teksti1 = $("<p/>");
teksti1.text("Lorem ipsum... 1");
artikkeli.append(teksti1);

var teksti2 = $("<p/>");
teksti2.text("Lorem ipsum... 2");
artikkeli.append(teksti2);

$("#osio").append(artikkeli);

Tapahtumien käsittely

JQuery rakentaa JavaScriptin valmiiden komponenttien päälle, joten sillä on toiminnallisuus myös tapahtumankäsittelijöiden rekisteröimiseen sivun komponenteille. Eräs hyvin hyödyllinen tapahtumankäsittelijä liittyy sivun latautumiseen: komennolla $(document).ready(function() {}); voidaan määritellä funktion runko, joka suoritetaan kun sivun latautuminen on valmis.

Kun sivun latautuminen on valmis, voimme olla varmoja siitä, että sivulla on kaikki siihen kuuluvat elementit. Tällöin on näppärää tehdä myös kyselyjä palvelimelle. Jos haluaisimme että id-attribuutin arvolla "osio" määriteltyyn elementtiin lisättäisiin kaksi tekstielementtiä sisältävä artikkelielementti kun sivu on latautunut, olisi tarvittava Javascript-koodi seuraavanlainen:

$(document).ready(function() {
  var artikkeli = $("<article/>");

  var teksti1 = $("<p/>");
  teksti1.text("Lorem ipsum... 1");
  artikkeli.append(teksti1);

  var teksti2 = $("<p/>");
  teksti2.text("Lorem ipsum... 2");
  artikkeli.append(teksti2);

  $("#osio").append(artikkeli);
});

Kyselyt palvelimelle

JQuery tarjoaa myös tuen kyselyjen tekemiseen erilliselle palvelinkomponentille.

Kyselyt hoituvat kätevästi JQueryn $.getJSON-funktiolla. Alla olevassa esimerkissä haemme ICNDb.comista oleellista dataa.

Kyselyn palauttama data ohjataan $.getJSON-funktion toisena parametrina määriteltävään funktioon. Alla olevassa esimerkissä kutsumme vain alert-komentoa kaikelle palautettavalle datalle.

$.getJSON("http://api.icndb.com/jokes/random/5",
    function(data) {
        alert(data);
    }
);

Ylläoleva esimerkki tulostaa vastaukset konsoliin -- huomaa, että jQuery muuntaa merkkijonomuotoiset vastaukset automaattisesti JSON-olioksi. Käytetään JQueryn each-komentoa listassa olevien elementtien iterointiin. Komennolle each voi antaa parametrina iteroitavan listan, sekä funktion, jota kutsutaan jokaisella listassa olevalla oliolla.

$.getJSON("http://api.icndb.com/jokes/random/5",
    function(data) {
        $.each(data.value, function(i, item) {
            alert(i);
            alert(item);
            alert("-----");
        });
    }
);

Nyt ylläoleva komento tulostaa vastauksen value-kentässä olevat oliot yksitellen. Oletetaan, että käytössämme on elementti, jonka tunnus on "vitsit". JQuery tarjoaa myös mahdollisuuden nopeaan tekstielementtien luontiin komennolla $("<p/>"). Elementteihin voi asettaa tekstin text-komennolla, ja elementin voi lisätä tietyllä tunnuksella määriteltyyn elementtiin komennolla appendTo("#tunnus").

$.getJSON("http://api.icndb.com/jokes/random/5",
    function(data) {
        $.each(data.value, function(i, item) {
            $("<p/>").text(item.joke).appendTo("#vitsit");
        });
    }
);

Tiedon lähettäminen palvelimelle

Jos tiedämme, että palvelu palauttaa JSON-dataa, voimme käyttää yllä käsiteltyä lähestymistapaa. Esimerkiksi viestien noutaminen Chat-chat -tehtävän viestipalvelimelta onnistuu seuraavalla komennolla. Tässä tapauksessa lisäämme jokaiseen viestiin liittyvän message-attribuutin "vitsit"-tunnuksella määriteltyyn elementtiin. Osoitteessa http://bad.herokuapp.com/app/messages on valmiina viestejä tarjoava sovellus.

$.getJSON("http://bad.herokuapp.com/app/messages", function(data) {
    $.each(data, function(i, item) {
        $("<p/>").text(item.message).appendTo("#vitsit");
    });
});

Yllä oleva komento on lyhenne alla määritellystä komennosta.

$.ajax({
    url: "http://bad.herokuapp.com/app/messages",
    dataType: 'json',
    success: parseMessages
});

function parseMessages(messages) {
    $.each(messages, function(i, item) {
        $("<p/>").text(item.message).appendTo("#vitsit");
    });
}

Komennolle $.ajax voi lisätä myös dataa, mitä lähetetään palvelimelle. Esimerkiksi seuraavalla komennolla lähetetään osoitteeseen http://bad.herokuapp.com/app/in olio, jonka sisällä on attribuutit name ja details. Lähetettävän datan tyyppi asetetaan attribuutilla contentType, alla ilmoitamme että data on json-muotoista, ja että se käyttää utf-8 -merkistöä.

var dataToSend = JSON.stringify({
        name: "bob",
        details: "i'm ted"
    });

$.ajax({
    url: "http://bad.herokuapp.com/app/in",
    dataType: 'json',
    contentType:'application/json; charset=utf-8',
    type: 'post',
    data: dataToSend
});

Pyynnössä voi sekä lähettää että vastaanottaa dataa. Attribuutin success asettaminen ylläolevaan pyyntöön aiheuttaa success-attribuutin arvona olevan funktion kutsun kun pyyntö on onnistunut.

Tehtävään on hahmoteltu tehtävien hallintaan tarkoitetun sovelluksen palvelinpuolen toiminnallisuutta. Lisää sovellukseen selainpuolen toiminnallisuus, joka mahdollistaa tehtävien lisäämisen sivulle Javascriptin avulla. Uusien tehtävien lisäämisen ei siis pidä aiheuttaa sivun uudelleenlatausta, vaan uusi tehtävä tulee lähettää palvelimelle Javascript-pyyntönä.

Kun saat sovelluksen toimimaan, mieti myös sen käytettävyyttä. Sovellukselle ei ole automaattisia testejä.

Rajoitettu pääsy resursseihin

Web-sivun julkisesti näkyvillä oleviin tietoihin kuten kuviin ja videoihin pääsee käsiksi lähes mistä tahansa palvelusta: esimerkiksi sivun lähdekoodissa oleva jQuery-kirjasto voi olla sovelluksesta erillisellä palvelimella. Tämä on hyödyllistä, sillä tällöin resursseja voidaan hajauttaa ympäri verkkoa ja yksittäisen palvelimen ei tarvitse lähettää kaikkea tietoa sivuston käyttäjälle.

Javascript-lähdekoodissa tehdyt pyynnöt ovat kuitenkin oletuksena rajoitettuja. Jos palvelimelle ei määritellä erillistä CORS-tukea, eivät selaimen sovelluksen osoitteen ulkopuolelta tekemät Javascript-pyynnöt sovellukseen onnistu.

Yksinkertaisimmillaan CORS-tuen saa lisättyä kontrollerimetodille annotaation @CrossOrigin-avulla, jolle määritellään osoitteet, joissa sijaitsevista osoitteista pyyntöjä saa tehdä.

    @CrossOrigin(origins = "/**")
    @RequestMapping(method=RequestMethod.GET)
    @ResponseBody
    public Book getBook() {
        Book book = new Book();
        book.setName("Spring API");
        return book;
    }

Koko sovelluksen tasolla vastaavan määrittelyn voi tehdä erillisen konfiguraatiotiedoston avulla.

@Configuration
@EnableWebMvc
public class WebConfig extends WebMvcConfigurerAdapter {

	@Override
	public void addCorsMappings(CorsRegistry registry) {
		registry.addMapping("/**");
	}
}

Nyt sovellukseen voi tehdä Javascript-pyynnön missä tahansa sijaitsevasta sovelluksesta.

Web-sovellusten tietoturva

Tutustutaan tässä web-sovellusten tietoturvaan liittyviin teemoihin. Aloita katsomalla Juhani Erosen ARTTech-seminaari aiheesta Your privacy is protected by .. what exactly?.

Suojattu yhteys

Kommunikointi selaimen ja palvelimen välillä halutaan salata käytännössä aina. HTTPS on käytännössä HTTP-pyyntöjen tekemistä SSL (tai TLS)-salauksella höystettynä. HTTPS mahdollistaa sekä käytetyn palvelun verifioinnin sertifikaattien avulla että lähetetyn ja vastaanotetun tiedon salauksen.

HTTPS-pyynnöissä asiakas ja palvelin sopivat käytettävästä salausmekanismista ennen varsinaista kommunikaatiota. Käytännössä selain ottaa ensiksi yhteyden palvelimen HTTPS-pyyntöjä kuuntelevaan porttiin (yleensä 443), lähettäen palvelimelle listan selaimella käytössä olevista salausmekanismeista. Palvelin valitsee näistä parhaiten sille sopivan (käytännössä vahvimman) salausmekanismin, ja lähettää takaisin salaustunnisteen (palvelimen nimi, sertifikaatti, julkinen salausavain). Selain ottaa mahdollisesti yhteyttä sertifikaatin tarjoajaan -- joka on kolmas osapuoli -- ja tarkistaa onko sertifikaatti kunnossa.

Selain lähettää tämän jälkeen palvelimelle salauksessa käytettävän satunnaisluvun palvelimen lähettämällä salausavaimella salattuna. Palvelin purkaa viestin ja saa haltuunsa selaimen haluaman satunnaisluvun. Viesti voidaan nyt lähettää salattuna satunnaislukua ja julkista salausavainta käyttäen.

Käytännössä kaikki web-palvelimet tarjoavat HTTPS-toiminnallisuuden valmiina, joskin se täytyy ottaa palvelimilla käyttöön. Esimerkiksi Herokussa HTTPS on oletuksena käytössä sovelluksissa -- aiemmin mahdollisesti tekemääsi sovellukseen pääsee käsiksi siis myös osoitteen https://sovelluksen-nimi.herokuapp.com kautta. Tämä ei kuitenkaan estä käyttäjiä tekemästä pyyntöjä sovellukselle ilman HTTPS-yhteyttä -- jos haluat, että käyttäjien tulee tehdä kaikki pyynnöt HTTPS-yhteyden yli, lisää tuotantokonfiguraatioon seuraava rivi.

security.require-ssl=true

HSTS

HTTPS-yhteyden pakottaminen ei aina riitä. Jos ilkeämielinen henkilö pääsee käsiksi ensimmäiseen HTTP-pyyntöön, joka ohjaisi käyttäjän tekemään HTTPS-pyyntöjä palvelimelle, ovat HTTPS-pyynnötkin riskialttiita. HSTS-politiikka, missä sovellus kertoo käyttäjälle vaativansa aina HTTPS-protokollan käytön on eräs ratkaisu tähän. Tällöin selaimen muistiin jää tieto siitä, että selaimen tulee käyttää HTTPS-protokollaa sovelluksessa -- jos sovellus yrittää tarjota jotain muuta, tietää selain, että jotain on pielessä. Tästä lisää https://tools.ietf.org/html/rfc6797.

Syötteiden validointi

Lomakkeiden ja lähetettävän datan validointi, eli oikeellisuuden tarkistaminen, on tärkeää. Ensimmäinen askel -- jonka olemme jo ottaneet -- on tallennettavan datan esittäminen ohjelmaan liittyvien käsitteiden kautta. Olemme käyttäneet datan tallentamisessa olioita, joihin on määritelty sopivat kenttien tyypit. Tämä helpottaa työtämme jo hieman: esimerkiksi numerokenttiin ei saa asetettua merkkijonoja. Käyttämämme Spring Bootin mukana tulee Hibernate-projektin komponentti, joka tarjoaa validointitoiminnallisuuden.

Validaatiosääntöjen määrittely tapahtuu annotaatioilla. Muokataan alla määriteltyä luokkaa Person siten, että henkilöllä tulee olla henkilötunnus, nimi ja sähköpostiosoite.

// pakkaus jne
public class Person {

    private String socialSecurityNumber;
    private String name;
    private String email;

    // getterit ja setterit
}

Sovitaan että henkilötunnus ei saa koskaan olla tyhjä ja sen tulee olla tasan 11 merkkiä pitkä. Nimen tulee olla vähintään 5 merkkiä pitkä, ja korkeintaan 30 merkkiä pitkä, ja sähköpostiosoitteen tulee olla validi sähköpostiosoite. Annotaatio @NotBlank varmistaa ettei annotoitu attribuutti ole tyhjä -- lisätään se kaikkiin kenttiin. Annotaatiolla @Length voidaan määritellä pituusrajoitteita muuttujalle, ja annotaatiolla @Email varmistetaan, että attribuutin arvo on varmasti sähköpostiosoite.

// pakkaus

import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;

public class Person {

    @NotBlank
    @Length(min = 11, max = 11)
    private String socialSecurityNumber;

    @NotBlank
    @Length(min = 5, max = 30)
    private String name;

    @NotBlank
    @Email
    private String email;

    // getterit ja setterit

Olion validoinnin päälle kytkeminen tapahtuu kontrollerissa

Kontrollerimetodit validoivat olion jos kontrollerimetodissa olevalle @ModelAttribute-annotaatiolla merkatulle oliolle on asetettu myös annotaatio @Valid (javax.validation.Valid).

    @RequestMapping(method = RequestMethod.POST)
    public String create(@Valid @ModelAttribute Person person) {
        // .. esimerkiksi tallennus ja uudelleenohjaus
    }

Spring validoi olion pyynnön vastaanottamisen yhteydessä, mutta validointivirheet eivät ole kovin kaunista luettavaa. Yllä olevalla kontrollerimetodilla virheellisen nimen kohdalla saamme hieman kaoottisen ilmoituksen.

Whitelabel Error Page

This application has no explicit mapping for /error, so you are seeing this as a fallback.

aika
There was an unexpected error (type=Bad Request, status=400).
Validation failed for object='person'. Error count: 1

Virheelle täytyy selvästi tehdä jotain..

Validointivirheiden käsittely

Validointivirheet aiheuttavat poikkeuksen, joka näkyy ylläolevana virheviestinä, jos niitä ei erikseen käsitellä. Validointivirheiden käsittely tapahtuu luokan BindingResult avulla, joka toimii validointivirheiden tallennuspaikkana. Luokan BindingResult kautta voimme käsitellä virheitä. BindingResult-olio kuvaa aina yksittäisen olion luomisen ja validoinnin onnistumista, ja se tulee asettaa heti validoitavan olion jälkeen. Seuraavassa esimerkki kontrollerista, jossa validoinnin tulos lisätään automaattisesti BindingResult-olioon.

    @RequestMapping(method = RequestMethod.POST)
    public String create(@Valid @ModelAttribute Person person, BindingResult bindingResult) {
        if(bindingResult.hasErrors()) {
            // validoinnissa virheitä: virheiden käsittely
        }

        // muu toteutus
    }

Ylläolevassa esimerkissä kaikki validointivirheet tallennetaan BindingResult-olioon. Oliolla on metodi hasErrors, jonka perusteella päätämme jatketaanko pyynnön prosessointia vai ei. Yleinen muoto lomakedataa tallentaville kontrollereille on seuraavanlainen:

    @RequestMapping(method = RequestMethod.POST)
    public String create(@Valid @ModelAttribute Person person, BindingResult bindingResult) {
        if(bindingResult.hasErrors()) {
            return "lomakesivu";
        }

        // .. esimerkiksi tallennus

        return "redirect:/index";
    }

Yllä oletetaan että lomake lähetettiin näkymästä "lomakesivu": käytännössä validoinnin epäonnistuminen johtaa nyt siihen, että pyyntö ohjataan takaisin lomakesivulle.

Thymeleaf-lomakkeet ja BindingResult

Lomakkeiden validointivirheet saadaan käyttäjän näkyville Thymeleafin avulla. Lomakkeet määritellään kuten normaalit HTML-lomakkeet, mutta niihin lisätään muutama apuväline. Lomakkeen attribuutti th:object kertoo olion, johon lomakkeen kentät tulee pyrkiä liittämään (huom! tämän tulee olla määriteltynä myös lomakkeen palauttavassa kontrollerimetodissa -- palaamme tähän kohta). Sitä käytetään yhdessä kontrolleriluokan ModelAttribute-annotaation kanssa. Lomakkeen kentät määritellään attribuutin th:field avulla, jossa oleva *{arvo} liitetään lomakkeeseen liittyvään olioon. Oleellisin virheviestin näkymisen kannalta on kuitenkin attribuuttiyhdistelmä th:if="${#fields.hasErrors('arvo')}" th:errors="*{arvo}", joka näyttää virheviestin jos sellainen on olemassa.

Luodaan lomake aiemmin nähdyn Person-olion luomiseen.

<form action="#" th:action="@{/persons}" th:object="${person}" method="POST">
    <table>
        <tr>
            <td>SSN: </td>
            <td><input type="text" th:field="*{socialSecurityNumber}" /></td>
            <td th:if="${#fields.hasErrors('socialSecurityNumber')}" th:errors="*{socialSecurityNumber}">SSN Virheviesti</td>
        </tr>
        <tr>
            <td>Name: </td>
            <td><input type="text" th:field="*{name}" /></td>
            <td th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name Virheviesti</td>
        </tr>
        <tr>
            <td>Email: </td>
            <td><input type="text" th:field="*{email}" /></td>
            <td th:if="${#fields.hasErrors('email')}" th:errors="*{email}">Email Virheviesti</td>
        </tr>
        <tr>
            <td><button type="submit">Submit</button></td>
        </tr>
    </table>
</form>

Yllä oleva lomake lähettää lomakkeen tiedot osoitteessa <sovellus>/persons olevalle kontrollerimetodille. Lomakkeelle tullessa tarvitsemme erillisen tiedon käytössä olevasta oliosta. Alla on näytetty sekä kontrollerimetodi, joka ohjaa GET-pyynnöt lomakkeeseen, että kontrollerimetodi, joka käsittelee POST-tyyppiset pyynnöt. Huomaa erityisesti @ModelAttribute-annotaatio kummassakin metodissa. Metodissa view olion nimi on person, joka vastaa lomakkeessa olevaa th:object-attribuuttia. Tämän avulla lomake tietää, mitä oliota käsitellään.

    @RequestMapping(method = RequestMethod.GET)
    public String view(@ModelAttribute Person person) {
        return "lomake";
    }

    @RequestMapping(method = RequestMethod.POST)
    public String create(@Valid @ModelAttribute Person person, BindingResult bindingResult) {
        if(bindingResult.hasErrors()) {
            return "lomake";
        }

        // .. tallennus ja uudelleenohjaus
    }

Jos lomakkeella lähetetyissä kentissä on virheitä, virheet tallentuvat BindingResult-olioon. Tarkistamme kontrollerimetodissa create ensin virheiden olemassaolon -- jos virheitä on, palataan takaisin lomakkeeseen. Tällöin validointivirheet tuodaan lomakkeen käyttöön BindingResult-oliosta, jonka lomakkeen kentät täytetään @ModelAttribute-annotaatiolla merkitystä oliosta. Huomaa että virheet ovat pyyntökohtaisia, ja uudelleenohjauspyyntö kadottaa virheet.

Huom! Springin lomakkeita käytettäessä lomakesivut haluavat käyttöönsä olion, johon data kytketään jo sivua ladattaessa. Yllä lisäsimme pyyntöön Person-olion seuraavasti:

    @RequestMapping(method = RequestMethod.GET)
    public String view(@ModelAttribute Person person) {
        return "lomake";
    }

Toinen vaihtoehto on luoda kontrolleriluokkaan erillinen metodi, jonka sisältämä arvo lisätään automaattisesti pyyntöön. Tällöin lomakkeen näyttävä kontrollerimetodi ei tarvitse erikseen ModelAttribute-parametria. Tällöin toteutus olisi esimerkiksi seuraavanlainen:

    @ModelAttribute
    private Person getPerson() {
        return new Person();
    }
        
    @RequestMapping(method = RequestMethod.GET)
    public String view() {
        return "lomake";
    }

    @RequestMapping(method = RequestMethod.POST)
    public String create(@Valid @ModelAttribute Person person, BindingResult bindingResult) {
        if(bindingResult.hasErrors()) {
            return "lomake";
        }

        // .. tallennus ja uudelleenohjaus
    }

Thymeleafin avulla tehdyistä lomakkeista ja niiden yhteistyöstä Springin kanssa löytyy lisää osoitteesta http://www.thymeleaf.org/doc/tutorials/2.1/thymeleafspring.html#creating-a-form.

Validointi ja entiteetit

Vaikka edellisessä esimerkissä käyttämäämme Person-luokkaa ei oltu merkitty @Entity-annotaatiolla -- eli se ei ollut tallennettavissa JPAn avulla tietokantaan -- mikään ei estä meitä lisäämästä sille @Entity-annotaatiota. Toisaalta, lomakkeet voivat usein sisältää tietoa, joka liittyy useaan eri talletettavaan olioon. Tällöin voi luoda erillisen lomakkeen tietoihin liittyvän lomakeolio, jonka pohjalta luodaan tietokantaan tallennettavat oliot kunhan validointi onnistuu. Erilliseen lomakeobjektiin voi täyttää myös kannasta haettavia listoja ym. ennalta.

Kun validointisäännöt määritellään entiteetille, tapahtuu validointi kontrollerin lisäksi myös tietokantatallennusten yhteydessä.

Tehtävän mukana tulee sovellus, jota käytetään ilmoittatumiseen. Tällä hetkellä käyttäjä voi ilmoittautua juhliin oikeastaan minkälaisilla tiedoilla tahansa. Tehtävänäsi on toteuttaa parametreille seuraavanlainen validointi:

  1. Nimen (name) tulee olla vähintään 4 merkkiä pitkä ja enintään 30 merkkiä pitkä.
  2. Osoitteen (address) tulee olla vähintään 4 merkkiä pitkä ja enintään 50 merkkiä pitkä.
  3. Sähköpostiosoitteen (email) tulee olla validi sähköpostiosoite.

Tehtäväpohjan mukana tuleviin sivuihin on toteutettu valmiiksi lomake. Tehtävänäsi on toteuttaa validointitoiminnallisuus pakkauksessa wad.domain olevaan luokkaan Registration.

Jos yksikin tarkastuksista epäonnistuu, tulee käyttäjälle näyttää rekisteröitymislomake uudelleen. Muista lisätä kontrolleriin validoitavalle parametrille annotaatio @Valid. Virheviestien ei tule näkyä vastauksessa jos lomakkeessa ei ole virhettä. Käyttöliittymä on tehtävässä valmiina.

Käyttäjätunnukset ja pääsynvalvonta

Tutustuimme aiemmin käyttäjän tunnistamiseen eli autentikointiin. Autentikoinnin lisäksi sovelluksissa on tärkeää varmistaa, että käyttäjä saa tehdä asioita, joita hän yrittää tehdä: autorisointi. Jos käyttäjän tunnistaminen toimii mutta sovellus ei tarkista oikeuksia tarkemmin, on mahdollista päätyä esimerkiksi tilanteeseen, missä käyttäjä pääsee tekemään epätoivottuja asioita.

Muutama sana salasanoista

Salasanoja ei tule tallentaa selväkielisenä tietokantaan. Salasanoja ei tule -- myöskään -- tallentaa salattuna tietokantaan ilman, että niihin on lisätty erillinen "suola", eli satunnainen merkkijono, joka tekee salasanasta hieman vaikeammin tunnistettavan.

Vuonna 2010 tehty tutkimus vihjasi, että noin 75% ihmisistä käyttää samaa salasanaa sähköpostissa ja sosiaalisen median palveluissa. Jos käyttäjän sosiaalisen median salasana vuodetaan selkokielisenä, on siis mahdollista, että samalla myös hänen salasana esimerkiksi Facebookiin tai Google Driveen on päätynyt julkiseksi tiedoksi. Jos ilman "suolausta" salattu salasana vuodetaan, voi se mahdollisesti löytyä verkossa olevista valmiista salasanalistoista, mitkä sisältävät salasana-salaus -pareja. Jostain syystä salasanat ovat myös usein ennustettavissa.

Suolan lisääminen salasanaan ei auta tilanteissa, missä salasanat ovat ennustettavissa, koska salasanojen koneellinen läpikäynti on melko nopeaa. Salausmenetelmänä kannattaakin käyttää sekä salasanan suolausta, että algoritmia, joka on hidas laskea. Eräs tällainen on jo valmiiksi Springin kautta käyttämämme BCrypt-algoritmi.

https://xkcd.com/936/ -- xkcd: Password strength.

Näkymätason autorisointi

Määrittelimme aiemmin oikeuksia sovelluksen polkuihin liittyen. Tämä ei kuitenkaan aina riitä, vaan käyttöliitymissä halutaan usein rajoittaa toiminta esimerkiksi käyttäjäroolien perusteella. Thymeleaf-projektiin löytyy liitännäinen, jonka avulla voimme lisätä tarkistuksia HTML-sivuille. Liitännäisen saa käyttöön lisäämällä seuraavan riippuvuuden pom.xml-tiedostoon.

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity4</artifactId>
</dependency>   
            

Kun näkymiien html-elementtiin lisätään sec:-nimiavaruuden määrittely, voidaan sivulle määritellä elementtejä, joiden sisältö näytetään vain esimerkiksi tietyllä roolilla kirjautuneelle käyttäjälle. Seuraavassa esimerkissä teksti "salaisuus" näkyy vain käyttäjälle, jolla on rooli "ADMIN".

<html xmlns="http://www.w3.org/1999/xhtml" 
    xmlns:th="http://www.thymeleaf.org" 
    xmlns:sec="http://www.springframework.org/security/tags">

...
<div sec:authorize="hasAuthority('ADMIN')">
    <p>salaisuus</p>
</div>
...
            

Attribuutilla sec:authorize määritellään säännöt, joita tarkistuksessa käytetään. Attribuutille käy mm. arvot isAuthenticated(), hasAuthority('...') ja hasAnyAuthority('...'). Lisää sääntöjä löytyy Spring Securityn dokumentaatiosta.

Metoditason autorisointi

Pelkän näkymätason autorisoinnin ongelmana on se, että usein toimintaa halutaan rajoittaa tarkemmin -- esimerkiksi siten, että tietyt operaatiot (esim. poisto tai lisäys) mahdollistetaan vain tietyille käyttäjille tai käyttäjien oikeuksille. Käyttöliittymän näkymää rajoittamalla ei voida rajoittaa kutsuja polkuihin, ja aiemmin luotu polkuihin tehtävien kutsujen rajoitus ei auta esimerkiksi REST-tyyppisissä osoitteissa, varsinkin jos GET-pyyntöihin halutaan oikeus kaikille.

Saamme sovellukseemme käyttöön myös metoditason autorisoinnin. Lisäämällä tietoturvakonfiguraatiotiedostoon luokkatason annotaation @EnableGlobalMethodSecurity(securedEnabled = true, proxyTargetClass = true), Spring Security etsii metodeja, joissa käytetään sopivia annotaatioita ja suojaa ne. Suojaus tapahtuu käytännössä siten, että metodeihin luodaan proxy-metodit; aina kun metodia kutsutaan, kutsutaan ensin tietoturvakomponenttia, joka tarkistaa onko käyttäjä kirjautunut.

Kun konfiguraatiotiedostoon on lisätty annotaatio, on käytössämme muunmuassa annotaatio @Secured. Alla olevassa esimerkissä post-metodin käyttöön vaaditaan "ADMIN"-oikeudet.

    @Secured("ADMIN")
    @RequestMapping(method = RequestMethod.POST)
    public String post() {
        // ..
        return "redirect:/posts";
    }
            

Tehtävässä on hahmoteltu viestien näyttämiseen tarkoitettua sovellusta.

Luo sovellukseen tietoturvakonfiguraatio, missä määritellään kaksi käyttäjää. Ensimmäisellä käyttäjällä "user", jonka salasana on "password" on "USER"-oikeus. Toisella käyttäjällä "postman", jonka salasana on "pat", on "POSTER"-oikeus.

Muokkaa näkymää messages.html siten, että vain käyttäjät, joilla on "POSTER"-oikeus näkee lomakkeen, jolla voi lisätä uusia viestejä.

Muokkaa lisäksi konfiguraatiota siten, että käyttäjä voi kirjautua ulos osoitteesta /logout. Voit käyttää seuraavaa koodia (joutunet lisäämään konfiguraatioon muutakin..).

http.formLogin()
    .permitAll()
    .and()
    .logout()
    .logoutUrl("/logout")
    .logoutSuccessUrl("/login");

Lisää tämän jälkeen sovellukseen metoditason suojaus millä rajoitat POST-pyyntöjen tekemisen osoitteeseen /message vain käyttäjille, joilla on "POSTER"-oikeus. Vaikka testit päästäisivät sinut läpi jo ennen tämän toteutusta, tee se silti.

Käyttäjän identiteetin varmistaminen vaatii käyttäjälistan, joka taas yleensä ottaen tarkoittaa käyttäjän rekisteröintiä jonkinlaiseen palveluun. Käyttäjän rekisteröitymisen vaatiminen heti sovellusta käynnistettäessä voi rajoittaa käyttäjien määrää huomattavasti, joten rekisteröitymistä kannattaa pyytää vasta kun siihen on tarve.

Erillinen rekisteröityminen ja uuden salasanan keksiminen ei ole aina tarpeen. Web-sovelluksille on käytössä useita kolmannen osapuolen tarjoamia keskitettyjä identiteetinhallintapalveluita. Esimerkiksi OAuth2:n avulla sovelluskehittäjä voi antaa käyttäjilleen mahdollisuuden käyttää jo olemassaolevia tunnuksia. Myös erilaiset sosiaalisen median palveluihin perustuvat autentikointimekanismit ovat yleistyneet viime aikoina.

Osoitteessa http://authebin.herokuapp.com/ on rajapinta tunnistautumista varten. Rajapinta olettaa, että sille lähetetään JSON-tyyppinen käyttäjätunnuksen ja salasanan sisältävä pyyntö. Olion muoto on seuraavanlainen:

{"username":"tunnus","password":"salasana"}

Tutustu tehtäväpohjassa olevaan luokkaan CustomAuthenticationProvider sekä sen käyttöön SecurityConfiguration-luokassa. Toteuta toiminnallisuus, minkä avulla käyttäjä voi kirjautua osoitteessa http://authebin.herokuapp.com olevan palvelun avulla. Tässä tehtävässä lienee hyötyä sekä RestTemplate-luokasta että HttpEntity-oliosta.

Tehtävässä ei ole testejä. Palauttamalla tehtävän, olet testannut että se toimii toivotusti ja tunnistautuminen kolmannen osapuolen rajapinnan kautta onnistuu.

Tietokannan käyttö ja muut kolmannen osapuolen palvelut

Kolmannen osapuolen palveluissa kuten tietokannoissa ja muissa sovelluksissa tulee kiinnittää huomiota yhteyteen sekä tietokannan salasanaan ja konfiguraatioon. Yhteyden tietokantaan kannattaa olla salattu, ja tietokantaa käyttävien käyttäjien oikeudet rajattu niin, että heillä on oikeudet vain tarvitsemiinsa operaatioihin.

Esimerkiksi tietokantaan voidaan määritellä yksi käyttäjä, jolla on vain lukuoikeudet tiettyihin tauluihin, ja toinen käyttäjä, joka voi myös kirjoittaa näihin tauluihin. Kaikkia oikeuksia ei kannata antaa kaikille käyttäjille, sillä sovellukseen voi eksyä SQL-injektiomahdollisuus. Tämä tarkoittaa tilannetta, missä sovelluksen käyttäjä pääsee syöttämään tietoa tietokantaan tehtäviin kyselyihin, mikä mahdollistaa esimerkiksi tietokannan tuhoamisen.

School: Hi, this is your son's school. We're having some computer trouble.

Mom: Oh, dear -- Did he break something?

School: In a way. Did you really name your son Robert'); DROP TABLE Students;--?

Mom: Oh. Yes. Little Bobby Tables we call him.

School: Well, we've lost this year's student records. I hope you're happy.

Mom: And I hope you've learned to sanitize your database inputs.
http://xkcd.com/327/ -- Exploits of a Mom.

 

Spring Data JPA:ta sekä parameterisoituja kyselyitä käytettäessä tämä on onneksi melko vaikeaa.

Tietokannanhallintajärjestelmän sisältävään koneeseen tulee muutenkin kiinnittää huomiota. Hyvin toteutettu sovellus, oikein konfiguroitu tietokanta sekä turvallinen yhteys ei auta, jos tietokannanhallintajärjestelmän sisältävässä koneessa on esimerkiksi vanhentunut käyttöjärjestelmä tai toinen sovellus, joiden kautta koneelle pystyy murtautumaan.

Tyypillisimpiä tietoturvauhkia

OWASP (Open Web Application Security Project) on verkkosovellusten tietoturvaan keskittynyt kansainvälinen järjestö, jonka tavoitteena on tiedottaa tietoturvariskeistä ja sitä kautta edesauttaa turvallisten web-sovellusten kehitystä. OWASP-yhteisö pitää myös yllä listaa merkittävimmistä web-tietoturvariskeistä. Vuoden 2013 lista on seuraava:

  1. Injection -- sovellukseen jääneet aukot, jotka mahdollistavat esimerkiksi SQL-injektioiden tekemisen.

  2. Broken Authentication and Session Management -- autentikaatio esimerkiksi siten, että evästeisiin on helppo päästä käsiksi tai siten, että tieto autentikaatiosta kulkee osoitteessa.

  3. Cross-Site Scripting (XSS) -- Mahdollisuus syöttää sivulle Javascript-koodia esimerkiksi tekstikentän kautta. Tämä mahdollistaa mm. toisella koneella olevan Javascript-koodin suorittamisen, tai lomaketietojen lähettämisen kolmannen osapuolen palveluun.

  4. Insecure Direct Object References -- mahdollisuus päästä käsiksi esimerkiksi palvelimella sijaitseviin tiedostoihin muokkaamalla polkua tai lähettämällä palvelimelle sopivaa dataa. Yksinkertaisin kokeilu lienee ../-merkkijonon kokeilemista sovelluksen polussa.

  5. Security Misconfiguration -- huonosti tehdyt tietoturvakonfiguraatiot.

  6. Sensitive Data Exposure -- yhteyksien tulee olla suojattu.

  7. Missing Function Level Access Control -- autorisaatiota ei tapahdu metoditasolla.

  8. Cross-Site Request Forgery (CSRF) -- sovelluksessa XSS-aukko, joka mahdollistaa epätoivotun pyynnön lähettämisen toiselle palvelimelle. Lomakkeisiin voidaan myös määritellä erillinen otsaketieto, joka on uniikki ja luodaan sivun latauksen yhteydessä.

  9. Using Components with Known Vulnerabilities -- sovelluksessa käytetään osia, joissa on tunnettuja tietoturvariskejä.

  10. Unvalidated Redirects and Forwards -- älä käytä parametreja uudelleenohjauksissa. Riskinä on väärien parametrien syöttäminen ja sitä kautta epätoivottuun tietoon pääseminen.

Tutustu listaan tarkemmin osoitteessa https://www.owasp.org/index.php/Category:OWASP_Top_Ten_Project. He tarjoavat dokumentaatiossaan kuvaukset riskeistä, sekä esimerkkejä hyökkäyksistä; Tässä esimerkki XSS-filtterien kiertämisestä.