Grails Spring Security Core 插件自定义身份验证
演示如何使用 Spring Security Core 插件创建自定义身份验证
作者:塞尔吉奥·德尔·阿莫
Grails 版本 4.0.1
1 Grails 培训
Grails 培训 - 由创建和积极维护 Grails 框架的人员开发和提供!。
2 开始使用
在本指南中,你将编写一个自定义身份验证机制。 Spring Security Core 插件 允许进行大量的自定义,我们将在接下来探索这一点。
2.1 你需要准备什么
要完成本指南,你需要以下内容
-
一些时间
-
一个不错的文本编辑器或 IDE
-
安装 JDK 1.8 或更高版本,并已正确配置
JAVA_HOME
2.2 如何完成指南
要开始,请执行以下操作
-
下载并解压缩源代码
或
Grails 指南仓库包含两个文件夹
-
initial
初始项目。通常是一个简单的 Grails 应用程序,其中包含一些附加代码,以便让你轻松上手。 -
complete
已完成的示例。这是演练指南中介绍的步骤并将其更改应用于initial
文件夹的结果。
若要完成指南,请转到 initial
文件夹
-
cd
进入grails-guides/grails-spring-security-core-plugin-custom-authentication/initial
并按照下一部分中的说明进行操作。
若要 cd 进入 grails-guides/grails-spring-security-core-plugin-custom-authentication/complete ,可以直接查看已完成示例 |
3 编写应用程序
当我们谈论双因素认证时,我们常常想到两件事
-
用户知道的信息(例如用户名/密码)
-
用户拥有的物品。
对于后者,在西班牙,银行通常会签发一张坐标卡
用户需要输入用户名、密码和一个坐标才能登录到银行账户。
我们将定制Spring Security Core 插件在 Grails 3 应用程序中实现此类登录机制。
首先,我们需要添加 Spring Security Core 插件作为依赖项
compile 'org.grails.plugins:spring-security-core:4.0.3'
3.1 领域类
使用 s2-quickstart
生成默认 Spring Security Core 领域类
grails s2-quickstart demo User Role
s2-quickstart
脚本生成三个领域类;User
、Role
和 UserRole
。建议将密码编码逻辑移至领域类外部。
自 Grails 3.2.8 起,默认情况下已禁用 GORM 实体中的服务注入。 |
每个用户都将拥有一张坐标卡。因此,我们略微修改了 User 领域类
package demo
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
@EqualsAndHashCode(includes='username')
@ToString(includes='username', includeNames=true, includePackage=false)
class User implements Serializable {
private static final long serialVersionUID = 1
transient springSecurityService
String username
String password
boolean enabled = true
boolean accountExpired
boolean accountLocked
boolean passwordExpired
static hasMany = [coordinates: SecurityCoordinate]
Set<Role> getAuthorities() {
UserRole.findAllByUser(this)*.role
}
def beforeInsert() {
encodePassword()
}
def beforeUpdate() {
if (isDirty('password')) {
encodePassword()
}
}
protected void encodePassword() {
password = springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password) : password
}
static transients = ['springSecurityService']
static constraints = {
password blank: false, password: true
username blank: false, unique: true
}
static mapping = {
password column: '`password`'
autowire true
}
}
package demo
import groovy.transform.CompileStatic
@CompileStatic
class SecurityCoordinate {
String position
String value
static belongsTo = [user: User]
}
3.2 安全控制器
此控制器仅限于具有 ROLE_CLIENT
角色的用户使用
package demo
import grails.plugin.springsecurity.annotation.Secured
class BankController {
@Secured(['ROLE_CLIENT'])
def index() {
render 'Welcome to your bank'
}
}
3.3 种子数据
我们将使用一些种子数据填充数据库
package demo
import grails.gorm.services.Service
@Service(Role)
interface RoleService {
Role save(String authority)
Role findByAuthority(String authority)
}
package demo
import grails.gorm.services.Service
@Service(User)
interface UserService {
User findByUsername(String username)
User save(User user)
}
package demo
import grails.gorm.services.Service
@Service(UserRole)
interface UserRoleService {
UserRole save(User user, Role role)
}
package demo
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
class BootStrap {
RoleService roleService
UserService userService
UserRoleService userRoleService
static Map<String, String> BANKCARD =
['A1': '10', 'A2': '84', 'A3': '93', 'A4': '12', 'A5': '92',
'A6': '58', 'A7': '38', 'A8': '28', 'A9': '36', 'A10': '02',
'B1': '99', 'B2': '29', 'B3': '10', 'B4': '23', 'B5': '33',
'B6': '47', 'B7': '58', 'B8': '39', 'B9': '34', 'B10': '18',
'C1': '28', 'C2': '05', 'C3': '29', 'C4': '03', 'C5': '94',
'C6': '14', 'C7': '41', 'C8': '33', 'C9': '11', 'C10': '39',
'D1': '01', 'D2': '49', 'D3': '39', 'D4': '79', 'D5': '53',
'D6': '38', 'D7': '17', 'D8': '88', 'D9': '70', 'D10': '12'
]
def init = { servletContext ->
List<String> authorities = ['ROLE_CLIENT']
authorities.each { authority ->
if ( !roleService.findByAuthority(authority) ) {
roleService.save(authority)
}
}
if ( !userService.findByUsername('sherlock') ) {
User u = new User(username: 'sherlock', password: 'elementary')
BANKCARD.each { k, v ->
u.addToCoordinates(new SecurityCoordinate(position: k, value: v, user: u))
}
u = userService.save(u)
userRoleService.save(u, roleService.findByAuthority('ROLE_CLIENT'))
}
}
def destroy = {
}
}
3.4 自定义登录表单
我们覆盖 LoginController 和 auth.gsp,以便每次用户被定向到登录表单时都显示一个随机坐标字段。
package demo
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
class LoginController extends grails.plugin.springsecurity.LoginController implements GrailsConfigurationAware {
List<String> coordinatePositions
def auth() {
ConfigObject conf = getConf()
if (springSecurityService.isLoggedIn()) {
redirect uri: conf.successHandler.defaultTargetUrl
return
}
Collections.shuffle(coordinatePositions)
String position = coordinatePositions.first()
String postUrl = request.contextPath + conf.apf.filterProcessesUrl
render view: 'auth', model: [postUrl: postUrl,
rememberMeParameter: conf.rememberMe.parameter,
usernameParameter: conf.apf.usernameParameter,
passwordParameter: conf.apf.passwordParameter,
gspLayout: conf.gsp.layoutAuth,
position: position]
}
@Override
void setConfiguration(Config co) {
coordinatePositions = co.getProperty('security.coordinate.positions', List, []) as List<String>
}
}
我们有一个有效的职位列表作为 application.yml
中的一个配置列表
---
security:
coordinate:
positions:
- A1
- A2
- A3
- A4
- A5
- A6
- A7
- A8
- A9
- A10
- B1
- B2
- B3
- B4
- B5
- A6
- A7
- B8
- B9
- B10
- C1
- C2
- C3
- C4
- C5
- C6
- C7
- C8
- C9
- C10
- D1
- D2
- D3
- D4
- D5
- D6
- D7
- D8
- D9
- D10
被覆盖的 GSP 文件显示坐标位置输入字段
<html>
<head>
<meta name="layout" content="${gspLayout ?: 'main'}"/>
<title><g:message code='springSecurity.login.title'/></title>
<style type="text/css" media="screen">
#login {
margin: 15px 0px;
padding: 0px;
text-align: center;
}
#login .inner {
width: 340px;
padding-bottom: 6px;
margin: 60px auto;
text-align: left;
border: 1px solid #aab;
background-color: #f0f0fa;
-moz-box-shadow: 2px 2px 2px #eee;
-webkit-box-shadow: 2px 2px 2px #eee;
-khtml-box-shadow: 2px 2px 2px #eee;
box-shadow: 2px 2px 2px #eee;
}
#login .inner .fheader {
padding: 18px 26px 14px 26px;
background-color: #f7f7ff;
margin: 0px 0 14px 0;
color: #2e3741;
font-size: 18px;
font-weight: bold;
}
#login .inner .cssform p {
clear: left;
margin: 0;
padding: 4px 0 3px 0;
padding-left: 105px;
margin-bottom: 20px;
height: 1%;
}
#login .inner .cssform input[type="text"] {
width: 120px;
}
#login .inner .cssform label {
font-weight: bold;
float: left;
text-align: right;
margin-left: -105px;
width: 110px;
padding-top: 3px;
padding-right: 10px;
}
#login #remember_me_holder {
padding-left: 120px;
}
#login #submit {
margin-left: 15px;
}
#login #remember_me_holder label {
float: none;
margin-left: 0;
text-align: left;
width: 200px
}
#login .inner .login_message {
padding: 6px 25px 20px 25px;
color: #c33;
}
#login .inner .text_ {
width: 120px;
}
#login .inner .chk {
height: 12px;
}
</style>
</head>
<body>
<div id="login">
<div class="inner">
<div class="fheader"><g:message code='springSecurity.login.header'/></div>
<g:if test='${flash.message}'>
<div class="login_message">${flash.message}</div>
</g:if>
<form action="${postUrl ?: '/login/authenticate'}" method="POST" id="loginForm" class="cssform" autocomplete="off">
<p>
<label for="username"><g:message code='springSecurity.login.username.label'/>:</label>
<input type="text" class="text_" name="${usernameParameter ?: 'username'}" id="username"/>
</p>
<p>
<label for="password"><g:message code='springSecurity.login.password.label'/>:</label>
<input type="password" class="text_" name="${passwordParameter ?: 'password'}" id="password"/>
</p>
<p>
<label for="coordinateValue">${position}</label>
<input type="hidden" name="coordinatePosition" id="coordinatePosition" value="${position}"/>
<input type="text" class="text_" name="coordinateValue" id="coordinateValue"/>
</p>
<p id="remember_me_holder">
<input type="checkbox" class="chk" name="${rememberMeParameter ?: 'remember-me'}" id="remember_me" <g:if test='${hasCookie}'>checked="checked"</g:if>/>
<label for="remember_me"><g:message code='springSecurity.login.remember.me.label'/></label>
</p>
<p>
<input type="submit" id="submit" value="${message(code: 'springSecurity.login.button')}"/>
</p>
</form>
</div>
</div>
<script>
(function() {
document.forms['loginForm'].elements['${usernameParameter ?: 'username'}'].focus();
})();
</script>
</body>
</html>
3.5 认证提供者
创建自定义 AuthenticationProvider
实现。它是一个实现
org.springframework.security.authentication.AuthenticationProvider
的类
-
通常会
扩展一个类似的类。例如,插件随附的
DaoAuthenticationProvider
。
-
org.springframework.security.authentication.dao.DaoAuthenticationProvider
在本指南中,我们选择第一个选项。
不过,我们从将用来验证用户提供的坐标的构件开始。在本指南中,我们使用 Grails 服务。
package demo
import groovy.transform.CompileStatic
@CompileStatic
interface CoordinateValidator {
boolean isValidValueForPositionAndUserName(String value, String position, String username)
}
package demo
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
@Transactional
@CompileStatic
class CoordinateValidatorService implements CoordinateValidator {
@ReadOnly
@Override
boolean isValidValueForPositionAndUserName(String v, String p, String name) {
SecurityCoordinate.where {
position == p && value == v && user.username == name
}.count() as boolean
}
}
我们将在自定义认证提供程序中注入此服务作为 Bean。为此,我们首先注册 Bean
coordinateValidator(CoordinateValidatorService)
我们扩展了 DaoAuthenticationProvider
以添加我们的附加检查。
package demo
import groovy.transform.CompileStatic
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.core.AuthenticationException
import org.springframework.security.core.userdetails.UserDetails
@CompileStatic
class TwoFactorAuthenticationProvider extends DaoAuthenticationProvider {
CoordinateValidator coordinateValidator
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
super.additionalAuthenticationChecks(userDetails, authentication)
Object details = authentication.details
if ( !(details instanceof TwoFactorAuthenticationDetails) ) {
logger.debug("Authentication failed: authenticationToken principal is not a TwoFactorPrincipal");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
def twoFactorAuthenticationDetails = details as TwoFactorAuthenticationDetails
if ( !coordinateValidator.isValidValueForPositionAndUserName(twoFactorAuthenticationDetails.coordinateValue, twoFactorAuthenticationDetails.coordinatePosition, authentication.name) ) {
logger.debug("Authentication failed: coordiante note valid");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}
}
我们需要将我们的自定义 AuthenticationProvider
作为 Bean 注册。
twoFactorAuthenticationProvider(TwoFactorAuthenticationProvider) {
coordinateValidator = ref('coordinateValidator')
userDetailsService = ref('userDetailsService')
passwordEncoder = ref('passwordEncoder')
userCache = ref('userCache')
preAuthenticationChecks = ref('preAuthenticationChecks')
postAuthenticationChecks = ref('postAuthenticationChecks')
authoritiesMapper = ref('authoritiesMapper')
hideUserNotFoundExceptions = true
}
此外,我们想要使用 TwoFactorAuthenticationProvider
代替 DaoAuthenticationProvider
。为此,我们定义了应使用以进行身份验证的提供程序
grails.plugin.springsecurity.providerNames = [
'twoFactorAuthenticationProvider',
'anonymousAuthenticationProvider',
'rememberMeAuthenticationProvider']
3.6 认证
为创建自定义认证,我们经常需要自定义 Authentication
。
org.springframework.security.core.Authentication
Authentication 表示认证请求的令牌或在方法处理过请求后身份验证的主体
AuthenticationManager.authenticate(Authentication)
这通常意味着
-
要么扩展一个现有实现。
org.springframework.security.authentication.UsernamePasswordAuthenticationToken
-
要么直接实现接口
Authentication
我们试图实现的是对插件已提供的常见用户名/密码功能进行扩展。我们不需要自定义认证对象。相反,我们将使用 UsernamePasswordAuthenticationToken
对象并将自定义信息(坐标)放入 details
属性中。
3.7 过滤器
如果你正在使用自定义 Authentication
,则可能需要创建一个过滤器。
这意味着对自定义 java.servlet.Filter
实现进行编码。
你有几个选择
-
扩展
GenericFilterBean
org.springframework.web.filter.GenericFilterBean
-
扩展类似的过滤器,例如
UsernamePasswordAuthenticationFilter
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
-
直接实现接口
在本指南中,我们不必创建自定义过滤器。相反,我们正在使用 UsernamePasswordAuthenticationFilter
并重写该过滤器使用的 authenticationDetailsSource
Bean。
authenticationDetailsSource(TwoFactorAuthenticationDetailsSource)
package demo
import groovy.transform.CompileStatic
import org.springframework.security.web.authentication.WebAuthenticationDetails
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import javax.servlet.http.HttpServletRequest
@CompileStatic
class TwoFactorAuthenticationDetailsSource extends WebAuthenticationDetailsSource {
@Override
WebAuthenticationDetails buildDetails(HttpServletRequest context) {
TwoFactorAuthenticationDetails details = new TwoFactorAuthenticationDetails(context)
String position = obtainCoordinatePosition(context)
details.coordinatePosition = position
String value = obtainCoordinateValue(context)
details.coordinateValue = value
details
}
/**
* Get the Coordinate Position from the request.
* @param request
* @return
*/
private static String obtainCoordinatePosition(HttpServletRequest request) {
return request.getParameter('coordinatePosition')
}
/**
* Get the Coordinate Value from the request.
* @param request
* @return
*/
private static String obtainCoordinateValue(HttpServletRequest request) {
return request.getParameter('coordinateValue')
}
}
package demo
import groovy.transform.Canonical
import groovy.transform.CompileStatic
import org.springframework.security.web.authentication.WebAuthenticationDetails
import javax.servlet.http.HttpServletRequest
@Canonical
@CompileStatic
class TwoFactorAuthenticationDetails extends WebAuthenticationDetails {
String coordinatePosition
String coordinateValue
TwoFactorAuthenticationDetails(HttpServletRequest request) {
super(request)
}
}
3.8 功能测试
Grails 与 Geb 无缝集成。因此,为我们的自定义登录表单开发功能测试十分容易。
package demo
import geb.spock.GebSpec
import grails.testing.mixin.integration.Integration
@Integration
class BankControllerSpec extends GebSpec {
def 'test bank controller is secured'() {
when:
baseUrl = "http://localhost:${serverPort}/"
go 'bank'
then:
at LoginPage
when:
LoginPage loginPage = browser.page(LoginPage)
loginPage.login('sherlock', 'elementary', BootStrap.BANKCARD[loginPage.position()])
then:
driver.pageSource.contains('Welcome to your bank')
}
}
package demo
import geb.Page
class LoginPage extends Page {
static url = 'login/auth'
static at = { title == 'Login' }
static content = {
usernameField { $('#username', 0) }
passwordField { $('#password', 0) }
positionField { $('#coordinatePosition', 0)}
valueField { $('#coordinateValue', 0) }
submitField { $('#submit', 0) }
}
String position() {
positionField.getAttribute('value')
}
void login(String username, String password, String value) {
usernameField << username
passwordField << password
valueField << value
submitField.click()
}
}
4 运行应用程序
要运行测试
./grailsw
grails> test-app
grails> open test-report