Spring Security

Frissítve: 2019. március 3.

Technológiák: Spring Framework 5.1.5, Spring Security 5.1.4, JPA, H2, Thymeleaf, Maven, Jetty

A Spring Security egy nyílt forráskódú projekt Java alkalmazások autentikációjának és autorizációjának megvalósítására. Az autentikáció azt jelenti, hogy a felhasználó tesz egy állítást, hogy ő kicsoda, és azt bizonyítja is. A legtöbbször ez felhasználónév és jelszó párossal történik, de lehet bonyolultabb megoldás, mint tanúsítvány (akár hardver tokenen), ujjlenyomat, stb. Az autorizáció az erőforráshoz való hozzáféréskor ellenőrzi, hogy a felhasználónak van-e hozzá jogosultsága. A Spring Security független projektként indult Acegi Security néven. Legkönnyebben Springes alkalmazásokkal integrálható, de nem kötelező a Spring használata. Persze az összes Springes technológiához illeszthető. Főleg webes alkalmazásoknál szokták használni, de működik vastag klienses környezetben is. Ez alapján egyszerűen beépíthető egy Spring + Spring MVC alkalmazásba, de használható többek között Struts-cal, Swinggel, de gyakorlatilag bármilyen Java alkalmazásban.

Előnye, hogy nem függ a környezettől (pl. alkalmazásszerver), nem kell az üzleti logikát átfűzni a jogosultságkezelést végző kóddal (, hanem aspektus-orientált módon adható meg). Egyszerű módon konfigurálható, és a legtöbb beállításnak van alapértelmezett értéke is, mellyel működik a biztonság, de tetszőleges mértékben testre szabható, a legtöbb osztály akár saját implementációra is kicserélhető (pluginelhetőség). Implementálva van benne hozzáférési listák kezelése (Access Control Lists).

Támogatja a HTTP BASIC, HTTP Digest és form alapú autentikációt, X.509 tanúsítványokat, valamint az OAuth 2.0-át, OpenID-t.

A felhasználók és a hozzá kapcsolódó szerepkörök tárolhatóak memóriában, adatbázisban, LDAP szerveren. Ezekhez adottak beépített implementációk, de saját is készíthető. Támogatja a jelszó hashelését pl. SCrypt, PBKDF2 vagy BCrypt algoritmussal. Támogat régebbi algoritmusokat is, mint pl. MD4, de annak nem biztonságos volta miatt már deprecated. A felhasználóval kapcsolatos információkat képes cache-elni is. Különböző eseményekre eseménykezelőket lehet aggatni, pl. bejelentkezés, így könnyen megoldható pl. audit naplózás. Könnyen illeszthető a CAS single sign on megoldáshoz.

Beépítetten nyújt támogatást a CSRF támadási mód ellen.

Kompatibilis a Servlet Security API-val, használhatóak az EJB 3 annotációi, illeszthető a JAAS szabványhoz.

Képes a security propagation-re, azaz az alkalmazások különböző rétegei között átvinni a security context-et (pl. a vastag kliensről a szerverre).

Java kódban egyszerűen lehet lekérni a bejelentkezett felhasználót, valamint annotációkkal adható meg, hogy mely metódus milyen jogosultsággal hívható meg.

Webes környezetben a védett URL-eket komplex módokon lehet megadni, akár Ant féle megadási móddal, akár reguláris kifejezésekkel.

Konfigurálható, hogy védet tartalmak esetén történjen https-re átirányítás. Alapból implementálva van benne két Remember-Me (Persistent Login) megoldás is, azaz a böngésző cookie-ban jegyezze meg a bejelentkezés tényét.

Használható WebSockettal, valamint Spring WebFlux-szal.

A Spring Security komplex támogatást nyújt a teszteléshez.

