测试安全 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 如何完成本指南
开始前请执行以下操作
-
下载并解压源代码
或
-
克隆 Git 仓库
git clone 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 保护应用程序
为 Grails Spring Security Rest 插件 和 Spring Security Core 的最新版本在我们的 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 命令,以生成 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 核心 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 | 仅允许经过身份验证且具有 ROLE_BOSS 或 ROLE_EMPLOYEE 角色的用户访问的 URL |
7 | Grails 默认的身份验证端点,适用于 Spring Security Rest。它应允许匿名访问 |
8 | Grails 的 Spring Security Rest 默认的刷新令牌终结点。它应该允许匿名访问 |
9 | 仅向经过身份验证且具有角色 ROLE_BOSS 的用户提供可访问的 URL |
我们将在 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’s HttpClient。我们需要添加依赖项
testImplementation "io.micronaut:micronaut-http-client"
Micronaut HTTP 客户端使得轻松将 JSON Payload 绑定到 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 == 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。
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