Tehtävät
Kuudennen osan tavoitteet

Osaa selittää autentikaation ja autorisaation erot. Osaa luoda sovelluksen, joka pyytää käyttäjää kirjautumaan. Osaa määritellä kirjautumista vaativia polkuja ja metodeja, sekä piilottaa näkymän osia erilaisilta käyttäjäryhmiltä. Tuntee tapoja tiedon validointiin ja osaa validoida lomakedataa. Tietää web-sovellusten tyypillisimmät haavoittuvuudet sekä niihin vaikuttavat tekijät (OWASP). Välttää tyypillisimmät haavoittuvuudet omassa ohjelmistossaan.

Autentikaatio ja autorisaatio

Autentikaatiolla tarkoitetaan käyttäjän tunnistamista esimerkiksi kirjautumisen yhteydessä, ja autorisaatiolla tarkoitetaan käyttäjän oikeuksien varmistamista käyttäjän haluamiin toimintoihin.

Tunnistautumis- ja kirjautumistoiminnallisuus rakennetaan evästeiden avulla. Jos käyttäjällä ei ole evästettä, mikä liittyy kirjautuneen käyttäjän sessioon, hänet ohjataan kirjautumissivulle. Kirjautumisen yhteydessä käyttäjään liittyvään evästeeseen lisätään tieto siitä, että käyttäjä on kirjautunut -- tämän jälkeen sovellus tietää, että käyttäjä on kirjautunut.

Kirjautumissivuja ja -palveluita on kirjoitettu useita, ja sellainen löytyy lähes jokaisesta web-sovelluskehyksestä. Myös Spring-sovelluskehyksessä löytyy oma projekti kirjautumistoiminnallisuuden toteuttamiseen. Käytämme seuraavaksi Spring Security -projektia. Sen saa käyttöön lisäämällä Spring Boot -projektin pom.xml-tiedostoon seuraavan riippuvuuden.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Komponentti tuo käyttöömme komponentin, joka tarkastelee pyyntöjä ennen kuin pyynnöt ohjataan kontrollerien metodeille. Jos käyttäjän tulee olla kirjautunut päästäkseen haluamaansa osoitteeseen, komponentti ohjaa pyynnön tarvittaessa erilliselle kirjautumisivulle.

Mistä käyttäjätunnus ja salasana?

Jos Spring Security -komponentin ottaa käyttöön, mutta ei luo siihen liittyvää konfiguraatiota, ovat oletuksena kaikki polut salattu. Käyttäjätunnus on oletuksena user, salasana löytyy sovelluksen käynnistyksen yhteydessä tulostuvista viesteistä.

Oletussalasanan voi asettaa konfiguraatiotiedostossa. Alla olevalla esimerkillä käyttäjätunnukseksi tulisi user ja salasanaksi saippuakauppias.

security.user.password=saippuakauppias
  

Salasanan lisääminen julkiseen versionhallintaan ei ole kuitenkaan hyvä idea. Salasanan voi asettaa myös tuotantoympäristön ympäristömuuttujien avulla. Esimerkiksi Herokun ympäristömuuttujien asettamiseen löytyy opas osoitteesta https://devcenter.heroku.com/articles/config-vars. Esimerkiksi ympäristömuuttujan MYPASSWORD saa käyttöön seuraavalla tavalla.

security.user.password=${MYPASSWORD}
  

Tunnusten ja salattavien sivujen määrittely

Kirjautumista varten luodaan erillinen konfiguraatiotiedosto, jossa määritellään sovellukseen liittyvät salattavat sivut. Oletuskonfiguraatiolla pääsy estetään käytännössä kaikkiin sovelluksen resursseihin, ja ohjelmoijan tulee kertoa ne resurssit, joihin käyttäjillä on pääsy.

