Spring Security z JWT dla REST API
Opublikowany: 2022-03-11Spring jest uważany za zaufaną platformę w ekosystemie Java i jest szeroko stosowany. Nie ma już sensu odnosić się do Springa jako frameworka, ponieważ jest to bardziej ogólny termin obejmujący różne frameworki. Jednym z tych frameworków jest Spring Security, który jest potężnym i konfigurowalnym frameworkiem uwierzytelniania i autoryzacji. Jest uważany za de facto standard do zabezpieczania aplikacji opartych na Spring.
Mimo swojej popularności muszę przyznać, że jeśli chodzi o aplikacje jednostronicowe, konfiguracja nie jest prosta i prosta. Podejrzewam, że powodem jest to, że zaczęło się bardziej jako framework zorientowany na aplikacje MVC, w którym renderowanie stron internetowych odbywa się po stronie serwera, a komunikacja jest oparta na sesjach.
Jeśli backend jest oparty na Javie i Springu, sensowne jest użycie Spring Security do uwierzytelniania/autoryzacji i skonfigurowanie go do komunikacji bezstanowej. Chociaż istnieje wiele artykułów wyjaśniających, jak to się robi, dla mnie konfiguracja po raz pierwszy była frustrująca i musiałem czytać i podsumowywać informacje z wielu źródeł. Dlatego zdecydowałem się napisać ten artykuł, w którym postaram się podsumować i omówić wszystkie wymagane subtelne szczegóły i niedociągnięcia, które możesz napotkać podczas procesu konfiguracji.
Definiowanie terminologii
Zanim zagłębimy się w szczegóły techniczne, chcę wyraźnie zdefiniować terminologię używaną w kontekście Spring Security, aby mieć pewność, że wszyscy mówimy tym samym językiem.
Oto warunki, którymi musimy się zająć:
- Uwierzytelnianie odnosi się do procesu weryfikacji tożsamości użytkownika na podstawie podanych poświadczeń. Typowym przykładem jest wpisanie nazwy użytkownika i hasła podczas logowania do witryny. Możesz myśleć o tym jako o odpowiedzi na pytanie Kim jesteś? .
- Autoryzacja odnosi się do procesu określania, czy użytkownik ma odpowiednie uprawnienia do wykonania określonej czynności lub odczytania określonych danych, przy założeniu, że użytkownik został pomyślnie uwierzytelniony. Możesz myśleć o tym jako o odpowiedzi na pytanie Czy użytkownik może to zrobić/przeczytać? .
- Zasada odnosi się do aktualnie uwierzytelnionego użytkownika.
- Uprawnienie przyznane odnosi się do uprawnienia uwierzytelnionego użytkownika.
- Rola odnosi się do grupy uprawnień uwierzytelnionego użytkownika.
Tworzenie podstawowej aplikacji Spring
Zanim przejdziemy do konfiguracji frameworka Spring Security, stwórzmy podstawową aplikację internetową Spring. W tym celu możemy użyć Spring Initializr i wygenerować projekt szablonu. W przypadku prostej aplikacji internetowej wystarczy zależność od frameworka Spring:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>
Po stworzeniu projektu możemy dodać do niego prosty kontroler REST w następujący sposób:
@RestController @RequestMapping("hello") public class HelloRestController { @GetMapping("user") public String helloUser() { return "Hello User"; } @GetMapping("admin") public String helloAdmin() { return "Hello Admin"; } }
Po tym, jeśli zbudujemy i uruchomimy projekt, możemy uzyskać dostęp do następujących adresów URL w przeglądarce internetowej:
-
http://localhost:8080/hello/user
zwróci ciągHello User
. -
http://localhost:8080/hello/admin
zwróci ciągHello Admin
.
Teraz możemy dodać framework Spring Security do naszego projektu i możemy to zrobić dodając następującą zależność do naszego pliku pom.xml
:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> </dependencies>
Dodanie innych zależności od Springa zwykle nie ma natychmiastowego wpływu na aplikację, dopóki nie dostarczymy odpowiedniej konfiguracji, ale Spring Security różni się tym, że ma natychmiastowy efekt, co zwykle dezorientuje nowych użytkowników. Po dodaniu, jeśli przebudujemy i uruchomimy projekt, a następnie spróbujemy uzyskać dostęp do jednego z wyżej wymienionych adresów URL zamiast wyświetlać wynik, zostaniemy przekierowani do http://localhost:8080/login
. Jest to zachowanie domyślne, ponieważ struktura Spring Security wymaga uwierzytelniania od razu dla wszystkich adresów URL.
Aby przejść uwierzytelnianie, możemy użyć domyślnej nazwy user
i znaleźć automatycznie wygenerowane hasło w naszej konsoli:
Using generated security password: 1fc15145-dfee-4bec-a009-e32ca21c77ce
Pamiętaj, że hasło zmienia się przy każdym ponownym uruchomieniu aplikacji. Jeśli chcemy zmienić to zachowanie i uczynić hasło statycznym, możemy dodać następującą konfigurację do naszego pliku application.properties
:
spring.security.user.password=Test12345_
Teraz, jeśli wprowadzimy dane uwierzytelniające w formularzu logowania, zostaniemy przekierowani z powrotem na nasz adres URL i zobaczymy poprawny wynik. Należy pamiętać, że proces uwierzytelniania out-of-the-box jest oparty na sesji, a jeśli chcemy się wylogować, możemy uzyskać dostęp do następującego adresu URL: http://localhost:8080/logout
To gotowe zachowanie może być przydatne w przypadku klasycznych aplikacji internetowych MVC, w których mamy uwierzytelnianie oparte na sesji, ale w przypadku aplikacji jednostronicowych zwykle nie jest to przydatne, ponieważ w większości przypadków mamy do czynienia po stronie klienta renderowanie i bezstanowe uwierzytelnianie oparte na JWT. W takim przypadku będziemy musieli mocno dostosować framework Spring Security, co zrobimy w dalszej części artykułu.
Jako przykład zaimplementujemy klasyczną aplikację internetową księgarni i stworzymy zaplecze, które zapewni API CRUD do tworzenia autorów i książek oraz API do zarządzania użytkownikami i uwierzytelniania.
Przegląd architektury zabezpieczeń Spring
Zanim zaczniemy dostosowywać konfigurację, omówmy najpierw, jak działa uwierzytelnianie Spring Security za kulisami.
Poniższy diagram przedstawia przepływ i pokazuje, jak przetwarzane są żądania uwierzytelnienia:
Wiosenna architektura bezpieczeństwa

