Skip to content

Latest commit

 

History

History
1696 lines (1216 loc) · 49.3 KB

File metadata and controls

1696 lines (1216 loc) · 49.3 KB

二、我们发布的应用的全栈登录和注册

JSON Web 令牌JWT)是一种安全令牌格式,虽然相对较新,但运行良好。这是一个开放标准(RFC 7519),在处理 web 应用环境中各方之间传递声明的问题时,它改进了 oAuth2 和 OpenID 连接。

在实践中,流程如下所示:

  • 服务器分配一个编码的 JSON 对象
  • 客户端收到警报后,它会将每个请求中的编码令牌发送到服务器
  • 基于该令牌,服务器知道谁在发送请求

值得一游http://jwt.io/ 在开始使用网站之前,请先浏览网站并使用它:

成功登录后,JWT 的解决方案向我们的前端应用提供一个对象,告诉我们当前用户的授权:

{'iss': 'PublishginAppIssuer','name': 'John Doe','admin':true}

iss是发行人的财产——在我们的例子中,它将是我们发布应用的后端应用。登录用户的名称显而易见--John Doe已成功登录。admin属性只是说一个已识别的用户(使用正确的登录名和密码登录到我们后端的应用)是管理员('admin': true flag)。在本章中,您将学习如何使用它。

除了前面的示例中所述的内容外,JWT 的响应还包含有关主题/声明、签名 SHA256 生成的令牌和到期日期的信息。这里的重要规则是,您必须确定您的代币的发行人。您需要信任随响应提供的内容。这听起来可能很复杂,但在实际应用中却非常简单。

重要的是,您需要保护 JWT 生成的令牌——这将在本章后面详细介绍。

流程如下:

  1. 我们客户端的发布应用从我们的 express 服务器请求令牌。
  2. 发布支持的应用向前端 Redux 的应用发出令牌。
  3. 之后,每次从后端获取数据时,我们都会检查用户是否可以访问后端上请求的资源——该资源使用令牌。

在我们的例子中,资源是 falcor 路由的路由,它与后端有着密切的关系,但这在更分布式的平台上也可以工作。

请记住,JWT 令牌类似于私钥——您必须保证它们的安全!

JWT 令牌的结构

标头包含后端所需的信息,用于根据该信息(元数据、算法和使用的密钥)识别要执行的加密操作:

{ 
'typ': 'JWT', 
'alg': 'HS256' 
}

一般来说,这一部分对我们来说是 100%开箱即用的,所以我们在实现它时不必关心头。

第二部分包括以 JSON 格式提供的声明,例如:

  • 发卡机构:这让我们知道是谁发行了代币
  • 观众:这让我们知道这个令牌必须被我们的应用使用
  • 发行日期:告知代币创建时间
  • 到期日期:这让我们知道令牌何时到期,所以我们必须生成一个新的令牌
  • 主题:这让我们知道应用的哪个部分可以使用令牌(在更大的应用中有用)

除了这些声明之外,我们还可以创建由应用创建者专门定义的自定义声明:

{ 
'iss': 'http://theIssuerAddress', 
'exp': '1450819372', 
'aud': 'http://myAppAddress', 
'sub': 'publishingApp', 
'scope': ['read'] 
}

新 MongoDB 用户集合

我们需要在数据库中创建一个用户集合。用户将拥有以下权限:

  • 在发布应用中添加新文章
  • 在发布应用中编辑现有文章
  • 删除发布应用中的文章

第一步是我们需要创建一个集合。您可以从 Robomongo 中的 GUI(在本书开头介绍)执行此操作,但我们将使用命令行。

首先,我们需要创建一个名为initPubUsers.js的文件:

$ [[you are in the root directory of your project]]
$ touch initPubUsers.js

然后在initPubUsers.js中添加以下内容:

[ 
  { 
'username' : 'admin', 
'password' : 'c5a0df4e293953d6048e78bd9849ec0ddce811f0b29f72564714e474615a7852', 
'firstName' : 'Kamil', 
'lastName' : 'Przeorski', 
'email' : 'kamil@mobilewebpro.pl', 
'role' : 'admin', 
'verified' : false, 
'imageUrl' : 'http://lorempixel.com/100/100/people/' 
  } 
]

解释

SHA256 字符串c5a0df4e293953d6048e78bd9849ec0ddce811f0b29f72564714e474615a7852相当于密码 123456,salt 的字符串等于pubApp

如果您想自己生成这个咸密码散列,请转到http://www.xorbin.com/tools/sha256-hash-calculator 并在其网站上键入123456pubApp。您将看到以下屏幕:

这些步骤仅在开始时需要。稍后,我们需要编写一个注册表,为我们自己添加密码。

将 initPubUsers.js 文件导入 MongoDB

在我们的initPubUsers.js文件中有了正确的内容后,我们可以运行如下命令行,以便将新的pubUsers集合导入我们的数据库:

mongoimport --db local --collection pubUsers --jsonArrayinitPubUsers.js --host=127.0.0.1

导入第一章中的文章,配置 Node.js、Express.js、MongoDB、Mongoose、Falcor 和 Redux的全栈后,您将获得与我们相同的终端输出,看起来类似:

2009-04-03T11:36:00.566+0200  connected to: 127.0.0.1
2009-04-03T11:36:00.569+0200  imported 1 document

处理登录的 falcor 路由

现在我们需要开始与 falcor 路由合作,以创建一个新的端点,该端点将使用 JWT 库为客户端应用提供唯一的令牌。

我们需要做的第一件事是在后端提供secret

让我们创建secret端点的配置文件:

$ cd server
$ touch configSecret.js

现在我们需要在secret中加入以下内容:

export default { 
'secret': process.env.JWT_SECRET || 'devSecretGoesHere' 
}

将来我们会在生产服务器上使用环境变量,所以符号process.env.JWT_SECRET || 'devSecretGoesHere'表示JWT_SECRET的环境变量不存在,所以使用默认secret端点的string,devSecretGoesHere。此时,我们不需要任何开发环境变量。

创建 falcor 路由的登录(后端)

