react基础4授权与部署

react基础4授权与部署

最终章。可能有番外吧。。但是也要在完成大作业之后了

前三章:

react基础3表单与后端

react基础2排序分页与查询+路由

react基础组件+如何组合组件

Authentication and Authorization

Introduction

Registering a New User

利用postman 进行post,成功了

Submitting the Registration Form

首先新建一个userService.js 这个文件封装了调用后端api的函数,沟通了前后端。

1
2
3
4
5
6
7
8
9
10
11
import http from "./httpService";
import { apiUrl } from "../config.json";
const apiEndpoint = apiUrl + "/users";

export function register(user) {
return http.post(apiEndpoint, {
email: user.username,
password: user.password,
name: user.name,
});
}

然后再registerForm中导入,我们可以用这种语法

1
import * as userService from "../services/userService";

这样的语法 ,会在registerForm中创建一个userServices对象,然后从userServices导入的所有函数都会成为他的方法
然后修改doSubmit方法,调用userService.register() 来注册

1
2
3
doSubmit = async () => {
await userService.register(this.state.data);
};

我们看到在前端输入信息提交之后,在dev-tools中可以看到这条注册信息,然后后端会在MongoDb上载入信息

Handling Registration Errors

现在来处理注册时出现的错误,利用try,catch

如果出现错误且错误码为400,那么就是说用户的操作出现了问题。我们需要在表单中提示一些错误的信息。

这里我们只能得到用户已经被注册了的错误。所以我们先克隆errors。然后让errors的username属性设置成ex.response.data,然后再实现更新。ex.response.data是服务器给我们报的错误

1
2
3
4
5
6
7
8
9
10
11
doSubmit = async () => {
try {
await userService.register(this.state.data);
} catch (ex) {
if (ex.response && ex.response.status === 400) {
const errors = { ...this.state.errors };
errors.username = ex.response.data;
this.setState({ errors });
}
}
};

Logging in a User

我们先做登录页,登录页做好了我们做注册成功跳转页面这个功能。我们在postman中实现登录。发现返回了一堆代码,这其实是一种 ID card

实现逻辑是这样的:所以如果用户发送了合法的用户名和密码的组合给了服务器,服务器给用户一个认证卡。以后无论何时,用户都需要请求apiEndpoint,apiEndpoint需要认证这个用户,客户端会把这个token发给server,如果他是一个合法的token,服务器就会执行用户请求

Submitting the Login Form

首先新建一个authService.js 这里面封装了调用后端的post函数,并把promise返回

1
2
3
4
5
6
7
import http from "./httpService";
import { apiUrl } from "../config.json";
const apiEndpoint = apiUrl + "/auth";

export function login(email, password) {
return http.post(apiEndpoint, { email, password });
}

在loginForm.jsx中导入以后,我们重写doSubmit函数

1
2
3
4
doSubmit = async () => {
const { data } = this.state;
await login(data.username, data.password);
};

那么我如果输入刚才登陆的账号密码,那么我们看到返回了一个token

Handling Login Errors

现在来处理登陆时发生的错误,利用try catch,逻辑和注册表单一样

1
2
3
4
5
6
7
8
9
10
11
12
doSubmit = async () => {
try {
const { data } = this.state;
await login(data.username, data.password);
} catch (ex) {
if (ex.response && ex.response.status === 400) {
const errors = { ...this.state.errors };
errors.username = ex.response.data;
this.setState({ errors });
}
}
};

Storing the JWT(JSON Token)

我们得到了Token之后,现在我们应该把token保留在客户端。每个浏览器都有一个小型的本地数据库。这个数据库可以保存键值对

因为login函数(http.post)会返回一个promise对象,其中的data存放着返回的jwt,也就是我们想要的token,那么我们重命名它,然后存储到浏览器本地的小型数据库中。然后,点击提交的以后自动转回到首页

