Osa 5

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>

Yllä kuvattu riippuvuus 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 — tästä esimerkki mm. oppaassa https://www.baeldung.com/spring-security-login.

Tunnusten ja suojattavien 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. Edellisessä osassa käytetyt konfiguraatioprofiilit ovat tässä erittäin hyödyllisiä.

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

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        // withdefaultpasswordencoder on deprekoitu mutta toimii yhä
        UserDetails user = User.withDefaultPasswordEncoder()
                               .username("hei")
                               .password("maailma")
                               .authorities("USER")
                               .build();
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(user);
        return manager;
    }
}

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. Yllä todetaan, että käyttäjä tulee tunnistaa jokaisen pyynnön yhteydessä (anyRequest().authenticated()), mutta kirjautumiseen käytettyyn lomakkeeseen on kaikilla pääsy (formLogin().permitAll()). Vastaavasti uloskirjautumistoiminnallisuus on kaikille sallittu.

Toisessa osassa public UserDetailsService userDetailsService() määritellään käyttäjätietojen hakemiseen tarkoitetun UserDetailsService-rajapinnan toteuttava olio. Yllä luodaan ensin käyttäjätunnus hei salasanalla maailma. Käyttäjätunnuksella on rooli USER (tarkemmin pääsy resursseihin, joihin USER-käyttäjällä on pääsy — palaamme näihin myöhemmin). Luotu käyttäjätunnus lisätään uuteen olevaan käyttäjien hallinnasta vastaavaan InMemoryUserDetailsManager-olioon — olio toteuttaa UserDetailsService-rajapinnan.


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", "/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.

Loading

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

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", "/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 salasanoja ei ole tallennettu selkokielisenä, mutta salasanojen mekaaninen arvaaminen on toki yhä mahdollista.

Loading
:
Loading interface...
:
Loading interface...

Kirjaudu sisään nähdäksesi tehtävän.

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();
:
Loading interface...
:
Loading interface...

Kirjaudu sisään nähdäksesi tehtävän.

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","/h2-console/**").permitAll()
        .antMatchers(HttpMethod.GET, "/").permitAll()
        .antMatchers(HttpMethod.POST, "/").authenticated()
        .anyRequest().authenticated();
    http.formLogin()
        .permitAll();
}
Loading

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.

Loading

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.

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 {

    // paikallinen profiili
}

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

Pääsit aliluvun loppuun! Jatka tästä seuraavaan osaan: