React下的登录信息处理

不同于传统的前端项目,React + React Router架构的SPA(Single Page Application)网站在不同的路由间共享一个状态集合,这就需要我们重新考虑登录验证的整体设计。

Q:登录信息保存在哪儿?

A:一般来说浏览器的数据存储有3种方法:Cookie、localStorage、sessionStorage,它们的异同如下:

特性 Cookie localStorage sessionStorage
数据的生命期 可设置失效时间,默认是关闭浏览器后失效 除非被清除,否则永久保存 仅在当前会话下有效,关闭页面或浏览器后被清除
存放数据大小 4K左右 一般为5MB 一般为5MB
与服务器端通信 每次都会携带在HTTP头中,如果使用cookie保存过多数据会带来性能问题 仅在客户端(即浏览器)中保存,不参与和服务器的通信 仅在客户端(即浏览器)中保存,不参与和服务器的通信
易用性 需要程序员自己封装,源生的Cookie接口不友好 源生接口可以接受,亦可再次封装来对Object和Array有更好的支持 源生接口可以接受,亦可再次封装来对Object和Array有更好的支持
兼容性 所有现代浏览器 IE8+、Chrome4+ IE8+、Chrome5+

如果保存在sessionStorage中,在同一浏览器的不同Tab间无法共享状态信息,显然不能满足要求。由于localStorage兼容性尚可(即使需要兼容IE8以下浏览器也可以通过polyfill使用userData实现),而且具有较好的性能表现,故我们采用localStorage作为登录信息的保存方法。

Q:何时验证?

A:简单来说,就是利用react-router的onEnter方法
以这个路由结构为例:(index.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ReactDOM.render(
<Provider store={store}>
<Router history={hashHistory}>
<Route name='主页' path="/" component={App}>
<IndexRoute component={HomePage} />
<Route name='登录' path='/login' component={LoginPage} />
<Route name='所有活动' path='/all-event' component={EventPage} >
<Route name='' path='/all-event/:id' component={EventDetailPage} />
</Route>
<Route name='404: No Match for route' path='*' component={NoMatchPage} />
</Route>
</Router>
</Provider>,
document.getElementById('root')
);

登录后,我们将登录信息同时保存到redux和localStorage中。前者用于在各个组件中取出登录信息,比如用户名等;后者用于持久化登录信息。因为当页面关闭,redux中的信息就会丢失,所以我们需要在index.js中将登录信息从localStorage传递给redux。

完整的index.js如下:

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
34
35
36
37
38
39
40
41
42
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import { Router, Route, hashHistory, IndexRoute } from 'react-router';
import thunk from 'redux-thunk';
import reducer from './reducers';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import EventPage from './pages/EventPage';
import EventDetailPage from './pages/EventDetailPage';
import NoMatchPage from './pages/NoMatchPage';
import { getAccount } from './public/common/extend';
import { LOGIN } from './constants/ActionTypes';
import { createAction } from 'redux-actions';

const finalCreateStore = applyMiddleware(thunk)(createStore);
let store = finalCreateStore(reducer);

// localStorage传递给redux
const account = getAccount();
if (account !== null) {
let action_login = createAction(LOGIN);
store.dispatch(action_login(account));
}

ReactDOM.render(
<Provider store={store}>
<Router history={hashHistory}>
<Route name='主页' path="/" component={App} >
<IndexRoute component={HomePage} onEnter={getAccount} />
<Route name='登录' path='/login' component={LoginPage} onEnter={getAccount} />
<Route name='所有活动' path='/all-event' component={EventPage} onEnter={getAccount} >
<Route name='' path='/all-event/:id' component={EventDetailPage} onEnter={getAccount} />
</Route>
<Route name='404: No Match for route' path='*' component={NoMatchPage} />
</Route>
</Router>
</Provider>,
document.getElementById('root')
);

getAccount方法的作用是:
① 获取登录信息
② 如果未登录则重定向到登录页,如果已经登录且在登录页,重定向到主页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getAccount(router) {
let account = JSON.parse(localStorage.getItem('account'));
let isLoginPage = false;
if (router) {
isLoginPage = router.location.pathname === "/login";
}
if (account !== null && isLoginPage) {
window.location.href = '/#/';
} else if (account === null && !isLoginPage) {
let url = window.location.href;
window.location.href = '/#/login?callback=' + encodeURIComponent(url);
}
return account;
}

Q:如何验证登录信息的有效性?

A:以JWT Token为例,Token中携带了自身的过期时间,可初步验证Token的有效性。但我们知道localStorage是对用户开放的,如果登录信息被篡改了怎么办?这就要介绍一下JWT Token的基本原理了。

一个JWT Token由头部(Header)、载荷(Payload)、签名(Signture)3部分组成,每个部分用一个「.」连接。其中签名是由载荷的Base64编码用后端服务器上的密钥通过HS256算法加密得到的。所以,如果载荷和签名的任意部分被篡改,后端服务器可以轻易识别,从而通过API返回401或403错误。

而Token的过期时间就保存在载荷里,只要我们对载荷部分进行Base64解码,就可以得到Token的过期时间。由此我们对getAccount方法改造如下:

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
34
35
36
37
38
39
40
41
function getAccount(router) {
let account = JSON.parse(localStorage.getItem('account'));
// 判断Token过期与否
if (account !== null && !account.token) {
let token = account.token;
let origin = token.substring(token.indexOf("."), token.lastIndexOf("."));
let exp = Base64.decode(origin).exp;
let now = Date.parse(new Date()) / 1000;
if (exp <= now) {
account = null;
localStorage.clear();
}
}
let isLoginPage = false;
if (router) {
isLoginPage = router.location.pathname === "/login";
}
redirectLogin(account, isLoginPage);
return account;
}

// 根据登录信息和当前所在的页面决定页面跳转
function redirectLogin(account, isLoginPage) {
if (account !== null && isLoginPage) {
window.location.href = '/#/';
} else if (account === null && !isLoginPage) {
let url = window.location.href;
window.location.href = '/#/login?callback=' + encodeURIComponent(url);
}
}

// 在每个Ajax请求中捕捉异常,如果API返回401或403,说明Token无效,自动跳转到登录页
function handleExp(error) {
// 状态码解析
if (error !== undefined && error.response !== undefined && (error.response.status === 401 || error.response.status === 403)) {
redirectLogin(null, false);
return;
}
// ......
// 其他异常处理语句,略
}

参考资料:
详说 Cookie, LocalStorage 与 SessionStorage