显示导航

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 编写应用程序

当我们谈论双因素认证时,我们常常想到两件事

  • 用户知道的信息(例如用户名/密码)

  • 用户拥有的物品。

对于后者,在西班牙,银行通常会签发一张坐标卡

caja espagna coordenadas

用户需要输入用户名、密码和一个坐标才能登录到银行账户。

我们将定制Spring Security Core 插件在 Grails 3 应用程序中实现此类登录机制。

首先,我们需要添加 Spring Security Core 插件作为依赖项

/build.gradle
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 脚本生成三个领域类;UserRoleUserRole。建议将密码编码逻辑移至领域类外部。

自 Grails 3.2.8 起,默认情况下已禁用 GORM 实体中的服务注入。

每个用户都将拥有一张坐标卡。因此,我们略微修改了 User 领域类

/grails-app/domain/demo/User.groovy
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
        }
}
/grails-app/domain/demo/SecurityCoordinate.groovy
package demo

import groovy.transform.CompileStatic

@CompileStatic
class SecurityCoordinate {
    String position
    String value
    static belongsTo = [user: User]
}

3.2 安全控制器

此控制器仅限于具有 ROLE_CLIENT 角色的用户使用

grails-app/controllers/demo/BankController.groovy
package demo

import grails.plugin.springsecurity.annotation.Secured

class BankController {

    @Secured(['ROLE_CLIENT'])
    def index() {
        render 'Welcome to your bank'
    }
}

3.3 种子数据

我们将使用一些种子数据填充数据库

/grails-app/services/demo/RoleService.groovy
package demo

import grails.gorm.services.Service

@Service(Role)
interface RoleService {
    Role save(String authority)
    Role findByAuthority(String authority)
}
/grails-app/services/demo/UserService.groovy
package demo

import grails.gorm.services.Service

@Service(User)
interface UserService {

    User findByUsername(String username)

    User save(User user)
}
/grails-app/services/demo/UserRoleService.groovy
package demo

import grails.gorm.services.Service

@Service(UserRole)
interface UserRoleService {

    UserRole save(User user, Role role)
}
/grails-app/init/demo/BootStrap.groovy
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 自定义登录表单

我们覆盖 LoginControllerauth.gsp,以便每次用户被定向到登录表单时都显示一个随机坐标字段。

/grails-app/controllers/demo/LoginController.groovy
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 中的一个配置列表

grails-app/conf/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 文件显示坐标位置输入字段

login
/grails-app/views/login/auth.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 服务。

/src/main/groovy/demo/CoordinateValidator.groovy
package demo

import groovy.transform.CompileStatic

@CompileStatic
interface CoordinateValidator {

    boolean isValidValueForPositionAndUserName(String value, String position, String username)
}
/grails-app/services/demo/CoordinateValidatorService.groovy
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

/grails-app/conf/spring/resources.groovy
coordinateValidator(CoordinateValidatorService)

我们扩展了 DaoAuthenticationProvider 以添加我们的附加检查。

/src/main/groovy/demo/TwoFactorAuthenticationProvider.groovy
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 注册。

/grails-app/conf/spring/resources.groovy
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-app/conf/application.groovy
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。

/grails-app/conf/spring/resources.groovy
authenticationDetailsSource(TwoFactorAuthenticationDetailsSource)
/src/main/groovy/demo/TwoFactorAuthenticationDetailsSource.groovy
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')
    }
}
/src/main/groovy/demo/TwoFactorAuthenticationDetails.groovy
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 无缝集成。因此,为我们的自定义登录表单开发功能测试十分容易。

/src/integration-test/groovy/demo/BankControllerSpec.groovy
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')
    }
}
/src/integration-test/groovy/demo/LoginPage.groovy
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

5 你需要帮助 Grails 吗?

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

OCI 是 Grails 的基地

结识团队