显示导航

使用 Spring Security 创建 React 应用程序

了解如何将 Spring Security 添加到您的 React 应用程序

作者:Ben Rhine、Zachary Klein

Grails 版本 3.3.2

1 Grails 培训

Grails 培训 - 由创建并积极维护 Grails 框架的人员开发并提供!。

2 开始使用

在本指南中,您将了解如何使用 Spring Security REST(使用默认的 JWT Token 身份验证)保护您的 React/Grails 应用程序。

在许多 React 应用程序中,React Router 等库用于处理客户端路由,包括登录/注销重定向。在本指南中,我们不会使用任何专用路由解决方案,以便将学习体验集中在身份验证功能上。

2.1 所需条件

要完成本指南,您需要以下内容

  • 一些时间

  • 一个不错的文本编辑器或 IDE

  • 已安装 JDK 1.8 或更高版本,并以适当方式配置了 JAVA_HOME

2.2 如何完成指南

要开始执行,请执行下列操作

Grails 指南仓库包含两个文件夹

  • initial 初始项目。通常是包含一些附加代码以让你领先一步的简单 Grails 应用程序。

  • complete 已完成的示例。它是遵循指南介绍的步骤并针对 initial 文件夹应用这些更改的结果。

要完成指南,请转到 initial 文件夹

  • cdgrails-guides/react-spring-security/initial

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

如果你 cdgrails-guides/react-spring-security/complete,你可以直接转到已完成的示例

3 编写应用程序

对于本指南,你应使用指南仓库中包含的 initial 项目开始。此项目包含一个可运行的 Grails/React 应用程序,它提供基本 CRUD 功能和一个简单的(不安全的)RESTful API。随意启动应用程序并试用一下,并查看现有代码。

4 运行应用程序

本指南中的应用程序使用 react 配置文件,它提供多项目客户端/服务器构建。这意味着你必须独立启动服务器(Grails)和客户端(React)应用程序。

~ cd initial/

要启动服务器应用程序,请运行以下命令。

~ ./gradlew server:bootRun

这会启动 Grails 应用程序,该应用程序将在 http://localhost:8080 上运行

要启动客户端应用程序,请打开第二个终端会话(在同一目录中),并运行以下命令

~ ./gradlew client:start

React 应用程序将可在 http://localhost:3000 上使用。浏览到该网址,你应该会看到应用程序的主页。

5 创建服务器

initial 项目基于已完成的 构建 React 应用程序 指南中的项目。请参阅该指南以了解所提供代码的详细信息。

向此项目中添加安全性的第一步是在我们的 Grails 应用程序中安装 Spring Security 插件,并保护我们的 API 端点。Spring Security REST 插件支持无状态、基于令牌的身份验证模型,非常适合保护 API 和单页应用程序。请参阅下图以了解安全模型的概述。

Stateless Authentication Model

5.1 安装 Spring Security

为了保护我们的应用程序,我们将使用 Spring Security Core 插件和 Spring Security REST 插件。通过在 server/build.gradle 中添加这些代码行(在 dependencies 分段下)安装该项目中的这些插件

compile "org.grails.plugins:spring-security-core:3.2.0"
compile "org.grails.plugins:spring-security-rest:2.0.0.M2"
如欲了解 Spring Security Core 插件Spring Security REST 插件 的更多信息,请参阅文档。

5.2 配置 Spring Security

在我们应用程序中添加 Spring Security 后,我们可以生成默认的 Spring Security 配置。该插件提供了一个 s2-quickstart 命令,能够生成一组域类和配置帮助我们入门。

cd initial/server

然后执行以下操作

grails s2-quickstart demo User Role

这应生成以下域类

/server/grails-app/domain/demo/User.groovy
/server/grails-app/domain/demo/Role.groovy
/server/grails-app/domain/demo/UserRole.groovy

此外,s2-quickstartserver/grails-app/conf/application.groovy 中添加了一个额外的配置文件。Grails 项目既支持 YML,又支持 Groovy 配置,但推荐使用 YML。让我们删除 application.groovy 文件并在 application.yml 末尾添加以下代码片段