Luodaan oma konfiguraatiotiedosto SecurityConfiguration, joka sisältää sovelluksemme tietoturvakonfiguraation. Huom! Konfiguraatiotiedostoja kannattaa luoda useampia -- ainakin yksi tuotantokäyttöön ja yksi sovelluksen kehittämiseen tarkoitetulle hiekkalaatikolle. Kun konfiguraatiotiedostoja alkaa olla useampia, kannattaa ne lisätä erilliseen pakkaukseen.

// pakkaus 

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // Ei päästetä käyttäjää mihinkään sovelluksen resurssiin ilman
        // kirjautumista. Tarjotaan kuitenkin lomake kirjautumiseen, mihin
        // pääsee vapaasti. Tämän lisäksi uloskirjautumiseen tarjotaan
        // mahdollisuus kaikille. 
        http.authorizeRequests()
                .anyRequest().authenticated().and()
                .formLogin().permitAll().and()
                .logout().permitAll();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        // käyttäjällä jack, jonka salasana on bauer, on rooli USER
        auth.inMemoryAuthentication()
                .withUser("jack").password("bauer").roles("USER");
    }
}

Yllä oleva tietoturvakonfiguraatio koostuu kahdesta osasta. Ensimmäisessä osassa configure(HttpSecurity http) määritellään sovelluksen osoitteet, joihin on pääsy kielletty tai pääsy sallittu. Toisessa osassa public void configureGlobal(AuthenticationManagerBuilder auth) taas määritellään -- tässä tapauksessa -- käytössä olevat käyttäjätunnukset ja salasanat. Käyttäjälle tulee määritellä rooli -- yllä oletuksena on USER.

Kun määritellään osoitteita, joihin käyttäjä pääsee käsiksi, on hyvä varmistaa, että määrittelyssä on mukana lause anyRequest().authenticated() -- tämä käytännössä johtaa tilanteeseen, missä kaikki osoitteet, joita ei ole erikseen määritelty, vaatii kirjautumista. Voimme määritellä osoitteita, jotka eivät vaadi kirjautumista seuraavasti:

// ..
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .antMatchers("/free").permitAll()
            .antMatchers("/access").permitAll()
            .antMatchers("/to/*").permitAll()
            .anyRequest().authenticated().and()
            .formLogin().permitAll().and()
            .logout().permitAll();
}
// ..

Ylläolevassa esimerkissä osoitteisiin /free ja /access ei tarvitse kirjautumista. Tämän lisäksi kaikki osoitteet polun /to/ alla on kaikkien käytettävissä. Loput osoitteet on kaikilta kielletty. Komento formLogin().permitAll() määrittelee sivun käyttöön kirjautumissivun, johon annetaan kaikille pääsy, jonka lisäksi komento logout().permitAll() antaa kaikille pääsyn uloskirjautumistoiminnallisuuteen.

Tehtävässä on sovellus viestien näyttämiseen. Tehtävänäsi on lisätä siihen salaustoiminnallisuus -- kenenkään muun kuin käyttäjän "maxwell_smart" ei tule päästä viesteihin käsiksi. Aseta Maxwellin salasanaksi "kenkapuhelin".

Käyttäjätunnukset tallennetaan tyypillisesti tietokantaan, mistä ne voi tarvittaessa hakea. Salasanoja ei tule tallentaa sellaisenaan, sillä ne voivat joskus päätyä vääriin käsiin. Palaamme salasanojen tallentamismuotoon myöhemmin, nyt tutustumme vain siihen liittyvään tekniikkaan.

Klassinen erhe: USER

SQL-kielen spesifikaatiota heikosti tunteva aloitteleva web-ohjelmoija tekee usein USER-nimisen entiteetin tai attribuutin. Sana user on kuitenkin varattu SQL-spesifikaatiossa, joten sitä ei voi käyttää..