Ebben a posztban egy egyszerű webes alkalmazásba illesztését fogom bemutatni. A poszthoz egy példa projekt is tartozik, mely elérhető a GitHub-on. Egyszerű Spring MVC-s webes alkalmazás, Spring Data JPA perzisztens réteggel, Thymeleaf template engine-nel.

Tételezzük fel, hogy van egy webes alkalmazásunk, egy főoldallal. Nézzük meg lépésenként, hogy kell a Spring Security-t bevezetni.

Legegyszerűbb megoldás

A Servlet 3.0 szabvány már nem teszi kötelezővé a web.xml állomány használatát, ezért elég létrehozni egy osztályt, mely leszármazik a AbstractSecurityWebApplicationInitializer osztályból. Ez regisztrálja a Spring Security működéséhez szükséges Servlet Filtereket.

A Spring Security konfigurációját érdemes egy külön @Configuration annotációval ellátott osztályban megadni. A legegyszerűbb konfiguráció a következő:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .inMemoryAuthentication()
                .withUser("user")
                .password("user")
                .authorities("USER");
}

Ezt az osztályt a be kell töltenünk, ha már van egy AbstractAnnotationConfigDispatcherServletInitializer osztálytól leszármazó osztályunk, akkor annak getRootConfigClasses() metódusában.

public class WebAppInitializer
        extends AbstractAnnotationConfigDispatcherServletInitializer {

    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class[]{BackendConfig.class, SecurityConfig.class};
    }

    // ...
}

Ennek hatására a következők kerülnek beállításra:

  • Minden URL védett lesz, és csak bejelentkezés után lehet megtekinteni.
  • Működik a HTTP BASIC autentikáció is
  • Bármilyen URL-t beírva a böngésző átirányításra kerül a /login oldalra.
  • Bejelentkezni a user felhasználónévvel és user jelszóval lehetséges.
  • Ki lehet jelentkezni a /logout címre való post metódusú HTTP kéréssel. Vigyázat, get metódussal nem működik! Az űrlapon belül ezen kívül el kell helyezni a CSRF tokent is, erről még később lesz szó.

Látható, hogy a jelszót plain textben, szövegesen adtuk meg. Ez csak azért lehetséges, mert a PasswordEncoder NoOpPasswordEncoder implementációját használtuk. Ez deprecated, ugyanis sose használjuk élesben. Helyette használjuk a BCryptPasswordEncoder implementációt.

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

Ekkor a felhasználó jelszavát is át kell írnunk:

auth
        .inMemoryAuthentication()
        .withUser("user")
        .password("$2a$10$ADR5Ol7to6gUl4zdL1iasu4Oa/J9La4r30Jjbgaq0X946HvsWqTT2")
        .authorities("USER");

A Bcrypt egy jelszó hash algoritmus, mely magában foglal egy véletlenszerűen generált saltot is, azért, hogy ne lehessen jelszó adatbázisok alapján feltörni. Három részből áll, melyek dollárjelekkel ($) vannak elválasztva. Az első az algoritmus verziója, példánkban 2a. A második az ún. cost paraméter, példánkban 10. A harmadik részben az első 22 karakter a salt, a második 31 karakter pedig a hash.

Hogyan jutottunk ehhez a hash-hez? A következő kódrészlettel:

BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
System.out.println(passwordEncoder.encode("user"));

Amennyiben Thymeleaf template engine-t használunk, a Spring Security támogatáshoz el kell helyeznünk a thymeleaf-extras-springsecurity5 függőséget is a pom.xml állományban:

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
    <version>3.0.4.RELEASE</version>
</dependency>

Valamint meg kell adni egy SpringSecurityDialect példányt.

@Bean
public SpringTemplateEngine templateEngine() {
    SpringTemplateEngine engine = new SpringTemplateEngine();
    engine.setAdditionalDialects(Set.of(new SpringSecurityDialect()));
    engine.setTemplateResolver(templateResolver());
    return engine;
}

Ezek után a kijelentkező űrlap a következő:

