Wiosna jest uważana za godne zaufania ramy w ekosystemie Jawy i jest powszechnie stosowana. Nie ma już sensu nazywać Spring jako frameworka, ponieważ jest to raczej termin parasolowy, który obejmuje różne frameworki. Jednym z tych frameworków jest Spring Security, który jest potężnym i możliwym do dostosowania frameworkiem uwierzytelniania i autoryzacji. Jest on uważany za de facto standard zabezpieczania aplikacji opartych na Spring.

Pomimo jego popularności, muszę przyznać, że jeśli chodzi o aplikacje jednostronicowe, to nie jest on prosty i prosty w konfiguracji. Podejrzewam, że powodem jest to, że zaczął się on bardziej jako framework zorientowany na aplikacje MVC, gdzie rendering stron internetowych odbywa się po stronie serwera, a komunikacja odbywa się sesyjnie.

Jeśli back end jest oparty na Javie i Spring, sensowne jest użycie Spring Security do uwierzytelniania/autoryzacji i skonfigurowanie go do komunikacji bezstanowiskowej. Podczas gdy istnieje wiele artykułów wyjaśniających jak to jest robione, dla mnie wciąż frustrujące było skonfigurowanie go po raz pierwszy, a ja 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 dziwactwa, jakie można napotkać podczas procesu konfiguracji.

Definiowanie Terminologii

Zanim zagłębię się w szczegóły techniczne, chcę wyraźnie zdefiniować terminologię stosowaną w kontekście Spring Security, aby mieć pewność, że wszyscy mówimy tym samym językiem.

To są terminy, którymi musimy się zająć:

  • Uwierzytelnianie odnosi się do procesu weryfikacji tożsamości użytkownika, na podstawie dostarczonych danych uwierzytelniających. Typowym przykładem jest wprowadzenie nazwy użytkownika i hasła podczas logowania się na stronie internetowej. Można o tym myśleć jako o odpowiedzi na pytanie Kim jesteś?
  • Autoryzacja odnosi się do procesu ustalania, czy użytkownik posiada odpowiednie uprawnienia do wykonania określonej czynności lub odczytu określonych danych, przy założeniu, że użytkownik został skutecznie uwierzytelniony. Możesz o tym myśleć jako o odpowiedzi na pytanie Czy użytkownik może to zrobić/odczytać?
  • Zasada odnosi się do aktualnie uwierzytelnionego użytkownika.
  • Przyznana władza odnosi się do zgody uwierzytelnionego użytkownika.
  • Rola odnosi się do grupy uprawnień uwierzytelnionego użytkownika.

Tworzenie podstawowej aplikacji wiosennej

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. Dla prostej aplikacji webowej, wystarczy tylko zależność od frameworka Spring:

    
        org.springframework.boot
        Springboot-starter-web
    

Po utworzeniu projektu, możemy dodać do niego prosty kontroler REST w następujący sposób:

@RestController @RequestMapping("hello")
Klasa publiczna HelloRestController {i0}

    @GetMapping("użytkownik")
    Public String helloUser() {i1}
        wrócić "Hello User";
    }

    @GetMapping("admin")
    Public String helloAdmin() {i0}
        wrócić "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 łańcuch Hello User.
  • http://localhost:8080/hello/admin zwróci łańcuch Hello Admin.

Teraz, możemy dodać Spring Security framework do naszego projektu, i możemy to zrobić dodając następującą zależność do naszego pliku pom.xml:

    
      org.springframework.boot
      zabezpieczenie rozruchu sprężynowego
    

Dodawanie innych zależności od frameworka Spring zazwyczaj nie ma natychmiastowego wpływu na aplikację, dopóki nie podamy odpowiedniej konfiguracji, ale Spring Security różni się tym, że ma natychmiastowy efekt, a to zwykle dezorientuje nowych użytkowników. Po dodaniu go, jeśli przebudujemy i uruchomimy projekt, a następnie spróbujemy uzyskać dostęp do jednego z wyżej wymienionych adresów URL zamiast przeglądać wynik, zostaniemy przekierowani na http://localhost:8080/login. Jest to zachowanie domyślne, ponieważ framework Spring Security wymaga autoryzacji poza ramką dla wszystkich adresów URL.

Aby przekazać uwierzytelnienie, możemy użyć domyślnej nazwy użytkownika i znaleźć automatycznie wygenerowane hasło w naszej konsoli:

Użycie wygenerowanego hasła bezpieczeństwa: 1fc15145-dfee-4bec-a009-e32ca21c77ce

Proszę pamiętać, że hasło zmienia się za każdym razem, gdy ponownie uruchamiamy aplikację. 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 prawidłowy 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 nieszablonowe zachowanie może być użyteczne w klasycznych aplikacjach internetowych MVC, gdzie mamy uwierzytelnianie sesyjne, ale w przypadku aplikacji jednostronicowych zazwyczaj nie jest ono użyteczne, ponieważ w większości przypadków mamy renderowanie po stronie klienta i uwierzytelnianie bezpaństwowe oparte na JWT. W tym przypadku, będziemy musieli mocno dostosować framework Spring Security, co zrobimy w dalszej części artykułu.

Jako przykład, zaimplementujemy klasyczną aplikację internetową dla księgarń i stworzymy back end, który zapewni API CRUD do tworzenia autorów i książek oraz API do zarządzania użytkownikami i uwierzytelniania.

Spring Security Architecture Overview

Łańcuch filtrów zabezpieczających sprężynowych

Po dodaniu frameworka Spring Security do aplikacji, automatycznie rejestruje on łańcuch filtrów, który przechwytuje wszystkie przychodzące żądania. Łańcuch ten 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 na podstawie 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 z ważnych szczegółów, o którym chcę wspomnieć jest to, że filtry Spring Security są rejestrowane w najniższej kolejności i są pierwszymi wywoływanymi filtrami. W niektórych przypadkach użycia, jeśli chcesz umieścić przed nimi filtr niestandardowy, musisz dodać do ich kolejności wyściełanie. Można to zrobić w następującej konfiguracji:

spring.security.filter.order=10

Po dodaniu tej konfiguracji do naszego pliku application.properties, będziemy mieli miejsce na 10 niestandardowych filtrów przed filtrami Spring Security.

AuthenticationManager

Możesz myśleć o AuthenticationManager jako o koordynatorze, gdzie możesz zarejestrować wielu dostawców, a na podstawie rodzaju wniosku, dostarczy wniosek o uwierzytelnienie do właściwego dostawcy.

AuthenticationProvider

AuthenticationProvider przetwarza określone rodzaje uwierzytelnienia. Jego interfejs udostępnia tylko dwie funkcje:

  • Authenticate wykonuje uwierzytelnienie z żądaniem.
  • obsługuje 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 dane użytkownika z UserDetailsService.

UserDetailsService .

Usługa UserDetailsService jest opisana jako podstawowy interfejs, który ładuje dane specyficzne dla użytkownika w dokumentacji Spring.

W większości przypadków dostawcy usług uwierzytelniania pobierają informacje o tożsamości użytkownika na podstawie danych uwierzytelniających z bazy danych, a następnie przeprowadzają walidację. Ponieważ ten przypadek użycia jest tak powszechny, twórcy Spring postanowili wyodrębnić go jako oddzielny interfejs, który eksponuje pojedynczą funkcję:

  • loadUserByUsername akceptuje nazwę użytkownika jako parametr i zwraca obiekt tożsamości użytkownika.

Uwierzytelnianie przy użyciu JWT z zabezpieczeniami Spring

Po omówieniu wewnętrznych aspektów Spring Security, skonfigurujmy go do uwierzytelniania bezpaństwowego za pomocą tokena JWT.

W celu dostosowania do własnych potrzeb, potrzebujemy klasy konfiguracji opatrzonej adnotacją @EnableWebSecurity w naszej klasie. Ponadto, aby uprościć proces dostosowywania, framework eksponuje klasę WebSecurityConfigurerAdapter. Rozszerzymy ten adapter i obejmiemy obie jego funkcje w taki sposób:

  1. Skonfiguruj menedżera uwierzytelniania u właściwego dostawcy
  2. Konfiguracja zabezpieczeń sieciowych (publiczne adresy URL, prywatne adresy URL, autoryzacja, itp.)
