- spring security with form login AND secured REST api with http basic auth
- in memory authent for admin user AND jpa/hibernate persistence for users
- thymeleaf templates for the ui
- tests: security is tested for all html pages and the REST api
- use of a domain model
Openshift instance is here
./mvnw clean install
./mvnw spring-boot:run
- open your browser at http://localhost:8080
Security stuff are gathered under the com.orange.spring.demo.security
package.
We rely on annotations to configure the application, there is no xml configuration.
I am not saying that we should not use xml configuration, just that it is not the case here.
In the GlobalSecurityConfig class, we do the following things:
- we annotate the class with
@EnableWebSecurity
to enable spring security - we configure a password encoder
@Bean
for user passwords hashing. Then spring security can use this bean to check passwords for us, and we can use it as well when we create a new user. - we setup the admin account "in memory". This way it is easier to start, but the drawback of this approach is that you need to update the application when you want to add admin users or update their passwords. For users we rely on a database.
So it looks like this:
@EnableWebSecurity
public class GlobalSecurityConfig {
@Autowired
@Qualifier("userDetailsService")
private UserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
AppSecurityAdmin.addAdminInMemory(auth);
}
}
And for AppSecurityAdmin:
public class AppSecurityAdmin {
public static void addAdminInMemory(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("admin")
.password("admin password")
.authorities(AppSecurityRoles.authorities());
}
}
There are three security levels in the application:
- anonymous
- authenticated user: 'ROLE_USER'
- admin: 'ROLE_ADMIN'
These roles are configured at start up in AppSecurityRoles. First we check if the roles have been created in the DB, and if not we create it.
...
public enum Role { ROLE_USER, ROLE_ADMIN }
@Autowired
private UserRoleRepository userRoleRepository;
@PostConstruct
void init() {
addRoles();
}
private void addRoles() {
if (userRoleRepository.count() == 0) {
log.info("Add user roles");
userRoleRepository.save(
Arrays.asList(new UserRoleDB[] {
new UserRoleDB(ROLE_USER), new UserRoleDB(ROLE_ADMIN)
})
);
}
}
...
To authenticate users, we can rely on several mechanisms:
- session based: we create a new session after the user has successfully signed in through the login page. Then the session id present in http requests headers will let us do authentication.
- token based: OAuth, json web token, etc are another mean, typically for stateless applications. See here and a spring demo here
- http basic: the login and password of the user are encoded in the header of http requests. See here.
On this application we want to use:
- regular session based authentication for form login / html pages
- http basic authentication for the REST api
To do this we have defined two distinct WebSecurityConfigurerAdapter
doc:
- the ApiSecurityConfig class which will configure the security of the REST api
- the BrowserSecurityConfig class which will configure the security of the app when used with a browser
Both classes extends WebSecurityConfigurerAdapter
and override the configure
method to configure the HttpSecurity
instance.
Here we need to define a priority: in the security chain pipeline, which configurer will be applied first? the problem is solved in the ApiSecurityConfig class with the @Order(1)
annotation: the REST api configurer will be applied first.
It is done in the ApiSecurityConfig class:
@Configuration
@Order(1)
public class ApiSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
disableCsrfForNonBrowserApi(http);
initApi(http);
}
private void initApi(HttpSecurity http) throws Exception {
http
// configure the HttpSecurity to only be invoked when matching the provided ant pattern
.antMatcher("/ws/**")
// configure restricting access
.authorizeRequests()
// open api is... opened
.antMatchers("/ws/open/**").permitAll()
// admin api restricted to... ADMIN
.antMatchers("/ws/admin/**").hasRole("ADMIN")
// and the rest is allowed by any authenticated user
.antMatchers("/ws/sec/**").authenticated()
.and()
.httpBasic();
}
private void disableCsrfForNonBrowserApi(HttpSecurity http) throws Exception {
http.csrf().disable();
}
}
Since csrf protection is useless for REST api, we disable it. See spring doc as well.
Note the @Order
to apply REST security configurer first.
It is done in the BrowserSecurityConfig class:
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private Environment environment;
@Override
protected void configure(HttpSecurity http) throws Exception {
disableSecurityOnWebJars(http);
disableSecForDBConsole(http);
http
// configure the HttpSecurity to only be invoked when matching the provided ant pattern
.antMatcher("/**")
// configure restricting access
.authorizeRequests()
// open api is... opened
.antMatchers("/").permitAll()
// admin api restricted to... ADMIN
.antMatchers("/admin/**").hasRole("ADMIN")
// and the rest is allowed by any authenticated user
.anyRequest().authenticated()
.and()
// setup login & logout
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.logoutSuccessUrl("/")
.permitAll();
}
private void disableSecurityOnWebJars(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/webjars/**").permitAll();
}
private void disableSecForDBConsole(HttpSecurity http) throws Exception {
if (isDevProfile()) {
log.warn("Disable security to allow H2 console");
String url = "/h2-console/**";
http.csrf().ignoringAntMatchers(url);
http.authorizeRequests().antMatchers(url).permitAll();
http.headers().frameOptions().disable();
}
}
private boolean isDevProfile() {
return Arrays.asList(environment.getActiveProfiles()).contains("dev");
}
}
So what are we doing here:
- as we use webjars to retrieve client libraries like jQuery or Bootstrap, we need to disable security to allow the client to access these javascript files!
- as we use hibernate to store user accounts, we need to disable security in development mode, to access the h2-console. Check this [link](here as well
- then we configure authorized requests, for anonymous, authenticated users, admin...
The first time you enable security in your application, there is good chance that many integration or unit tests will fail.
There are two points to consider here:
- you think you have secured the application. You need to write tests to be sure!
- how to ease business tests when security is annoying you, ie. business tests fail not due to a bug but due to a restricted access problem.
A few links which can help:
- Spring security doc on testing
- Preview Spring Security Test: Method Security
- Testing Improvements in Spring Boot 1.4
A few points worthy to notice:
- some annotations let you mock authentication in your tests:
@WithMockUser @WithAnonymousUser @WithUserDetails
. Check the [doc](Spring security doc on testing). So if a business test requires to be authenticated, just mock it with@WithMockUser
and you are (almost) done. MockMvc
is just great to test your controllers, see the doc
Indeed, MockMvc
lets you perform requests with mocked csrf
protection, or with http-basic
, etc.
For instance to test REST authentication with http basic:
@Test
public void retrieveUsersAsAnAuthenticatedUser() throws Exception {
// given
mockMvc
// do
.perform(MockMvcRequestBuilders.get("/ws/sec/users")
.with(httpBasic(username, password))
.accept(MediaType.APPLICATION_JSON))
// then
.andExpect(status().isOk());
}
or to do a request with mocked csrf and authenticated as admin:
@Test
@WithMockUser(username = "admin", password = "admin password", roles = "ADMIN")
public void adminIsAuthorizedToCreateUser() throws Exception {
// given
mockMvc
// do
.perform(
post("/admin/user/create")
.with(csrf())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "titi")
.param("password", "toto")
)
// then
.andExpect(status().isOk());
}
We try to follow this architecture:
- a persistence layer
- a domain layer, which has no dependency to the persistence layer (so that changes to the persistence layer should not impact the domain).
- a service layer which is the interface between the domain and the persistence layers
- a controllers layer which rely on domain objects and services
In addition we try to have immutable objects in the domain:
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
/** let's try to be immutable **/
public class User {
// as we are immutable, we do not need getters and we can allow direct access to fields
public final long id;
public final String username;
}
About @RequiredArgsConstructor
, it is just a sugar, see lombok doc: it writes the constructor for us with mandatory (final) fields.
GPLv2, Copyright (C) 2016 Orange
Christophe Maldivi & Denis Boisset
- use rolling logs to avoid filling the disk...
- retry to use pre/post auth for repository, see spring data example here. Right now using "@EnableGlobalMethodSecurity(prePostEnabled = true)" involves a "object already built" exception. Maybe due to 1.4.0_M2 version?
- apply the uniq constraint on userDB
- improve UI to let admin remove users
- improve UI to let admin create a user as an administrator
As we use lombok, you need to add the lombok plugin to IntelliJ
console is enabled in dev mode (disabled by default)
- url: http://localhost:8080/h2-console
- driver: jdbc:h2:mem:testdb
More details here
- run the server on the command line with maven (mvn spring-boot:run)
- in IntelliJ, make sure that you have selected: Preferences->'Build, Execution, Deployment'->Compiler->'Make project automatically'
- for thymeleaf templates, it is necessary to force a build to be sure to use the new version
Here we need to install the 'livereload' plugin in the browser, see here