<form method="post" th:action="@{/logout}">
    <input type="submit" value="Kijelentkezés" />
</form>

A Thymeleaf az előző függőség miatt az űrlapba automatikusan legenerálja egy rejtett mezőben a CSRF tokent is.

<form method="post" action="/">
  <input type="hidden" name="_csrf" value="21756dda-139c-4e5e-95c4-a9a9aeb14285"/>
  <!-- ... -->              
</form>

A CSRF támadási módot egy példával a legegyszerűbb leírni. Amennyiben egy webbankon bejelentkezik egy felhasználó, majd átnavigál egy rosszindulatú lapra, ott létre lehet hozni egy olyan űrlapot, mely a webbankra küld egy post metódusú kérést, pl. egy átutalást. Erre megoldás a CSRF token, melyet a szerver állít elő minden űrlap lekérésekor, elhelyezni az űrlapban egy rejtett mezőben, és az űrlap mindig vissza is küldi. Ezt támadó oldal nem ismerheti, így visszaküldeni sem tudja, és így a szerver elutasítja.

Adatbázis

Amennyiben adatbázisból akarjuk lekérdezni a felhasználókat, erre is van lehetőség, legegyszerűbben az SQL lekérdezések megadásával.

Egyrészt kell egy tábla, adatokkal. (Itt most csak egy táblát használunk, a felhasználó egyszerre csak egy szerepkörrel rendelkezhet. Persze gyakoribb, hogy van egy külön szerepkör tábla.)

create table users (id bigint generated by default as identity (start with 1),
    username varchar(255), password varchar(255), role varchar(255), primary key (id));

insert into users (username, password, role)
  values ('user', '$2a$10$WK5DYDlnywXj9Yni1kj4WOdEpBOriamVlY8UI8Isa38ermsz1TH4S', 'ROLE_USER');
insert into users (username, password, role)
  values ('admin', '$2a$10$r3fC/h15stMd/RkqSuNaPesFQaFJmg6Z7/x77vWoxsZCUmdbm0gt2', 'ROLE_ADMIN');

Természetesen itt már a hash-elt jelszót kell megadnunk. Majd írjuk át a configure() metódust:

auth
        .jdbcAuthentication()
        .dataSource(dataSource)
        .usersByUsernameQuery(
        "select username, password, 1 from users " +
                "where username = ?")
        .authoritiesByUsernameQuery(
                "select username, role from users " +
                        "where username = ?");

Saját implementáció

A felhasználók betöltését implementálhatjuk magunk is. Ekkor a UserDetailsService interfészt kell implementálnunk. Én egy Spring Data JPA megoldást választottam, ehhez kell egy entitás is, mely a UserDetails interfészt implementálja.

@Entity
@Table(name="users")
public class User implements UserDetails, Serializable {

   @Id
   @GeneratedValue
   private Long id;

   private String username;

   private String password;

   private String role;

    @Override
    public Collection<GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(role));
    }

   @Override
   public String getPassword() {
       return password;
   }

   @Override
   public String getUsername() {
       return username;
   }

   @Override
   public boolean isAccountNonExpired() {
       return true;
   }

   @Override
   public boolean isAccountNonLocked() {
       return true;
   }

   @Override
   public boolean isCredentialsNonExpired() {
       return true;
   }

   @Override
   public boolean isEnabled() {
       return true;
   }

   // Többi getter és setter metódus
   // ...
}

Írtam egy UserRepository interfészt a Spring Data JPA szerint, amit aztán így használtam a UserService osztályból.

@Service
public class UserService implements UserDetailsService {

    private UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) {
        return userRepository.findUserByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
    }

    // ...
}

Ezt aztán így kell használnunk:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService);
}

Felhasználó lekérése

A Java kódból ezután a következőképpen kérhetjük le a bejelentkezés után a felhasználót:

User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();