@EnableWebSecurity
Klasa publiczna SecurityConfig rozszerza WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {i0}
        // TODO configure authentication manager
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {i0}
        // TODO configure web security
    }

}

W naszej przykładowej aplikacji, przechowujemy tożsamość użytkownika w bazie danych MongoDB, w kolekcji użytkowników. Tożsamości te są mapowane przez encję User, a ich operacje CRUD są definiowane przez repozytorium danych Spring Data UserRepo.

Teraz, po zaakceptowaniu prośby o uwierzytelnienie, musimy pobrać prawidłową tożsamość z bazy danych, korzystając z podanych danych uwierzytelniających, a następnie ją zweryfikować. Do tego celu potrzebna jest implementacja interfejsu UserDetailsService, który jest zdefiniowany w następujący sposób:

interfejs publiczny UserDetailsService {

    UserDetails loadUserByUsername(String username)
            rzuca UsernameNotFoundException;

}

Tutaj widzimy, że wymagany jest zwrot obiektu, który implementuje interfejs UserDetails, a nasza jednostka użytkownika implementuje go (szczegóły implementacji znajdują się w przykładowym repozytorium projektu). Biorąc pod uwagę fakt, że eksponuje on tylko prototyp jednofunkcyjny, możemy potraktować go jako interfejs funkcjonalny i dostarczyć implementację jako wyrażenie lambda.

@EnableWebSecurity
Klasa publiczna SecurityConfig rozszerza WebSecurityConfigurerAdapter {

    prywatny użytkownik końcowyRepo userRepo;

    public SecurityConfig(UserRepo userRepo) {
        this.userRepo = userRepo;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {i0}
        auth.userDetailsService(username -> userRepo
            .findByUsername(nazwa użytkownika)
            .orElse Throw(
                () -> new UsernameNotFoundException(
                    format("Użytkownik: %s, nie znaleziono", nazwa użytkownika)
                )
            ));
    }

    // Szczegóły pominięte dla zwięzłości

}

Tutaj, wywołanie funkcji auth.userDetailsService zainicjuje instancję DaoAuthenticationProvider za pomocą naszej implementacji interfejsu UserDetailsService i zarejestruje ją w menedżerze uwierzytelniania.

Wraz z dostawcą uwierzytelniania musimy skonfigurować menedżera uwierzytelniania z odpowiednim schematem kodowania haseł, który będzie używany do weryfikacji danych uwierzytelniających. W tym celu musimy wyeksponować preferowaną implementację interfejsu PasswordEncoder jako fasolki.

W naszym przykładowym projekcie użyjemy algorytmu bcrypt password-hashingu.

@EnableWebSecurity
Klasa publiczna SecurityConfig rozszerza WebSecurityConfigurerAdapter {

    prywatny użytkownik końcowyRepo userRepo;

    public SecurityConfig(UserRepo userRepo) {
        this.userRepo = userRepo;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {i0}
        auth.userDetailsService(username -> userRepo
            .findByUsername(nazwa użytkownika)
            .orElse Throw(
                () -> new UsernameNotFoundException(
                    format("Użytkownik: %s, nie znaleziono", nazwa użytkownika)
                )
            ));
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        zwrócić nowy BCryptPasswordEncoder();
    }

    // Szczegóły pominięte dla zwięzłości

}

Po skonfigurowaniu menedżera uwierzytelniania, musimy teraz skonfigurować zabezpieczenia internetowe. Implementujemy REST API i potrzebujemy bezpaństwowego uwierzytelniania za pomocą tokena JWT, dlatego też musimy ustawić następujące opcje:

  • Włączyć CORS i wyłączyć CSRF.
  • Ustawienie zarządzania sesją na stateless.
  • Ustawić obsługę wyjątków od nieautoryzowanych żądań.
  • Ustawienie uprawnień na punktach końcowych.
  • Dodaj filtr tokenowy JWT.

Ta konfiguracja jest realizowana w następujący sposób:

@EnableWebSecurity
Klasa publiczna SecurityConfig rozszerza WebSecurityConfigurerAdapter {

    prywatny użytkownik końcowyRepo userRepo;
    prywatny końcowy JwtTokenFilter jwtTokenFilter;

    Bezpieczeństwo publiczneConfig(UserRepo userRepo,
                          JwtTokenFilter jwtTokenFilter) {i0}
        this.userRepo = userRepo;
        this.jwtTokenFilter = jwtTokenFilter;
    }

    // Szczegóły pominięte dla zwięzłości

    @Override
    protected void configure(HttpSecurity http) throws Exception {i0}
        /Włączyć CORS i wyłączyć CSRF
        http = http.cors().and().csrf().disable();

        /Ustawienie zarządzania sesją na stateless
        http = http
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            ...i();

        // Ustawić obsługę nieautoryzowanych zgłoszeń
        http = http
            .exceptionHandling()
            .authenticationEntryPoint(
                (wniosek, odpowiedź, ex) -> {i1}
                    response.sendError(
                        HttpServletResponse.SC_UNAUTHORIZED,
                        ex.getMessage()
                    );
                }
            )
            ...i();

        /Ustawienie uprawnień na punktach końcowych
        http.autorizeRequests()
            /Nasze publiczne punkty końcowe
            .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()
            // Nasze prywatne punkty końcowe
            .anyRequest().authenticated();

        // Dodaj filtr żetonowy JWT
        http.addFilterBefore(
            jwtTokenFilter,
            UsernamePasswordAuthenticationFilter.class
        );
    }

    /Używany przez zabezpieczenie sprężynowe, jeśli CORS jest włączony.
    @Bean
    public CorsFilter corsFilter() {i0}
        UrlBasedCorsConfigurationSource source =
            nowy UrlBasedCorsConfigurationSource();
        CorsConfiguration config = nowy CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        source.registerCorsConfiguration("/**", config);
        zwróć nowy CorsFilter(source);
    }

}

Proszę zauważyć, że dodaliśmy filtr JwtTokenFilter przed Spring Security wewnętrzny UsernamePasswordAuthenticationFilter. Robimy to, ponieważ w tym momencie potrzebujemy dostępu do tożsamości użytkownika, aby przeprowadzić uwierzytelnianie/autoryzację, a jego ekstrakcja odbywa się wewnątrz filtra tokenowego JWT opartego na dostarczonym tokenie JWT. Jest to realizowane w następujący sposób:

@Component
klasa publiczna JwtTokenFilter rozszerza OncePerRequestFilter {i0}

    prywatny finał JwtTokenUtil jwtTokenUtil;
    prywatny użytkownik końcowyRepo userRepo;

    public JwtTokenFilter(JwtTokenUtil jwtTokenUtil,
                          UserRepo userRepo) {
        this.jwtTokenUtil = jwtTokenUtil;
        this.userRepo = userRepo;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    Odpowiedź HttpServletResponse,
                                    Łańcuch FilterChain)
            rzuca ServletException, IOException {i0}
        /Dostać nagłówek autoryzacji i zatwierdzić
        Final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (isEmpty(header) || !header.startsWith("Bearer ")) {
            chain.doFilter(prośba, odpowiedź);
            powrót;
        }

        /Zdobądź żeton jwt i zatwierdzaj
        Final String token = header.split(" ")[1].trim();
        if (!jwtTokenUtil.validate(token))) {
            chain.doFilter(prośba, odpowiedź);
            powrót;
        }

        /Zdobądź tożsamość użytkownika i ustaw ją na wiosenny kontekst bezpieczeństwa
        UserDetails userDetails = userRepo
            .findByUsername(jwtTokenUtil.getUsername(token))
            . orElse(nieważne);

        UsernamePasswordAuthenticationToken
            authentication = nowy UsernamePasswordAuthenticationToken(
                userDetails, nieważne,
                userDetails == null?
                    List.of() : userDetails.getAuthorities()
            );

        authentication.setDetails(
            nowy WebAuthenticationDetailsSource().buildDetails(request)
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);
        chain.doFilter(żądanie, odpowiedź);
    }

}