Käyttäjätunnuksen ja salasanan noutamista varten luomme käyttäjälle entiteetin sekä sopivan repository-toteutuksen. Tarvitsemme lisäksi oman UserDetailsService-rajapinnan toteutuksen, jota käytetään käyttäjän hakemiseen tietokannasta. Allaolevassa esimerkissä rajapinta on toteutettu siten, että tietokannasta haetaan käyttäjää. Jos käyttäjä löytyy, luomme siitä User-olion, jonka palvelu palauttaa.

// importit

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private AccountRepository accountRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accountRepository.findByUsername(username);
        if (account == null) {
            throw new UsernameNotFoundException("No such user: " + username);
        }

        return new org.springframework.security.core.userdetails.User(
                account.getUsername(),
                account.getPassword(),
                true,
                true,
                true,
                true,
                Arrays.asList(new SimpleGrantedAuthority("USER")));
    }
}

Kun oma UserDetailsService-luokka on toteutettu, voimme ottaa sen käyttöön SecurityConfiguration-luokassa.

// ..

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // mahdollistetaan h2-konsolin käyttö
        http.csrf().disable();
        http.headers().frameOptions().sameOrigin();
        
        http.authorizeRequests()
                .antMatchers("/h2-console/*").permitAll()
                .anyRequest().authenticated();
        http.formLogin()
                .permitAll();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Edellisessä esimerkissä salasanojen tallentamisessa käytetään BCrypt-algoritmia, joka rakentaa merkkijonomuotoisesta salasanasta hajautusarvon. Tällöin tietokantaan tallennettujen salasanojen vuoto ei ole täysi kriisi, vaikka ei siltikään toivottavaa.

Tehtävän ohjelmakoodiin on toteutettu käyttäjät tunnistava sovellus, joka tallentaa käyttäjien salasanat tietokantaan. Tutustu sovelluksen ohjelmakoodiin ja lisää DefaultController-luokassa olevaa ohjelmakoodia mukaillen sovellukseen toinen käyttäjä, jonka salasana on myös "smart".

Käy tämän jälkeen tarkastelemassa sovelluksen tietokantaa osoitteessa http://localhost:8080/h2-console (aseta JDBC URL -kentän arvoksi jdbc:h2:mem:testdb). Vaikka lisäämäsi käyttäjän salasana on myös "smart" pitäisi tietokannassa olevien hajautusarvojen olla erilaiset.

Tehtävässä ei ole testejä.

Kun käyttäjä on kirjautuneena, saa häneen liittyvän käyttäjätunnuksen ns. tietoturvakontekstista.

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Uloskirjautuminen

Pohdi: Käyttäjä voi uloskirjautua tekemällä POST-pyynnön sovelluksen osoitteeseen /logout. Miksi tavallinen GET-pyyntö ei riitä? Minkälaisia rajoitteita ja määreitä HTTP-protokollan GET-pyyntöihin liittyi?

Autentikaation tarpeen voi määritellä myös pyyntökohtaisesti. Alla olevassa esimerkissä GET-tyyppiset pyynnöt ovat sallittuja juuriosoitteeseen, mutta POST-tyyppiset pyynnöt juuriosoitteeseen eivät ole sallittuja.

@Override
protected void configure(HttpSecurity http) throws Exception {
    // mahdollistetaan h2-konsolin käyttö
    http.csrf().disable();
    http.headers().frameOptions().sameOrigin();
  
    http.authorizeRequests()
        .antMatchers("/h2-console/*").permitAll()
        .antMatchers(HttpMethod.GET, "/").permitAll()
        .antMatchers(HttpMethod.POST, "/").authenticated()
        .anyRequest().authenticated();
    http.formLogin()
        .permitAll();
}

Tehtävänäsi on täydentää kesken jäänyttä varaussovellusta siten, että kaikki käyttäjät näkevät varaukset, mutta vain kirjautuneet käyttäjät pääsevät lisäämään varauksia.

Kun käyttäjä tekee pyynnön sovelluksen juuripolkuun /reservations, tulee hänen nähdä varaussivu. Allaolevassa esimerkissä tietokannassa ei ole varauksia, mutta jos niitä on, tulee ne listata kohdan Current reservations alla.