1
2
3
4
5
6
7
8
9
10
11
 doSubmit = async () => {
try {
const { data } = this.state;
const { data: jwt } = await login(data.username, data.password);
console.log(jwt);
localStorage.setItem("token", jwt);
this.props.history.push("/");
} catch (ex) {
//...
}
};

Logging in the User upon Registration

现在我们想从 Headers(服务器发给我的包的头部信息)中获取tokens,学会了这个就可以如何使用头部来传递信息了

为实现这个功能,我们对后端user.js进行修改

1
2
3
4
res
.header("x-auth-token", token)
.header("access-control-expose-headers", "x-auth-token")
.send(_.pick(user, ["_id", "name", "email"]));

我们修改registerForm.js中的doSubmit,让他从headers中获取token并且存入localStorage。最后跳转到首页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
doSubmit = async () => {
try {
const response = await userService.register(this.state.data);
console.log(response);
localStorage.setItem("token", response.headers["x-auth-token"]);
this.props.history.push('/')
} catch (ex) {
if (ex.response && ex.response.status === 400) {
const errors = { ...this.state.errors };
errors.username = ex.response.data;
this.setState({ errors });
}
}
};

下面是console.log(response)打印出来的内容,我们就是从这个headers中获取token

JSON Web Tokens (JWT)

https://jwt.io/

在这个网站我们可以验证我们得到的Token是否合法

我们看到和我注册的信息是一致的

这相当于一个JSON对象,利用Base64URL加密算法进行编码得到的结果。我们看到左边有刚刚的id,name,email,最后一行是创建的事件。这就是为什么JWT是一个ID card的原因了,他就像驾照活着护照。存放了很多用户的信息

蓝色部分只有服务器才有。除非服务器被hack了,他们是不能通过修改token来伪装的。

Getting the Current User

下面我们来说说如何在本地得到token,解码它,然后读取装在信息,并且显示在导航栏上

npm i jwt-decode@2.2.0

在app.js中导入 jwt-decode

这样说吧,jwt-decode 导出了一个默认函数,然后我们把他叫做jwtDecode,我们随便叫他什么都可以。只要有辨识度即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import jwtDecode from "jwt-decode";
//...
class App extends Component {
state = {};
componentDidMount() {
try {
const jwt = localStorage.getItem("token");
const user = jwtDecode(jwt);
this.setState({ user });
}catch(ex){}
}
//...
}
//...同时,我们向NavBar中传递user信息
<NavBar user={this.state.user} />

Displaying the Current User on NavBar

接下来我们实现:没有登陆就只显示Login和Register,登陆了就显示用户名和logout链接

首先我们在NavBar的()中把user解构出来,如果user非空,那么就显示user的名字和Logout按钮,否则就显示Login和Register

那我们就这样渲染,我们知道

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
          
//....
{!user && (
<React.Fragment>
<NavLink className="nav-item nav-link" to="/login">
Login
</NavLink>
<NavLink className="nav-item nav-link" to="/register">
Register
</NavLink>
</React.Fragment>
)}
{user && (
<React.Fragment>
<NavLink className="nav-item nav-link" to="/profile">
{user.name}
</NavLink>
<NavLink className="nav-item nav-link" to="/logout">
Logout
</NavLink>
</React.Fragment>
)}
//....

但是这么做有个问题。我登陆以后返回首页,看到的还是login和register,只有当我重新刷新的时候,才会显示名字和logout。

这是因为在App组件当中,我们从本地获得JWT并且在cdm中解码了它,这个方法在程序的生命周期中只被调用了一次。为了解决这个问题,我们在登录和注册表单中,不要使用history的push方法来重定向

1
this.props.history.push("/");

我们要完全的重载应用.window.location强制重载。这样的结果就是App对象又会被Mount一次。这时候,我们的本地存储已经有JWT了,这样就可以解码它并且得到当前的user了

1
window.location = '/';

Logging out a User

logging out 就是把本地的token删除。

首先我们注册这个路由,然后渲染一个logout组件。当这个组件被装载时,我们删掉本地的token,然后将用户重定向到主页

