[JAVA] Implement a simple Rest API with Spring Security with Spring Boot 2.0

Overview

We have implemented a simple Rest API demo application using Spring Security and Spring Boot. The first half of the article describes the implementation around Spring Security, and the second half describes the controller implementation and its test code.

The source code can be found at rubytomato / demo-security-spring2.

environment

reference

Demo application requirements

How to authenticate

This demo application authenticates with your email address and password. Specifically, a POST request for an email address, password, and CSRF token is made to the login API, and if authentication is possible, HTTP status 200, a session cookie, and a new CSRF token cookie will be returned. Subsequent authentication and authorization are performed using session cookies.

About HTTP status code

In the case of an application that renders a page on the server side, it may redirect (or forward) to the page determined by a specific behavior (for example, return to the original page after login, transition to the error page when the request fails) This demo application basically doesn't do that, and it's supposed to leave it to the client if redirects are needed.

behavior HTTP status code Remarks
When login is successful 200 (Ok) Session generation
CSRF token generation
When login fails 401 (Unauthorized) Password error
Incorrect email address
When logout is successful 200 (Ok) Session discard
Cookie deletion
When logout fails 500 (Internal Server Error)
When the API is successful 200 (Ok) Authenticated, authorized and API executed
Response the result of API normal termination
When API fails 400 (Bad Request)
404 (Not Found)
500 (Internal Server Error)
Authenticated, authorized and API executed
Response to API abend
API authentication error 401 (Unauthorized) When calling the API without authentication
Including session expiration, fraudulent CSRF token, etc.
API not executed
API authorization error 403 (Forbidden) When calling an authenticated but not authorized API
API not executed

Retention of user information

User information is stored in the USER table with items such as user name, hashed password, email address (unique), and administrator flag. The admin flag (admin_flag) has a role for the user's application, 1 for the ADMIN role and 0 for the USER role. It is assumed that some APIs perform role authorization.

CREATE TABLE IF NOT EXISTS `user` (
  id BIGINT AUTO_INCREMENT,
  `name` VARCHAR(128) NOT NULL,
  password VARCHAR(256) NOT NULL,
  email VARCHAR(256) NOT NULL,
  admin_flag BOOLEAN NOT NULL DEFAULT FALSE,
  PRIMARY KEY (id),
  UNIQUE KEY (email)
)
ENGINE = INNODB,
CHARACTER SET = utf8mb4,
COLLATE utf8mb4_general_ci;

The user data used in the demo application looks like this: The password is hashed with the encode method of the BCryptPasswordEncoder class described later.

> select * from user;
+----+----------+--------------------------------------------------------------+-----------------------+------------+
| id | name     | password                                                     | email                 | admin_flag |
+----+----------+--------------------------------------------------------------+-----------------------+------------+
|  1 | kamimura | $2a$10$yiIGwxNPWwJ3CZ0SGAq3i.atLYrQNhzTyep1ALi6dbax1b1R2Y.cG | [email protected] |          1 |
|  2 | sakuma   | $2a$10$9jo/FSVljst5xJjuw9eyoumx2iVCUA.uBkUKeBo748bUIaPjypbte | [email protected]   |          0 |
|  3 | yukinaga | $2a$10$1OXUbgiuuIi3SOO3t.jyZOEY66ELL03dRcGpAKWql8HBXOag4YZ8q | [email protected] |          0 |
+----+----------+--------------------------------------------------------------+-----------------------+------------+
3 rows in set (0.00 sec)

Although it is not implemented in this demo application, it is assumed that the password entered when registering a new user is hashed with BCryptPasswordEncoder.encode and made persistent.

PasswordEncoder encoder = new BCryptPasswordEncoder();

User user = User.of(usernme, encoder.encode(rawPassword), email);
userRepository.save(user);

Security implementation

Spring Security configuration