Jos kirjautumaton käyttäjä yrittää tehdä varauksen, hänet ohjataan kirjautumissivulle.

Kun kirjautuminen onnistuu, voi käyttäjä tehdä varauksia.

Sovelluksen tulee kirjautumis- ja varaustoiminnallisuuden lisäksi myös varmistaa, että varaukset eivät mene päällekkäin.

Luokassa DefaultController luodaan muutamia testikäyttäjiä, joita voi (esimerkiksi) käyttää sovelluksen testauksessa. Tarvitset ainakin:

  • Palvelun käyttäjän tunnistautumiseen, jolla täydennät luokkaa SecurityConfiguration
  • Tavan aikaleimojen käsittelyyn (kertaa materiaalin kolmas osa)
  • Kontrollerin varausten käsittelyyn ja tekemiseen

Käyttäjien roolit

Käyttäjillä on usein erilaisia oikeuksia sovelluksessa. Verkkokaupassa kaikki voivat listata tuotteita sekä lisätä tuotteita ostoskoriin, mutta vain tunnistautuneet käyttäjät voivat tehdä tilauksia. Tunnistautuneista käyttäjistä vain osa, esimerkiksi kaupan työntekijät, voivat tehdä muokkauksia tuotteisiin.

Tällaisen toiminnan toteuttamiseen käytetään oikeuksia, joiden lisääminen vaatii muutamia muokkauksia aiempaan kirjautumistoiminnallisuuteemme. Aiemmin näkemässämme luokassa CustomUserDetailsService noudettiin käyttäjä seuraavasti:

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Account account = accountRepository.findByUsername(username);
    if (account == null) {
        throw new UsernameNotFoundException("No such user: " + username);
    }

    return new org.springframework.security.core.userdetails.User(
            account.getUsername(),
            account.getPassword(),
            true,
            true,
            true,
            true,
            Arrays.asList(new SimpleGrantedAuthority("USER")));
}

Palautettavan User-olion luomiseen liittyy lista oikeuksia. Yllä käyttäjälle on määritelty oikeus USER, mutta oikeuksia voisi olla myös useampi. Seuraava esimerkki palauttaa käyttäjän "USER" ja "ADMIN" -oikeuksilla.

return new org.springframework.security.core.userdetails.User(
        account.getUsername(),
        account.getPassword(),
        true,
        true,
        true,
        true,
        Arrays.asList(new SimpleGrantedAuthority("USER"), new SimpleGrantedAuthority("ADMIN")));

Oikeuksia käytetään käytettävissä olevien polkujen rajaamisessa. Voimme rajata luokassa SecurityConfiguration osan poluista esimerkiksi vain käyttäjille, joilla on ADMIN-oikeus. Alla olevassa esimerkissä kaikki käyttäjät saavat tehdä GET-pyynnön sovelluksen juuripolkuun. Vain ADMIN-käyttäjät pääsevät polkuun /clients, jonka lisäksi muille sivuille tarvitaan kirjautuminen (mikä tahansa oikeus). Kuka tahansa pääsee kirjautumislomakkeeseen käsiksi.

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .antMatchers(HttpMethod.GET, "/").permitAll()
            .antMatchers("/clients").hasAnyAuthority("ADMIN")
            .anyRequest().authenticated();
    http.formLogin()
            .permitAll();
}

Oikeuksia varten määritellään tyypillisesti erillinen tietokantataulu, ja käyttäjällä voi olla useampia oikeuksia.

Sovelluksessa on toteutettuna käyttäjienhallinta tällä hetkellä siten, että käyttäjillä ei ole erillisiä oikeuksia. Muokkaa sovellusta ja lisää sovellukseen käyttäjäkohtaiset oikeudet. Suojaa tämän jälkeen sovelluksen polut seuraavasti:

  • Kuka tahansa saa nähdä polusta /happypath palautetun tiedon
  • Vain USER tai ADMIN -käyttäjät saavat nähdä polusta /secretpath palautetun tiedon
  • Vain ADMIN-käyttäjät saavat nähdä polusta /adminpath palautetun tiedon

