SPA的鑒權方式和傳統的web應用不同:由於頁面的渲染不再依賴服務端,與服務端的交互都通過接口來完成,而REASTful風格的接口提倡無狀態(state less),通常不使用cookie和session來進行身份認證。
比較流行的一種方式是使用web token,所謂的token可以看作是一個標識身份的令牌。客戶端在登錄成功后可以獲得服務端加密后的token,然后在后續需要身份認證的接口請求中在header中帶上這個token,服務端就可以通過判斷token的有效性來驗證該請求是否合法。
我們先來改造一下服務端,實現一個簡單的基於token的身份認證(可直接復制代碼,無需關心具體實現)。
改造服務端
先在根目錄下執行npm i json-server -D,雖然一開始以全局的方式安裝過json-server這個工具,但本次要在代碼中使用json-server的api,需要將其安裝為項目依賴。
然后新建/server/auth.js文件,寫入以下代碼:
/**
* 到期時間
*/
const expireTime = 1000 * 60;
module.exports = function (req, res, next) {
res.header('Access-Control-Expose-Headers', 'access-token');
const now = Date.now();
let unauthorized = true; // 未授權
const token = req.headers['access-token'];
if (token) {
const expired = now - token > expireTime;
if (!expired) {
unauthorized = false;
res.header('access-token', now);
}
}
if (unauthorized) {
res.sendStatus(401);
} else {
next();
}
};
新建/server/index.js文件,寫入以下代碼:
const path = require('path');
const jsonServer = require('json-server');
const server = jsonServer.create();
const router = jsonServer.router(path.join(__dirname, 'db.json'));
const middlewares = jsonServer.defaults();
server.use(jsonServer.bodyParser);
server.use(middlewares);
server.post('/login', function (req, res, next) {
res.header('Access-Control-Expose-Headers', 'access-token');
const {account, password} = req.body;
if (account === 'admin' && password === '123456') {
res.header('access-token', Date.now());
res.json(true);
} else {
res.json(false);
}
});
server.use(require('./auth'));
server.use(router);
server.listen(8000, function () {
console.log('JSON Server is running in http://localhost:8000');
});
修改/package.json文件中的scripts.server:
{
...
"scripts": {
"server": "node server/index.js",
...
},
...
}
然后使用npm run server重啟服務器。
現在我們的服務器就擁有了身份認證的功能,訪問除了’/login’外的其它接口時,服務端會根據請求的header中access-token來判斷請求是否有效,如果無效則會返回401狀態碼。
當客戶端收到401的狀態碼時,需要跳轉到登錄頁面進行登錄,有效的管理員賬號為admin,密碼為123456。
以POST方法提交下面的參數到’http://localhost:8000/login‘接口,就能夠完成登錄。
{
"account": "admin",
"password": "123456"
}
登錄成功后,接口返回true,並且在返回的headers中包含了一個有效的access-token,用於在后面的請求中使用;登錄失敗則返回false。
access-token的有效期為1分鍾,每次有效的接口請求都會獲得新的access-token;若1分鍾內沒有做操作,則會過期需要重新登錄。
我們的access-token只是一個簡單的timestamp,且沒有做任何加密措施。
封裝fetch
由於我們每個接口的請求都需要加上一個名為access-token的header,在每次需要調用接口的時候都寫一遍就非常的不明智了,所以我們需要封裝fetch方法。
新建/src/utils/request.js,寫入以下代碼:
/**
* 封裝 fetch
*/
import { hashHistory } from 'react-router';
export default function request (method, url, body) {
method = method.toUpperCase();
if (method === 'GET') {
// fetch的GET不允許有body,參數只能放在url中
body = undefined;
} else {
body = body && JSON.stringify(body);
}
return fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Access-Token': sessionStorage.getItem('access_token') || '' // 從sessionStorage中獲取access token
},
body
})
.then((res) => {
if (res.status === 401) {
hashHistory.push('/login');
return Promise.reject('Unauthorized.');
} else {
const token = res.headers.get('access-token');
if (token) {
sessionStorage.setItem('access_token', token);
}
return res.json();
}
});
}
// GET 請求
export const get = url => request('GET', url);
// POST 請求
export const post = (url, body) => request('POST', url, body);
// PUT 上傳
export const put = (url, body) => request('PUT', url, body);
// DELETE 刪除
export const del = (url, body) => request('DELETE', url, body);
request方法封裝了添加access-token頭等邏輯,然后就可以在需要調用接口的時候使用request或get、post等方法了,比如/src/components/BookEditor.js:
...
import request, {get} from '../utils/request';
class BookEditor extends React.Component {
...
handleSubmit (e) {
...
let editType = '添加';
let apiUrl = 'http://localhost:8000/book';
let method = 'post';
if (editTarget) {
...
}
request(method, apiUrl, {
name: name.value,
price: price.value,
owner_id: owner_id.value
})
.then((res) => {
if (res.id) {
...
} else {
...
}
})
.catch((err) => console.error(err));
}
getRecommendUsers (partialUserId) {
get('http://localhost:8000/user?id_like=' + partialUserId)
.then((res) => {
if (res.length === 1 && res[0].id === partialUserId) {
return;
}
...
});
}
...
}
...
其它還有/src/components/UserEditor.js、/src/pages/BookEdit.js、/src/pages/BookList.js、/src/pages/UserEdit.js和/src/pages/UserList.js文件需要進行相應的修改。
/src/components/UserEditor.js
/**
* 用戶編輯器組件
*/
import React from 'react';
import FormItem from '../components/FormItem'; // 或寫成 ./FormItem
// 高階組件 formProvider表單驗證
import formProvider from '../utils/formProvider';
// 引入 prop-types
import PropTypes from 'prop-types';
// 引入 封裝fetch工具類
import request from '../utils/request';
class UserEditor extends React.Component {
// 按鈕提交事件
handleSubmit(e){
// 阻止表單submit事件自動跳轉頁面的動作
e.preventDefault();
// 定義常量
const { form: { name, age, gender }, formValid, editTarget} = this.props; // 組件傳值
// 驗證
if(!formValid){
alert('請填寫正確的信息后重試');
return;
}
// 默認值
let editType = '添加';
let apiUrl = 'http://localhost:8000/user';
let method = 'post';
// 判斷類型
if(editTarget){
editType = '編輯';
apiUrl += '/' + editTarget.id;
method = 'put';
}
// 發送請求
request(method,apiUrl, {
name: name.value,
age: age.value,
gender: gender.value
})
// 成功的回調
.then((res) => {
// 當添加成功時,返回的json對象中應包含一個有效的id字段
// 所以可以使用res.id來判斷添加是否成功
if(res.id){
alert(editType + '添加用戶成功!');
this.context.router.push('/user/list'); // 跳轉到用戶列表頁面
return;
}else{
alert(editType + '添加用戶失敗!');
}
})
// 失敗的回調
.catch((err) => console.error(err));
}
// 生命周期--組件加載中
componentWillMount(){
const {editTarget, setFormValues} = this.props;
if(editTarget){
setFormValues(editTarget);
}
}
render() {
// 定義常量
const {form: {name, age, gender}, onFormChange} = this.props;
return (
<form onSubmit={(e) => this.handleSubmit(e)}>
<FormItem label="用戶名:" valid={name.valid} error={name.error}>
<input
type="text"
value={name.value}
onChange={(e) => onFormChange('name', e.target.value)}/>
</FormItem>
<FormItem label="年齡:" valid={age.valid} error={age.error}>
<input
type="number"
value={age.value || ''}
onChange={(e) => onFormChange('age', e.target.value)}/>
</FormItem>
<FormItem label="性別:" valid={gender.valid} error={gender.error}>
<select
value={gender.value}
onChange={(e) => onFormChange('gender', e.target.value)}>
<option value="">請選擇</option>
<option value="male">男</option>
<option value="female">女</option>
</select>
</FormItem>
<br />
<input type="submit" value="提交" />
</form>
);
}
}
// 必須給UserEditor定義一個包含router屬性的contextTypes
// 使得組件中可以通過this.context.router來使用React Router提供的方法
UserEditor.contextTypes = {
router: PropTypes.object.isRequired
};
// 實例化
UserEditor = formProvider({ // field 對象
// 姓名
name: {
defaultValue: '',
rules: [
{
pattern: function (value) {
return value.length > 0;
},
error: '請輸入用戶名'
},
{
pattern: /^.{1,4}$/,
error: '用戶名最多4個字符'
}
]
},
// 年齡
age: {
defaultValue: 0,
rules: [
{
pattern: function(value){
return value >= 1 && value <= 100;
},
error: '請輸入1~100的年齡'
}
]
},
// 性別
gender: {
defaultValue: '',
rules: [
{
pattern: function(value) {
return !!value;
},
error: '請選擇性別'
}
]
}
})(UserEditor);
export default UserEditor;
/src/pages/BookEdit.js
/**
* 編輯圖書頁面
*/
import React from 'react';
// 布局組件
import HomeLayout from '../layouts/HomeLayout';
// 引入 prop-types
import PropTypes from 'prop-types';
// 圖書編輯器組件
import BookEditor from '../components/BookEditor';
// 引入 封裝fetch工具類
import { get } from '../utils/request';
class BookEdit extends React.Component {
// 構造器
constructor(props) {
super(props);
// 定義初始化狀態
this.state = {
book: null
};
}
// 生命周期--組件加載中
componentWillMount(){
// 定義常量
const bookId = this.context.router.params.id;
/**
* 發送請求
* 獲取用戶數據
*/
get('http://localhost:8000/book/' + bookId)
.then((res) => {
console.log(res);
// 設置狀態
this.setState({
book: res
});
})
}
render() {
const {book} = this.state;
return (
<HomeLayout title="編輯圖書">
{
book ? <BookEditor editTarget={book} /> : '加載中...'
}
</HomeLayout>
);
}
}
BookEdit.contextTypes = {
router: PropTypes.object.isRequired
};
export default BookEdit;
/src/pages/BookList.js
/**
* 圖書列表頁面
*/
import React from 'react';
// 布局組件
import HomeLayout from '../layouts/HomeLayout';
// 引入 prop-types
import PropTypes from 'prop-types';
// 引入 封裝fetch工具類
import { get, del } from '../utils/request';
class BookList extends React.Component {
// 構造器
constructor(props) {
super(props);
// 定義初始化狀態
this.state = {
bookList: []
};
}
/**
* 生命周期
* componentWillMount
* 組件初始化時只調用,以后組件更新不調用,整個生命周期只調用一次
*/
componentWillMount(){
// 請求數據
get('http://localhost:8000/book')
.then((res) => {
/**
* 成功的回調
* 數據賦值
*/
this.setState({
bookList: res
});
});
}
/**
* 編輯
*/
handleEdit(book){
// 跳轉編輯頁面
this.context.router.push('/book/edit/' + book.id);
}
/**
* 刪除
*/
handleDel(book){
// 確認框
const confirmed = window.confirm(`確認要刪除書名 ${book.name} 嗎?`);
// 判斷
if(confirmed){
// 執行刪除數據操作
del('http://localhost:8000/book/' + book.id, {
})
.then(res => {
/**
* 設置狀態
* array.filter
* 把Array的某些元素過濾掉,然后返回剩下的元素
*/
this.setState({
bookList: this.state.bookList.filter(item => item.id !== book.id)
});
alert('刪除用戶成功');
})
.catch(err => {
console.log(err);
alert('刪除用戶失敗');
});
}
}
render() {
// 定義變量
const { bookList } = this.state;
return (
<HomeLayout title="圖書列表">
<table>
<thead>
<tr>
<th>圖書ID</th>
<th>圖書名稱</th>
<th>價格</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{
bookList.map((book) => {
return (
<tr key={book.id}>
<td>{book.id}</td>
<td>{book.name}</td>
<td>{book.price}</td>
<td>
<a onClick={() => this.handleEdit(book)}>編輯</a>
<a onClick={() => this.handleDel(book)}>刪除</a>
</td>
</tr>
);
})
}
</tbody>
</table>
</HomeLayout>
);
}
}
/**
* 任何使用this.context.xxx的地方,必須在組件的contextTypes里定義對應的PropTypes
*/
BookList.contextTypes = {
router: PropTypes.object.isRequired
};
export default BookList;
/src/pages/UserEdit.js
/**
* 編輯用戶頁面
*/
import React from 'react';
// 布局組件
import HomeLayout from '../layouts/HomeLayout';
// 引入 prop-types
import PropTypes from 'prop-types';
// 用戶編輯器組件
import UserEditor from '../components/UserEditor';
// 引入 封裝fetch工具類
import { get } from '../utils/request';
class UserEdit extends React.Component {
// 構造器
constructor(props) {
super(props);
// 定義初始化狀態
this.state = {
user: null
};
}
// 生命周期--組件加載中
componentWillMount(){
// 定義常量
const userId = this.context.router.params.id;
/**
* 發送請求
* 獲取用戶數據
*/
get('http://localhost:8000/user/' + userId)
.then((res) => {
// 設置狀態
this.setState({
user: res
});
})
}
render() {
const {user} = this.state;
return (
<HomeLayout title="編輯用戶">
{
user ? <UserEditor editTarget={user} /> : '加載中...'
}
</HomeLayout>
);
}
}
UserEdit.contextTypes = {
router: PropTypes.object.isRequired
};
export default UserEdit;
/src/pages/UserList.js
/**
* 用戶列表頁面
*/
import React from 'react';
// 布局組件
import HomeLayout from '../layouts/HomeLayout';
// 引入 prop-types
import PropTypes from 'prop-types';
// 引入 封裝后的fetch工具類
import { get, del } from '../utils/request';
class UserList extends React.Component {
// 構造器
constructor(props) {
super(props);
// 定義初始化狀態
this.state = {
userList: []
};
}
/**
* 生命周期
* componentWillMount
* 組件初始化時只調用,以后組件更新不調用,整個生命周期只調用一次
*/
componentWillMount(){
// 請求數據
get('http://localhost:8000/user')
.then((res) => {
/**
* 成功的回調
* 數據賦值
*/
this.setState({
userList: res
});
});
}
/**
* 編輯
*/
handleEdit(user){
// 跳轉編輯頁面
this.context.router.push('/user/edit/' + user.id);
}
/**
* 刪除
*/
handleDel(user){
// 確認框
const confirmed = window.confirm(`確認要刪除用戶 ${user.name} 嗎?`);
// 判斷
if(confirmed){
// 執行刪除數據操作
del('http://localhost:8000/user/' + user.id, {
})
.then((res) => {
/**
* 設置狀態
* array.filter
* 把Array的某些元素過濾掉,然后返回剩下的元素
*/
this.setState({
userList: this.state.userList.filter(item => item.id !== user.id)
});
alert('刪除用戶成功');
})
.catch(err => {
console.log(err);
alert('刪除用戶失敗');
});
}
}
render() {
// 定義變量
const { userList } = this.state;
return (
<HomeLayout title="用戶列表">
<table>
<thead>
<tr>
<th>用戶ID</th>
<th>用戶名</th>
<th>性別</th>
<th>年齡</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{
userList.map((user) => {
return (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.name}</td>
<td>{user.gender}</td>
<td>{user.age}</td>
<td>
<a onClick={() => this.handleEdit(user)}>編輯</a>
<a onClick={() => this.handleDel(user)}>刪除</a>
</td>
</tr>
);
})
}
</tbody>
</table>
</HomeLayout>
);
}
}
/**
* 任何使用this.context.xxx的地方,必須在組件的contextTypes里定義對應的PropTypes
*/
UserList.contextTypes = {
router: PropTypes.object.isRequired
};
export default UserList;
實現登錄頁面
現在嘗試訪問一下用戶列表頁,發現表格里面並沒有數據,因為沒有登錄接口訪問被拒絕了並且嘗試跳轉到路由’/login’。
現在來實現一個登錄頁面組件,在/src/pages下新建Login.js文件:
/**
* 登錄頁
*/
import React from 'react';
// 頁面布局組件
import HomeLayout from '../layouts/HomeLayout';
import FormItem from '../components/FormItem';
// 引入 封裝后的fetch工具類
import { post } from '../utils/request';
// 表單驗證組件
import formProvider from '../utils/formProvider';
// 引入 prop-types
import PropTypes from 'prop-types';
class Login extends React.Component {
// 構造器
constructor () {
super();
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit (e) {
e.preventDefault();
const {formValid, form: {account, password}} = this.props;
if (!formValid) {
alert('請輸入賬號或密碼');
return;
}
post('http://localhost:8000/login', {
account: account.value,
password: password.value
})
.then((res) => {
if (res) {
this.context.router.push('/');
} else {
alert('登錄失敗,賬號或密碼錯誤');
}
})
}
render () {
const {form: {account, password}, onFormChange} = this.props;
return (
<HomeLayout title="請登錄">
<form onSubmit={this.handleSubmit}>
<FormItem label="賬號:" valid={account.valid} error={account.error}>
<input type="text" value={account.value} onChange={e => onFormChange('account', e.target.value)}/>
</FormItem>
<FormItem label="密碼:" valid={password.valid} error={password.error}>
<input type="password" value={password.value} onChange={e => onFormChange('password', e.target.value)}/>
</FormItem>
<br/>
<input type="submit" value="登錄"/>
</form>
</HomeLayout>
);
}
}
Login.contextTypes = {
router: PropTypes.object.isRequired
};
Login = formProvider({
account: {
defaultValue: '',
rules: [
{
pattern (value) {
return value.length > 0;
},
error: '請輸入賬號'
}
]
},
password: {
defaultValue: '',
rules: [
{
pattern (value) {
return value.length > 0;
},
error: '請輸入密碼'
}
]
}
})(Login);
export default Login;
登錄頁面組件和UserEditor或者BookEditor類似,都是一個表單。
在這里提交表單成功后跳轉到首頁。
最后,別忘了加上登錄頁面的路由。
最終效果

項目結構:

