Spring Security & REST


Spring Security & REST

Ten wpis ma za zadanie pokazać w jaki sposób z użyciem Spring Boota uwierzytelnić użytkownika za pomocą usług sieciowych typu REST. Domyślny mechanizm uwierzytelnienia w Spring Boot realizowany jest za pomocą standardowego formularza HTML:

Formularz HTML:

<form action="/process" method="POST">
    login: <input type="text" name="login"><br>
    password: <input type="password" name="password"><br>
    <input type="submit">
</form>

wartość enctype=”application/x-www-form-urlencoded”:

<form action="/process" method="POST" enctype="application/x-www-form-urlencoded">

jest domyślna (może być pominięta) – dane wysyłane są w postaci:

field1=value1&field2=value2

polecam w tym miejscu zapoznać się z artykułem https://javarevisited.blogspot.com/2017/06/difference-between-applicationx-www-form-urlencoded-vs-multipart-form-data.html gdzie w prosty sposób wyjaśniona jest różnica między application/x-www-form-urlencoded a multipart/form-data. Zaczynamy od nowego projektu Spring Boota – plik pom.xml – niezbędne zależności:

<dependencies>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-security</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
	<dependency>
		<groupId>com.fasterxml.jackson.core</groupId>
		<artifactId>jackson-databind</artifactId>
		<version>2.9.4</version>
	</dependency>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<version>1.18.10</version>
		<scope>provided</scope>
	</dependency>
	<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>
</dependencies>

HomeController:

@RestController
public class HomeController {
    @GetMapping("/")
    public UserDto userDto() {
        return new UserDto("user", "password");
    }
}

Klasa UserDto (z ang. data transfer object) z użyciem biblioteki Lombok – konstruktor bezparametrowy jest wymagany ze względu na błąd:

cannot deserialize from Object value (no delegate- or property-based Creator)
@Getter
@Setter
@AllArgsConstructor
@ToString
public class UserDto {
  public UserDto() {
  }
  String username;
  String password;
}

strona z logowaniem:

http://localhost:8080/login

domyślny login to user natomiast hasło generowane jest losowo automatycznie przy każdym starcie aplikacji co widać wyraźnie w logach:

Using generated security password: de5bcabc-1ca7-47c8-aa18-6d90b9940a5d

Zmieniamy teraz konfigurację w taki sposób aby strona główna była dostępna bez konieczności logowania się do aplikacji natomiast pozostałe adresy nie były dostępne oraz aby próba dostępu do zabezpieczonego adresu kończyła się kodem błędu 401  bez przekierowania na stronę logowania:

@Configuration
@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable();
    http
      .authorizeRequests()
      .antMatchers("/").permitAll()
      .anyRequest().authenticated()
      .and()
      .formLogin().permitAll()
      .and()
      .exceptionHandling()
      .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
  }
}

Wejście na stronę formularza z poziomu przeglądarki zakończy się teraz błędem 404. Możliwe jest jednak nadal za pomocą dowolnego klienta RESTowego zalogowanie się do aplikacji:

Whitelabel Error Page

This application has no explicit mapping for /error, so you are seeing this as a fallback.
Mon Oct 28 14:23:00 CET 2019
There was an unexpected error (type=Not Found, status=404).
No message available

Jako, że dane odczytywane są domyślnie przez Springa z formularza HTML to aby zmienić to zachowanie na odczyt danych wysłanych z użyciem metody POST w formacie JSON należy nadpisać domyślny filtr:

public class JsonObjectAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
 
    private final ObjectMapper objectMapper = new ObjectMapper();
 
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
        try {
            BufferedReader reader = request.getReader();
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
            UserDto authRequest = objectMapper.readValue(sb.toString(), UserDto.class);
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
                authRequest.getUsername(), authRequest.getPassword()
            );
            setDetails(request, token);
            return this.getAuthenticationManager().authenticate(token);
        } catch (IOException e) {
            throw new IllegalArgumentException(e.getMessage());
        }
    }
}

Uzupełniamy plik konfiguracyjny – SecurityConfig:

@Configuration
@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

  private RestAuthenticationSuccessHandler authenticationSuccessHandler;
  private RestAuthenticationFailureHandler authenticationFailureHandler;

  public SecurityConfig(RestAuthenticationSuccessHandler authenticationSuccessHandler,
                        RestAuthenticationFailureHandler authenticationFailureHandler) {
    this.authenticationSuccessHandler = authenticationSuccessHandler;
    this.authenticationFailureHandler = authenticationFailureHandler;
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable();
    http
            .authorizeRequests()
            .antMatchers("/").permitAll()
            .anyRequest().authenticated()
            .and()
            .addFilterBefore(authenticationFilter(), UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling()
            .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
  }

  @Bean
  public JsonObjectAuthenticationFilter authenticationFilter() throws Exception {
    JsonObjectAuthenticationFilter filter = new JsonObjectAuthenticationFilter();
    filter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
    filter.setAuthenticationFailureHandler(authenticationFailureHandler);
    filter.setAuthenticationManager(super.authenticationManagerBean());
    return filter;
  }
}

gdzie:

RestAuthenticationFailureHandler:

@Component
public class RestAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        super.onAuthenticationFailure(request, response, exception);
    }
}

RestAuthenticationSuccessHandler:

@Component
public class RestAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, 
                                        Authentication authentication) throws IOException, ServletException {
        clearAuthenticationAttributes(request);
    }
}

Logowanie za pomocą REST klienta Advanced Rest Client:

Zweryfikujmy działanie aplikacji wprowadzając następujące zmiany:

  • Metoda configure() – usuwamy – .antMatchers(“/”).permitAll():
@Override
protected void configure(HttpSecurity http) throws Exception {
  http.csrf().disable();
  http
          .authorizeRequests()
          .anyRequest().authenticated()
          .and()
          .addFilterBefore(authenticationFilter(), UsernamePasswordAuthenticationFilter.class)
          .exceptionHandling() // 1
          .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
}
  • oraz dodajemy adnotacje @Secured:
@RestController
public class HomeController {

    @Secured("ROLE_USER")
    @GetMapping("/")
    public UserDto userDto() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        authentication.getAuthorities().forEach(role -> System.out.println("[log] " + role));
        return new UserDto("user", "password");
    }
}

co spowoduje to, że dostęp do strony głównej będzie tylko dla zalogowanych użytkowników inaczej otrzymamy błąd 401:


Leave a comment

Your email address will not be published.


*