Przed wdrożeniem naszej funkcji API logowania, musimy zrobić jeszcze jeden krok - potrzebujemy dostępu do menedżera uwierzytelniania. Domyślnie nie jest on publicznie dostępny i musimy wyraźnie wyeksponować go jako fasolkę w naszej klasie konfiguracji.

Można to zrobić w następujący sposób:

@EnableWebSecurity
Klasa publiczna SecurityConfig rozszerza WebSecurityConfigurerAdapter {

    // Szczegóły pominięte dla zwięzłości

    @Override @Bean
    public AuthenticationManager authenticationManagerBean() rzuca Wyjątek {
        zwrócić super.authenticationManagerBean();
    }

}

A teraz jesteśmy gotowi do wdrożenia naszej funkcji API logowania:

@Api(tagi = "Uwierzytelnianie")
@RestController @RequestMapping(ścieżka = "api/public")
klasa publiczna AuthApi {i1}

    prywatny końcowy AuthenticationManager authenticationManager;
    private final JwtTokenUtil jwtTokenUtil;
    prywatny użytkownik końcowy UserViewMapper userViewMapper;

    public AuthApi(AuthenticationManager authenticationManager,
                   JwtTokenUtil jwtTokenUtil,
                   UserViewMapper userViewMapper) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenUtil = jwtTokenUtil;
        this.userViewMapper = userViewMapper;
    }

    @PostMapping("login")
    public ResponseEntity login(@RequestBody @Valid AuthRequest request) {
        starać się {i0}
            Authentication authenticate = uwierzytelnianieManager
                .authenticate(
                    nowa nazwa użytkownikaHasłoAuthenticationToken(
                        request.getUsername(), request.getPassword()
                    )
                );

            User user = (Użytkownik) authenticate.getPrincipal();

            powrót ResponseEntity.ok()
                .header(
                    HttpHeaders.AUTHORYZACJA,
                    jwtTokenUtil.generateAccessToken(użytkownik)
                )
                .body(userViewMapper.toUserView(użytkownik));
        } catch (BadCredentialsException ex) {i1}
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
    }

}

Tutaj weryfikujemy podane dane uwierzytelniające 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 organie odpowiedzi.

Autoryzacja z zabezpieczeniem wiosennym

W poprzedniej sekcji ustawiliśmy proces uwierzytelniania i skonfigurowaliśmy publiczne/prywatne adresy URL. Może to wystarczyć w przypadku prostych zastosowań, ale w większości przypadków rzeczywistego wykorzystania zawsze potrzebujemy polityk dostępu opartych na rolach dla naszych użytkowników. W tym rozdziale zajmiemy się tym problemem i stworzymy schemat autoryzacji oparty na rolach przy użyciu frameworka Spring Security.

W naszej przykładowej aplikacji zdefiniowaliśmy następujące trzy role:

  • USER_ADMIN pozwala nam na zarządzanie użytkownikami aplikacji.
  • AUTHOR_ADMIN pozwala nam na zarządzanie autorami.
  • BOOK_ADMIN pozwala nam na zarządzanie książkami.

Teraz musimy zastosować je do odpowiednich adresów URL:

  • api/public jest publicznie dostępny.
  • api/admin/użytkownik ma dostęp do użytkowników z rolą USER_ADMIN.
  • api/autor może uzyskać dostęp do użytkowników za pomocą roli AUTHOR_ADMIN.
  • api/autor może uzyskać dostęp do użytkowników za pomocą roli BOOK_ADMIN.

Szkielet bezpieczeństwa Spring daje nam dwie możliwości konfiguracji schematu autoryzacji:

  • Konfiguracja na podstawie adresu URL
  • Konfiguracja oparta na adnotacjach

Po pierwsze, zobaczmy jak działa konfiguracja oparta na adresie URL. Można ją zastosować do konfiguracji zabezpieczeń internetowych w następujący sposób:

@EnableWebSecurity
Klasa publiczna SecurityConfig rozszerza WebSecurityConfigurerAdapter {

    // Szczegóły pominięte dla zwięzłości

    @Override
    protected void configure(HttpSecurity http) throws Exception {i0}
        /Włączyć CORS i wyłączyć CSRF
        http = http.cors().and().csrf().disable();

        /Ustawienie zarządzania sesją na stateless
        http = http
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            ...i();

        // Ustawić obsługę nieautoryzowanych zgłoszeń
        http = http
            .exceptionHandling()
            .authenticationEntryPoint(
                (wniosek, odpowiedź, ex) -> {i1}
                    response.sendError(
                        HttpServletResponse.SC_UNAUTHORIZED,
                        ex.getMessage()
                    );
                }
            )
            ...i();

        /Ustawienie uprawnień na punktach końcowych
        http.autorizeRequests()
            /Nasze publiczne punkty końcowe
            .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()
            // Nasze prywatne punkty końcowe
            .antMatchers("/api/admin/user/**").hasRole(Role.USER_ADMIN)
            .antMatchers("/api/author/**").hasRole(Role.AUTHOR_ADMIN)
            .antMatchers("/api/book/**").hasRole(Role.BOOK_ADMIN)
            .anyRequest().authenticated();

        // Dodaj filtr żetonowy JWT
        http.addFilterBefore(
            jwtTokenFilter,
            UsernamePasswordAuthenticationFilter.class
        );
    }

    // Szczegóły pominięte dla zwięzłości

}

Jak widać, to podejście jest proste i proste, ale ma jedną wadę. Schemat autoryzacji w naszej aplikacji może być skomplikowany, 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.

Spring Security framework definiuje następujące adnotacje dla bezpieczeństwa sieci:

  • @PreAuthorize obsługuje Spring Expression Language i jest używany do zapewnienia kontroli dostępu w oparciu o wyrażenia przed wykonaniem metody.
  • @PostAuthorize obsługuje Spring Expression Language i jest używane 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 jest używane do filtrowania kolekcji lub tablic przed wykonaniem metody w oparciu o niestandardowe reguły bezpieczeństwa, które definiujemy.
  • @PostFilter obsługuje Spring Expression Language i służy do filtrowania zwracanej kolekcji lub tablic po wykonaniu metody w oparciu o zdefiniowane przez nas niestandardowe reguły bezpieczeństwa (daje możliwość dostępu do wyniku metody).
  • @Secured nie obsługuje Spring Expression Language i jest używane do określenia listy ról w danej metodzie.
  • @RolesAllowed nie obsługuje Spring Expression Language i jest odpowiednikiem adnotacji @Secured w JSR 250.

Adnotacje te są domyślnie wyłączone i mogą być włączone w naszej aplikacji w następujący sposób:

@EnableWebSecurity
@EnableGlobalMethodSecurity(
    securedEnabled = prawdziwe,
    jsr250Enabled = prawda,
    prePostEnabled = prawdziwy
)
Klasa publiczna SecurityConfig rozszerza WebSecurityConfigurerAdapter {

    // Szczegóły pominięte dla zwięzłości

}

securedEnabled = prawdziwy umożliwia @Secured annotację.
jsr250Enabled = true umożliwia @RolesAllowed annotation.
prePostEnabled = true włącza @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter anotacje.

Po ich włączeniu możemy egzekwować zasady dostępu oparte na rolach na naszych punktach końcowych API w ten sposób:

@Api(tagi = "UserAdmin")
@RestController @RequestMapping(ścieżka = "api/admin/użytkownik")
@Roles Allowed(Role.USER_ADMIN)
klasa publiczna UserAdminApi {i0}

	// Szczegóły pominięte dla zwięzłości

}