Lisää sovellukseen myös seuraavat käyttäjät:

Käyttäjätunnus Salasana Oikeudet
larry larry USER
moe moe USER ja ADMIN
curly curly ADMIN
Käyttäjän luominen

Käyttäjien luominen tapahtuu sovelluksissa tyypillisesti erillisen "rekisteröidy"-sivun kautta. Tämä on sivu, missä muutkin sivut, ja tallentaa tietoa tietokantaan normaalisti. Käyttäjän salasanan luomisessa tosin hyödynnetään esimerkiksi BCrypt-salausta.

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.
Käyttäjän luominen

Käyttäjien luominen tapahtuu sovelluksissa tyypillisesti erillisen "rekisteröidy"-sivun kautta. Tämä on sivu, missä muutkin sivut, ja tallentaa tietoa tietokantaan normaalisti. Käyttäjän salasanan luomisessa tosin hyödynnetään esimerkiksi BCrypt-algoritmia.

Tunnusten ja salattavien sivujen määrittely

Tutustuimme edellä 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.

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.

Näkymän muutokset liittyvät käytettävyyteen

Edellä lisätty toiminnallisuus liittyy sovelluksen käytettävyyteen. Vaikka linkkiä ei näytettäisi osana sivua, kuka tahansa voi muokata sivun rakennetta selaimellaan. Tällöin pyynnön voi myös tehdä osoitteeseen, jota sivulla ei aluksi näy.

Tämä pätee oikeastaan kaikkeen selainpuolen toiminnallisuuteen. Web-sivuilla Javascriptin avulla toteutettu dynaaminen toiminnallisuus on hyödyllistä käytettävyyden kannalta, mutta se ei millään tavalla takaa, että sovellus olisi turvallinen käyttää. Tietoturva toteutetaan suurelta osin palvelinpäässä.

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 käyttäjän tekemiä pyyntöjä, sillä pyynnöt voidaan tehdä myös "käyttöliittymän ohi" esimerkiksi Postmanin avulla.

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")
@PostMapping("/posts")
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.

Merkittävään osaan näistä löytyy myös Spring-komponentit. Esimerkiki Facebookin käyttäminen kirjautumisessa on melko suoraviivaista -- aiheeseen löytyy opas mm. osoitteesta http://www.baeldung.com/facebook-authentication-with-spring-security-and-social.

Profiilit ja käyttäjät

Kuten aiemmin kurssilla opimme, osa Springin konfiguraatiosta tapahtuu ohjelmallisesti. Esimerkiksi tietoturvaan liittyvät asetukset, esimerkiksi aiemmin näkemämme SecurityConfiguration-luokka, määritellään usein ohjelmallisesti. Haluamme kuitenkin luoda tilanteen, missä tuotannossa on eri asetukset kuin kehityksessä.

Tämä onnistuu @Profile-annotaation avulla, jonka kautta voimme asettaa tietyt luokat tai metodit käyttöön vain kun @Profile-annotaatiossa määritelty profiili on käytössä. Esimerkiksi aiemmin luomamme SecurityConfiguration-luokka voidaan määritellä tuotantokäyttöön seuraavasti:

// importit

@Profile("production")
@Configuration
@EnableWebSecurity
public class ProductionSecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated();
        http.formLogin()
                .permitAll();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Voimme luoda erillisen tietoturvaprofiilin, jota käytetään oletuksena sovelluskehityksessä. Oletusprofiili määritellään merkkijonolla default.

// importit