Teraz podzielmy ten diagram na komponenty i omówmy każdy z nich osobno.
Sprężynowy łańcuch filtrów zabezpieczających
Kiedy dodasz framework Spring Security do swojej aplikacji, automatycznie rejestruje ona łańcuch filtrów, który przechwytuje wszystkie przychodzące żądania. Ten łańcuch składa się z różnych filtrów, a każdy z nich obsługuje konkretny przypadek użycia.
Na przykład:
- Sprawdź, czy żądany adres URL jest publicznie dostępny na podstawie konfiguracji.
- W przypadku uwierzytelniania opartego na sesji sprawdź, czy użytkownik jest już uwierzytelniony w bieżącej sesji.
- Sprawdź, czy użytkownik jest upoważniony do wykonania żądanej akcji i tak dalej.
Jednym ważnym szczegółem, o którym chcę wspomnieć, jest to, że filtry Spring Security są zarejestrowane w najniższej kolejności i są pierwszymi wywoływanymi filtrami. W niektórych przypadkach, jeśli chcesz umieścić przed nimi swój niestandardowy filtr, będziesz musiał dodać dopełnienie do ich zamówienia. Można to zrobić za pomocą następującej konfiguracji:
spring.security.filter.order=10
Po dodaniu tej konfiguracji do naszego pliku application.properties
, będziemy mieć miejsce na 10 niestandardowych filtrów przed filtrami Spring Security.
Menedżer uwierzytelniania
Można myśleć o AuthenticationManager
jako o koordynatorze, w którym można zarejestrować wielu dostawców i na podstawie typu żądania dostarczy on żądanie uwierzytelnienia do właściwego dostawcy.
Dostawca uwierzytelniania
AuthenticationProvider
przetwarza określone typy uwierzytelniania. Jego interfejs udostępnia tylko dwie funkcje:
-
authenticate
wykonuje uwierzytelnianie z żądaniem. -
supports
sprawdzanie, czy ten dostawca obsługuje wskazany typ uwierzytelniania.
Jedną z ważnych implementacji interfejsu, którego używamy w naszym przykładowym projekcie, jest DaoAuthenticationProvider
, który pobiera szczegóły użytkownika z UserDetailsService
.
SzczegółyUżytkownikaUsługa
UserDetailsService
jest opisany jako podstawowy interfejs, który ładuje dane specyficzne dla użytkownika w dokumentacji Springa.
W większości przypadków dostawcy uwierzytelniania wyodrębniają informacje o tożsamości użytkownika na podstawie poświadczeń z bazy danych, a następnie przeprowadzają weryfikację. Ponieważ ten przypadek użycia jest tak powszechny, deweloperzy Springa postanowili wyodrębnić go jako osobny interfejs, który eksponuje pojedynczą funkcję:
-
loadUserByUsername
akceptuje nazwę użytkownika jako parametr i zwraca obiekt tożsamości użytkownika.
Uwierzytelnianie za pomocą JWT z Spring Security
Po omówieniu wewnętrznych elementów frameworka Spring Security, skonfigurujmy go do uwierzytelniania bezstanowego za pomocą tokena JWT.
Aby dostosować Spring Security, potrzebujemy klasy konfiguracyjnej z adnotacją @EnableWebSecurity
w naszej ścieżce klas. Ponadto, aby uprościć proces dostosowywania, platforma udostępnia klasę WebSecurityConfigurerAdapter
. Rozszerzymy ten adapter i zastąpimy obie jego funkcje, aby:
- Skonfiguruj menedżera uwierzytelniania z odpowiednim dostawcą
- Skonfiguruj zabezpieczenia sieciowe (publiczne adresy URL, prywatne adresy URL, autoryzacja itp.)
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { // TODO configure authentication manager } @Override protected void configure(HttpSecurity http) throws Exception { // TODO configure web security } }
W naszej przykładowej aplikacji przechowujemy tożsamości użytkowników w bazie danych MongoDB, w kolekcji users
. Tożsamości te są mapowane przez encję User
, a ich operacje CRUD są definiowane przez repozytorium UserRepo
Spring Data.
Teraz, gdy zaakceptujemy żądanie uwierzytelnienia, musimy pobrać poprawną tożsamość z bazy danych za pomocą dostarczonych danych uwierzytelniających, a następnie ją zweryfikować. W tym celu potrzebujemy implementacji interfejsu UserDetailsService
, który jest zdefiniowany w następujący sposób:
public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }
Tutaj widzimy, że wymagane jest zwrócenie obiektu, który implementuje interfejs UserDetails
, a implementuje go nasza encja User
(szczegóły implementacji można znaleźć w repozytorium przykładowego projektu). Biorąc pod uwagę fakt, że eksponuje tylko prototyp jednofunkcyjny, możemy traktować go jako interfejs funkcjonalny i zapewnić implementację jako wyrażenie lambda.
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { private final UserRepo userRepo; public SecurityConfig(UserRepo userRepo) { this.userRepo = userRepo; } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(username -> userRepo .findByUsername(username) .orElseThrow( () -> new UsernameNotFoundException( format("User: %s, not found", username) ) )); } // Details omitted for brevity }
W tym przypadku wywołanie funkcji auth.userDetailsService
zainicjuje wystąpienie DaoAuthenticationProvider
przy użyciu naszej implementacji interfejsu UserDetailsService
i zarejestruje je w menedżerze uwierzytelniania.
Wraz z dostawcą uwierzytelniania musimy skonfigurować menedżera uwierzytelniania z poprawnym schematem kodowania hasła, który będzie używany do weryfikacji poświadczeń. W tym celu musimy ujawnić preferowaną implementację interfejsu PasswordEncoder
jako fasolę.
W naszym przykładowym projekcie użyjemy algorytmu haszującego hasło bcrypt.
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { private final UserRepo userRepo; public SecurityConfig(UserRepo userRepo) { this.userRepo = userRepo; } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(username -> userRepo .findByUsername(username) .orElseThrow( () -> new UsernameNotFoundException( format("User: %s, not found", username) ) )); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // Details omitted for brevity }
Po skonfigurowaniu menedżera uwierzytelniania, musimy teraz skonfigurować zabezpieczenia sieciowe. Wdrażamy REST API i potrzebujemy uwierzytelniania bezstanowego za pomocą tokena JWT; dlatego musimy ustawić następujące opcje:
- Włącz CORS i wyłącz CSRF.
- Ustaw zarządzanie sesjami na bezstanowe.
- Ustaw obsługę wyjątków nieautoryzowanych żądań.
- Ustaw uprawnienia w punktach końcowych.
- Dodaj filtr tokenów JWT.
Ta konfiguracja jest realizowana w następujący sposób:
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { private final UserRepo userRepo; private final JwtTokenFilter jwtTokenFilter; public SecurityConfig(UserRepo userRepo, JwtTokenFilter jwtTokenFilter) { this.userRepo = userRepo; this.jwtTokenFilter = jwtTokenFilter; } // Details omitted for brevity @Override protected void configure(HttpSecurity http) throws Exception { // Enable CORS and disable CSRF http = http.cors().and().csrf().disable(); // Set session management to stateless http = http .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and(); // Set unauthorized requests exception handler http = http .exceptionHandling() .authenticationEntryPoint( (request, response, ex) -> { response.sendError( HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage() ); } ) .and(); // Set permissions on endpoints http.authorizeRequests() // Our public endpoints .antMatchers("/api/public/**").permitAll() .antMatchers(HttpMethod.GET, "/api/author/**").permitAll() .antMatchers(HttpMethod.POST, "/api/author/search").permitAll() .antMatchers(HttpMethod.GET, "/api/book/**").permitAll() .antMatchers(HttpMethod.POST, "/api/book/search").permitAll() // Our private endpoints .anyRequest().authenticated(); // Add JWT token filter http.addFilterBefore( jwtTokenFilter, UsernamePasswordAuthenticationFilter.class ); } // Used by spring security if CORS is enabled. @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }
Pamiętaj, że dodaliśmy JwtTokenFilter
przed wewnętrznym UsernamePasswordAuthenticationFilter
Spring Security . Robimy to, ponieważ w tym momencie potrzebujemy dostępu do tożsamości użytkownika, aby przeprowadzić uwierzytelnianie/autoryzację, a jej wyodrębnianie odbywa się wewnątrz filtru tokenów JWT na podstawie dostarczonego tokenu JWT. Jest to realizowane w następujący sposób:
@Component public class JwtTokenFilter extends OncePerRequestFilter { private final JwtTokenUtil jwtTokenUtil; private final UserRepo userRepo; public JwtTokenFilter(JwtTokenUtil jwtTokenUtil, UserRepo userRepo) { this.jwtTokenUtil = jwtTokenUtil; this.userRepo = userRepo; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // Get authorization header and validate final String header = request.getHeader(HttpHeaders.AUTHORIZATION); if (isEmpty(header) || !header.startsWith("Bearer ")) { chain.doFilter(request, response); return; } // Get jwt token and validate final String token = header.split(" ")[1].trim(); if (!jwtTokenUtil.validate(token)) { chain.doFilter(request, response); return; } // Get user identity and set it on the spring security context UserDetails userDetails = userRepo .findByUsername(jwtTokenUtil.getUsername(token)) .orElse(null); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails == null ? List.of() : userDetails.getAuthorities() ); authentication.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) ); SecurityContextHolder.getContext().setAuthentication(authentication); chain.doFilter(request, response); } }
Przed wdrożeniem funkcji API logowania musimy zadbać o jeszcze jeden krok - potrzebujemy dostępu do menedżera uwierzytelniania. Domyślnie nie jest publicznie dostępny i musimy jawnie ujawnić go jako fasolę w naszej klasie konfiguracyjnej.

Można to zrobić w następujący sposób:
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { // Details omitted for brevity @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } }
A teraz jesteśmy gotowi do wdrożenia naszej funkcji API logowania:
@Api(tags = "Authentication") @RestController @RequestMapping(path = "api/public") public class AuthApi { private final AuthenticationManager authenticationManager; private final JwtTokenUtil jwtTokenUtil; private final UserViewMapper userViewMapper; public AuthApi(AuthenticationManager authenticationManager, JwtTokenUtil jwtTokenUtil, UserViewMapper userViewMapper) { this.authenticationManager = authenticationManager; this.jwtTokenUtil = jwtTokenUtil; this.userViewMapper = userViewMapper; } @PostMapping("login") public ResponseEntity<UserView> login(@RequestBody @Valid AuthRequest request) { try { Authentication authenticate = authenticationManager .authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword() ) ); User user = (User) authenticate.getPrincipal(); return ResponseEntity.ok() .header( HttpHeaders.AUTHORIZATION, jwtTokenUtil.generateAccessToken(user) ) .body(userViewMapper.toUserView(user)); } catch (BadCredentialsException ex) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } } }
Tutaj weryfikujemy podane poświadczenia za pomocą menedżera uwierzytelniania, a w przypadku powodzenia generujemy token JWT i zwracamy go jako nagłówek odpowiedzi wraz z informacjami o tożsamości użytkownika w treści odpowiedzi.
Autoryzacja z Spring Security
W poprzedniej sekcji skonfigurowaliśmy proces uwierzytelniania i skonfigurowaliśmy publiczne/prywatne adresy URL. Może to wystarczyć w przypadku prostych aplikacji, ale w większości rzeczywistych przypadków użycia zawsze potrzebujemy zasad dostępu opartych na rolach dla naszych użytkowników. W tym rozdziale zajmiemy się tym problemem i skonfigurujemy oparty na rolach schemat autoryzacji przy użyciu frameworka Spring Security.
W naszej przykładowej aplikacji zdefiniowaliśmy następujące trzy role:
-
USER_ADMIN
pozwala nam zarządzać użytkownikami aplikacji. -
AUTHOR_ADMIN
pozwala nam zarządzać autorami. -
BOOK_ADMIN
pozwala nam zarządzać książkami.
Teraz musimy zastosować je do odpowiednich adresów URL:
-
api/public
jest publicznie dostępny. -
api/admin/user
mogą uzyskiwać dostęp do użytkowników z roląUSER_ADMIN
. -
api/author
może uzyskiwać dostęp do użytkowników z roląAUTHOR_ADMIN
. -
api/book
może uzyskiwać dostęp do użytkowników z roląBOOK_ADMIN
.
Framework Spring Security daje nam dwie opcje konfiguracji schematu autoryzacji:
- Konfiguracja oparta na adresach URL
- Konfiguracja oparta na adnotacjach
Najpierw zobaczmy, jak działa konfiguracja oparta na adresach URL. Można go zastosować do konfiguracji bezpieczeństwa sieci w następujący sposób:
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { // Details omitted for brevity @Override protected void configure(HttpSecurity http) throws Exception { // Enable CORS and disable CSRF http = http.cors().and().csrf().disable(); // Set session management to stateless http = http .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and(); // Set unauthorized requests exception handler http = http .exceptionHandling() .authenticationEntryPoint( (request, response, ex) -> { response.sendError( HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage() ); } ) .and(); // Set permissions on endpoints http.authorizeRequests() // Our public endpoints .antMatchers("/api/public/**").permitAll() .antMatchers(HttpMethod.GET, "/api/author/**").permitAll() .antMatchers(HttpMethod.POST, "/api/author/search").permitAll() .antMatchers(HttpMethod.GET, "/api/book/**").permitAll() .antMatchers(HttpMethod.POST, "/api/book/search").permitAll() // Our private endpoints .antMatchers("/api/admin/user/**").hasRole(Role.USER_ADMIN) .antMatchers("/api/author/**").hasRole(Role.AUTHOR_ADMIN) .antMatchers("/api/book/**").hasRole(Role.BOOK_ADMIN) .anyRequest().authenticated(); // Add JWT token filter http.addFilterBefore( jwtTokenFilter, UsernamePasswordAuthenticationFilter.class ); } // Details omitted for brevity }
Jak widać, takie podejście jest proste i jednoznaczne, ale ma jedną wadę. Schemat autoryzacji w naszej aplikacji może być złożony, a jeśli zdefiniujemy wszystkie reguły w jednym miejscu, stanie się on bardzo duży, złożony i trudny do odczytania. Z tego powodu zazwyczaj wolę używać konfiguracji opartej na adnotacjach.
Framework Spring Security definiuje następujące adnotacje dotyczące bezpieczeństwa sieci:
-
@PreAuthorize
obsługuje Spring Expression Language i służy do zapewnienia kontroli dostępu opartej na wyrażeniach przed wykonaniem metody. -
@PostAuthorize
obsługuje Spring Expression Language i służy do zapewnienia kontroli dostępu opartej na wyrażeniach po wykonaniu metody (zapewnia możliwość dostępu do wyniku metody). -
@PreFilter
obsługuje Spring Expression Language i służy do filtrowania kolekcji lub tablic przed wykonaniem metody na podstawie zdefiniowanych przez nas niestandardowych reguł bezpieczeństwa. -
@PostFilter
obsługuje Spring Expression Language i służy do filtrowania zwróconej kolekcji lub tablic po wykonaniu metody na podstawie zdefiniowanych przez nas niestandardowych reguł bezpieczeństwa (zapewnia możliwość dostępu do wyniku metody). -
@Secured
nie obsługuje Spring Expression Language i służy do określenia listy ról w metodzie. -
@RolesAllowed
nie obsługuje Spring Expression Language i jest odpowiednikiem adnotacji@Secured
w JSR 250.
Te adnotacje są domyślnie wyłączone i można je włączyć w naszej aplikacji w następujący sposób:
@EnableWebSecurity @EnableGlobalMethodSecurity( securedEnabled = true, jsr250Enabled = true, prePostEnabled = true ) public class SecurityConfig extends WebSecurityConfigurerAdapter { // Details omitted for brevity }
securedEnabled = true
włącza adnotację @Secured
.
jsr250Enabled = true
włącza adnotację @RolesAllowed
.
prePostEnabled = true
włącza @PreAuthorize
, @PostAuthorize
, @PreFilter
, @PostFilter
.
Po ich włączeniu możemy wymusić zasady dostępu oparte na rolach na naszych punktach końcowych API w następujący sposób:
@Api(tags = "UserAdmin") @RestController @RequestMapping(path = "api/admin/user") @RolesAllowed(Role.USER_ADMIN) public class UserAdminApi { // Details omitted for brevity } @Api(tags = "Author") @RestController @RequestMapping(path = "api/author") public class AuthorApi { // Details omitted for brevity @RolesAllowed(Role.AUTHOR_ADMIN) @PostMapping public void create() { } @RolesAllowed(Role.AUTHOR_ADMIN) @PutMapping("{id}") public void edit() { } @RolesAllowed(Role.AUTHOR_ADMIN) @DeleteMapping("{id}") public void delete() { } @GetMapping("{id}") public void get() { } @GetMapping("{id}/book") public void getBooks() { } @PostMapping("search") public void search() { } } @Api(tags = "Book") @RestController @RequestMapping(path = "api/book") public class BookApi { // Details omitted for brevity @RolesAllowed(Role.BOOK_ADMIN) @PostMapping public BookView create() { } @RolesAllowed(Role.BOOK_ADMIN) @PutMapping("{id}") public void edit() { } @RolesAllowed(Role.BOOK_ADMIN) @DeleteMapping("{id}") public void delete() { } @GetMapping("{id}") public void get() { } @GetMapping("{id}/author") public void getAuthors() { } @PostMapping("search") public void search() { } }
Należy pamiętać, że adnotacje dotyczące bezpieczeństwa mogą być dostarczane zarówno na poziomie klasy, jak i metody.
Przedstawione przykłady są proste i nie reprezentują rzeczywistych scenariuszy, ale Spring Security zapewnia bogaty zestaw adnotacji i możesz obsłużyć złożony schemat autoryzacji, jeśli zdecydujesz się ich użyć.
Domyślny prefiks nazwy roli
W tym oddzielnym podrozdziale chcę podkreślić jeszcze jeden subtelny szczegół, który dezorientuje wielu nowych użytkowników.
Struktura Spring Security rozróżnia dwa terminy:
-
Authority
reprezentuje indywidualne pozwolenie. -
Role
reprezentuje grupę uprawnień.
Oba mogą być reprezentowane za pomocą jednego interfejsu o nazwie GrantedAuthority
, a później sprawdzane za pomocą Spring Expression Language w adnotacjach Spring Security w następujący sposób:
-
Authority
: @PreAuthorize(“hasAuthority('EDIT_BOOK')”) -
Role
: @PreAuthorize(“hasRole('BOOK_ADMIN')”)
Aby różnica między tymi dwoma terminami była bardziej jednoznaczna, framework Spring Security domyślnie dodaje przedrostek ROLE_
do nazwy roli. Dlatego zamiast sprawdzać rolę o nazwie BOOK_ADMIN
, sprawdzi ROLE_BOOK_ADMIN
.
Osobiście uważam to zachowanie za mylące i wolę wyłączyć je w moich aplikacjach. Można go wyłączyć w konfiguracji Spring Security w następujący sposób:
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { // Details omitted for brevity @Bean GrantedAuthorityDefaults grantedAuthorityDefaults() { return new GrantedAuthorityDefaults(""); // Remove the ROLE_ prefix } }
Testowanie z Spring Security
Aby przetestować nasze punkty końcowe za pomocą testów jednostkowych lub integracyjnych podczas korzystania z frameworka Spring Security, musimy dodać zależność spring-security-test
wraz z spring-boot-starter-test
. Nasz plik kompilacji pom.xml
będzie wyglądał tak:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency>
Ta zależność daje nam dostęp do niektórych adnotacji, których można użyć do dodania kontekstu bezpieczeństwa do naszych funkcji testowych.
Te adnotacje to:
-
@WithMockUser
można dodać do metody testowej, aby emulować bieganie z fałszywym użytkownikiem. -
@WithUserDetails
można dodać do metody testowej, aby emulować działanie zUserDetails
zwróconymi zUserDetailsService
. -
@WithAnonymousUser
można dodać do metody testowej, aby emulować działanie z anonimowym użytkownikiem. Jest to przydatne, gdy użytkownik chce przeprowadzić większość testów jako określony użytkownik i zastąpić kilka metod, aby zachować anonimowość. -
@WithSecurityContext
określa, jakiegoSecurityContext
użyć, a wszystkie trzy opisane powyżej adnotacje są na nim oparte. Jeśli mamy konkretny przypadek użycia, możemy stworzyć własną adnotację, która używa@WithSecurityContext
do tworzenia dowolnegoSecurityContext
. Jego dyskusja wykracza poza zakres naszego artykułu i więcej szczegółów można znaleźć w dokumentacji Spring Security.
Najłatwiejszym sposobem na uruchomienie testów z konkretnym użytkownikiem jest użycie adnotacji @WithMockUser
. Możemy za jego pomocą stworzyć próbnego użytkownika i uruchomić test w następujący sposób:
@Test @WithMockUser(username="[email protected]", roles={"USER_ADMIN"}) public void test() { // Details omitted for brevity }
Takie podejście ma jednak kilka wad. Po pierwsze, próbny użytkownik nie istnieje, a jeśli uruchomisz test integracyjny, który później zapyta o informacje o użytkowniku z bazy danych, test zakończy się niepowodzeniem. Po drugie, pozornym użytkownikiem jest instancja klasy org.springframework.security.core.userdetails.User
, która jest wewnętrzną implementacją interfejsu UserDetails
we frameworku Spring, a jeśli mamy własną implementację, może to powodować konflikty później, podczas wykonanie testu.
Jeśli poprzednie wady blokują naszą aplikację, to adnotacja @WithUserDetails
jest drogą do zrobienia. Jest używany, gdy mamy niestandardowe UserDetails
i UserDetailsService
. Zakłada, że użytkownik istnieje, więc przed uruchomieniem testów musimy albo utworzyć rzeczywisty wiersz w bazie danych, albo dostarczyć instancję makiety UserDetailsService
.
Oto jak możemy użyć tej adnotacji:
@Test @WithUserDetails("[email protected]") public void test() { // Details omitted for brevity }
Jest to preferowana adnotacja w testach integracyjnych naszego przykładowego projektu, ponieważ mamy niestandardowe implementacje wyżej wymienionych interfejsów.
Korzystanie @WithAnonymousUser
umożliwia działanie jako anonimowy użytkownik. Jest to szczególnie wygodne, gdy chcesz przeprowadzić większość testów z określonym użytkownikiem, ale kilka testów jako użytkownik anonimowy. Na przykład, poniżej uruchomią przypadki testowe test1 i test2 z pozornym użytkownikiem i test3 z użytkownikiem anonimowym:
@SpringBootTest @AutoConfigureMockMvc @WithMockUser public class WithUserClassLevelAuthenticationTests { @Test public void test1() { // Details omitted for brevity } @Test public void test2() { // Details omitted for brevity } @Test @WithAnonymousUser public void test3() throws Exception { // Details omitted for brevity } }
Zawijanie
Na koniec chciałbym wspomnieć, że framework Spring Security prawdopodobnie nie wygra żadnego konkursu piękności i zdecydowanie ma stromą krzywą uczenia się. Spotkałem się z wieloma sytuacjami, w których został on zastąpiony jakimś własnym rozwiązaniem ze względu na złożoność początkowej konfiguracji. Ale gdy programiści zrozumieją jego wnętrze i zdołają skonfigurować początkową konfigurację, staje się ona stosunkowo prosta w użyciu.
W tym artykule starałem się pokazać wszystkie subtelne szczegóły konfiguracji i mam nadzieję, że przykłady okażą się przydatne. Pełne przykłady kodu można znaleźć w repozytorium Git mojego przykładowego projektu Spring Security.