@Api(tagi = "Autor")
@RestController @RequestMapping(ścieżka = "api/autor")
klasa publiczna AuthorApi {i1}

	// Szczegóły pominięte dla zwięzłości

	@Roles Allowed(Role.AUTHOR_ADMIN)
	@PostMapping
	public void create() { }

	@Roles Allowed(Role.AUTHOR_ADMIN)
	@PutMapping("{id}")
	public void edit() { }

	@Roles Allowed(Role.AUTHOR_ADMIN)
	@DeleteMapping("{id}")
	public void delete() { }

	@GetMapping("{id}")
	publiczna pustka get() { }

	@GetMapping("{id}/book")
	public void getBooks() { }

	@PostMapping("wyszukiwanie")
	Publiczne wyszukiwanie nieważne() { }

}

@Api(tagi = "Książka")
@RestController @RequestMapping(ścieżka = "api/book")
klasa publiczna BookApi {i1}

	// Szczegóły pominięte dla zwięzłości

	@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}")
	publiczna pustka get() { }

	@GetMapping("{id}/author")
	public void getAuthors() { }

	@PostMapping("wyszukiwanie")
	Publiczne wyszukiwanie nieważne() { }

}

Należy pamiętać, że adnotacje dotyczące bezpieczeństwa mogą być dostarczane zarówno na poziomie klasy, jak i na poziomie metody.

Pokazane przykłady są proste i nie reprezentują rzeczywistych scenariuszy, ale Spring Security dostarcza bogaty zestaw adnotacji, a ty możesz obsługiwać skomplikowany schemat autoryzacji, jeśli zdecydujesz się na ich użycie.

Domyślny prefiks nazwy roli (Role Name Default Prefix)

W tym odrębnym podrozdziale chciałbym podkreślić jeszcze jeden subtelny szczegół, który dezorientuje wielu nowych użytkowników.

W wiosennych ramach bezpieczeństwa rozróżnia się dwa terminy:

  • Władza reprezentuje indywidualne pozwolenie.
  • Rola reprezentuje grupę uprawnień.

Oba mogą być reprezentowane przez pojedynczy interfejs zwany GrantedAuthority i później sprawdzane za pomocą Spring Expression Language wewnątrz adnotacji Spring Security w następujący sposób:

  • Władza: @PreAuthorize("hasAuthority('EDIT_BOOK')")
  • Rola: @PreAuthorize("hasRole('BOOK_ADMIN')")

Aby różnica pomiędzy tymi dwoma pojęciami była bardziej wyraźna, Spring Security framework dodaje domyślnie prefiks ROLE_ do nazwy roli. Tak więc, zamiast sprawdzać dla roli o nazwie BOOK_ADMIN, będzie sprawdzać dla ROLE_BOOK_ADMIN.

Osobiście uważam to zachowanie za mylące i wolę je wyłączyć w moich aplikacjach. Może być wyłączony w konfiguracji Spring Security w następujący sposób:

@EnableWebSecurity
Klasa publiczna SecurityConfig rozszerza WebSecurityConfigurerAdapter {

    // Szczegóły pominięte dla zwięzłości

    @Bean
    GrantedAuthorityDefaults grantedAuthorityDefaults() {i0}
        return new GrantedAuthorityDefaults(""); // Remove the ROLE_ prefix
    }

}

Testowanie z zabezpieczeniem sprężynowym

Aby przetestować nasze punkty końcowe za pomocą testów jednostkowych lub integracyjnych podczas korzystania z systemu Spring Security, musimy dodać uzależnienie od testu wiosennego wraz z testem wiosennego rozruchu buta. Nasz plik pom.xml build będzie wyglądał tak:


    org.springframework.boot
    test rozrusznika sprężynowego
    Test
    
        
            org.junit.vintage
            junit-vintage-engine
        
    



    org.springframework.security
    test bezpieczeństwa wiosennego
    Test

Ta zależność daje nam dostęp do pewnych adnotacji, które mogą być użyte do dodania kontekstu bezpieczeństwa do naszych funkcji testowych.

