Grails Spring Security Core 插件自定义认证
演示如何使用 Spring Security Core 插件创建自定义认证
作者:Sergo del Amo
Grails 版本 3.3.0
1 Grails 培训
Grails 培训 - 由创建并积极维护 Grails 框架的人员开发和提供的!。
2 开始操作
在该指南中,你将编写一个自定义认证机制。 Spring Security Core 插件 允许高度自定义,我们接下来将探讨这一点。
2.1 所需内容
要完成本指南,你需要以下内容
-
一些时间
-
一个不错的文本编辑器或 IDE
-
已安装 JDK 1.7 或更高版本,并已相应配置
JAVA_HOME
2.2 如何完成该指南
要开始操作,请执行以下操作
-
下载 并解压缩源代码
或
Grails 指南仓库包含两个文件夹
-
initial
初始项目。通常是一个简单的 Grails 应用程序,包含一些其他代码,为你的项目争取更好的开局。 -
complete
一个已完成的示例。它是完成指南所介绍的步骤,将这些变更应用到initial
文件夹的结果。
要完成该指南,请转到 initial
文件夹
-
进入
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:3.2.0.M1'
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.compiler.GrailsCompileStatic
@GrailsCompileStatic
class BootStrap {
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 ->
def authorities = ['ROLE_CLIENT']
authorities.each {
if ( !Role.findByAuthority(it) ) {
new Role(authority: it).save()
}
}
if ( !User.findByUsername('sherlock') ) {
def u = new User(username: 'sherlock', password: 'elementary')
BANKCARD.each { k, v ->
u.addToCoordinates(new SecurityCoordinate(position: k, value: v, user: u))
}
u.save()
def ur = new UserRole(user: u, role: Role.findByAuthority('ROLE_CLIENT'))
ur.save()
}
}
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() {
def conf = getConf()
if (springSecurityService.isLoggedIn()) {
redirect uri: conf.successHandler.defaultTargetUrl
return
}
Collections.shuffle(coordinatePositions)
def 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
-
或者直接实现
AuthenticationProvider
接口
在本指南中,我们选择第一个选项。
不过,我们先从我们将用来验证用户提供的坐标的人工制品入手。在本指南中,我们使用 Grails 服务。
package demo
import groovy.transform.CompileStatic
@CompileStatic
interface CoordinateValidator {
boolean isValidValueForPositionAndUserName(String value, String position, String username)
}
package demo
import grails.transaction.Transactional
import groovy.transform.CompileStatic
@Transactional
@CompileStatic
class CoordinateValidatorService implements CoordinateValidator {
@Transactional(readOnly = true)
@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')
saltSource = ref('saltSource')
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
身份验证表示身份验证请求的令牌,或者在方法处理请求后身份验证的主体
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) {
def 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:
login('sherlock', 'elementary', BootStrap.BANKCARD[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