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
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.
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 |
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);
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();
}
}
configuration
.authorizeRequests()
.mvcMatchers("/prelogin", "/hello/**")
.permitAll()
.mvcMatchers("/user/**")
.hasRole("USER")
.mvcMatchers("/admin/**")
.hasRole("ADMIN")
.anyRequest()
.authenticated()
Set the API authorization.
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()
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.
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
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.
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"))
@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.
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
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
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());
}
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();
}
}
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 |
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;
}
}
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());
}
}
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
}
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();
}
}
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.
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
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}
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
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
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
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
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
This section describes the unit and integration test of the controller class of the application using Spring Security.
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.
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) {
}
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());
}
}
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());
}
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