Implement security in a class that inherits WebSecurityConfigurerAdapter. The individual implementations are described separately.

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            // AUTHORIZE
            .authorizeRequests()
                .mvcMatchers("/prelogin", "/hello/**")
                    .permitAll()
                .mvcMatchers("/user/**")
                    .hasRole("USER")
                .mvcMatchers("/admin/**")
                    .hasRole("ADMIN")
                .anyRequest()
                    .authenticated()
            .and()
            // EXCEPTION
            .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint())
                .accessDeniedHandler(accessDeniedHandler())
            .and()
            // LOGIN
            .formLogin()
                .loginProcessingUrl("/login").permitAll()
                    .usernameParameter("email")
                    .passwordParameter("pass")
                .successHandler(authenticationSuccessHandler())
                .failureHandler(authenticationFailureHandler())
            .and()
            // LOGOUT
            .logout()
                .logoutUrl("/logout")
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
                .logoutSuccessHandler(logoutSuccessHandler())
                //.addLogoutHandler(new CookieClearingLogoutHandler())
            .and()
             // CSRF
            .csrf()
                //.disable()
                //.ignoringAntMatchers("/login")
                .csrfTokenRepository(new CookieCsrfTokenRepository())
            ;
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth,
                                @Qualifier("simpleUserDetailsService") UserDetailsService userDetailsService,
                                PasswordEncoder passwordEncoder) throws Exception {
        auth.eraseCredentials(true)
                .userDetailsService(userDetailsService)
                .passwordEncoder(passwordEncoder);
    }

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

    AuthenticationEntryPoint authenticationEntryPoint() {
        return new SimpleAuthenticationEntryPoint();
    }

    AccessDeniedHandler accessDeniedHandler() {
        return new SimpleAccessDeniedHandler();
    }

    AuthenticationSuccessHandler authenticationSuccessHandler() {
        return new SimpleAuthenticationSuccessHandler();
    }

    AuthenticationFailureHandler authenticationFailureHandler() {
        return new SimpleAuthenticationFailureHandler();
    }

    LogoutSuccessHandler logoutSuccessHandler() {
        return new HttpStatusReturningLogoutSuccessHandler();
    }

}

Authorization settings

configuration


.authorizeRequests()
    .mvcMatchers("/prelogin", "/hello/**")
        .permitAll()
    .mvcMatchers("/user/**")
        .hasRole("USER")
    .mvcMatchers("/admin/**")
        .hasRole("ADMIN")
    .anyRequest()
        .authenticated()

Set the API authorization.

Authentication and authorization exception handling

configuration


.exceptionHandling()
    .authenticationEntryPoint(authenticationEntryPoint())
    .accessDeniedHandler(accessDeniedHandler())

authenticationEntryPoint()

15.2.1 AuthenticationEntryPoint

Set the processing when an unauthenticated user accesses an API that requires authentication.

AuthenticationEntryPoint authenticationEntryPoint() {
    return new SimpleAuthenticationEntryPoint();
}

It does not use the default or standard implementation class provided, but implements the process of returning HTTP status 401 and default message.

SimpleAuthenticationEntryPoint


public class SimpleAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException exception) throws IOException, ServletException {
        if (response.isCommitted()) {
            log.info("Response has already been committed.");
            return;
        }
        response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
    }

}

The default message looks like this: message is the value specified for the second parameter of the sendError method (HttpStatus.UNAUTHORIZED.getReasonPhrase).

HTTP/1.1 401
//abridgement

{
  "timestamp" : "2018-04-08T21:13:24.918+0000",
  "status" : 401,
  "error" : "Unauthorized",
  "message" : "Unauthorized",
  "path" : "/app/memo/1"
}

standard implementations

AuthenticationException

You may be able to find out more detailed reason for the exception in a subclass of AuthenticationException.

accessDeniedHandler()

15.2.2 AccessDeniedHandler

Sets what happens when a user accesses an authenticated but unauthorized resource.

AccessDeniedHandler accessDeniedHandler() {
    return new SimpleAccessDeniedHandler();
}

Instead of using the default or standard implementation classes provided, implement a handler that just returns an HTTP status 403 and a default message.

SimpleAccessDeniedHandler


public class SimpleAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException exception) throws IOException, ServletException {
        response.sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());
    }

}

standard implementations

AccessDeniedException

You can find out more detailed reason for the exception in a subclass of AccessDeniedException.

Authentication and processing on success / failure

15.4.1 Application Flow on Authentication Success and Failure

configuration


.formLogin()
    .loginProcessingUrl("/login").permitAll()
        .usernameParameter("email")
        .passwordParameter("pass")
    .successHandler(authenticationSuccessHandler())
    .failureHandler(authenticationFailureHandler())

loginProcessingUrl()