@Profile("default")
@Configuration
@EnableWebSecurity
public class DefaultSecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // sallitaan h2-konsolin käyttö
        http.csrf().disable();
        http.headers().frameOptions().sameOrigin();
        
        http.authorizeRequests()
                .antMatchers("/h2-console/*").permitAll()
                .anyRequest().authenticated();
        http.formLogin()
                .permitAll();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("jack").password("bauer").roles("USER");
    }
}

Nyt tuotantoympäristössä käyttäjät noudetaan tietokannasta, mutta kehitysympäristössä on erillinen testikäyttäjä. Jos profiilia ei ole erikseen määritelty, käytetään oletusprofiilia (default).

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, importit, annotaatiot
public class Person {

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

}

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 @NotEmpty varmistaa ettei annotoitu attribuutti ole tyhjä -- lisätään se kaikkiin kenttiin. Annotaatiolla @Size voidaan määritellä pituusrajoitteita muuttujalle, ja annotaatiolla @Email varmistetaan, että attribuutin arvo on varmasti sähköpostiosoite. Annotaatiot löytyvät pakkauksesta javax.validation.constraints.

// pakkaus, importit, annotaatiot
public class Person {

    @NotEmpty
    @Size(min = 11, max = 11)
    private String socialSecurityNumber;

    @NotEmpty
    @Size(min = 5, max = 30)
    private String name;

    @NotEmpty
    @Email
    private String email;
}

Olion validointi kontrollerissa

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

  @PostMapping("/persons")
  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.

@PostMapping("/persons")
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:

@PostMapping("/persons")
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 -- jos nimet poikkeavat toisistaan, lomakkeen näyttäminen antaa virheen Neither BindingResult nor plain target object for bean name ....

@GetMapping("/persons")
public String view(@ModelAttribute Person person) {
    return "lomake";
}

@PostMapping("/persons")
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:

@GetMapping("/persons")
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. Toteutus olisi esimerkiksi seuraavanlainen:

@ModelAttribute
private Person getPerson() {
    return new Person();
}
        
@GetMapping("/persons")
public String view() {
    return "lomake";
}

@PostMapping("/person")
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.

Muutama sana tietoturvasta

Tutustutaan lyhyesti web-sovellusten tietoturvaan liittyviin teemoihin. Aloita katsomalla Juhani Erosen ARTTech-seminaari aiheesta Your privacy is protected by .. what exactly?. Aiheeseen syvennytään tarkemmin tietoturvakursseilla.

Suojattu verkkoyhteys

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
Muutama sana turvallisesta verkkoyhteydestä

Jos yhteys selaimen ja sovelluksen välissä on kunnossa, on tilanne melko hyvä. Tässä välissä on hyvä kuitenkin mainita myös avointen verkkoyhteyksien käytöstä.

Jos selaimen käyttäjä käyttää sovellusta avoimen (salasanattoman) langattoman verkkoyhteyden kautta, voi lähetettyjä viestejä kuunnella (ja muokata) käytännössä kuka tahansa. Avoimissa verkoissa käyttöjärjestelmä kirjautuu siihen verkkoon, jonka signaali on vahvin. Jos ilkeämielinen henkilö rakentaa samannimisen verkon ja saa verkkoyhteyden signaalin vahvemmaksi kuin olemassaolevassa verkossa, ottaa käyttäjän käyttöjärjestelmä automaattisesti yhteyden ilkeämielisen henkilön verkkoon. Tällöin ilkeämielinen henkilö voi myös kuunnella verkkoliikennettä halutessaan.

Tähän liittyvä hieman humoristinen esitys DEF CON-konferenssissa.

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

Palvelinten fyysinen tietoturva

Tietoturvaan keskityttäessä on hyvä muistaa myös fyysinen tietoturva. Jos palvelinsaliin pääsee helposti, voi kuka tahansa kävellä sinne ja ottaa palvelimen kainaloonsa. Tällöin sovellukseen toteutetun tietoturvan hyöty on melko pieni.

Sisällysluettelo