测试受保护的 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 如何完成本指南
要开始,请执行以下操作
-
下载并解压该源代码
或
-
克隆 Git 代码库
git 克隆 https://github.com/grails-guides/grails-test-security.git
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
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
中。
'/api/announcements'(controller: 'apiAnnouncement')
控制器将是RestfulController,并且只会以 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 保护应用程序
在我们的build.gradle
文件中添加对Grails Spring Security Rest 插件和最新版本的Spring Security Core的依赖。
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命令,以生成User
和Authority
类。
./grailsw s2-quickstart example.grails User SecurityRole
s2-quickstart
脚本生成三个领域类;User
、SecurityRole
和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
}
}
如果你正在使用 GORM 6.0.10 或更高版本和 Spring Security core 3.1.2 或更高版本,s2-quickstart 将生成并注册一个用来处理密码编码的 bean
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'))
}
我们将在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
中填充我们的数据库,在应用程序启动时插入两个用户。
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 测试 REST API
要测试应用程序的 REST 部分,我们使用 Micronaut 的 HttpClient。我们需要添加依赖项
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
testCompile "io.micronaut:micronaut-http-client:$micronautVersion"
Micronaut HTTP 客户端,轻松将 JSON 有效负载绑定到 POGO。创建多个 POGO,我们会在测试中使用这些 POGO。
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
}
我们的第一个测试将验证 /api/announcements 端点仅允许具有 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 == '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。
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 运行应用程序
我们已经从该项目中删除了单元测试。我们的项目仅包含上一代码清单中显示的集成测试和功能测试。 |
要运行测试
./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:
gorm:
# Whether to autowire entities.
# Disabled by default for performance reasons.
autowire: true