A Context ThreadLocal változó, így szálanként egyedi. A metódus visszatérési értékét kényszeríthetjük a saját User osztályunkra.

Sőt, egy Spring MVC controller @RequestMapping annotációjával ellátott metódusának paramétereként is definiálhatjuk a bejelentkezett felhasználót, ellátva az @AuthenticationPrincipal annotációval, akkor a Spring MVC paraméterül átadja azt.

@GetMapping(value = "/")
public ModelAndView index(@AuthenticationPrincipal User user) {
    logger.debug("Logged in user: {}", user);
    return new ModelAndView("index",
      Map.of("users", userService.listUsers(), "user", new User()));
}

Használat Thymeleafben

Amennyiben használni akarjuk a Spring Security funkcióit Thymeleaf template-ben, definiálnunk kell a névteret:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">

A felhasználó nevének és különböző tulajdonságainak megjelenítésére a authentication tag való:

<span sec:authentication="name">Bob</span>

Egy feltétel szerint megjeleníteni egy HTML részletet a következőképp lehet. A div törzse csak akkor jelenik meg, ha a felhasználó belépett.

<div sec:authorize="isAuthenticated()">

</div>

A következő div törzse csak akkor jelenik meg, ha a felhasználó rendelkezik ROLE_ADMIN jogosultsággal.

<div sec:authorize="hasRole('ROLE_ADMIN')">
</div>

Használat JSP-ben

JSP-ben használhatjuk a Spring Security tag library-t is, melynek definíciója:

<%@ taglib prefix="security"
    uri="http://www.springframework.org/security/tags" %>

Az authentication tag visszaadja az Authentication objektumot, és annak tulajdonságait tudjuk lekérni:

<security:authentication property="principal.username" />

Valamint az authorize tag törzse csak a feltétel teljesítésekor jelenik meg. Az access attribútumának kell egy EL kifejezést átadni.

<security:authorize access="hasRole('ROLE_ADMIN')">
       <!-- Felhasználók felvételére szolgáló form. -->
</security:authorize>

Saját bejelentkező képernyő

Ez esetben még mindig nem vagyunk megelégedve a Spring Security által biztosított alapértelmezett bejelentkező képernyővel, emiatt szabjuk azt testre. A WebSecurityConfigurerAdapter configure(HttpSecurity http) metódusát kell felülírnunk.

Itt kell megadni a védendő URL-eket. Természetesen többet is megadhatunk, akár Ant típusú kifejezéssel, és hozzájuk szabályokat, hogy milyen feltételekkel érhető el. A Spring Security használatakor az egyik leggyakoribb hiba, hogy a bejelentkezési képernyőt is letiltjuk, így végtelen ciklus alakulhat ki, erre a böngésző figyelmeztet.

A konfiguráció tehát a következő:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            .authorizeRequests()
            .antMatchers("/")
                .access("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
            .and()
            .formLogin()
            .loginPage("/login")
            .and()
            .logout();
}

Látható, hogy csak a főoldal (“/”) van levédve, a többi bejelentkezés nélkül megtekinthető. Vigyázzunk, nehogy levédjük pl. a statikus tartalmakat (CSS, JavaScript fájlok).

Majd nézzük a bejelentkező formot tartalmazó Thymeleaf részletet:

<div th:if="${param.error}">
    Sikertelen bejelentkezés
</div>

<div th:if="${param.logout}">
    Sikeres kijelentkezés
</div>

<form th:action="@{login}" method="post">
    <input type="text" name="username"/>
    <input type="password" name="password"/>
    <input type="submit" value="Bejelentkezés"/>
</form>

Ahogy említettem, a Thymeleaf a CSRF tokent automatikusan beleteszi egy hidden inputként.

Ugyanez JSP-vel:

<c:if test="${not empty param.error}">
    Sikertelen bejelentkezés
</c:if>

<c:if test="${not empty param.logout}">
    Sikeres kijelentkezés
</c:if>