Set the login page and parameter name. No authentication is required to access this page. (permitAll)

successHandler()

Set a handler that implements the processing when authentication is successful.

AuthenticationSuccessHandler authenticationSuccessHandler() {
    return new SimpleAuthenticationSuccessHandler();
}

It does not use the default or standard implementation class provided, but implements a handler that only returns HTTP status 200.

SimpleAuthenticationSuccessHandler


@Slf4j
public class SimpleAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication auth) throws IOException, ServletException {
        if (response.isCommitted()) {
            log.info("Response has already been committed.");
            return;
        }

        response.setStatus(HttpStatus.OK.value());
        clearAuthenticationAttributes(request);
    }

    /**
     * Removes temporary authentication-related data which may have been stored in the
     * session during the authentication process.
     */
    private void clearAuthenticationAttributes(HttpServletRequest request) {
        HttpSession session = request.getSession(false);

        if (session == null) {
            return;
        }
        session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
    }
}

standard implementations

failureHandler()

Set a handler that implements the processing when authentication fails.

AuthenticationFailureHandler authenticationFailureHandler() {
    return new SimpleAuthenticationFailureHandler();
}

Instead of using the default or standard implementation classes provided, implement a handler that just returns an HTTP status 403 and a default message.

SimpleAuthenticationFailureHandler


public class SimpleAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        response.sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());
    }

}

standard implementations

Processing at logout

configuration


.logout()
    .logoutUrl("/logout")
    .invalidateHttpSession(true)
    .deleteCookies("JSESSIONID")
    .logoutSuccessHandler(logoutSuccessHandler())
    //.addLogoutHandler(new CookieClearingLogoutHandler())

logoutUrl()

Set the logout page.

logoutSuccessHandler()

Set a handler that implements the processing when logout ends normally. Since there is a standard implementation class HttpStatusReturningLogoutSuccessHandler of Spring Security that only returns HTTP status, I used this. Session destruction and cookie deletion performed at logout are performed in the configuration, so implementation is not required.

5.5.2 LogoutSuccessHandler

LogoutSuccessHandler logoutSuccessHandler() {
  return new HttpStatusReturningLogoutSuccessHandler();
}

standard implementations

addLogoutHandler()

I didn't use it in this demo application, but you can add a handler to execute when the logout is finished.

standard implementations

CSRF

By default, CSRF protection is enabled and holds the CSRF token in HttpSession. Since the login API is also subject to CSRF measures, a CSRF token is required at login, but if you do not want it for the login API, specify the URL in ignoringAntMatchers.

configuration


.csrf()
    //.ignoringAntMatchers("/login")
    .csrfTokenRepository(new CookieCsrfTokenRepository())

If you want to disable CSRF measures, add disable.

configuration


.csrf()
    .disable()

csrfTokenRepository

We used the standard implementation class CookieCsrfTokenRepository that holds CSRF tokens in cookies.

standard implementations

HEADER

By default, cache control is disabled and the following headers are set. We've left the defaults for this demo application as they don't need to be customized.

X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY

If you want to disable cache control

configuration


.headers()
    .cacheControl()
        .disable()

If you want to disable other options as well

configuration


.headers()
    .cacheControl()
        .disable()
    .frameOptions()
        .disable()
    .xssProtection()
        .disable()
    .contentTypeOptions()
        .disable()

If you want to add any header

.headers()
    .addHeaderWriter(new StaticHeadersWriter("X-TEST-STATIC-HEADER", "dummy_value"))

Authentication process configuration

@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth,
    @Qualifier("simpleUserDetailsService") UserDetailsService userDetailsService,
    PasswordEncoder passwordEncoder) throws Exception {

    auth.eraseCredentials(true)
        .userDetailsService(userDetailsService)
        .passwordEncoder(passwordEncoder);

}

UserDetailsService

9.2.2 The UserDetailsService 10.2 UserDetailsService Implementations

The UserDetailsService interface defines only one method, loadUserByUsername. The class that implements this interface must override loadUserByUsername and return any credential class that implements the UserDetails interface.

In this demo application, user information is stored in the USER table of the database, so as shown in the example below, the USER table is searched using the UserRepository, and if a user is found, an instance of the authentication information class SimpleLoginUser that implements the UserDetails interface is created. And return.

