In this article I aim to demystify the experience of securing a ReST API with SpringBoot using the HTTP Basic Authentication mechanism. Source code for this project is available on GitHub.
This project will be a ground-up, complete example comprising of a secured API endpoint and a publicly accessible endpoint. The sometimes messy internals of Spring Security will be explored in detail, including gotchas that I’ve found along the way.
The user information (username and password) is passed to the API via Basic Authentication. In a web browser this will be an internal popup requesting the information. In a tool such as Postman, this information will be entered under the Authorization tab, with the selection of “Basic Auth”.
The data store for authenticating users on the backend shall be a Postgres database with a simple table. Initially however, I will show how to authenticate users using a hardcoded in-memory user. This solution can be chosen if you do not want to use a database – though it doesn’t mimic a real world scenario very well!
Firstly, set up a SpringBoot application with the following dependencies
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-jpa --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> </dependencies>
Configure a Postgres database:
- Create a schema called micros
- Create a table called users
The following DDL can be used to create the tables
create table micro.users ( id serial constraint users_pk primary key, username varchar not null, password varchar not null, last_login timestamp, roles varchar );
Use a database tool to insert data in to this table. For now, we require a value for username, password and role. Insert values similar to:
INSERT INTO micro.users (id, username, password, last_login, roles) VALUES (1, 'rohan', 'password', null, 'ADMIN');
For our secured API, we are therefore assuming that user rohan will be able to access any API that has role security of ADMIN. More on that later.
Set up application.properties in SpringBoot to connect to our Database
spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.PostgreSQLDialect spring.jpa.hibernate.ddl-auto=none spring.jpa.hibernate.show-sql=true spring.datasource.url=jdbc:postgresql://localhost:5432/postgres spring.datasource.username=postgres spring.datasource.password=rocknroll
With the housekeeping out of the way, lets turn to the SpringBoot application and create a very simple API for a client to use.
Rest Controller
@RestController @RequestMapping("/api") public class HelloService { @GetMapping(path = "/hello") @PreAuthorize("hasRole('ADMIN')") public HelloObject sayHello() { HelloObject helloObject = new HelloObject(); helloObject.setData(new SimpleDateFormat("yyyyMMdd HHmmss").format(new Date())); helloObject.setNumber(1); return helloObject; } @GetMapping(path = "/goodbye") public HelloObject sayGoodBye() { HelloObject helloObject = new HelloObject(); helloObject.setData(new SimpleDateFormat("yyyyMMdd HHmmss").format(new Date())); helloObject.setNumber(2); return helloObject; } }
Our return object is a simple JSON
public class HelloObject { private String data; private Integer number; public String getData() { return data; } public void setData(String data) { this.data = data; } public Integer getNumber() { return number; } public void setNumber(Integer number) { this.number = number; } }
At this point we want to test our unsecured API works correctly, so we will remove the PreAuthorize anotation. The API should serve back a JSON response when tested with the following URL http://localhost:8080/api/hello
(If there is a 401 response, you will need to remove springboot-starter-security from the pom.xml)
To secure the application, add the following dependency to the POM file
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
Note, now that we have added this dependency, SpringBoot will automatically fail all responses. This is normal, we need to set up Spring Security to enable this API. By default all traffic will be rejected unless specifically configured otherwise. And from a security standpoint this is a good thing!
Next we will configure Spring Security. This will be in a file called SecurityConfig. It will extend WebSecurityConfigurerAdapter. Here we will specify that our endpoint shall be protected. In addition we will allow another endpoint to be publicly accessible.
Web security configuration
@Configuration @EnableWebSecurity //The @EnableWebSecurity is a marker annotation. It allows Spring to find (it's a @Configuration and, therefore, @Component) and automatically apply the class to the global WebSecurity. public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserDetailsService userDetailsService; @Autowired private BCryptPasswordEncoder encodedPass; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService); //auth.inMemoryAuthentication().withUser("rohan").password("$2a$10$a89v06ied5ZWvnL5raVilOymiLGOrWs1LeDC/vVoYaz4tlj7Bmd16").roles("ADMIN"); } @Override protected void configure(HttpSecurity http) throws Exception { http.httpBasic().and().authorizeRequests() .antMatchers("/api/goodbye").permitAll() .antMatchers("/api/**").hasRole("ADMIN") .and().csrf().disable() } }
The directive (really just a method call) httpBasic enables httpBasic authentication. We need this to field the username and password being passed in. authorizeRequests() will do as it implies – authorize all requests that are made and return an error (401) otherwise . antMatchers() applies logic to paths specified. In this case anything under /api/ should return successfully only for a user with ADMIN role.
We placed the permitAll() for our public API above the general condition as the /** will capture everything, including ‘goodbye’. So if placed underneath it won’t work.
Since we are not using a browser we don’t need cross-site-request-forgery checking so we disable it.
Method level security
Make a small configuration class to enable method level annotations for @PreAuthorize
We can also add our BCrypt password encoder bean here. I tried adding this in SecurityConfig class but got a circular reference error preventing the application from starting, so to fix that I put it here.
@Configuration @EnableGlobalMethodSecurity( prePostEnabled = true, securedEnabled = true, jsr250Enabled = true) public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration { @Bean public BCryptPasswordEncoder encodedPass(){ return new BCryptPasswordEncoder(); } }
Now lets set up the User Service
Note: If you want to check only against an in user memory registry, you can do this by commenting out the UserService line and uncommenting auth.inMemoryAuthentication
@Component public class CustomUserDetailsService implements org.springframework.security.core.userdetails.UserDetailsService { @Autowired private UsersDao usersDao; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Users user = usersDao.findByUsername(username); String[] role = user.getRoles().split(","); List<GrantedAuthority> authorities = new ArrayList<>(); if (role != null && role.length > 0) { for (String aRole: role) { authorities.add(new SimpleGrantedAuthority("ROLE_" + aRole.toUpperCase())); } } // see https://stackoverflow.com/questions/37615034/spring-security-spring-boot-how-to-set-roles-for-users //UserDetails userdeets = new User(user.getUsername(), "$2a$10$deUsrZi0ZtpbBhwov0ODDOxbsgMGoRhv/c7f5yLEAr2cQ18itHG8q", authorities); UserDetails userdeets = new User(user.getUsername(), new BCryptPasswordEncoder().encode(user.getPassword()), authorities); System.out.println("UserDetails: "+user.getUsername()); return userdeets; } }
The CustomUserService will extend Springs UserService. It’s task is to take input from Basic Authentication and use this to authenticate the user. In our case we are using it as a look up to a database table. We add all the comma separated roles that are entered in the ‘roles’ field in our database table. For now we should just have ADMIN in this roles database field.
A couple of gotchas: In order to authenticate a role as specified in the @PreAuthorize annotation, we need to add the string ROLE_ to the role name.
We also must encode our password on our side when attempting to match the password. SpringBoot will automatically present the passed in value retrieved via Http Basic Auth as BCrypt encoded. It occurs behind-the-scenes when we add the .httpBasic directive. You can test this for yourself!
Testing the application
If we add username and password to Basic Authentication fields and make a call to our protected /hello API, we should get a response such as this:
{ "data": "20220309 172800", "number": 1 }
If we make the username or password wrong, we should get a response like this:
{ "timestamp": "2022-03-09T06:30:09.613+00:00", "status": 401, "error": "Unauthorized", "path": "/api/hello" }
There is a slight hiccup that I found. If we successfully authenticate and then change the password, it will still return a valid response – why? SpringBoot session management is caching the credentials . To overcome this, add the following line to the Spring Security configuration (after crsf disable)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
This will now check the password is OK on every invocation which is what we want.
Full source code is available at: https://github.com/rptrus/MicroServiceA