Protecting a Spring Boot App with Apache Shiro
My favorite thing about Apache Shiro is how easy it makes handling authorization. You can use a role-based access control (RBAC) model of assigning roles to users and then permissions to roles. This makes dealing with the inevitable requirements change simple. Your code does not change, just the permissions associated with the roles. In this post I want to demonstrate just how simple it is, using a Spring Boot application and walking through how I’d handle the following scenario:
Your boss (The Supreme Commander) shows up at your desk and tells you the current volunteer (Stormtrooper) registration application needs have different access roles for the different types of employees.
- Officers can register new “volunteers”
- Underlings (you and I) only have read access the volunteers
- Anyone from outside the organization doesn’t have any access to the “volunteers”
- It should go without saying the Supreme Commander has access to everything
Start with a REST Application
To get started, grab this Spring Boot example. It’ll get you started with a set of REST endpoints which expose CRUD operations to manage a list of Stormtroopers. You’ll be adding authentication and authorization using Apache Shiro. All of the code is up on GitHub.
Using the Apache Shiro Spring Boot starter is all you need, just add the dependency to your pom. (where ${shiro.version}
is at least 1.4.0):
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-web-starter</artifactId>
<version>${shiro.version}</version>
</dependency>
Jumping into the code we will start with our StormtrooperController
, and simply add annotations:
@RestController
@RequestMapping(path = "/troopers",
produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public class StormtrooperController {
private final StormtrooperDao trooperDao;
@Autowired
public StormtrooperController(StormtrooperDao trooperDao) {
this.trooperDao = trooperDao;
}
@GetMapping()
@RequiresRoles(logical = Logical.OR, value = {"admin", "officer", "underling"})
public Collection<Stormtrooper> listTroopers() {
return trooperDao.listStormtroopers();
}
@GetMapping(path = "/{id}")
@RequiresRoles(logical = Logical.OR, value = {"admin", "officer", "underling"})
public Stormtrooper getTrooper(@PathVariable("id") String id) throws NotFoundException {
Stormtrooper stormtrooper = trooperDao.getStormtrooper(id);
if (stormtrooper == null) {
throw new NotFoundException(id);
}
return stormtrooper;
}
@PostMapping()
@RequiresRoles(logical = Logical.OR, value = {"admin", "officer"})
public Stormtrooper createTrooper(@RequestBody Stormtrooper trooper) {
return trooperDao.addStormtrooper(trooper);
}
@PostMapping(path = "/{id}")
@RequiresRoles("admin")
public Stormtrooper updateTrooper(@PathVariable("id") String id, @RequestBody Stormtrooper updatedTrooper) throws NotFoundException {
return trooperDao.updateStormtrooper(id, updatedTrooper);
}
@DeleteMapping(path = "/{id}")
@ResponseStatus(value = HttpStatus.NO_CONTENT)
@RequiresRoles("admin")
public void deleteTrooper(@PathVariable("id") String id) {
trooperDao.deleteStormtrooper(id);
}
}
In the code block above you’re using Shiro’s @RequiresRoles
annotation to describe your use-case. You’ll notice the logical OR
to allow any of these roles access. This is great, your code is done, it was pretty easy to add, just a single line.
You could stop here but, roles are not that flexible, and if you put them directly in your code you’re now tightly coupled to those names/IDs.
Stop Using Roles
Imagine your application has been deployed and is working fine, the following week your boss stops by your desk and tells you to to make some changes:
- Officers need to be able to update troopers
- He feels that the term ‘admin’ is fine for most superiors, but is not suitable to the Dark Lord
Fine, you say, easy enough, you can just make a few small changes to the method signatures:
@GetMapping()
@RequiresRoles(logical = Logical.OR, value = {"emperor", "admin", "emperor", "officer", "underling"})
public Collection<Stormtrooper> listTroopers()
@GetMapping(path = "/{id}")
@RequiresRoles(logical = Logical.OR, value = {"emperor", "admin", "officer", "underling"})
public Stormtrooper getTrooper(@PathVariable("id") String id) throws NotFoundException
@PostMapping()
@RequiresRoles(logical = Logical.OR, value = {"emperor", "admin", "officer"})
public Stormtrooper createTrooper(@RequestBody Stormtrooper trooper)
@PostMapping(path = "/{id}")
@RequiresRoles(logical = Logical.OR, value = {"emperor", "admin", "officer"})
public Stormtrooper updateTrooper(@PathVariable("id") String id, @RequestBody Stormtrooper updatedTrooper) throws NotFoundException
@DeleteMapping(path = "/{id}")
@ResponseStatus(value = HttpStatus.NO_CONTENT)
@RequiresRoles(logical = Logical.OR, value = {"emperor", "admin"})
public void deleteTrooper(@PathVariable("id") String id)
After another round of testing the deployment and you’re back in action!
Wait, take a step back. Roles are great for simple use cases and making a change like this would work fine, but you know this will be changed again. Instead of changing your code every time the requirements change slightly, let’s decouple the roles and what they represent from your code. Instead, use permissions. Your method signatures will look like this:
@GetMapping()
@RequiresPermissions("troopers:read")
public Collection<Stormtrooper> listTroopers()
@GetMapping(path = "/{id}")
@RequiresPermissions("troopers:read")
public Stormtrooper getTrooper(@PathVariable("id") String id) throws NotFoundException
@PostMapping()
@RequiresPermissions("troopers:create")
public Stormtrooper createTrooper(@RequestBody Stormtrooper trooper)
@PostMapping(path = "/{id}")
@RequiresPermissions("troopers:update")
public Stormtrooper updateTrooper(@PathVariable("id") String id, @RequestBody Stormtrooper updatedTrooper) throws NotFoundException
@DeleteMapping(path = "/{id}")
@ResponseStatus(value = HttpStatus.NO_CONTENT)
@RequiresPermissions("troopers:delete")
public void deleteTrooper(@PathVariable("id") String id)
By using Shiro’s @RequiresPermissions
annotation, this code would work with the original requirements and the new requirements without modification. The only thing that changes is how you map those permissions to roles, and in turn, to users. This could be done externally from your application in a database, or for this example a simple properties file.
NOTE: This example uses static usernames and passwords all stored as clear text, this is fine for a blog post, but seriously, manage your passwords correctly!
To meet the original requirements, the role-to-permission mapping would look like this:
role.admin = troopers:*
role.officer = troopers:create, troopers:read
role.underling = troopers:read
For the updated requirements, you would just change the file slightly to add the new ‘emperor’ role, and grant officers the ‘update’ permission:
role.emperor = *
role.admin = troopers:*
role.officer = troopers:create, troopers:read, troopers:update
role.underling = troopers:read
If the permission syntax looks a little funny to you, take a look at Apache Shiro’s Wildcard Permission documentation for an in depth explanation.
Apache Shiro and Spring
We’ve already covered the Maven dependencies and the actual REST controller, but our application will also need a Realm
and error handling.
If you take a look at the SpringBootApp
class you will notice a few things that were NOT in the original example.
@Bean
public Realm realm() {
// uses 'classpath:shiro-users.properties' by default
PropertiesRealm realm = new PropertiesRealm();
// Caching isn't needed in this example, but we can still turn it on
realm.setCachingEnabled(true);
return realm;
}
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
// use permissive to NOT require authentication, our controller Annotations will decide that
chainDefinition.addPathDefinition("/**", "authcBasic[permissive]");
return chainDefinition;
}
@Bean
public CacheManager cacheManager() {
// Caching isn't needed in this example, but we will use the MemoryConstrainedCacheManager for this example.
return new MemoryConstrainedCacheManager();
}
First you have defined a Shiro Realm
, a realm is simply a user-store specific DAO. Shiro supports many different types of Realms out of the box (Active Directory, LDAP, Database, file, etc.).
Next up you have the ShiroFilterChainDefinition
which you’ve configured to allow BASIC authentication but NOT required it by using the ‘permissive’ option. This way your annotations configure everything. Instead of using annotations (or in addition to using them) you could define your permission to URL mappings with Ant-style paths. This example would look something like:
chainDefinition.addPathDefinition("/troopers/**", "authcBasic, rest[troopers]");
This would map any resource starting with the path /troopers
to require BASIC authentication, and use the ‘rest’ filter which based on the HTTP request method, appends a CRUD action to the permission string. For example an HTTP GET
would map to ‘read’ so the full permission string for a ‘GET’ request would be troopers:read
(just like you did with your annotations).
Exception Handling
The last bit of code you have handles exceptions.
@ExceptionHandler(UnauthenticatedException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public void handleException(UnauthenticatedException e) {
log.debug("{} was thrown", e.getClass(), e);
}
@ExceptionHandler(AuthorizationException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public void handleException(AuthorizationException e) {
log.debug("{} was thrown", e.getClass(), e);
}
@ExceptionHandler(NotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public @ResponseBody ErrorMessage handleException(NotFoundException e) {
String id = e.getMessage();
return new ErrorMessage("Trooper Not Found: "+ id +", why aren't you at your post? "+ id +", do you copy?");
}
The first two handle Shiro exceptions and simply set the status to 401 or 403. A 401 for invalid or missing user/passwords, and a 403 for any valid logged in user that does NOT have access to the resource. Lastly, you’ll want to handle any NotFoundException
with a 404 and return a JSON serialized ErrorMessage
object.
Fire it Up!
If you put all of this together, or you just grab the code from GitHub, you can start the application using mvn spring-boot:run
. Once you have everything running you can start making requests!
$ curl http://localhost:8080/troopers
HTTP/1.1 401
Content-Length: 0
Date: Thu, 26 Jan 2017 21:12:41 GMT
WWW-Authenticate: BASIC realm="application"
Don’t forget, you need to authenticate!
$ curl --user emperor:secret http://localhost:8080/troopers
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Date: Thu, 26 Jan 2017 21:14:17 GMT
Transfer-Encoding: chunked
[
{
"id": "FN-0128",
"planetOfOrigin": "Naboo",
"species": "Twi'lek",
"type": "Sand"
},
{
"id": "FN-1383",
"planetOfOrigin": "Hoth",
"species": "Human",
"type": "Basic"
},
{
"id": "FN-1692",
"planetOfOrigin": "Hoth",
"species": "Nikto",
"type": "Marine"
},
...
A 404
looks would look like this:
$ curl --user emperor:secret http://localhost:8080/troopers/TK-421
HTTP/1.1 404
Content-Type: application/json;charset=UTF-8
Date: Thu, 26 Jan 2017 21:15:54 GMT
Transfer-Encoding: chunked
{
"error": "Trooper Not Found: TK-421, why aren't you at your post? TK-421, do you copy?"
}
Learn More About Apache Shiro
This example has shown how easy it is to integrate Apache Shiro into a Spring Boot application, how using permissions allow for greater flexibility over roles, and all it takes is a single Annotation in your controller.
At Stormpath we were happy to be able to commit our support to Apache Shiro, and we’ve carried that commitment forward to Okta. Look forward to more Shiro content from our team, including tutorials on using Shiro with Okta and OAuth plus how to add an AngularJS frontend to this volunteer application. Stay tuned, the Empire needs YOU!
If you have questions on this example you can send them to Apache Shiro’s user list, me on Twitter, or just leave them in the comments section below!
To learn more, check out these posts:
Okta Developer Blog Comment Policy
We welcome relevant and respectful comments. Off-topic comments may be removed.