@Service("simpleUserDetailsService")
public class SimpleUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

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

    @Override
    public UserDetails loadUserByUsername(final String email) {
        //Search user entities from the database by email
        return userRepository.findByEmail(email)
                .map(SimpleLoginUser::new)
                .orElseThrow(() -> new UsernameNotFoundException("user not found"));
    }
}

UserDetails

Some of the user details are excerpted below because the explanation on the reference page is easy to understand.

9.2.2 The UserDetailsService

UserDetails is a core interface in Spring Security. It represents a principal, but in an extensible and application-specific way. Think of UserDetails as the adapter between your own user database and what Spring Security needs inside the SecurityContextHolder.

Inherit User that implements UserDetails and implement application-specific authentication information class SimpleLoginUser. If you have the information required by your application requirements, define it in the fields of this class. This example defines an instance of the User entity.

SimpleLoginUser


public class SimpleLoginUser extends org.springframework.security.core.userdetails.User {

    //User entity
    private com.example.demo.entity.User user;

    public User getUser() {
        return user;
    }

    public SimpleLoginUser(User user) {
        super(user.getName(), user.getPassword(), determineRoles(user.getAdmin()));
        this.user = user;
    }

    private static final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER");
    private static final List<GrantedAuthority> ADMIN_ROLES = AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER");

    private static List<GrantedAuthority> determineRoles(boolean isAdmin) {
        return isAdmin ? ADMIN_ROLES : USER_ROLES;
    }
}

Password Encoder

10.3 Password Encoding

The password encoding used the standard implementation class BCryptPasswordEncoder. There are several other standard implementation classes, but you can also implement the PasswordEncoder interface to create the encoder you need if it doesn't meet your application requirements.

PasswordEncoder


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

standard implementations

DelegatingPasswordEncoder

It is an encoder implemented from Spring Security 5.0. In the past, once the encoding algorithm was decided, there was the problem that it was difficult to change the adopted algorithm later.

This encoder delegates the process to the existing encoding class as the class name suggests, but appends the algorithm ID to the beginning of the encoded hash value. By default, BCryptPasswordEncoder is used, so {bcrpt} indicating Bcrypt is added to the hash value as shown in the example below.

DelegatingPasswordEncoder looks up this ID to determine which encoding class to use.

PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
String password = encoder.encode("dummy_password");
System.out.println(password);
// {bcrypt}$2a$10$7qLNvQ7CZ80.VcZGtfe2QuMk7NlWP8ktJyEZoqToo1L7.zi9dIy76

Controller to implement

39.5 Spring MVC and CSRF Integration

In the argument of the controller

@GetMapping
public String greeting(@AuthenticationPrincipal(expression = "user") User user, CsrfToken csrfToken) {
    log.debug("token : {}", csrfToken.getToken());
    log.debug("access user : {}", user.toString());

}

Pre-login API

An API that returns the CSRF token required by the login API.

method path body content type request body
GET /prelogin
@RestController
@RequestMapping(path = "prelogin")
public class PreLoginController {

    @GetMapping
    public String preLogin(HttpServletRequest request) {
        DefaultCsrfToken token = (DefaultCsrfToken) request.getAttribute("_csrf");
        if (token == null) {
            throw new RuntimeException("could not get a token.");
        }
        return token.getToken();
    }

}

Login / logout API

No implementation is required as it is enabled in the Security Configuration settings.

method path body content type request body
POST /login application/x-www-form-urlencoded email={email}
pass={password}
_csrf={CSRF-TOKEN}
POST /logout

API that does not require authentication

It is an API that anyone can access without authentication or authorization.

method path body content type request body
GET /hello
GET /hello/{message}
POST /hello application/x-www-form-urlencoded message={message}

HelloController


@RestController
@RequestMapping(path = "hello")
@Slf4j
public class HelloController {

    @GetMapping
    public String greeting() {
        return "hello world";
    }

    @GetMapping(path = "{message}")
    public String greeting(@PathVariable(name = "message") String message) {
        return "hello " + message;
    }

    @PostMapping
    public String postGreeting(@RequestParam(name = "message") String message) {
        return "hello " + message;
    }

}

API that requires authentication and does not require authorization

It is an API that can be accessed by any authenticated user.

method path body content type request body
GET /memo/1
GET /memo/list
@RestController
@RequestMapping(path = "memo")
public class MemoController {

