Testing a Secured Grails Application
In this guide you will see how to test the security constraints you added with Grails Spring Security REST Plugin and Grails Spring Security Core Plugin.
Authors: Sergio del Amo
Grails Version: 5.0.1
1 Grails Training
Grails Training - Developed and delivered by the folks who created and actively maintain the Grails framework!.
2 Getting Started
In this guide you are going to:
-
Create a functional test of a REST API endpoint secured with the Grails Spring Security Rest Plugin
-
Create a
GebSpec
Functional test of a page secured with the Grails Spring Security Core Plugin. When you try to access such a page you are redirected to a sign-in form.
2.1 What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE
-
JDK 1.8 or greater installed with
JAVA_HOME
configured appropriately
2.2 How to complete the guide
To get started do the following:
-
Download and unzip the source
or
-
Clone the Git repository:
git clone https://github.com/grails-guides/grails-test-security.git
The Grails guides repositories contain two folders:
-
initial
Initial project. Often a simple Grails app with some additional code to give you a head-start. -
complete
A completed example. It is the result of working through the steps presented by the guide and applying those changes to theinitial
folder.
To complete the guide, go to the initial
folder
-
cd
intograils-guides/grails-test-security/initial
and follow the instructions in the next sections.
You can go right to the completed example if you cd into grails-guides/grails-test-security/complete
|
3 Writing the Application
We are going to write an app which combines both a traditional web application and an API. API endpoints will be prefixed with /api/
3.1 Domain Class
We’ll create a Domain Class, Announcement
, which we are going to use as an example through this guide:
./grailsw create-domain-class Announcement
| Created grails-app/domain/example/grails/Announcement.groovy
| Created src/test/groovy/example/grails/AnnouncementSpec.groovy
package example.grails
import groovy.transform.CompileStatic
@CompileStatic
class Announcement {
String message
static constraints = {
}
}
3.2 Web Controller
We are going to use static Scaffolding to generate a controller for the web app.
./grailsw generate-all Announcement
| Rendered template Controller.groovy to destination grails-app/controllers/example/grails/AnnouncementController.groovy
| Rendered template Spec.groovy to destination src/test/groovy/example/grails/AnnouncementControllerSpec.groovy
| Scaffolding completed for grails-app/domain/example/grails/Announcement.groovy
| Rendered template edit.gsp to destination grails-app/views/announcement/edit.gsp
| Rendered template create.gsp to destination grails-app/views/announcement/create.gsp
| Rendered template index.gsp to destination grails-app/views/announcement/index.gsp
| Rendered template show.gsp to destination grails-app/views/announcement/show.gsp
| Views generated for grails-app/domain/example/grails/Announcement.groovy
3.3 Api Controller
To create the Announcement
API controller we’ll use the create-controller command
./grailsw create-controller ApiAnnouncement
Created grails-app/controllers/example/grails/ApiAnnouncementController.groovy
| Created src/test/groovy/example/grails/ApiAnnouncementControllerSpec.groovy
We want this controller to handle any requests to /api/announcements. We’ll need to add the following line to our UrlMappings
.
'/api/announcements'(controller: 'apiAnnouncement')
The controller will be a RestfulController, and will only respond with JSON.
package example.grails
import grails.rest.RestfulController
import groovy.transform.CompileStatic
@CompileStatic
class ApiAnnouncementController extends RestfulController {
static responseFormats = ['json']
ApiAnnouncementController() {
super(Announcement)
}
}
3.4 Securing the App
Add a dependency on the Grails Spring Security Rest plugin
and to the latest release of Spring Security Core
to our build.gradle
file.
compile 'org.grails.plugins:spring-security-rest:3.0.0.RC1'
compile 'org.grails.plugins:spring-security-core:4.0.0.RC2'
We’ll run the s2-quickstart command, provided by the Spring Security Core Plugin,
to generate User
and Authority
classes.
./grailsw s2-quickstart example.grails User SecurityRole
s2-quickstart
script generates three domain classes; User
, SecurityRole
and UserSecurityRole
.
package example.grails
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
@EqualsAndHashCode(includes='username')
@ToString(includes='username', includeNames=true, includePackage=false)
class User implements Serializable {
private static final long serialVersionUID = 1
String username
String password
boolean enabled = true
boolean accountExpired
boolean accountLocked
boolean passwordExpired
Set<SecurityRole> getAuthorities() {
(UserSecurityRole.findAllByUser(this) as List<UserSecurityRole>)*.securityRole as Set<SecurityRole>
}
static constraints = {
password blank: false, password: true
username blank: false, unique: true
}
static mapping = {
password column: '`password`'
}
}
package example.grails
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
@EqualsAndHashCode(includes='authority')
@ToString(includes='authority', includeNames=true, includePackage=false)
class SecurityRole implements Serializable {
private static final long serialVersionUID = 1
String authority
static constraints = {
authority blank: false, unique: true
}
static mapping = {
cache true
}
}
package example.grails
import grails.gorm.DetachedCriteria
import groovy.transform.ToString
import org.codehaus.groovy.util.HashCodeHelper
import grails.compiler.GrailsCompileStatic
@SuppressWarnings(['FactoryMethodName', 'Instanceof'])
@GrailsCompileStatic
@ToString(cache=true, includeNames=true, includePackage=false)
class UserSecurityRole implements Serializable {
private static final long serialVersionUID = 1
User user
SecurityRole securityRole
@Override
boolean equals(other) {
if (other instanceof UserSecurityRole) {
other.userId == user?.id && other.securityRoleId == securityRole?.id
}
}
@Override
int hashCode() {
int hashCode = HashCodeHelper.initHash()
if (user) {
hashCode = HashCodeHelper.updateHash(hashCode, user.id)
}
if (securityRole) {
hashCode = HashCodeHelper.updateHash(hashCode, securityRole.id)
}
hashCode
}
static UserSecurityRole get(long userId, long securityRoleId) {
criteriaFor(userId, securityRoleId).get()
}
static boolean exists(long userId, long securityRoleId) {
criteriaFor(userId, securityRoleId).count()
}
private static DetachedCriteria criteriaFor(long userId, long securityRoleId) {
UserSecurityRole.where {
user == User.load(userId) &&
securityRole == SecurityRole.load(securityRoleId)
}
}
static UserSecurityRole create(User user, SecurityRole securityRole, boolean flush = false) {
def instance = new UserSecurityRole(user: user, securityRole: securityRole)
instance.save(flush: flush)
instance
}
static boolean remove(User u, SecurityRole r) {
if (u != null && r != null) {
UserSecurityRole.where { user == u && securityRole == r }.deleteAll()
}
}
static int removeAll(User u) {
u == null ? 0 : UserSecurityRole.where { user == u }.deleteAll() as int
}
static int removeAll(SecurityRole r) {
r == null ? 0 : UserSecurityRole.where { securityRole == r }.deleteAll() as int
}
static constraints = {
securityRole validator: { SecurityRole r, UserSecurityRole ur ->
if (ur.user?.id) {
UserSecurityRole.withNewSession {
if (UserSecurityRole.exists(ur.user.id, r.id)) {
return ['userRole.exists']
}
}
}
}
}
static mapping = {
id composite: ['user', 'securityRole']
version false
}
}
If you are using GORM 6.0.10 or a later version and Spring Security core 3.1.2 or later version, s2-quickstart generates and registers a bean to handle password encoding:
package example.grails
import grails.plugin.springsecurity.SpringSecurityService
import org.grails.datastore.mapping.core.Datastore
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEventListener
import org.grails.datastore.mapping.engine.event.EventType
import org.grails.datastore.mapping.engine.event.PreInsertEvent
import org.grails.datastore.mapping.engine.event.PreUpdateEvent
import org.springframework.context.ApplicationEvent
import org.springframework.beans.factory.annotation.Autowired
import groovy.transform.CompileStatic
@SuppressWarnings(['UnnecessaryGetter', 'LineLength', 'Instanceof'])
@CompileStatic
class UserPasswordEncoderListener extends AbstractPersistenceEventListener {
@Autowired
SpringSecurityService springSecurityService
UserPasswordEncoderListener(final Datastore datastore) {
super(datastore)
}
@Override
protected void onPersistenceEvent(AbstractPersistenceEvent event) {
if (event.entityObject instanceof User) {
User u = (event.entityObject as User)
if (u.password && (event.eventType == EventType.PreInsert || (event.eventType == EventType.PreUpdate && u.isDirty('password')))) {
event.getEntityAccess().setProperty('password', encodePassword(u.password))
}
}
}
@Override
boolean supportsEventType(Class<? extends ApplicationEvent> eventType) {
eventType == PreUpdateEvent || eventType == PreInsertEvent
}
private String encodePassword(String password) {
springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password) : password
}
}
import example.grails.UserPasswordEncoderListener
// Place your Spring DSL code here
beans = {
userPasswordEncoderListener(UserPasswordEncoderListener, ref('hibernateDatastore'))
}
We’ll configure the security rules of the application in the file application.groovy, as show below. We have one stateless chain and one Traditional Chain.
grails {
plugin {
springsecurity {
rest {
token {
storage {
jwt {
secret = 'pleaseChangeThisSecretForANewOne'
}
}
}
}
securityConfigType = "InterceptUrlMap" (1)
filterChain {
chainMap = [
[pattern: '/api/**',filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'],(2)
[pattern: '/**', filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter'] (3)
]
}
userLookup {
userDomainClassName = 'example.grails.User' (4)
authorityJoinClassName = 'example.grails.UserSecurityRole' (4)
}
authority {
className = 'example.grails.SecurityRole' (4)
}
interceptUrlMap = [
[pattern: '/', access: ['permitAll']],
[pattern: '/error', access: ['permitAll']],
[pattern: '/index', access: ['permitAll']],
[pattern: '/index.gsp', access: ['permitAll']],
[pattern: '/shutdown', access: ['permitAll']],
[pattern: '/assets/**', access: ['permitAll']],
[pattern: '/**/js/**', access: ['permitAll']],
[pattern: '/**/css/**', access: ['permitAll']],
[pattern: '/**/images/**', access: ['permitAll']],
[pattern: '/**/favicon.ico', access: ['permitAll']],
[pattern: '/login/**', access: ['permitAll']], (5)
[pattern: '/logout', access: ['permitAll']],
[pattern: '/logout/**', access: ['permitAll']],
[pattern: '/announcement', access: ['ROLE_BOSS', 'ROLE_EMPLOYEE']],
[pattern: '/announcement/index', access: ['ROLE_BOSS', 'ROLE_EMPLOYEE']], (6)
[pattern: '/announcement/create', access: ['ROLE_BOSS']],
[pattern: '/announcement/save', access: ['ROLE_BOSS']],
[pattern: '/announcement/update', access: ['ROLE_BOSS']],
[pattern: '/announcement/delete/*', access: ['ROLE_BOSS']],
[pattern: '/announcement/edit/*', access: ['ROLE_BOSS']],
[pattern: '/announcement/show/*', access: ['ROLE_BOSS', 'ROLE_EMPLOYEE']],
[pattern: '/api/login', access: ['ROLE_ANONYMOUS']], (7)
[pattern: '/oauth/access_token', access: ['ROLE_ANONYMOUS']], (8)
[pattern: '/api/announcements', access: ['ROLE_BOSS'], httpMethod: 'GET'], (9)
[pattern: '/api/announcements/*', access: ['ROLE_BOSS'], httpMethod: 'GET'],
[pattern: '/api/announcements/*', access: ['ROLE_BOSS'], httpMethod: 'DELETE'],
[pattern: '/api/announcements', access: ['ROLE_BOSS'], httpMethod: 'POST'],
[pattern: '/api/announcements/*', access: ['ROLE_BOSS'], httpMethod: 'PUT']
]
}
}
}
1 | We choose to configure security with a InterceptUrlMap |
2 | Stateless Chain |
3 | Traditional chain |
4 | Classes generated by the s2-quickstart script |
5 | URL of login page |
6 | Url accesible only for users authenticated and with role ROLE_BOSS or ROLE_EMPLOYEE |
7 | Spring Security Rest for Grails default Authentication Endpoint. It should allow anonymous access |
8 | Spring Security Rest for Grails default Refresh Token Endpoint. It should allow anonymous access |
9 | Url accesible only for users authenticated and with role ROLE_BOSS |
We’ll populate our database in BootStrap
, inserting two users when the application starts.
package example.grails
import grails.gorm.services.Service
@Service(User)
interface UserService {
User save(String username, String password)
User findByUsername(String username)
}
package example.grails
import grails.gorm.services.Service
@Service(SecurityRole)
interface SecurityRoleService {
SecurityRole save(String authority)
SecurityRole findByAuthority(String authority)
}
package example.grails
import grails.gorm.services.Service
@Service(UserSecurityRole)
interface UserSecurityRoleService {
UserSecurityRole save(User user, SecurityRole securityRole)
}
package example.grails
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
class BootStrap {
AnnouncementService announcementService
UserService userService
SecurityRoleService securityRoleService
UserSecurityRoleService userSecurityRoleService
def init = { servletContext ->
List<String> authorities = ['ROLE_BOSS', 'ROLE_EMPLOYEE']
authorities.each { String authority ->
if ( !securityRoleService.findByAuthority(authority) ) {
securityRoleService.save(authority)
}
}
if ( !userService.findByUsername('sherlock') ) {
User u = userService.save('sherlock', 'elementary')
userSecurityRoleService.save(u, securityRoleService.findByAuthority('ROLE_BOSS'))
}
if ( !userService.findByUsername('watson') ) {
User u = userService.save('watson', '221Bbakerstreet')
userSecurityRoleService.save(u, securityRoleService.findByAuthority('ROLE_EMPLOYEE'))
}
announcementService.save('The Hound of the Baskervilles')
}
def destroy = {
}
}
3.5 Test Rest Api
To test REST part of the application, we use Micronaut’s HttpClient. We need to add the dependency:
testImplementation "io.micronaut:micronaut-http-client"
Micronaut HTTP client makes easy to bind JSON Payloads to POGOs. Create several POGOs which we will use in the test:
package example.grails
import com.fasterxml.jackson.annotation.JsonProperty
class BearerToken {
@JsonProperty('access_token')
String accessToken
@JsonProperty('refresh_token')
String refreshToken
List<String> roles
String username
}
package example.grails
import groovy.transform.CompileStatic
@CompileStatic
class CustomError {
Integer status
String error
String message
String path
}
package example.grails
import groovy.transform.CompileStatic
@CompileStatic
class UserCredentials {
String username
String password
}
Our first test will verify that the /api/announcements endpoint is only accessible to users with role ROLE_BOSS.
package example.grails
import grails.testing.mixin.integration.Integration
import io.micronaut.core.type.Argument
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.exceptions.HttpClientException
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification
import grails.testing.spock.OnceBefore
@SuppressWarnings(['MethodName', 'DuplicateNumberLiteral', 'Instanceof'])
@Integration
class ApiAnnouncementControllerSpec extends Specification {
@Shared
@AutoCleanup
HttpClient client
@OnceBefore (1)
void init() {
client = HttpClient.create(new URL("http://localhost:$serverPort")) (2)
}
def 'test /api/announcements url is secured'() {
when:
HttpRequest request = HttpRequest.GET('/api/announcements')
client.toBlocking().exchange(request, (3)
Argument.of(List, AnnouncementView),
Argument.of(CustomError))
then:
HttpClientException e = thrown(HttpClientException)
e.response.status == HttpStatus.UNAUTHORIZED (4)
when:
Optional<CustomError> jsonError = e.response.getBody(CustomError)
then:
jsonError.isPresent()
jsonError.get().status == 401
jsonError.get().error == 'Unauthorized'
jsonError.get().message == null
jsonError.get().path == '/api/announcements'
}
def "test a user with the role ROLE_BOSS is able to access /api/announcements url"() {
when: 'login with the sherlock'
UserCredentials credentials = new UserCredentials(username: 'sherlock', password: 'elementary')
HttpRequest request = HttpRequest.POST('/api/login', credentials) (5)
HttpResponse<BearerToken> resp = client.toBlocking().exchange(request, BearerToken)
then:
resp.status.code == 200
resp.body().roles.find { it == 'ROLE_BOSS' }
when:
String accessToken = resp.body().accessToken
then:
accessToken
when:
HttpResponse<List> rsp = client.toBlocking().exchange(HttpRequest.GET('/api/announcements')
.header('Authorization', "Bearer ${accessToken}"), Argument.of(List, AnnouncementView)) (6)
then:
rsp.status.code == 200 (7)
rsp.body() != null
((List)rsp.body()).size() == 1
((List)rsp.body()).get(0) instanceof AnnouncementView
((AnnouncementView) ((List)rsp.body()).get(0)).message == 'The Hound of the Baskervilles'
}
def "test a user with the role ROLE_EMPLOYEE is NOT able to access /api/announcements url"() {
when: 'login with the watson'
UserCredentials creds = new UserCredentials(username: 'watson', password: '221Bbakerstreet')
HttpRequest request = HttpRequest.POST('/api/login', creds)
HttpResponse<BearerToken> resp = client.toBlocking().exchange(request, BearerToken)
then:
resp.status.code == 200
!resp.body().roles.find { it == 'ROLE_BOSS' }
resp.body().roles.find { it == 'ROLE_EMPLOYEE' }
when:
String accessToken = resp.body().accessToken
then:
accessToken
when:
resp = client.toBlocking().exchange(HttpRequest.GET('/api/announcements')
.header('Authorization', "Bearer ${accessToken}"))
then:
def e = thrown(HttpClientException)
e.response.status == HttpStatus.FORBIDDEN (8)
}
}
1 | The grails.testing.spock.OnceBefore annotation is a shorthand way of accomplishing the same behavior that would be accomplished by applying both the @RunOnce and @Before annotations to a fixture method. |
2 | serverPort property is automatically injected and it contains the random port where the app will be running for the functional test. |
3 | With Micronaut HTTP client you can bind POJOs to the response body easily. |
4 | The server returns a 401, indicating that the resource is secured. |
5 | Call the authentication endpoint |
6 | We pass the access_token we obtained from the authentication endpoint in the request headers. |
7 | An authenticated user with the role ROLE_BOSS can access the resource. |
8 | An authenticated user but without the role ROLE_BOSS is not allowed to access the resource. |
3.6 Test Web Endpoint
Now we are going to execute a Functional Test using the Geb framework.
To make this test easier to read and maintain we’ve created two Geb Pages: a LoginPage and an AnnouncementListingPage.
package example.grails
import geb.Page
class LoginPage extends Page {
static url = '/login/auth'
static at = {
title == 'Login'
}
static content = {
loginButton { $('#submit', 0) }
usernameInputField { $('#username', 0) }
passwordInputField { $('#password', 0) }
}
void login(String username, String password) {
usernameInputField << username
passwordInputField << password
loginButton.click()
}
}
package example.grails
import geb.Page
class AnnouncementListingPage extends Page {
static url = '/announcement/index'
static at = {
$('#list-announcement').text()?.contains 'Announcement List'
}
}
package example.grails
import geb.spock.GebSpec
import grails.testing.mixin.integration.Integration
@SuppressWarnings('MethodName')
@Integration
class AnnouncementControllerSpec extends GebSpec {
void 'test /announcement/index is secured, but accesible to users with role ROLE_BOSS'() {
when: 'try to visit announcement listing without login'
go '/announcement/index'
then: 'it is redirected to login page'
at LoginPage
when: 'signs in with a ROLE_BOSS user'
LoginPage page = browser.page(LoginPage)
page.login('sherlock', 'elementary')
then: 'he gets access to the announcement listing page'
at AnnouncementListingPage
}
void 'test /announcement/index is secured, but accesible to users with role ROLE_EMPLOYEE'() {
when: 'try to visit announcement listing without login'
go '/announcement/index'
then: 'it is redirected to login page'
at LoginPage
when: 'signs in with a ROLE_EMPLOYEE user'
LoginPage page = browser.page(LoginPage)
page.login('watson', '221Bbakerstreet')
then: 'he gets access to the announcement listing page'
at AnnouncementListingPage
}
}
4 Running the Application
We have removed the unit tests in this project. Our project contains only the integration and functional tests displayed in the previous code listings. |
To run the tests:
./grailsw
grails> test-app
grails> open test-report