---
grails:
    plugin:
        springsecurity:
            userLookup:
                userDomainClassName: demo.User
                authorityJoinClassName: demo.UserRole
            authority:
                className: demo.Role
            filterChain:
                chainMap:
                    -
                        pattern: /assets/**
                        filters: none
                    -
                        pattern: /**/js/**
                        filters: none
                    -
                        pattern: /**/css/**
                        filters: none
                    -
                        pattern: /**/images/**
                        filters: none
                    - # Stateless chain
                        pattern: /api/**
                        filters: JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter
                    - # Traditional Chain
                        pattern: /**
                        filters: JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter
                controllerAnnotations:
                    staticRules:
                        -
                            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

如欲了解详情,请参阅 Spring Security REST 文档Spring Security Core 文档

5.3 保护我们的 API

保护我们的 API 的第一步是更新我们的 Driver 域类。在我们的应用程序中,Driver 将成为一个“用户”,但是 Spring Security 已经生成了一个 User 类以便进行身份验证。我们可以修改配置以使用 Driver,但是我们将采用另一种方法,让 Driver 成为 User 的一个子类。

编辑 server/grails-app/domain/demo/Driver.groovy

server/grails-app/domain/demo/Driver.groovy
package demo

import grails.compiler.GrailsCompileStatic
import grails.plugin.springsecurity.annotation.Secured
import grails.rest.Resource

@GrailsCompileStatic
@Secured(['ROLE_DRIVER'])
@Resource(uri = '/api/driver')
class Driver extends User {

    String name

    static hasMany = [ vehicles: Vehicle ]

    static constraints = {
        vehicles nullable: true
    }
}

除了扩展 User 类之外,我们还可以使用 @Secured 注解,将对该域资源的访问限制为具有 ROLE_DRIVER 角色的用户。我们还没有创建该角色,但很快我们就会解决。

如果你现在要运行服务器应用程序,你将会收到一个 401 响应,以访问 /api/driver

让我们保护剩余的域资源,如下所示

server/grails-app/domain/demo/Make.groovy
package demo

import grails.plugin.springsecurity.annotation.Secured
import grails.rest.Resource
import groovy.transform.CompileStatic

@CompileStatic
@Secured(['ROLE_DRIVER'])
@Resource(uri = '/api/make')
class Make {

    String name

    static constraints = {
    }
}
server/grails-app/domain/demo/Model.groovy
package demo

import grails.plugin.springsecurity.annotation.Secured
import grails.rest.Resource
import groovy.transform.CompileStatic

@CompileStatic
@Secured(['ROLE_DRIVER'])
@Resource(uri = '/api/model')
class Model {

    String name

    static constraints = {
    }
}
server/grails-app/domain/demo/Vehicle.groovy
package demo

import grails.plugin.springsecurity.annotation.Secured
import grails.rest.Resource
import groovy.transform.CompileStatic

@CompileStatic
@Secured(['ROLE_DRIVER'])
@Resource(uri = '/api/vehicle')
class Vehicle {

    String name

    Make make
    Model model

    static belongsTo = [driver: Driver]

    static constraints = {
    }
}

5.4 更新我们的初始数据

信不信由你,我们的应用程序的服务器端安全部分已经完成了!我们最后需要做的就是创建我们之前提到的 ROLE_DRIVER 角色。我们还应使用一些用户/密码预填充我们的数据库,以便于我们登录。

编辑 server/grails-app/init/demo/BootStrap.groovy

server/grails-app/init/demo/BootStrap.groovy
@Slf4j
class BootStrap {

    def init = { servletContext ->
        log.info "Loading database..."
        def driver1 = new Driver(name: "Susan", username: "susan", password: "password1").save() (1)
        def driver2 = new Driver(name: "Pedro", username:  "pedro", password: "password2").save()

        Role role = new Role(authority: "ROLE_DRIVER").save()  (2)

        UserRole.create(driver1, role, true)  (3)
        UserRole.create(driver2, role, true)

        def nissan = new Make(name: "Nissan").save()
        def ford = new Make(name: "Ford").save()

        def titan = new Model(name: "Titan").save()
        def leaf = new Model(name: "Leaf").save()
        def windstar = new Model(name: "Windstar").save()

        new Vehicle(name: "Pickup", driver: driver1, make: nissan, model: titan).save()
1 由于我们扩展了 User 类,所以我们现在可以在我们的 Driver 域对象上设置一个 usernamepassword 属性。在持久化之前将加密 password
2 在这里,我们正在创建 ROLE_DRIVER 角色 - 我们可以创建任意数量的需求,甚至可以创建角色层次结构来支持复杂的访问控制。
3 UserRole 表示 UserRole 之间的连接表。该类包含一个 create 方法,我们可以使用该方法快速关联 UserRole 对象。

5.5 测试我们的安全 API

启动服务器应用(如果尚未在运行)

~ ./gradlew server:bootRun

在另一个终端会话中,如果您对我们的某项资源发出 curl 请求,您将收到 401 响应。

~ curl -i 0:8080/api/vehicle
HTTP/1.1 401
WWW-Authenticate: Bearer
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Thu, 08 Jun 2017 05:42:05 GMT

{"timestamp":1496900525475,"status":401,"error":"Unauthorized","message":"No message available","path":"/api/vehicle"}

为了发出安全请求,我们需要先使用有效的用户凭证向服务器进行身份验证。默认情况下,我们可以出于此目的使用 /api/login 端点。

使用 BootStrap.groovy 中某一 Driver 对象的凭证对 /api/login 发出请求

curl -i -H "Content-Type: application/json" --data '{"username":"susan","password":"password1"}' 0:8080/api/login
HTTP/1.1 200
Cache-Control: no-store
Pragma: no-cache
Content-Type: application/json;charset=UTF-8
Content-Length: 2157
Date: Thu, 08 Jun 2017 05:45:44 GMT

{"username":"susan","roles":["ROLE_DRIVER"],"token_type":"Bearer","access_token":"eyJhbGciOiJIUzI1NiJ9...","expires_in":3600,"refresh_token":"eyJhbGciOiJIUzI1NiJ9..."}

请注意,在响应中,除了用户详细信息/已授予的角色外,我们还收到了 access_token - 这是我们需要向我们的服务器提供的令牌,以对请求进行身份验证。为此,我们通过使用我们的令牌设置 Authorization 头。让我们使用此 access_token 再次尝试我们的 /api/vehicle 请求

curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." 0:8080/api/vehicle
HTTP/1.1 200
X-Application-Context: application:development
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Thu, 08 Jun 2017 05:49:01 GMT

[{"id":1,"name":"Pickup","make":{"name":"Nissan","id":1},"model":{"name":"Titan","id":1},"driver":{"name":"Susan","id":1}},{"id":2,"name":"Economy","make":{"name":"Nissan","id":1},"model":{"name":"Leaf","id":2},"driver":{"name":"Susan","id":1}},{"id":3,"name":"Minivan","make":{"name":"Ford","id":2},"model":{"name":"Windstar","id":3},"driver":{"name":"Pedro","id":2}}]

恭喜!您的 API 现在受到保护!现在,我们可以继续在我们的 React 客户端应用中支持身份验证。

请注意, /api/login 响应中实际上还有另一个令牌:refresh_token。当 access_token 过期时,您可以使用 refresh_token 获得新 access_token。稍后我们将在本指南中了解它的工作原理。

6 构建客户端

API 受保护后,我们需要为用户提供一种通过 React 应用向服务器进行身份验证的方法。一旦用户登录,我们需要使用他们的凭证对 API 发出请求,并允许用户注销并使他们的令牌失效。

6.1 无状态登录组件

让我们首先创建登录组件。我们将此 4 个道具传递到此组件:userDetailserror 变量(以表示窗体输入和可能的错误消息),以及 changeHandleronSubmit 函数。

如果您熟悉 React,您可能知道这是一个“无状态函数组件”。这种样式的 React 组件从字面上看是一个简单的函数,没有内部状态。此组件仅通过道具获得其状态和动态功能,这些道具由父组件(在本例中为 App)传递过来。
client/src/Login.js
import React from 'react';
import {Jumbotron, Row, Col, Form, FormGroup, ControlLabel, FormControl, Button} from 'react-bootstrap';

const Login = ({userDetails, error, inputChangeHandler, onSubmit}) => {

  return (
    <Row>
      <Jumbotron>
        <h1>Welcome to the Garage</h1>
      </Jumbotron>
      <Row>
        <Col sm={4} smOffset={4}>

          {error ? <p className="alert alert-danger">{error} </p> : null} (1)

          <Form onSubmit={onSubmit}> (2)
            <FormGroup>
              <ControlLabel>Login</ControlLabel >
              <FormControl type='text' name='username' placeholder='Username'
                           value={userDetails.username} (3)
                           onChange={inputChangeHandler}/>  (4)
              <FormControl type='password' name='password' placeholder='Password'
                           value={userDetails.password} (3)
                           onChange={inputChangeHandler}/>  (4)
            </FormGroup>
            <FormGroup>
              <Button bsStyle="success" type="submit">Login</Button>
            </FormGroup>
          </Form>
        </Col>
      </Row>
    </Row>
  );
};

export default Login;
1 如果我们有 error,则渲染错误消息 - 否则不渲染任何内容。
2 当提交登录窗体时,将调用 onSubmit 函数。
3 userDetails 道具包含用户迄今为止输入的用户名和密码。
4 每当用户在窗体字段中键入字符时,inputChangeHandler 函数都会触发。在下一节中,我们将看到它的作用。
该表单样式是“受控组件”的示例,“受控组件”。这意味着输入值在“上游”(在本例中,在 userDetails 属性中)设置,并且仅在上游值更改时才会更新。当调用我们的 inputChangeHandler 函数时,将发生该更改。这也称为“单向数据绑定”。

7 添加客户端安全

7.1 创建配置

此时,我们将开始向 React 应用添加安全配置。让我们先在 client/src 下面创建一个 security 目录。

~ cd client/src
~ mkdir security

在新 security 目录中创建一个名为 auth.js 的文件

~ cd security
~ touch auth.js

7.2 处理身份验证

让我们继续编辑 auth.js。此文件是“普通”JavaScript 文件(不是 React 组件),并且将包含四个与身份验证相关的核心函数(logInlogOut`refreshTokenisLoggedIn)。这些函数将作为 模块 导出,以便可以在任何 React 组件(或 JavaScript 文件)中导入和使用它们。我们将使用 HTML5 的 localStorage 对象在成功登录后存储用户的令牌。

client/src/security/auth.js
import {SERVER_URL} from './../config';
import {checkResponseStatus} from './../handlers/responseHandlers';
import headers from './../security/headers';
import 'whatwg-fetch';
import qs from 'qs';

(1)
export default {
  logIn(auth) { (2)
    localStorage.auth = JSON.stringify(auth);
  },

  logOut() { (3)
    delete localStorage.auth;
  },

  refreshToken() { (4)
    return fetch(
      `${SERVER_URL}/oauth/access_token`,
      { method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
        },
        body: qs.stringify({ (5)
          grant_type: 'refresh_token',
          refresh_token: JSON.parse(localStorage.auth).refresh_token
        })
      })
      .then(checkResponseStatus)
      .then((a) => localStorage.auth = JSON.stringify(a))
      .catch(() => { throw new Error("Unable to refresh!")})
  },

  loggedIn() {  (6)
    return localStorage.auth && fetch(
        `${SERVER_URL}/api/vehicle`, (7)
        {headers: headers()})
        .then(checkResponseStatus)
        .then(() => { return true })
        .catch(this.refreshToken)
        .catch(() => { return false });
  }
};
1 export 关键字表示这是一个 JavaScript 模块,可以使用 import 关键字导入它。一个 JavaScript 文件可以导出多个模块,因此 default 表示默认导入的模块(未指定特定模块)。
2 logIn 采用一个身份验证对象,将其转换为 JSON 字符串,并将其存储在我们的 localStorage 中以备后用。
3 logOut 甚至更简单 - 我们只需删除存储在 localStorage.auth 中的对象。
4 refreshToken 是最复杂的函数。它对 /oauth/access_token 端点(这是 Spring Security REST 默认设置的)发出 POST 请求,其中包含 refresh_token 作为 URL 参数。如果成功,它将收到与普通登录一样的 JWT 令牌响应 - 在这种情况下,我们按照之前的方法将新令牌存储在我们的 localStorage 中(覆盖前面的令牌)。
5 因为刷新端点需要 form-urlencoded 正文,所以我们使用 qs 包中的 stringify 函数将我们的 JavaScript 对象转换为正确的格式。
6 isLoggedIn 返回我们在 localStorage 中是否有一个 auth 对象,此外,可以通过针对安全的 API 发出 fetch 请求并检查响应来验证我们的令牌是否仍然有效(在实际应用中,您可能需要为此目的设置一个特殊的端点)。如果 checkResponseStatus 函数引发错误,则会调用第一个 catch 语句,这将调用上面描述的 refreshToken 函数。如果函数引发另一个错误,则最后的 catch 语句将触发,并且认证将失败。
7 我们使用反引号代替引号来表示字符串。这是一项 ES6 语法,称为 "模板字符串",它们允许我们编写多行字符串以及使用 ${expression} 语法在字符串中使用表达式。
服务器的 api/login 端点会向我们响应一个 access_token,它会过期和一个刷新令牌,该令牌永不过期,它允许我们通过 oauth/access_token 端点刷新 access_token。请查看 插件文档,以了解有关访问令牌到期和刷新选项的更多信息。

编写 headers() 函数

请注意,在我们 isLoggedIn 中的 fetch 调用中,我们正在调用一个 headers() 函数(从 headers.js 导入)。此函数将返回一个包含我们的请求标头的对象,包括来自 localStorage 的令牌。现在让我们创建此函数。

client/src/security 下创建一个新的 JavaScript 文件,称为 headers.js

client/src/security/headers.js
export default () => { (1)
  return {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${localStorage.auth ? JSON.parse(localStorage.auth).access_token : null}`
  }
}
1 我们再次导出一个模块,但这一次模块本身就是函数(使用 ES6 "箭头函数" 语法,类似于 Groovy 闭包)。该函数是匿名的,因此名称将是用于 import 模块的任何变量(例如,import headers from './headers')。

来自 headers.js 模块的函数返回一个带有"Authorization"标头的对象,并添加 access_token(通过解析 localStorage.auth JSON 对象获取)。当是时候验证我们的 API 调用时,稍后我们将使用此函数。

安装 qs 包

请记住,我们在上面的 refreshToken() 函数中使用了 qs。这是一个有用的实用程序包,可以执行多种类型的转换。在 refreshToken 中,我们使用此包将我们的请求主体转换为 form-urlencoded 格式。现在,我们需要将该程序包添加到我们的 package.json

client/package.json
{
  "name": "client",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "react": "16.1.1",
    "react-bootstrap": "0.31.5",
    "react-dom": "16.1.1",
    "react-scripts": "1.0.17"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}
1 添加此行,以将 qs 依赖项添加到我们项目中。

如果本地安装了 yarnnpm,则可以运行 install 命令来安装新软件包。

~ yarn install

但是,用于运行 client 应用的 Gradle 任务将自动执行安装,因此您无需执行以上步骤。

7.3 编写响应处理程序

为了实现我们的认证,我们将编写几个“处理程序”函数,以便在我们整个应用中导入和重复使用。

我们最初在 client/src 下创建一个 handlers 目录。

~ cd client/src
~ mkdir handlers

现在我们准备编写我们的第一个处理程序。创建以下两个文件

~ touch responseHandlers.js
~ touch errorHandlers.js

7.4 创建我们的响应处理程序

我们的第一个处理程序是 checkResponseStatus。此函数将检查响应的 HTTP 状态,然后返回响应的 JSON 或引发错误。我们可以使用 fetch API 将此函数与我们的 REST 调用链接在一起。

client/src/handlers/responseHandlers.js
import Auth from '../security/auth';

(1)
export const checkResponseStatus = (response) => {
    if(response.status >= 200 && response.status < 300) {
        return response.json()
    } else {
        let error = new Error(response.statusText);
        error.response = response;
        throw error;
    }
};

export const loginResponseHandler = (response, handler) => {
    Auth.logIn(response);

    if(handler) {
        handler.call();
    }
};
1 export 关键字使此函数对任何导入此文件的 JavaScript 文件可用。

checkResponseStatus 函数获取一个 HTTP 响应,然后检查该状态代码是否在成功范围内,然后返回响应的 JSON 主体。如果 HTTP 状态代码在此范围内之外,则会引发错误。

loginResponseHandler 使用我们之前编写的 Auth.login(response) 函数。如果将其他函数作为第二个参数传递,它会执行该函数。

7.5 创建默认的错误处理程序

checkStatusResponse 函数类似,我们将编写一个 defaultErrorHandler,它将获取一个 JavaScript 错误对象和一个自定义处理程序(在默认错误处理之后调用)。

client/src/handlers/errorHandlers.js
export const defaultErrorHandler = (error, handler) => {
    console.error(error);

    if(handler) {
        handler.call();
    }
};

defaultErrorHandler 函数非常简单:它将错误记录到控制台。如果在第二个参数中传递了其他处理程序,它会调用该函数。

8 将它们全部放在一起

现在我们终于可以将这些部分连接在一起,让我们的客户端安全性发挥作用。以下是我们需要采取的步骤

  • 更新我们的 App 组件状态,以便包括用于 Login 表单的用户详细信息。

  • 检查我们是否已登录,并在用户未通过认证时显示 Login 组件。

  • Login 表单详细信息提交到服务器,并获取(和存储)结果令牌

  • 提供一种注销方式(从 localStorage 中删除令牌)

  • 在对其 API 的 REST 调用中使用令牌

供参考,完整的 App.js 文件如下所示 - 在剩余部分中,我们将逐个部分讲解此组件(从上到下),并演示如何完成上述步骤。

client/src/App.js
import React, {Component} from 'react';
import Garage from './Garage';
import Auth from './security/auth';
import Login from './Login';
import {Grid} from 'react-bootstrap';
import {SERVER_URL} from './config';
import {defaultErrorHandler} from './handlers/errorHandlers';
import {checkResponseStatus, loginResponseHandler} from './handlers/responseHandlers';

class App extends Component {

  //tag::state[]
  constructor() {
    super();

    this.state = {
      userDetails: {
        username: '',
        password: ''
      },
      route: '',
      error: null
    }
  }

  reset = () => { (1)
    this.setState({
      userDetails: {
        username: '',
        password: ''
      },
      route: 'login',
      error: null
    });
  };
  //end::state[]

  //tag::lifecycle[]
  componentDidMount() {
    console.log('app mounting...');

    (async () => {
      if (await Auth.loggedIn()) {
        this.setState({route: 'garage'})
      } else {
        this.setState({route: 'login'});
      }
    })();
  }

  componentDidUpdate() {
    if (this.state.route !== 'login' && !Auth.loggedIn()) {
      this.setState({route: 'login'})
    }
  }
  //end::lifecycle[]

  //tag::inputChangeHandler[]
  inputChangeHandler = (event) => {
    let {userDetails} = this.state;
    const target = event.target;

    userDetails[target.name] = target.value; (1)

    this.setState({userDetails});
  };
  //end::inputChangeHandler[]

  //tag::login[]
  login = (e) => {
    console.log('login');
    e.preventDefault(); (1)

    fetch(`${SERVER_URL}/api/login`, { (2)
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(this.state.userDetails)
    }).then(checkResponseStatus) (3)
      .then(response => loginResponseHandler(response, this.customLoginHandler)) (4)
      .catch(error => defaultErrorHandler(error, this.customErrorHandler)); (5)
  };
  //end::login[]

  //tag::handler[]
  customLoginHandler = () => { (1)
    this.setState({route: 'garage'});
  };

  customErrorHandler = (error) => { (2)
    this.reset();
    this.setState({error: error.message});
  };
  //end::handler[]


  //tag::logout[]
  logoutHandler = () => {
    Auth.logOut();
    this.reset();
  };
  //end::logout[]


  //tag::routing[]
  contentForRoute() { (1)
    const {error, userDetails, route} = this.state;

    const loginContent = <Login error={error} (2)
                                userDetails={userDetails}
                                inputChangeHandler={this.inputChangeHandler}
                                onSubmit={this.login}/>;

    const garageContent = <Garage logoutHandler={this.logoutHandler}/>;

    switch (route) {
      case 'login':
        return loginContent;
      case 'garage':
        return garageContent;
      default:
        return <p>Loading...</p>;
    }
  };

  render() { (3)
    const content = this.contentForRoute();

    return (
      <Grid>
        {content}
      </Grid>
    );
  };
  //end::routing[]
}

export default App;

8.1 创建我们的状态

首先,让我们构建我们的App状态对象。这是在组件构造函数中完成的。您应该在我们的状态中识别我们的用户和错误字段,因为我们在某些函数中已经使用了它们。此外,我们稍后添加一个route变量以供使用。

client/src/App.js
constructor() {
  super();

  this.state = {
    userDetails: {
      username: '',
      password: ''
    },
    route: '',
    error: null
  }
}

reset = () => { (1)
  this.setState({
    userDetails: {
      username: '',
      password: ''
    },
    route: 'login',
    error: null
  });
};
1 我们添加了一个reset函数,这样在需要时我们就可以轻松返回到初始状态

8.2 React 生命周期方法

接下来,在我们的App组件中,我们需要实现 React 的其中两个“生命周期”方法,它们将检查我们是否已登录。

有关组件生命周期的更多信息,请参阅React 文档
client/src/App.js
componentDidMount() {
  console.log('app mounting...');

  (async () => {
    if (await Auth.loggedIn()) {
      this.setState({route: 'garage'})
    } else {
      this.setState({route: 'login'});
    }
  })();
}

componentDidUpdate() {
  if (this.state.route !== 'login' && !Auth.loggedIn()) {
    this.setState({route: 'login'})
  }
}

componentDidMount中,我们正在使用Auth.isLoggedIn()函数来查看我们是否已登录。因为isLoggedIn使用一个异步fetch调用,所以我们正在使用async/await关键字来防止我们的检查在fetch调用完成之前返回。假设isLoggedIn返回 true,我们将我们的route状态变量设置为“garage”——否则,将其设置为login

我们在componentDidUpdate中执行类似检查,如果我们不再登录,则重定向到login路由。

有关async/await的更多信息,请参阅https://mdn.org.cn上的文档

8.3 登录表单更改处理程序

正如我们在创建Login组件时讨论的那样(参见[loginScreen]),我们需要定义一个处理函数,以便在用户名/密码值发生更改时更新我们的userDetails状态对象。

client/src/App.js
inputChangeHandler = (event) => {
  let {userDetails} = this.state;
  const target = event.target;

  userDetails[target.name] = target.value; (1)

  this.setState({userDetails});
};
1 请注意,我们将对用户名和密码使用相同的处理程序,因为在每个表单输入上设置的name属性可用于分配正确的变量。

8.4 处理登录

client/src/App.js
login = (e) => {
  console.log('login');
  e.preventDefault(); (1)

  fetch(`${SERVER_URL}/api/login`, { (2)
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(this.state.userDetails)
  }).then(checkResponseStatus) (3)
    .then(response => loginResponseHandler(response, this.customLoginHandler)) (4)
    .catch(error => defaultErrorHandler(error, this.customErrorHandler)); (5)
};
1 我们调用e.preventDefault();以禁用Login表单的默认提交事件。
2 使用fetch,我们提出了一个 POST 请求,其中包含用户通过Login表单输入的凭证。
3 我们将checkResponseStatus链接到我们的fetch调用,以验证该请求是否成功。
4 假设成功,我们将loginResponseHandler添加到该链条中以完成登录流程。
5 任何错误都将与我们的customErrorHandler一起传递给defaultErrorHandler函数。
client/src/App.js
customLoginHandler = () => { (1)
  this.setState({route: 'garage'});
};

customErrorHandler = (error) => { (2)
  this.reset();
  this.setState({error: error.message});
};

请注意“自定义”处理函数的使用

  1. customLoginHandler在成功登录后更新this.state.route

  2. customErrorHandler`清除`userDetails状态变量,并设置错误消息。

现在,您应该能够成功登录应用程序,但在完成之前还需要做几件事。

8.5 处理注销

logout 处理程序会简单地调用我们早先编写的 Auth.logOut() 函数。然后,我们调用 reset(),它让我们返回到初始状态。

client/src/App.js
logoutHandler = () => {
  Auth.logOut();
  this.reset();
};

8.6 路由

在单页应用程序中,路由使用户能够在你的应用程序中导航,就像它由多个“页面”组成一样。在许多 React 应用程序中,React Router 库用于处理此需求。但是,我们将在本指南中采用一种简单的方法,即在状态中存储我们的当前“路由”,并根据该变量选择要渲染哪个组件。

client/src/App.js
contentForRoute() { (1)
  const {error, userDetails, route} = this.state;

  const loginContent = <Login error={error} (2)
                              userDetails={userDetails}
                              inputChangeHandler={this.inputChangeHandler}
                              onSubmit={this.login}/>;

  const garageContent = <Garage logoutHandler={this.logoutHandler}/>;

  switch (route) {
    case 'login':
      return loginContent;
    case 'garage':
      return garageContent;
    default:
      return <p>Loading...</p>;
  }
};

render() { (3)
  const content = this.contentForRoute();

  return (
    <Grid>
      {content}
    </Grid>
  );
};
1 我们创建了一个 contentForRoute 函数,它将根据 this.state.route 选择合适的组件来渲染。如果尚未设置路由,我们显示一条“加载中……​”消息。
2 请注意,这里我们正在将 inputChangeHandlerloginlogoutHandler 处理程序作为道具传递给这些组件。
3 最后,在 render 函数中,我们根据路由渲染我们早前计算出内容。
请注意我们如何将我们的处理程序函数作为**引用**传递(onSubmit={this.login}),而不是作为函数调用(onSubmit={this.login()})。这是因为我们希望我们的子组件能够访问这些函数并在稍后调用它们——我们不希望在组件被渲染时调用它们!

8.7 对 REST 调用进行身份验证

在这一点上,我们的登录和注销功能已经完成。最后一步是对我们的 REST 调用对我们的 API 进行身份验证。这发生在下面所示的 Garage 组件中。

client/src/Garage.js
import React from 'react';
import Vehicles from './Vehicles';
import AddVehicleForm from './AddVehicleForm';
import { Row, Jumbotron, Button } from 'react-bootstrap';
import { SERVER_URL } from './config';
import headers from './security/headers';
import 'whatwg-fetch';

class Garage extends React.Component {

  constructor() {
    super();

    this.state = {
      vehicles: [],
      makes: [],
      models: [],
      drivers: []
    }
  }

  componentDidMount() {
      fetch(`${SERVER_URL}/api/vehicle`, {
        method: 'GET',
        headers: headers(), (1)
      })
      .then(r => r.json())
      .then(json => this.setState({vehicles: json}))
      .catch(error => console.error('Error retrieving vehicles: ' + error));

      fetch(`${SERVER_URL}/api/make`, {
        method: 'GET',
        headers: headers() (1)
      })
      .then(r => r.json())
      .then(json => this.setState({makes: json}))
      .catch(error => console.error('Error retrieving makes: ' + error));

      fetch(`${SERVER_URL}/api/model`, {
        method: 'GET',
        headers: headers() (1)
    })
      .then(r => r.json())
      .then(json => this.setState({models: json}))
      .catch(error => console.error('Error retrieving models ' + error));

    fetch(`${SERVER_URL}/api/driver`, {
        method: 'GET',
        headers: headers() (1)
    })
      .then(r => r.json())
      .then(json => this.setState({drivers: json}))
      .catch(error => console.error('Error retrieving drivers: ' + error));
  }

  submitNewVehicle = (vehicle) => {
    fetch(`${SERVER_URL}/api/vehicle`, {
      method: 'POST',
      headers: headers(), (1)
      body: JSON.stringify(vehicle)
    }).then(r => r.json())
      .then(json => {
        let vehicles = this.state.vehicles;
        vehicles.push({id: json.id, name: json.name, make: json.make, model: json.model, driver: json.driver});
        this.setState({vehicles});
      })
      .catch(ex => console.error('Unable to save vehicle', ex));
  };

  render() {
    const {vehicles, makes, models, drivers} = this.state;
    (2)
    const logoutButton = <Button bsStyle="warning" className="pull-right" onClick={this.props.logoutHandler} >Log Out</Button>;

    return <Row>
      <Jumbotron>
        <h1>Welcome to the Garage</h1>
        {logoutButton}
      </Jumbotron>
      <Row>
        <AddVehicleForm onSubmit={this.submitNewVehicle} makes={makes} models={models} drivers={drivers}/>
      </Row>
      <Row>
        <Vehicles vehicles={vehicles} />
      </Row>
    </Row>;
  }
}

export default Garage;
1 再次注意 headers() 函数的使用,它用于为我们所有的 API 调用返回承载令牌的请求头。
2 单击时,注销按钮将会执行 logoutHandler 函数。启动应用程序并验证你是否能够成功登录和进行身份验证。恭喜!你已经使用 Grails 和 Spring Security 保护了你的 React 应用程序!

9 你需要 Grails 的帮助吗?

Object Computing, Inc. (OCI) 赞助了本指南的创作。该公司还提供各种咨询和支持服务。

OCI 是 Grails 的家园

认识团队