显示导航

测试受保护的 Grails 应用程序

在本指南中,您将了解如何测试使用 Grails Spring Security REST 插件和 Grails Spring Security Core 插件添加的安全限制。

作者:Sergio del Amo

Grails 版本 3.3.8

1 Grails 培训

Grails 培训 - 由创建、积极维护 Grails 框架的人员开发并提供!

2 开始

在本指南中,您将

  • 创建一个使用 Grails Spring Security Rest 插件保护的 REST API 端点的功能测试

  • 创建一个使用 Grails Spring Security 核心插件保护的页面的 GebSpec 功能测试。当您尝试访问此类页面时,您将被重定向到登录表单。

2.1 您需要准备什么

要完成本指南,您需要具备以下条件

  • 一定的时间

  • 一个不错的文本编辑器或 IDE

  • 安装的 JDK 1.7 或更高版本,并正确配置了 JAVA_HOME

2.2 如何完成本指南

要开始,请执行以下操作

Grails 指南代码库包含两个文件夹

  • initial 初始项目。通常是一个简单的 Grails 应用程序,另有一些额外的代码可让您领先一步。

  • complete 一个完整的例子。它是教程指导完成步骤并应用于initial文件夹的结果。

若要完成教程,请转到initial文件夹

  • cd进入grails-guides/grails-test-security/initial

并遵循后续章节中的说明。

如果你cd进入grails-guides/grails-test-security/complete,你可以直接转到完整样例

3 编写应用程序

我们准备编写一个既结合了传统 Web 应用程序又拥有 API 的应用程序。API 端点将以/api/为前缀

3.1 领域类

我们将创建一个领域类Announcement,我们准备在本教程中使用它作为示例

./grailsw create-domain-class Announcement
| Created grails-app/domain/example/grails/Announcement.groovy
| Created src/test/groovy/example/grails/AnnouncementSpec.groovy
grails-app/domain/example/grails/Announcement.groovy
package example.grails

import groovy.transform.CompileStatic

@CompileStatic
class Announcement {

    String message

    static constraints = {
    }
}

3.2 Web 控制器

我们将使用静态脚手架为 Web 应用程序生成一个控制器。

./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 控制器

若要创建Announcement API 控制器,我们将使用create-controller 命令

./grailsw create-controller ApiAnnouncement
Created grails-app/controllers/example/grails/ApiAnnouncementController.groovy
| Created src/test/groovy/example/grails/ApiAnnouncementControllerSpec.groovy

我们希望该控制器处理任何到/api/announcements的请求。我们需要将如下行添加到我们的UrlMappings 中。

grails-app/controllers/example/grails/UrlMappings.groovy
'/api/announcements'(controller: 'apiAnnouncement')

控制器将是RestfulController,并且只会以 JSON 响应。

grails-app/controllers/example/grails/ApiAnnouncementController.groovy
package example.grails

import grails.rest.RestfulController
import groovy.transform.CompileStatic

@CompileStatic
class ApiAnnouncementController extends RestfulController {
    static responseFormats = ['json']

    ApiAnnouncementController() {
        super(Announcement)
    }
}

3.4 保护应用程序

在我们的build.gradle文件中添加对Grails Spring Security Rest 插件和最新版本的Spring Security Core的依赖。

/build.gradle
    compile 'org.grails.plugins:spring-security-rest:2.0.0.RC1'
    compile 'org.grails.plugins:spring-security-core:3.2.3'

我们运行Spring Security Core 插件提供的s2-quickstart命令,以生成UserAuthority类。

./grailsw s2-quickstart example.grails User SecurityRole

s2-quickstart脚本生成三个领域类;UserSecurityRoleUserSecurityRole