logout.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
import React, { Component } from "react";
class Logout extends Component {
componentDidMount() {
localStorage.removeItem("token");
//重定向
window.location = "/";
}
render() {
return null;
}
}

export default Logout;

然后注册路由之后。我们就可以正常退出了

Refactoring

现在我们有很多的文件出现了token,以后我们如果想换其他键值,就会比较麻烦,所以我们只需要一个模块来完成授权工作,所有授权相关的都在那个模块当中。所以我们希望把保存和删除token的方法,放到authService当中。这样这个服务就是应用中唯一负责处理授权的地方。

authService.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import http from "./httpService";
import { apiUrl } from "../config.json";
import jwtDecode from "jwt-decode";
const apiEndpoint = apiUrl + "/auth";

export async function login(email, password) {
const { data: jwt } = await http.post(apiEndpoint, { email, password });
localStorage.setItem("token", jwt);
}

export function loginWithJwt(jwt) {
localStorage.setItem("token", jwt);
}

export function logout() {
localStorage.removeItem("token");
}

export function getCurrentUser() {
try {
const jwt = localStorage.getItem("token");
return jwtDecode(jwt);
} catch (ex) {
return null;
}
}

export default {
login,
logout,
getCurrenrUser,
loginWithJwt,
};

App,loginForm,registerForm,logout中对于token的操作,都换成

import auth from “../services/authService”; 然后相对应引用auth.loginWithJwt, auth.login, auth.logout, auth.getCurrentUser,…

首先来看看App.js: 在cdm中调用getCurrentUser方法,从本地数据库中取出token并用user接受这个对象

然后更新user,如果当前没有token,那么就返回一个null值, user为null

1
2
3
4
5
6
7
8
class App extends Component {
state = {};
componentDidMount() {
const user = auth.getCurrentUser();
this.setState({ user });
}
//...
}

然后来看loginForm:调用auth.login,authService会帮他提取出JWT并存储到本地数据库中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//... 
doSubmit = async () => {
try {
const { data } = this.state;
await auth.login(data.username, data.password);
window.location = "/";
//window.location 这和用户登录没关系,只是重定向可以调到主页也可以其他页,不管login的事
} catch (ex) {
if (ex.response && ex.response.status === 400) {
const errors = { ...this.state.errors };
errors.username = ex.response.data;
this.setState({ errors });
}
}
};
//...

registerForm: 调用loginWithJwt,authService接收headers中提取出来的JWT并存储到本地数据库中

1
2
3
4
5
6
7
8
9
10
11
12
//...
doSubmit = async () => {
try {
const response = await userService.register(this.state.data);
console.log(response);
auth.loginWithJwt(response.headers["x-auth-token"]);
window.location = "/";
} catch (ex) {
//...
}
};
//...

最后来看看logout: authService会删除本地的token

1
2
3
4
5
6
//...
componentDidMount() {
auth.Logout();
window.location = "/";//同样不管logout函数的事情
}
//...

Calling Protected API Endpoints

受保护的终端要求用户认证或者登录,并且有明确的授权

后端 的config中的default.json中修改 “requiresAuth”: true 这样的话,如果是没有授权的用户就无法修改或者删除我后端数据库的内容了。

然后,我们需要给有token的用户授权。这个工作需要在httpServer中实现。因为这个模块的职责就是和终端进行http对话。现在我们添加一个配置一个信息,目的是让axios无论发送什么给服务器都带上这个头部

首先在authservice中写一个返回 JWT 的函数

1
2
3
export function getJwt() {
return localStorage.getItem(tokenKey);
}

然后再httpService中添加以下配置,这样就实现了对注册用户的认证

1
axios.defaults.headers.common['x-auth-token'] = auth.getJwt();

Fixing Bi-directional Dependencies

我们在authService和httpService之间缔造了一个双向依赖关系,这是很危险的

在这里,http更为重要,auth是建立在http之上的,所以我们既要在http中抹去对authService的依赖,又要在http中获取JWT。所以我们与其调用auth中的函数获取token,不如就直接跟auth说把token给我拿来,就像下面这张图

