Spring Boot is a popular framework to create web application. With it, we can easy to create stand-alone and production-ready web applications. And there is Spring Initializr tool can help us to create base project which only need choose the libraries which we need.
But, for new developers, it needs to spend your much time to learn and try, not easy to get into the swing of it. Begin from this, I will use some of posts to show how to use it to accomplish our jobs for API server without front-end.
By default, when we create a Spring Boot application with Spring Initializr, it will be a project which contains front-end and backend. You can easy to implement server processes in it, and also can implement web pages with Thymeleaf or other frameworks in same project. But now, the pattern of separating front-end and backend to 2 separated projects is very popular. If we use Spring Boot to do it, there are some different between before.
In this post we will focus on how to use Spring Security to build a project to do the user authentication and authorization, and send the results to front-end with JSON in response.
How does the Spring Security work
In this section, we only discuss how Spring Security work on Servlet based applications.
Looks following picture, Spring Security’s Servlet is based on Servlet Filters. When client send a request, container creates a FilterChain. It contains Filters and Servlet. What Filters will be used is depended on request URI. Spring Security registered the DelegatingFilterProxy at position of Filter 1. The DelegatingFilterProxy contains FilterChainProxy. When a request here, it will delegate to SecurityFilterChain which match the request URI. SecurityFilterChain will do the jobs of authentication and authorization.
To do the jobs, each SecurityFilterChain contains a serials of Security Filters, and they may have different numbers. For example, in above picture, the SecurityFilterChain 0 has n Filters, and the SecurityFilterChain N has m Filters. All these filters will be executed one by one with order.
Below is a comprehensize list of Spring Security Filter ordering.
ChannelProcessingFilter
ConcurrentSessionFilter
WebAsyncManagerIntegrationFilter
SecurityContextPersistenceFilter
HeaderWriterFilter
CorsFilter
CsrfFilter
LogoutFilter
OAuth2AuthorizationRequestRedirectFilter
Saml2WebSsoAuthenticationRequestFilter
X509AuthenticationFilter
AbstractPreAuthenticatedProcessingFilter
CasAuthenticationFilter
OAuth2LoginAuthenticationFilter
Saml2WebSsoAuthenticationFilter
UsernamePasswordAuthenticationFilter
ConcurrentSessionFilter
OpenIDAuthenticationFilter
DefaultLoginPageGeneratingFilter
DefaultLogoutPageGeneratingFilter
DigestAuthenticationFilter
BearerTokenAuthenticationFilter
BasicAuthenticationFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
JaasApiIntegrationFilter
RememberMeAuthenticationFilter
AnonymousAuthenticationFilter
OAuth2AuthorizationCodeGrantFilter
SessionManagementFilter
ExceptionTranslationFilter
FilterSecurityInterceptor
SwitchUserFilter
Now, let’s see how the authentication will be done when we use user’s name and password to login.
When we use username and password to login application, the UsernamePasswordAuthenticationFilter will take the job. It will use UserDetailsService bean to get the original user’s information from database or other storages, and then user PasswordEncorder encode the password in request and compare it with original. If passed, a SecurityContext will be created and filled principal, credentials and authorities and save to SecurityContextHolder. At last, call AuthenticationSuccessHandler to response. Otherwise, call AuthenticationFailureHandler to response failure.
In above picture, I marked some objects to Green. They will be used in this post to accomplish the goal.
Basic implementation with built-in logic
Ok, if you already understood how the Spring Security work, we can start to write our code.
To use Spring Security in Sprint Boot, we just need create project with following 2 dependencies
spring-boot-starter-security
spring-boot-starter-web
In demo project, we use version ‘2.2.6.RELEASE’ of them.
First, we need provide the UserDetailsService to get user’s original information from database or other storages. In this example, I just store the data in the memory.
In this example, we have 2 users. One is Tom, he has Visitor role and only has View permission to view the book list. Another is Jerry, he take the Admin role, and has full permission to add book, delete book, update book’s information, and view book list.
@Override public UserDetails loadUserByUsername(String name)throws UsernameNotFoundException { User user = userServiceImpl.findByName(name); if (null == user) { returnnull; }
Collection<SimpleGrantedAuthority> authorities = new HashSet<>(); for (Role role : user.getRoles()) { for (Permission permission : role.getPermissions()) { authorities.add(new SimpleGrantedAuthority(permission.getPermission().name())); } }
To handle the exceptions which thrown during authentication, we need implement AccessDeniedHandler to handle ‘Access Denied’ exception, and AuthenticationEntryPoint for others.
After above, we need manage the expired strategy of user session. When the session expired, we need notify front-end to show login page or do other actions.
/** * Exception Handling */ .and().exceptionHandling() // set process when autentication failed .authenticationEntryPoint(authenticationEntryPoint) // set handler for access denied .accessDeniedHandler(accessDeniedHandler)
@Service publicclassBookServiceImplimplementsBookService{ private Map<Long, Book> books = new HashMap<Long, Book>() {{ put(new Long(0), new Book(0, "Charlotte's web", "Friendship of Charlotte and Web")); put(new Long(1), new Book(1, "Animal Farm", "Revolution of animal in a farm")); put(new Long(2), new Book(2, "The old man and the Sea", "A story of an old man and a fish")); }};
@Override publicvoidadd(Book book){ long newId = books.size(); books.put(new Long(newId), new Book(newId, book.getName(), book.getDescription())); }
In this controller, the ‘listAll’ api need ‘View’ permission, it makes both of Tom and Jerry can access it. And ‘add’, ‘delete’ and ‘update’ apis will need ‘Create’, ‘Delete’ and ‘Modify’ permissions, it means only Jerry can access them.
@PreAuthorize("hasAuthority('Create')")// If use custom access decision, remove it @PostMapping() public ResponseEntity add(@RequestBody Book book){ bookService.add(book); return ResponseEntity.ok().build(); }
@PreAuthorize("hasAuthority('Delete')")// If use custom access decision, remove it @DeleteMapping("/{id}") public ResponseEntity delete(@PathVariablelong id){ bookService.delete(id); return ResponseEntity.ok().build(); }
@PreAuthorize("hasAuthority('Modify')")// If use custom access decision, remove it @PutMapping() public ResponseEntity<Book> update(@RequestBody Book book){ return ResponseEntity.ok(bookService.update(book)); }
@PreAuthorize("hasAuthority('View')")// If use custom access decision, remove it @GetMapping() public ResponseEntity<List<Book>> listAll() { return ResponseEntity.ok(bookService.findAll()); } }
Ok, we can build and launch our application to test.
First, we add ‘Hello’ request to test the result before login
send it, it should always succeed.
Now, add ‘List Book’ request
send it without login, you should receive ‘Unauthorized’ response
Now, add ‘Login’ request in Postman as following
send it, if succeed, you will see
otherwise, you will see
send ‘List Book’ request again, you should get book list now.
Add other 3 requests, and also use current session to send requests, you should get ‘Access Denied’ response.
Then logout and re-login with Jerry. You should get succeed responses for all requests.
Customize authentication process
With above, the basic authentication and authorization process has been done. But, there is a problem. See the login request in Postman, you will find the username and password is shown in url. For security, it is not a good idea. We need move them into request body.
Read the source code of UsernamePasswordAuthnticationFilter class, we can find how the username and password is gotten from path variables.
publicvoidsetUsernameParameter(String usernameParameter){ Assert.hasText(usernameParameter, "Username parameter must not be empty or null"); this.usernameParameter = usernameParameter; }
publicvoidsetPasswordParameter(String passwordParameter){ Assert.hasText(passwordParameter, "Password parameter must not be empty or null"); this.passwordParameter = passwordParameter; }
So, if we want to get them from body, we just need customize this bean. To implement it, we can extends it and override ‘attemptAuthentication’ method.
@Component publicclassJsonAuthenticationFilterextendsUsernamePasswordAuthenticationFilter{ /** * Must has, otherwise following error will be occurred during launching application. * "java.lang.IllegalArgumentException: authenticationManager must be specified" */ @Override @Autowired publicvoidsetAuthenticationManager(AuthenticationManager authenticationManager){ super.setAuthenticationManager(authenticationManager); }
/** * Exception Handling */ .and().exceptionHandling() // set process when autentication failed .authenticationEntryPoint(authenticationEntryPoint) // set handler for access denied .accessDeniedHandler(accessDeniedHandler)
/** * Session */ .and().sessionManagement() .maximumSessions(1) .expiredSessionStrategy(sessionInformationExpiredStrategy); // For login with Json data in body http.addFilterAt(jsonAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); } }
Relaunch and add new login request in Postman as following
send it, you should get succeed response.
Enhance authorization for APIs
By default, the permission is hard-coded on the apis with following code during developing
1
@PreAuthorize("hasAuthority('View')")
If we has requirements to ask it should can be configured after deployed, how to make it? There also has easy way to make it to truth.
The AbstractSecurityInterceptor is used to get the required permissions and compare with current user’s. If has, the api will be called; otherwise, ‘Access Denied’ exception will be thrown.
To make it, we need implement some classes
DemoAbstractSecurityInterceptor extends AbstractSecurityInterceptor class and implements Filter interface as the entrance to run our logic
DemoFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource interface to fetch the permissions of each api from database or other storages
DemoAccessDecisionManager implements AccessDecisionManager to judge the permissions are match or not.
PathPermissionService interface and PathPermissionServiceImpl to fetch permissions by request path and method
String[] attributes = new String[permissions.size()]; for (int index = 0; index < permissions.size(); index++) { attributes[index] = permissions.get(index).code(); }
return SecurityConfig.createList(attributes); }
@Override public Collection<ConfigAttribute> getAllConfigAttributes(){ returnnull; }
/** * Exception Handling */ .and().exceptionHandling() // set process when autentication failed .authenticationEntryPoint(authenticationEntryPoint) // set handler for access denied .accessDeniedHandler(accessDeniedHandler)
// for custom access decision http.addFilterBefore(securityInterceptor, FilterSecurityInterceptor.class); // For login with Json data in body http.addFilterAt(jsonAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); } }
OK, all done. Launch and re-test with requests in Postman. All of above should work well.
Conclusion
I hope this post can give you help. If you have any questions or suggestions, please feel free to add your comments to Issue