grails-app/domain/example/grails/User.groovy
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`'
        }
}
grails-app/domain/example/grails/SecurityRole.groovy
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
        }
}
grails-app/domain/example/grails/UserSecurityRole.groovy
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
        }
}

如果你正在使用 GORM 6.0.10 或更高版本和 Spring Security core 3.1.2 或更高版本,s2-quickstart 将生成并注册一个用来处理密码编码的 bean

src/main/groovy/example/grails/UserPasswordEncoderListener.groovy
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
    }
}
grails-app/conf/spring/resources.groovy
import example.grails.UserPasswordEncoderListener
// Place your Spring DSL code here
beans = {
    userPasswordEncoderListener(UserPasswordEncoderListener, ref('hibernateDatastore'))
}

我们将在application.groovy文件中配置应用程序的安全规则,如下所示。我们有一个无状态链和一个传统链

grails-app/conf/application.groovy
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 我们选择使用InterceptUrlMap来配置安全性
2 无状态链
3 传统流程
4 s2-quickstart 脚本生成的类
5 登录页 URL
6 URL 仅对用户经过身份验证并且具有 ROLE_BOSS 或 ROLE_EMPLOYEE 角色的访问
7 Spring Security Rest for Grails 默认验证端点。应允许匿名访问
8 Spring Security Rest for Grails 默认刷新令牌端点。应允许匿名访问
9 URL 仅对经过身份验证并且具有 ROLE_BOSS 角色的用户访问

我们会在 BootStrap 中填充我们的数据库,在应用程序启动时插入两个用户。

grails-app/services/example/grails/UserService.groovy
package example.grails

import grails.gorm.services.Service

@Service(User)
interface UserService {

    User save(String username, String password)

    User findByUsername(String username)
}
grails-app/services/example/grails/SecurityRoleService.groovy
package example.grails

import grails.gorm.services.Service

@Service(SecurityRole)
interface SecurityRoleService {

    SecurityRole save(String authority)

    SecurityRole findByAuthority(String authority)
}
grails-app/services/example/grails/UserSecurityRoleService.groovy
package example.grails

import grails.gorm.services.Service

@Service(UserSecurityRole)
interface UserSecurityRoleService {

    UserSecurityRole save(User user, SecurityRole securityRole)
}
grails-app/init/example/grails/BootStrap.groovy
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 测试 REST API

要测试应用程序的 REST 部分,我们使用 Micronaut 的 HttpClient。我们需要添加依赖项

/gradle.properties
grailsVersion=3.3.8
gormVersion=6.1.10.RELEASE
gradleWrapperVersion=3.5
assetPipelineVersion=2.14.8
micronautVersion=1.2.6
gebVersion=2.3
seleniumVersion=3.14.0
/build.gradle
    testCompile "io.micronaut:micronaut-http-client:$micronautVersion"

Micronaut HTTP 客户端,轻松将 JSON 有效负载绑定到 POGO。创建多个 POGO,我们会在测试中使用这些 POGO。

/src/integration-test/groovy/example/grails/BearerToken.groovy
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
}
/src/integration-test/groovy/example/grails/CustomError.groovy
package example.grails

import groovy.transform.CompileStatic

@CompileStatic
class CustomError {
    Integer status
    String error
    String message
    String path
}
/src/integration-test/groovy/example/grails/UserCredentials.groovy
package example.grails

import groovy.transform.CompileStatic

@CompileStatic
class UserCredentials {
    String username
    String password
}

我们的第一个测试将验证 /api/announcements 端点仅允许具有 ROLE_BOSS 角色的用户访问。

/src/integration-test/groovy/example/grails/ApiAnnouncementControllerSpec.groovy
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 == 'No message available'
        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 grails.testing.spock.OnceBefore 注释是完成相同行为的简便方法,方法是对固定装置方法应用 @RunOnce@Before 注释。
2 自动注入 serverPort 属性,其中包含应用程序将为功能测试运行的随机端口。
3 使用 Micronaut HTTP 客户端,你可以轻松将 POGO 绑定到响应体。
4 服务器返回 401,指示资源受到保护。
5 调用验证端点
6 我们在请求头中传递了从验证端点获取的 access_token
7 具有 ROLE_BOSS 角色经过验证的用户可以访问资源。
8 经过验证但没有 ROLE_BOSS 角色的用户无法访问该资源。

3.6 测试 Web 端点

现在,我们准备使用 Geb 框架 执行功能测试。

为了使此测试更易于阅读和维护,我们创建了两个 Geb 页面:一个 LoginPage 和一个 AnnouncementListingPage

/src/integration-test/groovy/example/grails/LoginPage.groovy
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()
    }
}
/src/integration-test/groovy/example/grails/AnnouncementListingPage.groovy
package example.grails

import geb.Page

class AnnouncementListingPage extends Page {
    static url = '/announcement/index'

    static at = {
        $('#list-announcement').text()?.contains 'Announcement List'
    }
}
/src/integration-test/groovy/example/grails/AnnouncementControllerSpec.groovy
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 运行应用程序

我们已经从该项目中删除了单元测试。我们的项目仅包含上一代码清单中显示的集成测试和功能测试。

要运行测试

./grailsw
grails> test-app
grails> open test-report

5 附录 A

对于 GORM 6.0.10 或 Spring Security Core 插件 3.1.2 之前的版本,s2-quickstart 通过在 User 类中注入 SpringSecurityService 直接处理密码编码。自 Grails 3.2.8 起,已禁用域类中的服务注入。但是,你可以打开域类中的服务注入

grails-app/conf/application.yml
grails:
    gorm:
        # Whether to autowire entities.
        # Disabled by default for performance reasons.
        autowire: true

6 你需要关于 Grails 的帮助吗?

Object Computing, Inc. (OCI) 赞助了本指南的创建。提供各种咨询和支持服务。

OCI 是 Grails 之家

认识团队