<c:url value="/login" var="loginUrl"/>

<form action="${loginUrl}" method="post">
    <input type="text" name="username" value=""/>
    <input type="password" name="password" value="" />
    <input type="hidden"
        name="${_csrf.parameterName}"
        value="${_csrf.token}"/>
    <input type="submit" value="Bejelentkezés"/>
</form>

Felhasználónév megjegyzése

A Spring Security sikertelen bejelentkezés esetén nem jegyzi meg a felhasználónevet. Ezt nekünk kell implementálni.

Ennek megoldására írni kell egy SimpleUrlAuthenticationFailureHandler leszármazottat, mely sikertelen bejelentkezés esetén kerül meghívásra, és a felhasználónevet a sessionbe menti. Utána a bejelentkező oldalon ezt ki kell venni.

A UsernameInUrlAuthenticationFailureHandler implementációja:

public class UsernameInUrlAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    public static final String LAST_USERNAME_KEY = "LAST_USERNAME";

    public UsernameInUrlAuthenticationFailureHandler() {
        super("/login?error");
    }

    @Override
    public void onAuthenticationFailure(
            HttpServletRequest request, HttpServletResponse response,
            AuthenticationException exception)
            throws IOException, ServletException {

        String usernameParameter =
                UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_USERNAME_KEY;
        String lastUserName = request.getParameter(usernameParameter);

        HttpSession session = request.getSession(false);
        if (session != null || isAllowSessionCreation()) {
            request.getSession().setAttribute(LAST_USERNAME_KEY, lastUserName);
        }

        super.onAuthenticationFailure(request, response, exception);
    }
}

Ezt természetesen beanként kell deklarálni, és beállítani a configure(HttpSecurity http) metódusban:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            .authorizeRequests()
            .antMatchers("/")
                .access("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
            .and()
            .formLogin()
            .loginPage("/login")
            .failureHandler(usernameInUrlAuthenticationFailureHandler())
            .and()
            .logout();
}

@Bean
public UsernameInUrlAuthenticationFailureHandler usernameInUrlAuthenticationFailureHandler() {
    return new UsernameInUrlAuthenticationFailureHandler();
}

Valamint a megváltozott form:

Felhasználónév: <input th:field=*{username} type="text" />

Metódus szintű jogosultságkezelés

Ezen kívül a Spring Security képes arra is, hogy különböző metódusok meghívása esetén is végezzen jogosultság ellenőrzést. Ezt deklaratív módon, annotációval is meg lehet adni. Ekkor egyrészt deklarálni kell, hogy metódus szintű hozzáférés ellenőrzést szeretnénk, ekkor a @EnableGlobalMethodSecurity annotációt kell elhelyezni az SecurityConfig osztályunkon:

@EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  // ...
}

Valamint használjuk a @PreAuthorize annotációt a védendő metóduson:

@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public void addUser(String name, String password, String roles) {
  // ...
}

Tesztelés

Amennyiben az autentikációt is tesztelni akarjuk, a következőket használhatjuk. Először kell a következő függőség.

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

A @WithMockUser annotáció bejelentkeztet egy user felhasználót, USER szerepkörrel.

Ezt természtesen paraméterezni is lehet, pl. @WithMockUser(roles = {"ADMIN"}).

Amennyiben azonban egy felhasználót a klasszikus módon szeretnénk bejelentkeztetni, úgy, mintha az űrlapon történne a bejelentkezés, adjuk meg a felhasználónevét a @WithUserDetails annotációval, pl. @WithUserDetails("admin").

A MockMvc-nek is meg lehet adni a bejelentkezett felhasználót a következőképp:

mockMvc.perform(post("/")
                .param("username", "johndoe")
                .param("password", "johndoe")
                .param("role", "ROLE_USER")
                .with(user("admin").roles("ADMIN"))
                .with(csrf()))
                .andExpect(status().is3xxRedirection());