显示导航

测试安全 Grails 应用程序

在本指南中,您将看到如何测试使用 Grails Spring 安全 REST 插件和 Grails Spring 安全核心插件添加的安全约束。

作者:Sergio del Amo

Grails 版本 5.0.1

1 Grails 培训

Grails 培训 - 由创建和积极维护 Grails 框架的人员开发和交付!。

2 开始

在本指南中,你将

  • 使用 Grails Spring 安全 Rest 插件保护 REST API 端点功能测试

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

2.1 你需要什么

要完成本指南,您需要以下内容

  • 一段时间

  • 不错的文本编辑器或 IDE

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

2.2 如何完成本指南

开始前请执行以下操作

Grails 指南仓库包含两个文件夹

  • initial 初始项目。通常是一个简单的 Grails 应用程序,并附有其他代码,以便您可以领先一步。

  • complete 一个完成的示例。这是完成指南并对 initial 文件夹应用这些更改的结果。

要完成指南,请转到 initial 文件夹

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

并按照下一节中的说明进行操作。

如果你 cdgrails-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 保护应用程序

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

/build.gradle
    compile 'org.grails.plugins:spring-security-rest:3.0.0.RC1'
    compile 'org.grails.plugins:spring-security-core:4.0.0.RC2'

我们将运行 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 核心 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 仅允许经过身份验证且具有 ROLE_BOSS 或 ROLE_EMPLOYEE 角色的用户访问的 URL
7 Grails 默认的身份验证端点,适用于 Spring Security Rest。它应允许匿名访问
8 Grails 的 Spring Security Rest 默认的刷新令牌终结点。它应该允许匿名访问
9 仅向经过身份验证且具有角色 ROLE_BOSS 的用户提供可访问的 URL

我们将在 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’s HttpClient。我们需要添加依赖项

/build.gradle
    testImplementation "io.micronaut:micronaut-http-client"

Micronaut HTTP 客户端使得轻松将 JSON Payload 绑定到 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 == 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 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 您需要 Grails 的帮助吗?

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

OCI 是 Grails 的家园

认识团队