显示导航

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

并按照下一部分中的说明进行操作。

如果你cdgrails-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:3.2.0.M1'

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/init/demo/BootStrap.groovy
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 自定义登录表单

我们重写了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() {

        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 中将有效位置列表作为一个配置列表。

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

  • 或者直接实现AuthenticationProvider接口

在本指南中,我们选择第一个选项。

不过,我们先从我们将用来验证用户提供的坐标的人工制品入手。在本指南中,我们使用 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.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

/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')
    saltSource = ref('saltSource')
    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

身份验证表示身份验证请求的令牌,或者在方法处理请求后身份验证的主体

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) {
        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')
    }
}
/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:
        login('sherlock', 'elementary', BootStrap.BANKCARD[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 的家园

认识团队