为了使我们的代码库更有条理,我们将创建一个名为routesSession.js的新文件,而不是在server/routes.js文件中再添加一条路由,并在该文件中保留与当前登录用户会话相关的所有端点。

确保您在server目录中:

$ cd server

首先打开server.js文件,添加一行代码,允许您将用户名和密码发布到后端。添加以下内容:

app.use(bodyParser.urlencoded({extended: false}));

这必须添加到app.use(bodyParser.json({extended: false}));下,因此您将以server.js代码结束,代码开头如下:

import http from 'http'; 
import express from 'express'; 
import cors from 'cors'; 
import bodyParser from 'body-parser'; 
import mongoose from 'mongoose'; 
import falcor from 'falcor'; 
import falcorExpress from 'falcor-express'; 
import Router from 'falcor-router'; 
import routes from './routes.js'; 

var app = express(); 
app.server = http.createServer(app); 

// CORS - 3rd party middleware 
app.use(cors()); 

// This is required by falcor-express middleware to work correctly with falcor-browser 
app.use(bodyParser.json({extended: false})); 
app.use(bodyParser.urlencoded({extended: false})); 

最后一行是必须添加的新行,以使其正常工作。然后在同一目录中使用以下内容创建新文件:

$ touch routesSession.js 

并将此初始内容放入routesSession.js文件:

export default [ 
  {  
    route: ['login'] , 
    call: (callPath, args) => 
      { 
      const { username, password } = args[0]; 

      const userStatementQuery = { 
          $and: [ 
              { 'username': username }, 
              { 'password': password } 
          ] 
        } 
      } 
  } 
];

呼叫路由如何工作

我们刚刚在routesSession.js文件中创建了一个初始呼叫登录路径。我们不使用 GET 方法,而是使用'call'(**call: async (callPath, args) => **)。这相当于旧 RESTful 方法中的 POST。

Falcor 路由中 call 和 get 方法的区别在于,我们可以提供带有args的参数。这允许我们从客户端获取用户名和密码:

计划是,在我们收到具有以下内容的凭据后:

const { username, password } = args[0];

然后,我们将检查他们对我们的数据库与一个用户管理员。用户需要知道真正的明文密码是123456,才能获得正确的登录 JWTtoken:

我们在这一步中还准备了一个userStatementQuery——这将在以后查询数据库时使用:

const userStatementQuery = { 
  $and: [ 
      { 'username': username }, 
      { 'password': password } 
  ] 
}

分离数据库 configs-configMongoose.js

我们需要将 DB 配置与routes.js分开:

$ [[we are in the server/ directory]]
$ touch configMongoose.js

以及它的新内容:

import mongoose from 'mongoose'; 

const conf = { 
  hostname: process.env.MONGO_HOSTNAME || 'localhost', 
  port: process.env.MONGO_PORT || 27017, 
  env: process.env.MONGO_ENV || 'local', 
}; 

mongoose.connect(`mongodb://${conf.hostname}:  
${conf.port}/${conf.env}`); 

const articleSchema = { 
articleTitle:String, 
articleContent:String 
}; 

const Article = mongoose.model('Article', articleSchema,  
'articles'); 

export default { 
  Article 
};

解释

我们刚刚引入了以下新的env变量:MONGO_HOSTNAMEMONGO_PORTMONGO_ENV。我们将在准备生产环境时使用它们。

mongodb://${conf.hostname}:${conf.port}/${conf.env}表达式正在使用自 ECMAScript 6 以来可用的模板功能。

configMongoose.jsconfig的其余部分将为您所知,正如我们在第 1 章中介绍的,使用 Node.js、Express.js、MongoDB、Mongoose、Falcor 和 Redux配置全栈。

改进 routes.js 文件

在我们创建了两个新文件configMongoose.jsroutesSession.js之后,我们必须改进server/routes.js文件,以使所有内容都能协同工作。 第一步是从routes.js中删除以下代码:

import mongoose from 'mongoose'; 

mongoose.connect('mongodb://localhost/local'); 

const articleSchema = { 
articleTitle:String, 
articleContent:String 
}; 

const Article = mongoose.model('Article', articleSchema,  
'articles');

将其替换为以下新代码:

import configMongoosefrom './configMongoose'; 
import sessionRoutes from './routesSession'; 
const Article = configMongoose.Article;

另外,我们需要将sessionRoutes扩展到我们当前的PublishingAppRoutes中,如下所示:

const PublishingAppRoutes = [ 
    ...sessionRoutes, 
  { 
  route: 'articles.length',

PublishingAppRoutes开始时,您需要传播...sessionRoutesroutes,这样登录路径将可用于 Falcor 的路径。

解释

我们去掉了帮助我们运行第一个 Mongoose 查询(获取文章)的旧代码,并将所有内容移动到configMongoose,以便我们可以在项目周围的不同文件中使用它。我们还导入了会话路由,然后使用...扩展操作将它们扩展到名为PublishingAppRoutes的数组中。

在实现 JWT 之前检查应用是否工作

此时,当执行npm start时,应用应该工作并显示文章列表:

当使用npm start运行时,您应该获得以下信息,以验证一切正常工作:

Hash: eeeb09711c820a7978d5 
Version2,: webpack 1.12.14 
Time: 2609ms 
 Asset    Size  Chunks             Chunk Names 
app.js  1.9 MB       0  [emitted]  main 
   [0] multi main 40 bytes {0} [built] 
    + 634 hidden modules 
Started on port 3000

创建 Mongoose 用户模型

在文件configMongoose.js中,我们需要创建并导出一个User模型。将以下代码添加到该文件:

const userSchema = { 
'username' : String, 
'password' : String, 
'firstName' : String, 
'lastName' : String, 
'email' : String, 
'role' : String, 
'verified' : Boolean, 
'imageUrl' : String 
}; 

const User = mongoose.model('User', userSchema, 'pubUsers'); 

export default { 
  Article, 
  User 
};

解释

userSchema描述了我们用户的 JSON 模型。用户是我们 Mongoose 的模型,它指向我们 MongoDB 中的pubUsers集合。最后,我们通过将User模型添加到导出默认值的对象来导出它。

在 routesSession.js 文件中实现 JWT

第一步是将我们的User模型导出到routesSession范围,方法是在该文件顶部添加一条import语句:

import configMongoosefrom './configMongoose'; 
const User = configMongoose.User;

安装jsonwebtokencrypto(适用于 SHA256):

$ npmi --save jsonwebtoken crypto

您安装jsonwebtoken后,我们需要将其导入routesSession.js

import jwt from 'jsonwebtoken'; 
import crypto from 'crypto'; 
import jwtSecret from './configSecret';

导入routesSession中的所有内容后,继续使用route: ['login']

您需要对userStatementQuery进行改进,因此它将使用saltedPassword而不是纯文本:

const saltedPassword = password+'pubApp';  
// pubApp is our salt string 
const saltedPassHash = crypto 
.createHash('sha256') 
.update(saltedPassword) 
.digest('hex'); 
const userStatementQuery = { 
  $and: [ 
      { 'username': username }, 
      { 'password': saltedPassHash } 
  ] 
}

因此,我们将查询一个带盐的 SHA256 密码,而不是纯文本。

userStatementQuery项下为退货承诺,具体内容如下:

return User.find(userStatementQuery, function(err, user) { 
   if (err) throw err; 
 }).then((result) => { 
   if(result.length) { 
     return null;  
     // SUCCESSFUL LOGIN mocked now (will implement next) 
   } else { 
     // INVALID LOGIN 
     return [ 
       { 
         path: ['login', 'token'],  
         value: "INVALID" 
       }, 
       { 
         path: ['login', 'error'],  
         value: "NO USER FOUND, incorrect login  
         information" 
       } 
     ]; 
   } 
   return result; 
 });

解释

User.find是来自 Mongoose 用户模型(我们在configMongoose.js中创建)的承诺——这是一种标准方法。然后作为第一个参数,我们提供了过滤器的对象userStatementQuery,其中包含用户名和密码:(*{ username, password } = args[0];)

接下来,我们提供一个在查询完成时作为回调的函数:(function(err, user) {)。我们使用if(result.length) {计算结果的数量。

如果result.length=== 0那么我们已经模拟了return语句,我们正在运行else代码,返回如下:

 return [ 
    { 
      path: ['login', 'token'],  
      value: "INVALID" 
    }, 
    { 
      path: ['login', 'error'],  
      value: 'NO USER FOUND, incorrect login  
      information' 
    } 
  ];

正如您稍后将了解到的,我们将在前端请求该令牌的路径,['login', 'token']。在本例中,我们没有找到提供的正确用户名和密码,因此我们返回"INVALID"字符串,而不是 JWT 令牌。路径['login', 'error']更详细地描述了错误的类型,以便向提供无效登录凭据的用户显示消息。

成功登录 falcor 路由

我们需要改进成功登录路径。我们有处理无效登录的案例;我们需要创建一个案例来处理成功登录,因此请替换此代码:

return null; // SUCCESSFUL LOGIN mocked now (will implement next)

使用此代码返回成功登录的详细信息:

const role = result[0].role; 
const userDetailsToHash = username+role; 
const token = jwt.sign(userDetailsToHash, jwtSecret.secret); 
return [ 
  { 
    path: ['login', 'token'], 
    value: token 
  }, 
  { 
    path: ['login', 'username'], 
    value: username 
  }, 
  { 
    path: ['login', 'role'], 
    value: role 
  }, 
  { 
    path: ['login', 'error'], 
    value: false 
  } 
];

解释

如您所见,我们现在从 DB 获取的唯一内容是角色value === result[0].role。我们需要将其添加到 hash 中,因为我们不希望我们的应用易受攻击,这样普通用户就可以通过一些黑客攻击获得管理员角色。令牌的值是根据userDetailsToHash = username+role计算的——现在已经足够了。

在我们处理好这一点之后,在后端需要做的唯一一件事就是返回带有值的路径:

  • 带有['login', 'token']的登录令牌
  • 带有['login', 'username']的用户名
  • 使用['login', 'role']登录的用户角色
  • ['login', 'error']完全没有错误的信息

下一步是在前端使用此路线。运行应用,如果一切正常,我们可以在前端开始编码。

前端侧边和 Falcor

让我们在 Redux 应用中为登录创建一个新路由。为此,我们需要引入react-router

$ npmi --save react-router@1.0.0redux-simple-router@0.0.10redux-thunk@1.0.0

It's important to use the correct NPM's version otherwise things get broke!

安装完成后,我们需要在src中添加路由:

$ cd src
$ mkdir routes
$ cd routes
$ touch index.js

然后将此index.js文件的内容制作如下:

import React  from 'react'; 
import {Route, IndexRoute} from 'react-router'; 
import CoreLayout  from '../layouts/CoreLayout'; 
import PublishingApp  from '../layouts/PublishingApp'; 
import LoginView  from '../views/LoginView'; 

export default ( 
<Route component={CoreLayout} path='/'> 
<IndexRoute component={PublishingApp} name='home' /> 
<Route component={LoginView} path='login' name='login' /> 
</Route> 
);

在这一点上,我们的应用缺少两个名为CoreLayoutLoginView的组件(我们将在一分钟内实现它们)。

CoreLayout 组件

CoreLayout组件是我们整个应用的包装器。通过执行以下操作创建它:

cd ../layouts/ 
touch CoreLayout.js 

然后,使用以下内容填充它:

import React from 'react'; 
import {Link} from 'react-router'; 

class CoreLayout extends React.Component { 
  static propTypes = { 
    children : React.PropTypes.element 
  } 

  render () { 
    return ( 
<div> 
<span> 
Links: <Link to='/login'>Login</Link> |  
<Link to='/'>Home Page</Link> 
</span> 
<br/> 
          {this.props.children} 
</div> 
    ); 
  } 
} 

export default CoreLayout;

您可能知道,当前路线的所有内容都将进入{this.props.children}目标(即您必须事先知道的basicReact.JS概念)。我们还创建了两个链接到我们的路线作为标题。

LoginView 组件

目前,我们将创建一个模拟的LoginView组件。让我们创建views目录:

$ pwd
$ [[[you shall be at the src folder]]]
$ mkdir views
$ cd views
$ touch LoginView.js

LoginView.js文件的内容以FORM GOES HERE占位符显示在以下代码中:

import React from 'react'; 
import Falcor from 'falcor'; 
import falcorModel from '../falcorModel.js'; 
import {connect} from 'react-redux'; 
import {bindActionCreators} from 'redux'; 

const mapStateToProps = (state) => ({ 
  ...state 
}); 

// You can add your reducers here 
const mapDispatchToProps = (dispatch) => ({}); 

class LoginView extends React.Component { 
  render () { 
    return ( 
<div> 
<h1>Login view</h1> 
          FORM GOES HERE 
</div> 
    ); 
  } 
} 

export default connect(mapStateToProps, mapDispatchToProps)(LoginView);

我们已经完成了routes/index.js中所有缺失的部分,但在我们的路由应用开始工作之前,还有一些其他出色的工作要做。

我们应用的根容器

因为我们的应用越来越复杂,我们需要创建一个容器,它将生活在其中。为此,让我们在src位置执行以下操作:

$ pwd
$ [[[you shall be at the src folder]]]
$ mkdir containers
$ cd containers
$ touch Root.js

Root.js将成为我们的主根文件。该文件的内容如下:

import React  from 'react'; 
import {Provider}  from 'react-redux'; 
import {Router}  from 'react-router'; 
import routes   from '../routes'; 
import createHashHistory  from 'history/lib/createHashHistory'; 

const noQueryKeyHistory = createHashHistory({ 
queryKey: false 
}); 

export default class Root extends React.Component { 
  static propTypes = { 
    history : React.PropTypes.object.isRequired, 
    store   : React.PropTypes.object.isRequired 
  } 

  render () { 
    return ( 
<Provider store={this.props.store}> 
<div> 
<Router history={noQueryKeyHistory}> 
            {routes} 
</Router> 
</div> 
</Provider> 
    ); 
  } 
}

目前它只是一个简单的容器,但稍后我们将在其中实现更多调试、热重新加载等功能。noQueryKeyHistory对路由说,我们不想在 URL 中有任何随机字符串,这样我们的路由看起来会更好(没什么大不了的,你可以将 false 标志改为 true,看看我在说什么)。

configureStore 和 rootReducer 的剩余配置

让我们先创建rootReducer。我们为什么需要它?因为在更大的应用中,你总是会得到许多不同的减速器;例如,在我们的应用中,我们将使用以下减速器:

  • 物品减速器:保存物品相关的东西(RETURN_ALL_ARTICLES等)
  • 会话缩减器:与我们用户的会话相关(LOGINREGISTER等)
  • 编辑减速器:与编辑动作相关(EDIT_ARTICLEDELETE_ARTICLEADD_NEW_ARTICLE等)
  • 路由缩减器:这将管理我们路由的状态(开箱即用,因为它由 redux 简单路由的外部库管理)

让我们在reducers目录中创建一个index.js文件:

$ pwd
$ [[[you shall be at the src folder]]]
$ cd reducers
$ touch index.js

index.js的内容如下:

import {combineReducers} from 'redux'; 
import {routeReducer} from 'redux-simple-router'; 
import article  from './article'; 

export default combineReducers({ 
  routing: routeReducer, 
  article 
});

这里的新事物是我们从 Redux 引入了一个combineReducers函数。这正是我以前写过的。我们将有不止一个减速器——在我们的例子中,我们还从 redux 简单路由的库中引入了routeReducer

下一步是创建configureStore,该configureStore将管理我们的商店,并在本书后面实现服务器渲染:

$ pwd
$ [[[you shall be at the src folder]]]
$ mkdir store
$ cd store
$ touch configureStore.js

configureStore.js文件的内容如下:

import rootReducer  from '../reducers'; 
import thunk  from 'redux-thunk'; 
import {applyMiddleware,compose,createStore} from 'redux'; 

export default function configureStore (initialState, debug =  
false) { 
let createStoreWithMiddleware; 
const middleware = applyMiddleware(thunk); 

createStoreWithMiddleware = compose(middleware); 

const store = createStoreWithMiddleware(createStore)( 
rootReducer, initialState 
  ); 
  return store; 
}

在前面的代码中,我们正在导入我们最近创建的rootReducer。我们还导入了redux-thunk库,这对于服务器端渲染非常有用(本书后面将介绍)。

最后,我们导出了一个存储,它由许多不同的 reducer(当前路由和文章的 reducer,您可以在reducer/index.js中找到)组成,能够处理服务器呈现初始状态。

运行应用前 layouts/PublishingApp.js 中的最后调整

我们的应用中最后一个变化是,我们的发布应用中的代码已经过期。

为什么它过时了?因为我们已经介绍了rootReducercombineReducers。因此,如果您在此处的PublishingApp呈现中检查您的代码,它将不起作用:

let articlesJSX = []; 

for(let articleKey in this.props) { 
const articleDetails = this.props[articleKey]; 

const currentArticleJSX = ( 
<div key={articleKey}> 
<h2>{articleDetails.articleTitle}</h2> 
<h3>{articleDetails.articleContent}</h3> 
</div>); 

articlesJSX.push(currentArticleJSX); 
}

您需要将其更改为:

let articlesJSX = []; 

for(let articleKey in this.props.article) { 
const articleDetails = this.props.article[articleKey]; 

const currentArticleJSX = ( 
<div key={articleKey}> 
<h2>{articleDetails.articleTitle}</h2> 
<h3>{articleDetails.articleContent}</h3> 
</div>); 

articlesJSX.push(currentArticleJSX); 
}

你看到区别了吗?旧的for(let articleKey in this.props)已更改为for(let articleKey in this.props.article),而this.props[articleKey]已更改为this.props.article[articleKey]。为什么?我会再次回忆:现在每一个新的减速机都将通过其在routes/index.js中创建的名称在我们的应用中可用。我们已经命名了我们的 reducer 文章,所以我们现在必须将其添加到this.props.article中,以使这些东西能够协同工作。

运行应用之前 src/app.js 中的最后更改

最后一件事是改进src/app.js以便它将使用 root 的容器。我们需要更改旧代码:

// old codebase, to improve: 
import React from 'react' 
import { render } from 'react-dom' 
import { Provider } from 'react-redux' 
import { createStore } from 'redux' 
import article from './reducers/article' 
import PublishingApp from './layouts/PublishingApp' 

const store = createStore(article) 

render( 
<Provider store={store}> 
<PublishingApp store={store} /> 
</Provider>, 
document.getElementById('publishingAppRoot') 
);

我们需要将前面的代码更改为以下代码:

import React from 'react'; 
import ReactDOM from 'react-dom'; 
import createBrowserHistory from 'history/lib/createBrowserHistory'; 
import {syncReduxAndRouter} from 'redux-simple-router'; 
import Root from './containers/Root'; 
import configureStore from './store/configureStore'; 

const target  = document.getElementById('publishingAppRoot'); 
const history = createBrowserHistory(); 

export const store = configureStore(window.__INITIAL_STATE__); 

syncReduxAndRouter(history, store); 

const node = ( 
<Root 
      history={history} 
      store={store}  /> 
); 

ReactDOM.render(node, target);

我们开始直接使用Root而不是Provider,并且我们需要将存储和历史道具发送到Root组件。***export const store = configureStore(window.__INITIAL_STATE__)***部分用于服务器端渲染,我们将在以下章节之一中添加该渲染。我们还使用历史库使用 JavaScript 管理浏览器的历史。

我们的跑步应用截图

当前当您执行npm start时,您将看到以下两条路线。

主页

登录视图

处理将调用后端以进行身份验证的登录表单

好的,所以我们已经做了很多准备,准备有一个可扩展的项目结构(routesrootReducerconfigStores等等)。

为了从用户的角度让我们的应用更漂亮,我们将开始使用材质设计 CSS。为了简化表单的工作,我们将开始使用formsy-react库。让我们安装它:

$ npm i --save material-ui@0.14.4formsy-react@0.17.0

在编写本书时,Material UI 的.20.14.4 版本是最佳选择;我使用这个版本是因为生态系统变化太快,所以最好在这里标记使用过的版本,这样在遵循本书中的说明时就不会有任何意外。

formsy-react库是一个非常方便的库,它将帮助我们在发布应用中验证表单。我们将在登录和注册等页面上使用它,您将在下一页看到。

使用 LoginForm 和 DefaultInput 组件

在安装完新的依赖项后,让我们创建一个文件夹,用于保存与哑组件(无法访问任何存储的组件;它们通过回调与应用的其他部分通信——稍后您将了解更多信息)相关的文件:

$ pwd
$ [[[you shall be at the src folder]]]
$ mkdir components
$ cd components
$ touch DefaultInput.js

然后将此文件的内容制作如下:

import React from 'react'; 
import {TextField} from 'material-ui'; 
import {HOC} from 'formsy-react'; 

class DefaultInput extends React.Component { 
  constructor(props) { 
    super(props); 
    this.changeValue = this.changeValue.bind(this); 
    this.state = {currentText: null} 
  } 

changeValue(e) { 
this.setState({currentText: e.target.value}) 
this.props.setValue(e.target.value); 
this.props.onChange(e); 
  } 

  render() { 
    return ( 
<div> 

<TextField 
          ref={this.props.name} 
          floatingLabelText={this.props.title} 
          name={this.props.name} 
          onChange={this.changeValue} 
          required={this.props.required} 
          type={this.props.type} 
          value={this.state.currentText ?  
          this.state.currentText : this.props.value} 
          defaultValue={this.props.defaultValue} /> 
        {this.props.children} 
</div>); 
  } 
}; 

export default HOC(DefaultInput);

解释

来自formsy-react{HOC}是用导出默认值HOC(DefaultInput)装饰组件(React 的 ECMAScript 5 中的 akamixin)的另一种方式——您可以在中找到更多关于这方面的信息 https://github.com/christianalfoni/formsy-react/blob/master/API.md#formsyhoc

我们也在使用material-ui中的TextField;然后它具有不同的属性。以下是属性:

  • ref:我们希望每个输入都有ref及其名称(用户名和电子邮件)。
  • floatingLabelText:这是一个好看的浮动文本(称为标签)。
  • onChange:这表示当有人在文本字段中键入时必须调用的函数名。
  • required:这有助于我们管理表单中所需的输入。
  • value:这当然是我们文本字段的当前值。
  • defaultValue:这是一个初始值。记住,当组件调用组件的构造函数时,它只被调用一次,这一点非常重要。

当前文本(this.state.currentTextDefaultInput组件的值——在TextFieldonChange属性中给出的回调调用的每个changeValue事件上,它都会随着新值而变化。

LoginForm 并使其与 LoginView 一起工作

下一步是创建LoginForm。这将使用带有以下命令的DefaultInput组件:

$ pwd
$ [[[you shall be at the components folder]]]
$ touch LoginForm.js

那么我们src/components/LoginForm.js文件的内容如下:

import React from 'react'; 
import Formsy from 'formsy-react'; 
import {RaisedButton, Paper} from 'material-ui'; 
import DefaultInput from './DefaultInput'; 

export class LoginForm extends React.Component { 
  constructor() { 
    super(); 
  } 

  render() { 
    return ( 
<Formsy.FormonSubmit={this.props.onSubmit}> 
<Paper zDepth={1} style={{padding: 32}}> 
<h3>Log in</h3> 
<DefaultInput 
onChange={(event) => {}}  
name='username' 
title='Username (admin)' 
required /> 

<DefaultInput 
onChange={(event) => {}}  
type='password' 
name='password' 
title='Password (123456)' 
required /> 

<div style={{marginTop: 24}}> 
<RaisedButton 
              secondary={true} 
              type="submit" 
              style={{margin: '0 auto', display: 'block', width:  
              150}} 
              label={'Log in'} /> 
</div> 
</Paper> 
</Formsy.Form> 
    ); 
  } 
}

在前面的代码中,我们的LoginForm组件正在使用DefaultInput的组件。这是一个简单的React.js表单,提交后调用this.props.onSubmit——这个onSubmit函数将在src/views/LoginView.js智能组件中定义。我不会在组件上谈论太多附加样式,因为如何设置样式取决于您——您将在稍后看到我们应用应用样式的屏幕截图。

改进 src/views/LoginView.js

在运行应用之前,我们在此阶段开发的最后一部分是改进LoginView组件。

src/views/LoginView.js中进行以下更改。导入我们新的LoginForm组件:

import {LoginForm} from '../components/LoginForm.js'; 
Add a new constructor of that component: 
 constructor(props) { 
    super(props); 
    this.login = this.login.bind(this); 
    this.state = { 
      error: null 
    }; 
  }

然后在完成导入和构造函数之后,需要一个名为login的新函数:

async login(credentials) { 
console.info('credentials', credentials); 

    await falcorModel 
      .call(['login'],[credentials]) 
      .then((result) =>result); 

const tokenRes = await falcorModel.getValue('login.token'); 
console.info('tokenRes', tokenRes); 
    return; 
  }

此时,login函数只将我们新的 JWT 令牌打印到控制台——现在就足够了;稍后,我们将在此基础上构建更多。

最后一步是从以下方面改进我们的render功能:

 render () { 
    return ( 
<div> 
<h1>Login view</h1> 
          FORM GOES HERE 
</div> 
    ); 
  }

对于新的,如下所示:

 render () { 
    return ( 
<div> 
<h1>Login view</h1> 
<div style={{maxWidth: 450, margin: '0 auto'}}> 
<LoginForm 
onSubmit={this.login} /> 
</div> 
</div> 
    ); 
  }

伟大的现在我们完成了!运行npm start并在浏览器中运行后,您将看到以下内容:

正如您在浏览器控制台中看到的,我们可以看到提交凭证的对象(credentials Object {username: "admin", password: "123456"})以及从后端(tokenRes eyJhbGciOiJIUzI1NiJ9.YWRtaW5hZG1pbg.NKmrphxbqNcL_jFLBdTWGM6Y_Q78xks5E2TxBZRyjDA)获取的令牌。所有这些都告诉我们,为了在发布应用中实现登录机制,我们已经走上了正轨。

Important If you get an error, then make sure that you have used the 123456 password while creating the hash. Otherwise, type in the custom password that is valid to your case.

制作 DashboardView 的组件

此时,我们有一个尚未完成的登录功能,但在继续操作之前,让我们创建一个简单的src/views/DashboardView.js组件,在成功登录后显示:

$ pwd 
$ [[[you shall be at the views folder]]] 
$ touch DashboardView.js

添加一些简单的内容,如下所示:

import React from 'react'; 
import Falcor from 'falcor'; 
import falcorModel from '../falcorModel.js'; 
import { connect } from 'react-redux'; 
import { bindActionCreators } from 'redux'; 
import { LoginForm } from '../components/LoginForm.js'; 

const mapStateToProps = (state) => ({ 
  ...state 
}); 

// You can add your reducers here 
const mapDispatchToProps = (dispatch) => ({}); 

class DashboardView extends React.Component { 
render () { 
    return ( 
<div> 
<h1>Dashboard - loggedin!</h1> 
</div> 
    ); 
  } 
} 
export default connect(mapStateToProps, mapDispatchToProps)(DashboardView);

这是一个简单的组件,在这一点上是静态的。稍后,我们将在其中构建更多功能。

关于仪表盘,我们需要创建的最后一件事是在src/routes/index.js文件中创建一个新路由:

import DashboardView from '../views/DashboardView'; 

export default ( 
<Route component={CoreLayout} path='/'> 
<IndexRoute component={PublishingApp} name='home' /> 
<Route component={LoginView} path='login' name='login' /> 
<Route component={DashboardView} path='dashboard'   name='dashboard' /> 
</Route> 
);

我们刚刚使用 react 路由的配置添加了第二条路由。它使用位于../views/DashboardView文件中的DashboardView组件。

完成登录的机制

我们发布应用目前登录的最后改进仍在src/views/LoginView.js位置:

首先,让我们添加对无效登录的处理:

console.info('tokenRes', tokenRes); 

if(tokenRes === 'INVALID') { 
    const errorRes = await falcorModel.getValue('login.error'); 
    this.setState({error: errorRes}); 
    return; 
} 

return;

我们添加了这个if(tokenRes === 'INVALID')是为了用this.setState({error: errorRes})更新错误状态。

下一步是在render函数Snackbar中添加一个错误类型,该错误类型将向用户显示。在LoginView组件顶部添加此导入:

import { Snackbar } from 'material-ui';

然后您需要更新render函数,如下所示:

<Snackbar 
  autoHideDuration={4000} 
  open={!!this.state.error} 
  message={this.state.error || ''}  
  onRequestClose={() => null} />

所以在添加它之后,render函数将如下所示:

render () { 
  return ( 
<div> 
<h1>Login view</h1> 
<div style={{maxWidth: 450, margin: '0 auto'}}> 
<LoginForm 
onSubmit={this.login} /> 
</div> 
<Snackbar autoHideDuration={4000} 
          open={!!this.state.error} 
          message={this.state.error || ''}  
onRequestClose={() => null} /> 
</div> 
  ); 
}

此处需要SnackBar onRequestClose,否则您将从 Material UI 在开发人员控制台中得到警告。好的,我们正在处理登录错误,现在让我们来处理成功的登录。

处理 LoginView 组件中的成功登录

要处理成功令牌的后端响应,请添加登录功能:

if(tokenRes === 'INVALID') { 
const errorRes = await falcorModel.getValue('login.error'); 
this.setState({error: errorRes}); 
      return; 
    }

处理正确响应的新代码如下:

if(tokenRes) { 
const username = await falcorModel.getValue('login.username'); 
const role = await falcorModel.getValue('login.role'); 

localStorage.setItem('token', tokenRes); 
localStorage.setItem('username', username); 
localStorage.setItem('role', role); 

this.props.history.pushState(null, '/dashboard'); 
}

解释

在我们知道tokenRes不是INVALID并且它不是未定义的(否则会向用户显示致命错误)之后,我们将遵循某些步骤:

我们正在从 Falcor 的模型(await falcorModel.getValue('login.username'))中获取用户名。我们正在获取用户的角色(await falcorModel.getValue('login.role'))。然后我们将所有已知变量从后端保存到localStoragewith

localStorage.setItem('token', tokenRes); 
localStorage.setItem('username', username); 
localStorage.setItem('role', role);

同时,我们使用this.props.history.pushState(null, '/dashboard')将用户发送到/dashboard路线。

关于仪表板视图和安全性的几个重要注意事项

在这一点上,我们不会保护DashboardView,因为没有任何重要的东西需要保护——我们将在稍后将更多资产/功能放入此路径时进行保护,在本书的末尾,这将是一个编辑的仪表盘,它将控制系统中的所有文章。

我们剩下的唯一一步就是把它变成一个RegistrationView组件。在这一点上,这条路线也将适用于所有人。在本书的后面,我们将创建一个机制,这样只有主管理员才能将新编辑器添加到系统中(并管理它们)。

开始新编辑的注册工作

为了完成注册,我们先从位于server/configMongoose.js的 Mongoose 配置文件中对我们的用户方案进行一些更改:

const userSchema = { 
'username' : String, 
'password' : String, 
'firstName' : String, 
'lastName' : String, 
'email' : String, 
'role' : String, 
'verified' : Boolean, 
'imageUrl' : String 
};

新计划的适用范围如下:

const userSchema = { 
'username' : { type: String, index: {unique: true, dropDups: true }}, 
'password' : String, 
'firstName' : String, 
'lastName' : String, 
'email' : { type: String, index: {unique: true, dropDups: true }}, 
'role' : { type: String, default: 'editor' }, 
'verified' : Boolean, 
'imageUrl' : String 
};

如您所见,我们在usernameemail字段中添加了唯一索引。此外,我们还为角色添加了一个默认值,因为集合中的任何下一个用户都将是编辑器(而不是管理员)。

添加寄存器的 falcor 路由

在位于server/routesSession.js的文件中,您需要添加一个新的路由(在登录的路由旁边):

 {  
    route: ['register'], 
    call: (callPath, args) => 
      { 
        const newUserObj = args[0]; 
        newUserObj.password = newUserObj.password+'pubApp'; 
        newUserObj.password = crypto 
          .createHash('sha256') 
          .update(newUserObj.password) 
          .digest('hex'); 
          const newUser = new User(newUserObj); 
          return newUser.save((err, data) => { if (err) return err; }) 
          .then ((newRes) => { 
            /* 
              got new obj data, now let's get count: 
             */ 
             const newUserDetail = newRes.toObject(); 

            if(newUserDetail._id) { 
              return null; // Mocked for now 
            } else { 
              // registration failed 
              return [ 
                { 
                  path: ['register', 'newUserId'],  
                  value: 'INVALID' 
                }, 
                { 
                  path: ['register', 'error'],  
                  value: 'Registration failed - no id has been                                  
                  created' 
                } 
              ]; 
            } 
            return; 
          }).catch((reason) =>console.error(reason)); 
      } 
  }

这段代码实际上只是通过const newUserObj = args[0]从前端接收新用户的对象。

然后,我们对将存储在数据库中的密码进行加密:

newUserObj.password = newUserObj.password+'pubApp'; 
newUserObj.password = crypto 
  .createHash('sha256') 
  .update(newUserObj.password) 
  .digest('hex');

然后我们通过const newUser = new User(newUserObj)从 Mongoose 创建一个新的用户模型,因为newUser变量是用户的新模型(尚未保存)。

return newUser.save((err, data) => { if (err) return err; })

在将其保存到 db 中并解决承诺后,我们首先管理 db 中的一个无效条目,方法是将 Mongoose 结果的对象设置为一个简单的 JSON 结构,并带有const newUserDetail = newRes.toObject();

完成后,我们将返回一个INVALID信息给 Falcor 的模型:

 // registration failed 
    return [ 
      { 
        path: ['register', 'newUserId'],  
        value: 'INVALID' 
      }, 
      { 
        path: ['register', 'error'],  
        value: 'Registration failed - no id has been created' 
      }

因此,我们已经完成了处理来自 Falcor 的无效用户注册。下一步是替换此选项:

// you shall already have this in your codebase, just a recall 
if(newUserDetail._id) { 
  return null; // Mocked for now 
} 
The preceding code needs to be replaced with: 
if(newUserDetail._id) { 
const newUserId = newUserDetail._id.toString(); 

  return [ 
    { 
      path: ['register', 'newUserId'],  
      value: newUserId 
    }, 
    { 
      path: ['register', 'error'],  
      value: false  
    } 
  ]; 
}

解释

我们需要将新用户的 ID 转换为字符串newUserId = newUserDetail._id.toString()(否则会破坏代码)。

如您所见,我们有一个标准的 return 语句,它补充了 Falcor 中的模型。

为了快速回忆,在它在后端正确返回后,我们将能够在前端请求此值,如下所示:const newUserId = await falcorModel.getValue(['register', 'newUserId']);(这只是如何在客户端获取此新UserId的一个示例——不要将其写入代码中,我们将在一分钟内完成)。

再举几个例子,你就会习惯了。

前端实现(RegisterView 和 RegisterPerform)

让我们首先创建一个组件,该组件将通过以下操作在前端管理注册表表单:

$ pwd 
$ [[[you shall be at the components folder]]] 
$ touch RegisterForm.js 

该文件的内容将是:

import React from 'react'; 
import Formsy from 'formsy-react'; 
import {RaisedButton, Paper} from 'material-ui'; 
import DefaultInput from './DefaultInput'; 

export class RegisterForm extends React.Component { 
  constructor() { 
    super(); 
  } 

  render() { 
    return ( 
<Formsy.FormonSubmit={this.props.onSubmit}> 
<Paper zDepth={1} style={{padding: 32}}> 
<h3>Registration form</h3> 
<DefaultInput 
  onChange={(event) => {}}  
  name='username' 
  title='Username' 
  required /> 

<DefaultInput 
  onChange={(event) => {}}  
  name='firstName' 
  title='Firstname' 
  required /> 

<DefaultInput 
  onChange={(event) => {}}  
  name='lastName' 
  title='Lastname' 
  required /> 

<DefaultInput 
  onChange={(event) => {}}  
  name='email' 
  title='Email' 
  required /> 

<DefaultInput 
  onChange={(event) => {}}  
  type='password' 
  name='password' 
  title='Password' 
  required /> 

<div style={{marginTop: 24}}> 
<RaisedButton 
              secondary={true} 
              type="submit" 
              style={{margin: '0 auto', display:                      
              'block', width: 150}} 
              label={'Register'} /> 
</div> 
</Paper> 
</Formsy.Form> 
    ); 
  } 
}

前面的注册组件创建表单的方式与LoginForm完全相同。当用户点击Register按钮后,它会向src/views/RegisterView.js组件发送一个回调(我们稍后会创建这个组件)。

请记住,在 components 目录中,我们只保留哑组件,因此与应用其余部分的所有通信都必须通过回调完成,如本例所示。

注册视图

让我们创建一个RegisterView文件:

$ pwd 
$ [[[you shall be at the views folder]]] 
$ touch RegisterView.js

其内容是:

import React from 'react'; 
import falcorModel from '../falcorModel.js'; 
import { connect } from 'react-redux'; 
import { bindActionCreators } from 'redux'; 
import { Snackbar } from 'material-ui'; 
import { RegisterForm } from '../components/RegisterForm.js'; 

const mapStateToProps = (state) => ({  
  ...state  
}); 
const mapDispatchToProps = (dispatch) => ({});

这些是我们在智能组件中使用的标准组件(我们需要falcorModel与后端通信,需要mapStateToPropsmapDispatchToProps与 Redux 的 store/reducer 通信)。

好吧,这还不是注册视图的全部内容;接下来,让我们添加一个组件:

const mapDispatchToProps = (dispatch) => ({}); 

class RegisterView extends React.Component { 
  constructor(props) { 
    super(props); 
    this.register = this.register.bind(this); 
    this.state = { 
      error: null 
    }; 
  } 

  render () { 
    return ( 
<div> 
<h1>Register</h1> 
<div style={{maxWidth: 450, margin: '0 auto'}}> 
<RegisterForm 
onSubmit={this.register} /> 
</div> 
</div> 
    ); 
  } 
} 
export default connect(mapStateToProps, mapDispatchToProps)(RegisterView);

正如您在前面的代码片段中看到的,我们缺少register函数,因此在constructorrender函数之间添加函数,如下所示:

async register (newUserModel) {console.info("newUserModel",  newUserModel); 

    await falcorModel 
      .call(['register'],[newUserModel]) 
      .then((result) =>result); 

      const newUserId = await falcorModel.getValue(['register',  
      'newUserId']); 

    if(newUserId === 'INVALID') { 
      const errorRes = await falcorModel.getValue('register.error'); 

      this.setState({error: errorRes}); 
      return; 
    } 

    this.props.history.pushState(null, '/login'); 
  }

正如您所看到的,async register (newUserModel)函数是异步的,并且对等待对象友好。接下来,我们将登录到控制台,了解用户使用console.info("newUserModel", newUserModel)提交的内容。之后,我们通过调用查询 falcor 路由:

await falcorModel 
      .call(['register'],[newUserModel]) 
      .then((result) => result);

调用路由后,我们使用以下命令获取响应:

const newUserId = await falcorModel.getValue(['register', 'newUserId']);

根据后端的响应,我们将执行以下操作:

  • 对于INVALID我们正在获取错误消息并将其设置为组件状态(this.setState({error: errorRes}))
  • 如果用户已正确注册,则我们有他们的新 ID,我们要求用户使用历史推送状态(this.props.history.pushState(null, '/login');)登录

我们在routes/index.js中没有为RegisterView创建路由,并且CoreLayout中没有链接,因此我们的用户无法使用。在routes/index.js中添加新的导入:

import RegisterView from '../views/RegisterView';

然后添加一个路由,这样从routes/index.js导出的默认值如下所示:

export default ( 
<Route component={CoreLayout} path='/'> 
<IndexRoute component={PublishingApp} name='home' /> 
<Route component={LoginView} path='login' name='login' /> 
<Route component={DashboardView} path='dashboard'  name='dashboard' /> 
<Route component={RegisterView} path='register' name='register' /> 
</Route> 
);

最后,在src/layoutsCoreLayout.js文件的render方法中添加一个链接:

render () { 
    return ( 
<div> 
<span> 
   Links: 
  <Link to='/login'>Login</Link> 
  <Link to='/'>Home Page</Link> 
</span> 
  <br/> 
 {this.props.children} 
</div> 
    ); 
  }

此时,我们应该能够使用以下表格进行注册:

总结

在下一章中,我们将开始研究应用的服务器端渲染。这意味着对 Express 服务器的每个请求,我们都将根据客户端的请求生成 HTML 标记。这项功能对于像我们这样的应用非常有用,因为 web 加载的速度对于像我们这样的用户来说非常重要。

你可以想象,大多数新闻网站都是用于娱乐的,这意味着潜在用户的关注时间很短。装载速度很重要。还有一些观点认为服务器端渲染也有助于搜索引擎优化。

爬虫有更简单的方法来读取我们文章中的文本,因为它们不需要执行 JavaScript 来从服务器获取它(与非服务器端呈现单页应用相比)。

至少有一件事是肯定的:如果你的文章发布应用上有一个服务器端呈现,那么谷歌可能会发现你关心应用的快速加载,因此与不关心服务器端呈现的完整单页网站相比,它可能会给你带来一些劣势。