    private final MemoService service;

    public MemoController(MemoService service) {
        this.service = service;
    }

    @GetMapping(path = "{id}", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public ResponseEntity<Memo> id(@PathVariable(value = "id") Long id) {
        Optional<Memo> memo = service.findById(id);
        return memo.map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @GetMapping(path = "list", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public ResponseEntity<List<Memo>> list(Pageable page) {
        Page<Memo> memos = service.findAll(page);
        return ResponseEntity.ok(memos.getContent());
    }

}

APIs that require authentication and USER roles

An API that can be accessed if the authenticated user has the USER role.

method path body content type request body
GET /user
GET /user/echo/{message}
POST /user/echo application/json {"{key}": "{value}"}
@RestController
@RequestMapping(path = "user")
public class UserController {

    @GetMapping
    public String greeting(@AuthenticationPrincipal(expression = "user") User user) {
        return "hello " + user.getName();
    }

    @GetMapping(path = "echo/{message}")
    public String getEcho(@PathVariable(name = "message") String message) {
        return message.toUpperCase();
    }

    @PostMapping(path = "echo", consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public String postEcho(@RequestBody Map<String, String> message) {
        return message.toString();
    }

}

You can take the authenticated object of the authenticated user as an argument of the handler method.

public String greeting(@AuthenticationPrincipal SimpleLoginUser loginUser) {
    User user = loginUser.getUser();

    //abridgement
}

You can get the object directly by specifying the getter method of the authentication object (for example, user for getUser) in expression.

public String greeting(@AuthenticationPrincipal(expression = "user") User user) {

    //abridgement
}

APIs that require authentication and the ADMIN role

An API that can be accessed if the authenticated user has the ADMIN role.

method path body content type request body
GET /admin
GET /admin/{username}
GET /admin/echo/{message}
POST /admin/echo application/json {"{key}": "{value}"}
@RestController
@RequestMapping(path = "admin")
public class AdminController {

    private final UserService userService;

    public AdminController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public String greeting(@AuthenticationPrincipal(expression = "user") User user) {
         return "hello admin " + user.getName();
    }

    @GetMapping(path = "{name}")
    public String greeting(@AuthenticationPrincipal(expression = "user") User user, @PathVariable(name = "name") String name) {
         return userService.findByName(name).map(u -> "hello " + u.getName()).orElse("unknown user");
    }

    @GetMapping(path = "echo/{message}")
    public String getEcho(@PathVariable(name = "message") String message) {
        return message.toUpperCase();
    }

    @PostMapping(path = "echo", consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public String postEcho(@RequestBody Map<String, String> message) {
        return message.toString();
    }

}

API operation check

I checked the operation of the API with the curl command. The authentication result cookie is saved in a text file with the -c option, and the text file is specified with the -b option when sending.

API that does not require authentication

For unauthenticated users

> curl -i "http://localhost:9000/app/hello/world"

HTTP/1.1 200

If the API does not require authentication but is subject to CSRF, the CSRF token cookie and the CSRF token in the x-xsrf-token header are required at POST.

NG


> curl -i -X POST "http://localhost:9000/app/hello" -d "message=WORLD"

HTTP/1.1 401

OK


> curl -i -b cookie.txt -X POST "http://localhost:9000/app/hello" -d "message=WORLD" -d "_csrf={CSRF-TOKEN}"

HTTP/1.1 200

For authenticated users

> curl -i -b cookie.txt "http://localhost:9000/app/hello/world"

HTTP/1.1 200
> curl -i -b cookie.txt -X POST "http://localhost:9000/app/hello" -d "message=WORLD" -d "_csrf={CSRF-TOKEN}"

HTTP/1.1 200

Pre-login API

An API that returns the CSRF-TOKEN required at login.

prelogin


> curl -i -c cookie.txt "http://localhost:9000/app/prelogin"

HTTP/1.1 200

{CSRF-TOKEN}

Login API

First, access the pre-login API and obtain the CSRF-TOKEN to be used when logging in.

For a valid account

login


> curl -i -b cookie.txt -c cookie.txt -X POST "http://localhost:9000/app/login" -d "[email protected]" -d "pass=iWKw06pvj" -d "_csrf={CSRF-TOKEN}"

HTTP/1.1 200

By the way, the following contents are written in cookie.txt.

> type cookie.txt

# Netscape HTTP Cookie File
# http://curl.haxx.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

#HttpOnly_localhost     FALSE   /app    FALSE   0       XSRF-TOKEN      d10bdddd-d66d-4cfb-9417-fcdb9a3d4d71
#HttpOnly_localhost     FALSE   /app    FALSE   0       JSESSIONID      99096C52A9CCDC52ED4A15BCB0079CB5

In case of invalid account (wrong email address, wrong password, etc.)

login


> curl -i -b cookie.txt -c cookie.txt -X POST "http://localhost:9000/app/login" -d "[email protected]" -d "pass=hogehoge" -d "_csrf={CSRF-TOKEN}"

HTTP/1.1 401

API that can be accessed by authenticated users

For authenticated users

> curl -i -b cookie.txt "http://localhost:9000/app/memo/1"

HTTP/1.1 200

For unauthenticated users

> curl -i "http://localhost:9000/app/memo/1"

HTTP/1.1 401

APIs that require authentication and USER roles

For users with the USER role

> curl -i -b cookie.txt "http://localhost:9000/app/user"

HTTP/1.1 200

If you cannot specify the CSRF token in Request Body, specify it in the header.

> curl -i -b cookie.txt -H "Content-Type:application/json" -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/user/echo" -d "{\"message\": \"abc\"}"

HTTP/1.1 200

For unauthenticated users

> curl -i "http://localhost:9000/app/user"

HTTP/1.1 401

APIs that require authentication and the ADMIN role

For users with the ADMIN role

> curl -i -b cookie.txt "http://localhost:9000/app/admin"

HTTP/1.1 200
> curl -i -b cookie.txt -H "Content-Type:application/json" -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/admin/echo" -d "{\"message\": \"abc\"}"

HTTP/1.1 200

For invalid CSRF tokens

> curl -i -b cookie.txt -H "Content-Type:application/json" -H "x-xsrf-token:{INVALID-CSRF-TOKEN}" -X POST "http://localhost:9000/app/admin/echo" -d "{\"message\": \"abc\"}"

HTTP/1.1 403

For users who do not have the ADMIN role (Log in as a user who does not have the ADMIN role before checking.)

> curl -i -b cookie.txt "http://localhost:9000/app/admin"

HTTP/1.1 403

For unauthenticated users

> curl -i "http://localhost:9000/app/admin"

HTTP/1.1 401

Logout API

For authenticated users

logout


> curl -i -b cookie.txt -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/logout"

HTTP/1.1 200

For unauthenticated users

logout


> curl -i -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/logout"

HTTP/1.1 401

For invalid CSRF tokens

logout


> curl -i -b cookie.txt -H "x-xsrf-token:{INVALID-CSRF-TOKEN}" -X POST "http://localhost:9000/app/logout"

HTTP/1.1 403

Test code description

This section describes the unit and integration test of the controller class of the application using Spring Security.

Controller unit test

In this article, the test using the MockMvcTest annotation is a unit test. The confusing thing about unit tests is that authentication is enabled, but it doesn't reflect the behavior customized with SecurityConfig. The test code settings change depending on whether or not the security-related part (contents set in the SecurityConfig class) is also tested in the unit test.

Disable Spring Security features

If you want to test without considering the security part, you can disable the Spring Security function by specifying false in the secure attribute of WebMvcTest annotation.

@RunWith(SpringRunner.class)
@WebMvcTest(value = UserController.class, secure = false)
public class UserControllerTests {

    //Test code

}

With this setting, APIs that require authentication can be tested without authentication.

@Test
public void getEcho() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc")
        .accept(MediaType.TEXT_PLAIN);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(content().contentType(contentTypeText))
        .andExpect(content().string("ABC"))
        .andDo(print());
}

However, if the handler method takes the user's authentication object as an argument, it cannot be tested because the authentication object cannot be injected.

Example of taking an authentication object as an argument


@GetMapping
public String greeting(@AuthenticationPrincipal(expression = "user") User user) {

}

Enable some Spring Security features

The default state (secure = true). The authentication part is enabled, but the authorization part is invalid because the SecurityConfig settings are not reflected. For example, even if you restrict access by role, you can access any role in the test.

@RunWith(SpringRunner.class)
@WebMvcTest(value = UserController.class, secure = true)
public class UserControllerTests {

    //Test code

}

If the handler method of the controller under test takes a user's authentication object as an argument, you can specify a dummy authentication object with with (user (...)). I am trying to save the CSRF token in a cookie in SecurityConfig, but the XSRF-TOKEN cookie does not exist because it is not reflected in the test.

@Test
public void greeting() throws Exception {
    //Create a dummy authentication object
    User user = new User(1L, "test_user", "pass", "[email protected]", true);
    SimpleLoginUser loginUser = new SimpleLoginUser(user);

    RequestBuilder builder = MockMvcRequestBuilders.get("/user")
        .with(user(loginUser))
        .accept(MediaType.TEXT_PLAIN);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(authenticated().withUsername("test_user").withRoles("USER", "ADMIN"))
        .andExpect(content().contentType(contentTypeText))
        .andExpect(content().string("hello aaaa"))
        //.andExpect(cookie().exists("XSRF-TOKEN"))
        .andExpect(forwardedUrl(null))
        .andExpect(redirectedUrl(null))
        .andDo(print());
    }

If your API requires authentication and the handler method does not take an authentication object as an argument, you only need to specify the WithMockUser annotation.

@WithMockUser(roles = "USER")
@Test
public void getEcho() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc")
        .accept(MediaType.TEXT_PLAIN);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(content().contentType(contentTypeText))
        .andExpect(content().string("ABC"))
        .andDo(print());
}

If you do not add the WithMockUser annotation, HTTP status 401 will be returned because it is in an unauthenticated state.

@Test
public void getEcho_401() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc");

    mvc.perform(builder)
        .andExpect(status().is(HttpStatus.UNAUTHORIZED.value()))
        .andDo(print());
}

However, the next test will fail because the authorization setting is not enabled. The API under test can only be accessed by a user with the USER role, but it can also be accessed by a user with the ADMIN role.

@WithMockUser(roles = "ADMIN")
@Test
public void getEcho_403() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc");

    mvc.perform(builder)
        .andExpect(status().is(HttpStatus.FORBIDDEN.value()))
        .andDo(print());
}

For APIs that require a CSRF token, such as the POST method, specify with (csrf ()). If you want to use an invalid CSRF token, use with (csrf (). UseInvalidToken ()).

@WithMockUser(roles = "USER")
@Test
public void postEcho() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.post("/user/echo")
        .contentType(MediaType.APPLICATION_JSON_UTF8)
        .content("{\"message\": \"hello world\"}")
        .with(csrf())
        .accept(MediaType.APPLICATION_JSON_UTF8_VALUE);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(content().contentType(contentTypeJson))
        .andExpect(content().string("{message=hello world}"))
        .andDo(print());
}

Please note that APIs that do not require authentication require authentication. The API under test is defined in SecurityConfig as an API that does not require authentication, but the test will fail unless it is annotated with WithMockUser.

@RunWith(SpringRunner.class)
@WebMvcTest(value = HelloController.class)
public class HelloControllerTests {

    @Autowired
    private MockMvc mvc;

    final private MediaType contentTypeText = new MediaType(MediaType.TEXT_PLAIN.getType(),
            MediaType.TEXT_PLAIN.getSubtype(), Charset.forName("utf8"));

    @WithMockUser
    @Test
    public void greeting() throws Exception {
        RequestBuilder builder = MockMvcRequestBuilders.get("/hello")
            .accept(MediaType.TEXT_PLAIN);

        mvc.perform(builder)
            .andExpect(status().isOk())
            .andExpect(content().contentType(contentTypeText))
            .andExpect(content().string("hello world"))
            .andDo(print());
    }

}

Reflect the SecurityConfig settings

If your unit test requirements also include what you have set in SpringConfig, import the SecurityConfig class. Up to this point, the test will be conducted under almost the same conditions as the integration test, so it may be better to use the integration test depending on the level of detail of the test.

Importing SecurityConfig requires an instance of the class that implements the UserDetailsService interface, so we are preparing an instance mocked with MockBean.

@RunWith(SpringRunner.class)
@WebMvcTest(value = UserController.class)
@Import(value = {SecurityConfig.class})
public class UserControllerTests {

    @Autowired
    private MockMvc mvc;

    @MockBean(name = "simpleUserDetailsService")
    private UserDetailsService userDetailsService;

    //Test code

}

The authorization test that failed in "Enable some Spring Security features" above will also succeed. Users who do not have an accessible role will now get an HTTP status of 403.

@WithMockUser(roles = "ADMIN")
@Test
public void getEcho_403() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc");

    mvc.perform(builder)
        .andExpect(status().is(HttpStatus.FORBIDDEN.value()))
        .andDo(print());
}

Controller integration test

In this article, the test using the SpringBootTest annotation is referred to as the integration test. In the integration test, the settings of the SecurityConfig class are enabled by default.

In the unit test, MockMvc was autowired, but it seems that it needs to be built with the method with Before annotation as shown in the code below.

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserControllerIntegrationTests {

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @Before
    public void setup() {
        mvc = MockMvcBuilders
            .webAppContextSetup(context)
            .apply(springSecurity())
            .build();
    }

    //Test code

}

The test code is almost the same as the unit test code for "Reflect Security Config settings".

@WithMockUser(roles = "USER")
@Test
public void getEcho() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc")
        .accept(MediaType.TEXT_PLAIN);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(content().contentType(contentTypeText))
        .andExpect(content().string("ABC"))
        .andDo(print());
}