Te adnotacje są:

  • @WithMockUser może być dodany do metody testowej, aby emulować działanie z wyszydzonym użytkownikiem.
  • @WithUserDetails mogą być dodane do metody testowej, aby emulować uruchamianie za pomocą UserDetails zwracanych z usługi UserDetailsService.
  • @WithAnonymousUser może być dodany do metody testowej, aby emulować uruchamianie z anonimowym użytkownikiem. Jest to przydatne, gdy użytkownik chce uruchomić większość testów jako konkretny użytkownik i zastąpić kilka metod, aby być anonimowym.
  • @WithSecurityContext określa, czego używać SecurityContext, a wszystkie trzy adnotacje opisane powyżej są na nim oparte. Jeśli mamy konkretny przypadek użycia, możemy stworzyć własną adnotację, która wykorzystuje @WithSecurityContext do stworzenia dowolnego SecurityContext, którego chcemy. Jej omówienie znajduje się poza zakresem naszego artykułu, a dalsze szczegóły znajdują się w dokumentacji Spring Security.

Najprostszym sposobem na przeprowadzenie testów z konkretnym użytkownikiem jest użycie adnotacji @WithMockUser. Możemy stworzyć z niej kpiącego użytkownika i uruchomić test w następujący sposób:

@Test @WithMockUser(username="customUsername@example.io", role={"USER_ADMIN"})
Publicznie nieważny test() {i1}
	// Szczegóły pominięte dla zwięzłości
}

To podejście ma jednak kilka wad. Po pierwsze, kpiący użytkownik nie istnieje, a jeśli uruchomisz test integracyjny, który później zapyta użytkownika o informacje z bazy danych, test zakończy się niepowodzeniem. Po drugie, kpiący użytkownik jest instancją klasy org.springframework.security.core.userdetails.User, która jest wewnętrzną implementacją interfejsu UserDetails w frameworku Spring, a jeśli mamy własną implementację, może to powodować konflikty później, podczas wykonywania testu.

Jeśli poprzednie wady są blokerami dla naszej aplikacji, to adnotacja @WithUserDetails jest właściwym rozwiązaniem. Jest ona stosowana, gdy posiadamy własne implementacje UserDetails i UserDetailsService. Zakłada ona, że użytkownik istnieje, więc przed uruchomieniem testów musimy albo stworzyć rzeczywisty wiersz w bazie danych, albo dostarczyć instancję UserDetailsService mock.

W ten sposób możemy użyć tej adnotacji:

@Test @WithUserDetails("customUsername@example.io")
Publicznie nieważny test() {i1}
	// Szczegóły pominięte dla zwięzłości
}

Jest to preferowana adnotacja w testach integracyjnych naszego przykładowego projektu, ponieważ mamy niestandardowe implementacje wyżej wymienionych interfejsów.

Używanie @WithAnonymousUser pozwala na pracę jako anonimowy użytkownik. Jest to szczególnie wygodne, gdy chcesz przeprowadzić większość testów z konkretnym użytkownikiem, ale kilka testów jako anonimowy użytkownik. Na przykład, następujące przypadki testowe będą uruchamiane test1 i test2 z kpiącym użytkownikiem oraz test3 z anonimowym użytkownikiem:

@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
klasa publiczna ZUserClassLevelAuthenticationTests {i0}

    @Test
    publiczny test na nieważność1() {i1}
        // Szczegóły pominięte dla zwięzłości
    }

    @Test
    publiczny test na nieważność2() {i1}
        // Szczegóły pominięte dla zwięzłości
    }

    @Test @WithAnonymousUser
    publiczny test pustki3() rzuty Wyjątek {i0}
        // Szczegóły pominięte dla zwięzłości
    }
}

Owijanie się

Na koniec chciałbym wspomnieć, że wiosenne ramy bezpieczeństwa prawdopodobnie nie wygrają żadnego konkursu piękności i na pewno mają stromą krzywą uczenia się. Spotkałem się z wieloma sytuacjami, w których został on zastąpiony przez jakieś domowe rozwiązanie ze względu na jego początkową złożoność konfiguracji. Jednak gdy tylko deweloperzy zrozumieją jego wewnętrzną strukturę i zdołają skonfigurować wstępną konfigurację, staje się on stosunkowo prosty w użyciu.

Komentarze (0)

Zostaw komentarz