我们在httpService中写一个这个set函数,目的是获取到jwt并赋值给请求头

1
2
3
function setJwt(jwt){
axios.defaults.headers.common["x-auth-token"] = jwt;
}

然后把这个函数导出

在authService中我们调用setJwt,把getJwt得到的结果来传入到这个函数。这样,httpService中就会得到我们的jwt了。解决了双向依赖

1
http.setJwt(getJwt);

Authorization

在后端,我们实现了只有管理员才能删除电影的功能.这就相当于当后端收到一个删除电影的请求的时候,请确保用户被授权且是管理员,auth和admin是中间件函数

1
2
3
4
5
6
7
8
router.delete("/:id", [auth, admin], async (req, res) => {
const movie = await Movie.findByIdAndRemove(req.params.id);

if (!movie)
return res.status(404).send("The movie with the given ID was not found.");

res.send(movie);
});

auth函数的功能:首先他确认服务器的requiresAuth打开了没有,如果是关闭的,他会将控制权移交到下一个中间件函数,否则就读取头部信息中的 x-auth-token .如果没有token的话,我们就返回Access denied. No token provided。意思是没有办法验证。如果有token的话,就先验证这个token是不是合法,是就传递给另外一个,不是就返回Invalid token.

auth函数的实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const jwt = require("jsonwebtoken");
const config = require("config");

module.exports = function(req, res, next) {
if (!config.get("requiresAuth")) return next();

const token = req.header("x-auth-token");
if (!token) return res.status(401).send("Access denied. No token provided.");

try {
const decoded = jwt.verify(token, config.get("jwtPrivateKey"));
req.user = decoded;
next();
} catch (ex) {
res.status(400).send("Invalid token.");
}
};

admin函数的其实要检测设置,如果配置关闭,就传递控制权,不做任何事情;否则就检测是否用户为管理员,如果不是管理员,那么就返回 Access denied

1
2
3
4
5
6
7
8
9
10
11
const config = require("config");

module.exports = function(req, res, next) {
// 401 Unauthorized
// 403 Forbidden
if (!config.get("requiresAuth")) return next();

if (!req.user.isAdmin) return res.status(403).send("Access denied.");

next();
};

如果授权的话我们呢需要在数据库中修改

设置好后,重新登入,那么就可以删除movie了

Showing or Hiding Elements based on the User

现在我们想让没有登陆的用户看不到Delete,New Movie 按钮

在App中设置movie的路由.我们不能直接传入user进去,为了给子组件传递属性,我们要用到render

我们用render替换component,用箭头函数传入props,返回Movies组件,在movies组件当中加入所有的props还有当前的user属性

1
2
3
4
<Route
path="/movies"
render={(props) => <Movies {...props} user={this.state.user} />}
/>

然后在movies的 new movies中,我们这样修改(在上面已经解构了).只有当user存在,我们才可以说把这个NewMovie渲染出来

1
2
3
4
5
6
7
8
9
{user && (
<Link
to="/movies/new"
className="btn btn-primary"
style={{ marginBottom: 20 }}
>
New Movie
</Link>
)}

同理,我们可以隐藏Delete

1
2
3
4
5
6
7
8
9
content: movie => (
this.props.user&&(
<button
onClick={() => this.props.onDelete(movie)}
className="btn btn-danger btn-sm"
>
Delete
</button>)
)

效果图

Protecting Routes

但直接隐藏并不能阻止新建电影,因为可以直接通过输入网站来访问路由

所以我们来看看怎么保护这个路由(初步实现),没有办法再每个route中都重复这个逻辑

逻辑和刚才的一样(我们在上面已经结构了user),传入props,判断是否存在user,如果不存在,那么就重定向为login页面,存在那么渲染 MovieForm组件

1
2
3
4
5
6
7
<Route
path="/movies/:id"
render={(props) => {
if (!user) return <Redirect to="/login" />;
return <MovieForm {...props} />;
}}
/>