Recommended Posts

Implement a simple Rest API with Spring Security with Spring Boot 2.0
Implement a simple Rest API with Spring Security & JWT with Spring Boot 2.0
Implement a simple Web REST API server with Spring Boot + MySQL
Implement REST API in Spring Boot
Create a simple demo site with Spring Security with Spring Boot 2.1
Implement REST API with Spring Boot and JPA (Application Layer)
Implement REST API with Spring Boot and JPA (Infrastructure layer)
Let's make a simple API with EC2 + RDS + Spring boot ①
Implement REST API with Spring Boot and JPA (domain layer)
I made a simple search form with Spring Boot + GitHub Search API.
Create a simple search app with Spring Boot
Create a web api server with spring boot
Implement GraphQL with Spring Boot
Hello World (REST API) with Apache Camel + Spring Boot 2
[Spring Boot] Get user information with Rest API (beginner)
Customize REST API error response with Spring Boot (Part 2)
A memorandum when creating a REST service with Spring Boot
Customize REST API error response with Spring Boot (Part 1)
Create a simple on-demand batch with Spring Batch
Implement CRUD with Spring Boot + Thymeleaf + MySQL
Implement paging function with Spring Boot + Thymeleaf
[Beginner] Let's write REST API of Todo application with Spring Boot
Achieve BASIC authentication with Spring Boot + Spring Security
Create a website with Spring Boot + Gradle (jdk1.8.x)
Hash passwords with Spring Boot + Spring Security (with salt, with stretching)
Try to implement login function with Spring Boot
[Introduction to Spring Boot] Authentication function with Spring Security
Create a Spring Boot development environment with docker
Create Spring Cloud Config Server with security with Spring Boot 2.0
Download with Spring Boot
Automatically map DTOs to entities with Spring Boot API
Spring Boot with Spring Security Filter settings and addictive points
[JUnit 5] Write a validation test with Spring Boot! [Parameterization test]
Introduce swagger-ui to REST API implemented in Spring Boot
I wrote a test with Spring Boot + JUnit 5 now
Generate barcode with Spring Boot
Hello World with Spring Boot
Get started with Spring boot
Run LIFF with Spring Boot
SNS login with Spring Boot
File upload with Spring Boot
Spring Boot starting with copy
Login function with Spring Security
Spring Boot starting with Docker
Hello World with Spring Boot
Set cookies with Spring Boot
REST API testing with REST Assured
Use Spring JDBC with Spring Boot
Add module with Spring Boot
Getting Started with Spring Boot
Link API with Spring + Vue.js
Try using Spring Boot Security
Create microservices with Spring Boot
Send email with spring boot
Handle Java 8 date and time API with Thymeleaf with Spring Boot
A story packed with the basics of Spring Boot (solved)
Try hitting the zip code search API with Spring Boot
I made a simple MVC sample system using Spring Boot
With Spring boot, password is hashed and member registration & Spring security is used to implement login function.
Let's find out how to receive in Request Body with REST API of Spring Boot
Implemented authentication function with Spring Security ②