Spring Security és Spring Boot
Technológiák: Spring Security 6.1, Spring Boot 3.1, Thymeleaf, Spring Data JPA, H2
Utolsó frissítés: 2023. november 15.
(Azért írtam meg ezt a posztot, mert sokan kerestek a Spring Security-re, továbbá 2022 novemberében kijött a Spring Security 6 és Spring Boot 3, valamint teljesértékű Spring Security poszt Spring Boottal eddig hiányzott. A Spring Security használata Springgel Frameworkkel poszt továbbra is elérhető.)
A Spring Security egy olyan keretrendszer, mely támogatja az autentikációt, autorizációt és védelmet biztosít bizonyos támadási formák ellen. A Spring Security a de-facto szabványos eszköz a biztonság megvalósítására Springes alkalmazásokon belül.
A Spring Security támogatja a felhasználónév és jelszó párossal történő bejelentkezést, de ezen kívül pl. webszolgáltatások védelmére támogatja a HTTP BASIC, HTTP Digest és tanúsítvány alapú bejelentkezést, sőt az OAuth 2.0 használatát is.
A felhasználók és a hozzá kapcsolódó szerepkörök tárolhatóak memóriában, adatbázisban, LDAP szerveren, stb. Ezekhez adottak beépített implementációk, de saját is készíthető. Támogatja a jelszó hashelését különböző algoritmusokkal. 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.
Az alkalmazáson belül szerepkörökhöz lehet kötni bizonyos url-eket, valamint metódus szinten is meg lehet adni, hogy milyen szerepkörrel rendelkező felhasználó hívhatja meg.
A poszthoz egy példa projekt is tartozik, mely elérhető a GitHub-on. Egyszerű Spring Boot webes alkalmazás, Spring Data JPA perzisztens réteggel, Thymeleaf template engine-nel.
Legegyszerűbb védelem
A Spring Security már akkor védi az alkalmazást, ha szerepel a classpath-on. Ehhez elegendő
létrehozni egy üres alkalmazást a https://start.spring.io/ címen a Spring Web és Spring Security
függőségekkel, és egy index.html
oldalt.
Az alkalmazást elindítva a Spring Security csak bejelentkezés
után enged hozzáférést. A bejelentkező képernyőt a Spring Security generálta ki,
valamint a háttérben létrehozott egy felhasználót user
névvel. Minden indításkor új jelszót generál, melyet kiír a konzolra.
Ha a jelszót elrontom, hibaüzenetet kapok.
Sikeres bejelentkezés után megjelenik az index.html
állomány tartalma. Az oldal újratöltésekor sem kér jelszót.
A Spring Security automatikusan létrehozz egy kijelentkezési lehetőséget is, mely elérhető a /logout
címen.
Ezt meghívva a Spring Security rákérdez, hogy biztos ki akarok-e jelentkezni. Majd megjelenik a bejelentkező
képernyő egy üzenettel.
A Spring Security automatikusan létrehoz egy Basic autentikációs bejelentkezési lehetőséget is. Ekkor a http kérés fejlécében kell elküldeni a felhasználónevet és a jelszót.
Felhasználók betöltése adatbázisból saját implementációval
A felhasználók betöltését implementálhatjuk magunk is. Ekkor a UserDetailsService
interfészt kell implementálni. É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")
@NoArgsConstructor
@Data
public class User implements UserDetails, Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private String role;
public User(String username, String password, String role) {
this.username = username;
this.password = password;
this.role = role;
}
@Override
public Collection<GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(role));
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
Látható, hogy a szerepkörök kezelését leegyszerűsítettem úgy, hogy a felhasználónak
csak egy szerepköre lehet, melyet a role
attribútuma tartalmaz. Ezt a getAuthorities()
metódus konvertál a Spring Security által emészthető formába.
Írtam egy UserRepository
interfészt a Spring Data JPA szerint.
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findUserByUsername(String username);
}
Majd írtam egy UserService
osztályt, melynek implementálnia kell
a UserDetailsService
interfészt, melynek van egy
public UserDetails loadUserByUsername(String username)
metódusa.
Ez továbbhív a repository-ba. A Spring Security ezt a metódust
fogja meghívni a felhasználó bejelentkezésekor.
@Service
@AllArgsConstructor
public class UserService implements UserDetailsService {
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) {
return userRepository.findUserByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
}
Majd konfigurálom a Spring Security-t a SecurityConfig
osztályban.
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(
registry -> registry
.requestMatchers("/login")
.permitAll()
.requestMatchers("/")
// ROLE_ prefixet auto hozzáfűzi
.hasAnyRole("USER", "ADMIN")
)
.formLogin(conf -> conf
.loginPage("/login")
)
.logout(Customizer.withDefaults());
return http.build();
}
@Bean
public PasswordEncoder encoder() {
return new BCryptPasswordEncoder();
}
}
Az @EnableWebSecurity
annotációt rá kell tenni arra a @Configuration
annotációval ellátott osztályra, mely a SecurityFilterChain
beant fogja létrehozni.
A filterChain()
metódus adja meg a részletes konfigurációt. A bejelentkező
oldal a /login
címen lesz elérhető, és ezt bejelentkezés nélkül el kell tudni érni.
Az összes többi oldal eléréséhez szükséges a ROLE_USER
és ROLE_ADMIN
szerepkör. Fontos,
hogy a hasAnyRole()
hívásakor már ne használjunk ROLE_
előtagot, azt a metódus
alapból hozzáfűzi.
Kijelentkezni a /logout
alapértelmezett címen lehet.
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 jelszó hasheléséhez a BCrypt algoritmust használom, ehhez létrehoztam egy BCryptPasswordEncoder
beant.
Adatbázis létrehozása
Létre kell hozni a táblát is, valamint létrehozok két alapértelmezett felhasználót.
Ehhez a schema.sql
fájlban létrehozom a táblát.
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));
A felhasználóknál szükségem van a jelszavuk hash-ére. Ehhez létrehozok egy teszt osztályt.
class BCryptTest {
@Test
void testEncode() {
System.out.println(new BCryptPasswordEncoder().encode("user"));
}
}
Ennek eredménye:
$2a$10$WK5DYDlnywXj9Yni1kj4WOdEpBOriamVlY8UI8Isa38ermsz1TH4S
Érdekessége, hogy futtatásonként más értéket ad vissza. A hash 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.
A data.sql
fájlba hozom létre a felhasználókat.
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');
Saját bejelentkezési űrlap létrehozása
Saját űrlap létrehozásához Thymeleafet használtam. Ehhez kell egy UserController
controller.
package jtechlog.jtechlogbootsecurity;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.ModelAndView;
import java.util.Map;
@Controller
public class UserController {
@GetMapping("/login")
public ModelAndView login() {
return new ModelAndView("login");
}
}
És a Thymeleaf template login.html
néven.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<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>
</body>
</html>
A Thymeleafen kívül a org.thymeleaf.extras:thymeleaf-extras-springsecurity6
függőségre is szükség van,
és ekkor az űrlapba automatikusan legenerálja egy
rejtett mezőben a CSRF tokent is.
<form action="login" method="post">
<input type="hidden" name="_csrf" value="R2gwhxjM6tmpj-kCsdOwjy-52oT1Mjh35HfNrrGjEIQ4BzcDclpSsC74jOyEuI8x1f6EvRvf9-XFVwpa3UP8mIfAJOEOMA81"/>
<!-- ... -->
</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.
Felhasználó lekérése
Egy 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
paraméterül átadja azt.
@GetMapping(value = "/")
public ModelAndView index(@AuthenticationPrincipal User user) {
log.debug("Logged in user: {}", user);
return new ModelAndView("index",
Map.of("users", userService.listUsers(), "user", new User()));
}
A Java kódból a következőképpen kérhetjük le a bejelentkezés után a felhasználót:
var 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.
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-springsecurity6">
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>
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 {
private 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 {
var usernameParameter =
UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_USERNAME_KEY;
var lastUserName = request.getParameter(usernameParameter);
var 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:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, UsernameInUrlAuthenticationFailureHandler failureHandler) throws Exception {
http
.authorizeHttpRequests()
.requestMatchers("/login")
.permitAll()
.requestMatchers("/")
// ROLE_ prefixet auto hozzáfűzi
.hasAnyRole("USER", "ADMIN")
.and()
.formLogin()
.loginPage("/login")
.failureHandler(failureHandler)
.and()
.logout();
return http.build();
}
@Bean
public UsernameInUrlAuthenticationFailureHandler usernameInUrlAuthenticationFailureHandler() {
return new UsernameInUrlAuthenticationFailureHandler();
}
Valamint a megváltozott form, az előző felhasználónevet a sessionből veszi ki:
Felhasználónév: <input type="text" name="username" th:value="${session.lastUsername}"/>
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
@EnableMethodSecurity
annotációt kell elhelyezni az SecurityConfig
osztályunkon:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
// ...
}
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());