Extracting PrivateRoute

我们这里想把前面的逻辑封装成一个组件

PrivateRoute.jsx:返回一个标准的React Route。

原来的path现在已经囊括到{…rest}当中去了,render中我们要把!user 改成!auth.getCurrentUser()

除此之外,我们还需要判断当前的Component是否存在。因为在App.js当中,要么设置component要么设置render,所以如果有component,那么就返回继承了所有props的component,否则就继承所有props的render

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import auth from "../services/authService";
import React, { Component } from "react";
import { Route, Redirect } from "react-router-dom";
const PrivateRoute = ({ component: Component, render, ...rest }) => {
return (
<Route
{...rest}
render={(props) => {
if (!auth.getCurrentUser()) return <Redirect to="/login" />;
return Component ? <Component {...props} /> : render(props);
}}
/>
);
};

export default PrivateRoute;

然后我们在app.js中引入的时候,对之前的进行修改.

1
<PrivateRoute path="/movies/:id" component={MovieForm} />

现在如果我们点击movie的名字,那么我们不会跳到MovieForm表单,我们会跳到Login表单

Redirecting after Login

我们现在点击movie名字,登录好以后,不想再回到主页了,我想到这个movie的表单当中去,怎么办?

我们首先打印一下这个组件的props

我们发现在location中的pathname是有效的,我们就要重定向到这条路径。Redirect中我们把to设置成一个对象

我们首先传入一个pathname,这是默认的,我们也要设置state属性,这是用来传递附加数据的。我再这个state当中传入一个from属性把它设置为props.location 因为这个location对象保留了用户跳转前的 位置信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
return (
<Route
{...rest}
render={(props) => {
if (!auth.getCurrentUser())
return (
<Redirect
to={{
pathname: "/login",
state: { from: props.location },
}}
/>
);
return Component ? <Component {...props} /> : render(props);
}}
/>
);

然后我们转到loginForm去看看是否state已经设置了,如果设置了我们就取location中的pathname;如果state没有被设置,那么就回到主页当中.

比如上图,我是直接点击login进入LoginForm的,那么这时候state就不会被设置,那么登陆过后就直接跳转主页

1
2
3
4
5
6
7
8
9
10
11
doSubmit = async () => {
try {
const { data } = this.state;
await auth.login(data.username, data.password);
const { state } = this.props.location
window.location = state ? state.from.pathname : "/";
//...
}catch(ex){//...
}
//...
}

现在还需要注意到,在登陆状态下如果我们直接输入login,还是会跳转到login表单。所以在login表单我们要做一个重定向

1
if (auth.getCurrentUser()) return <Redirect to="/" />;

Exercise

现在我们要实现只有admin才能显示delete按钮(刚才是只要是用户就能显示)

Hiding the Delete Column

有几种方法隐藏这个Delete 列,一种方法是添加一个属性如visible或者hidden来动态控制它,这个方法需要修改好几个组件;还有一种方法是将这个Delete列直接删除,只在当前用户是管理员的情况下才添加进来,这个方法相对简单

我们为了不污染constructor,把delete列单独设置成了deleteColumn对象。然后写一个constructor,利用auth.getCurrentUser()获取当前的user

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
deleteColumn = {
key: "delete",
content: movie => (
<button
onClick={() => this.props.onDelete(movie)}
className="btn btn-danger btn-sm"
>
Delete
</button>
)
};

constructor() {
super();
const user = auth.getCurrentUser();
if (user && user.isAdmin) this.columns.push(this.deleteColumn);
}

我们设置mosh为管理员:再mongodb中添加 isAdmin: true

Deployment

Introduction

Environment Variables

Production Builds

Getting Started with Heroku

MongoDB in the Cloud

云数据库

Adding Code to a Git Repository

Deploying to Heroku

Viewing Logs

Setting Environment Variables on Heroku

Preparing the Font-end for Deployment

Deploying the Front-end

-------------本文结束,感谢您的阅读-------------