假设今天双十一,你兴致冲冲的打开淘宝,登陆后开始和别人一起“秒杀”,不巧的是你手滑关闭了浏览器,于是你赶紧重新打开淘宝,发现想买的东西居然还没售罄,果断付款。
在这个过程中,你有没有发现,在你点错退出到重新打开淘宝时,是不用重新登陆的。也许正是避免登陆的那十几秒帮助你成功的秒杀了想要的东西。这是因为浏览器记录了你的登陆状态。
浏览器记录用户状态可以用在很多场景,例如微博和QQ空间的主题,登陆状态记录,存储大规模数据以供查询等等。
浏览器要如何记录用户的状态?这就是本节的内容。
cookie算是最古老的存储方式之一了。服务器在返回请求时,在头部添加
Set-Cookie: <cookie-key>=<cookie-value>
这样,响应就携带了一个cookie。浏览器会把这个cookie存储下来,在下一次浏览器请求这个服务器的时候,cookie又会被发送给服务端,我们可以把要传送的数据通过cookie携带,达到通讯的目的。
cookie也可以通过客户端的javascript访问,虽然提供了便利但也增大了危险性(cookie可能被客户端修改)。因此如果是不希望cookie被客户端操作,可以在设置cookie时加上httpOnly属性,例如:
Set-Cookie: user=miku;httpOnly
这样客户端就不再能访问到这个cookie了。
cookie是危险的,在传输过程中可能遭到攻击,因此如果不使用**https**协议进行传输的话,cookie中不能放置任何涉及用户隐私的信息,例如密码。
下面我们就编写一个简单的服务端cookie模块,它能增删查改cookie,来让大家对操作cookie有直观的认识。
在项目目录中创建api/lib/cookie.js
/**
* 用作中间件的cookie模块
* 负责操作cookie
*
* @class Cookie
*/
class Cookie {
/**
* 构造cookie模块
* req是httpRequest
* res是httpResponse
* _cookie用于记录cookie列表
* 你可以通过req.cookie访问模块
* 返回请求时,会自动发送_cookie列表到客户端
*
* @param {Object} req
* @param {Object} res
* @return {Void}
*/
constructor(req, res) {
this._cookie = []
this._req = req
this._res = res
const _hackHead = res.writeHead
const cookie = req.headers.cookie
if(cookie) {
const cook = cookie.split(';')
cook.forEach((c) => {
this._cookie.push(c.trim())
})
}
req.cookie = this
res.writeHead = (...args) => {
res.setHeader('Set-Cookie', this._cookie)
_hackHead.apply(res, args)
}
}
/**
* 解析请求中的cookie
* 解析后的cookie是一个对象
* 对象的key为cookie的key
*
* @param {Void}
* @return {Object} cookies | null
*/
parse() {
if (this._req.headers.cookie){
let cookies = {}
this._req.headers.cookie.split(';').forEach((cookie) => {
let parts = cookie.split('=')
cookies[ parts[0].trim() ] = ( parts[1] || '').trim()
})
return cookies
} else {
return null
}
}
/**
* 向cookie列表中添加或修改cookie
* k是要添加的cookie名
* v是要添加的cookie值
* opt是cookie的选项,支持的值有:
* maxAge: 存活时间,以秒为单位
* expires: 过期时间,格式为GMT_String
* domain: 浏览器需要发送该cookie的主机名
* path: 需要发送cookie的url路径(包含其子域名)
* secure: 只能以https进行传输
* httpOnly: 在客户端不能被javascript访问
*
* @param {String} k
* @param {String} v
* @param {Object} opt
* @return {Void}
*/
set(k, v, opt={}) {
let cookie = `${k}=${v}`
const keys = Object.keys(opt)
for( let i = 0; i < keys.length; i++ ) {
const thisKey = keys[i]
cookie += `;${thisKey}=${opt[thisKey]}`
}
this._cookie.push(cookie)
}
/**
* 删除一个cookie
* k是要删除的cookie的key值
*
* @param {String} k
* @return {Void}
*/
remove(k) {
const regStr = '^' + k + '=[^;]+'
const reg = new RegExp(regStr)
for(let i = 0; i < this._cookie.length; i++) {
const cook = this._cookie[i]
if(reg.test(cook)) {
this._cookie[i] = cook + ';expires=Thu, 01 Jan 1970 00:00:00 GMT'
break
}
}
}
}
module.exports = Cookie
配合之前写的core
模块,现在我们可以用这种方式操作cookie了:
const Cookie = require('./../api/lib/cookie')
const app = require('./../api/lib/core')()
app.use('/', (req, res, next) => {
new Cookie(req, res)
next()
})
app.get('/', (req, res) => {
//设置cookie
req.cookie.set('user', 'mirone')
//修改cookie
req.cookie.set('user', 'kaka')
//删除cookie
req.cookie.remove('user')
//解析cookie
const cookieObj = req.cookie.parse()
res.end()
})
上文中提到了,cookie在非https协议的情境下是不安全的,所以应该尽量避免把重要信息放在其中。那么像登陆信息这样的数据,就不能直接存储在cookie中了。以存储用户登陆状态信息为例:
ifLogIn=true
这样记录登陆状态是非常危险的,只要被人修改状态,就能直接登录了。
那么肯定有人说,如果把用户+密码存储在后端,不就可以防止被修改状态了吗;登陆时直接验证本地的用户和密码即可。
这种机制存在一个问题,就是如果网络服务被监听,那么监听者可以直接拿到用户和密码,以后就可以凭此直接登陆。
因此我们需要一个机制,让判断的凭据可变而又难以找到规律。(很多网站会采用user+password+一个固定字符串然后加密的手段,但是这种办法是很容易被找到规律的)。
从字面意思来讲,session是指会话,用于在客户端与服务端之间创建关联。
session往往是依赖cookie实现的,session和cookie的最大区别在于它存储在服务端,一般的工作流程是:
- 用户登陆成功,服务端生成一个sessionId存储在客户端,同时把这个sessionId对应的用户存储在服务端
- 客户端再次访问网站,将本地cookie发送到服务端,服务端比对cookie中的sessionId与服务端存储的sessionId,如果比对合格,则登陆成功,否则登陆失败
依据我们之前已经做好的core和cookie模块,就很容易作出session模块了。
在项目目录中创建api/lib/session.js
/**
* 随机生成sid
* 算法为当前时间+随机数
*
* @param {Void}
* @return {Number}
*/
function generateSid() {
return (new Date()).getTime() + Math.random().toFixed(2)
}
/**
* 登陆成功时调用
* 实现单点登陆,删除数据库中相应username
* 重新生成username和sid
* sid发送给客户端
* req是httpRequest
* user是登陆的用户名称
* db是依赖于model模块的数据库连接实例
* 返回一个Promise实例
*
* @param {Object} req
* @param {Object} db
* @param {String} user
* @return {Promise}
*/
function sessionLogin(req, db, user) {
const _time = new Date()
_time.setYear(_time.getYear() + 1902)
return new Promise((resolve) => {
db.removeOne({
username: user
}, () => {
db.add({
username: user,
_id: generateSid()
}, (doc) => {
req.cookie.set('sid', doc._id, {
expires: _time,
httpOnly: true
})
resolve()
})
})
})
}
/**
* 校验用户登陆状态
* 依赖于core模块
* 是core模块的中间件
* 如果登陆成功,req.username有值,否则为null
* req是指httpRequest
* db是依赖于model模块的数据库连接实例
* 返回一个Promise实例
*
* @param {Object} req
* @param {Object} db
* @return {Promise}
*/
function sessionFilter(req, db) {
const _cookieObj = req.cookie.parse()
const _time = new Date()
_time.setYear(_time.getYear() + 1902)
return new Promise((resolve) => {
if(!_cookieObj||!_cookieObj.sid) {
resolve()
} else {
db.findOne({
_id: _cookieObj.sid
}, (doc) => {
if(doc) {
req.username = doc.username
req.cookie.set('sid', doc._id, {
expires: _time,
httpOnly: true
})
}
resolve()
})
}
})
}
module.exports = {
/**
* 可供调用的登陆状态生成函数
* req是httpRequest
* db是依赖于model模块的数据库连接实例
* user是用户的名称
* fn是之后操作的回调函数
*
* @param {Object} req
* @param {Object} db
* @param {String} user
* @param {Function} fn
* @return {Void}
*/
login: (req, db, user, fn) => {
sessionLogin(req, db, user).then(fn)
},
/**
* 可供调用的filter中间件
* req是httpRequest
* db是依赖于model模块的数据库连接实例
* next是依赖于中间件模块的next函数
*
* @param {Object} req
* @param {Object} db
* @param {String} user
* @param {Function} next
* @return {Void}
*/
filter: (req, db, next) => {
sessionFilter(req, db).then(() => {
next()
})
}
}
调用session模块时,也很简单:
const app = require('./../api/lib/core')()
const Cookie = require('./../api/lib/cookie')
const session = require('./../api/lib/session')
const Model = require('./../api/model/model')
const model = new Model()
model.addModel('session', {
sid: {type: String},
username: {type: String}
})
const db = model.init()
app.use('/', (req, res, next) => {
new Cookie(req, res)
next()
})
//中间件,访问时校验session
app.use('/', (req, res, next) => {
session.filter(req, db.session, next)
})
app.get('/', (req, res) => {
if(req.username) {
//session登陆成功
} else {
//session登陆失败
}
})
//登陆成功时写入session
app.get('/login', (req, res) => {
session.login(req, db.session, 'yourUsername', () => {
res.end()
})
})
如果用cookie存储信息,除了安全问题,还有一个必须要面对的问题就是容量。
单个cookie的最大大小是4kb,一个请求允许的最大cookie数量根据浏览器不同而不同,但是如果发送过多的cookie,后端处理会变得异常艰难。
因此,当客户端要存储的数据量比较大(例如用户自定义配置信息),或者客户端的数据存储不需要告知服务端时(例如文本编辑器的保存功能),就需要使用其它方案了。
Web Storage可以让网页在客户端存储数据。它分为localStorage
和webStorage
。它们的区别是前者在关闭浏览器时清空,后者没有存活时间的限制。
关于Web Storage的属性和操作,推荐阅读阮一峰老师的教程
当你要存储的数据量大到连Web Storage都无法满足你的时候,你就可以考虑使用IndexedDB了。
IndexedDB是客户端数据库,它能存储大量数据,查找也非常方便。使用IndexedDB可以用在两种场合,一种是需要前端存储大量数据但不需要告诉服务端时(例如游戏进度,个人配置),一种是查询数据量大,放在本地可以免除频繁请求造成的延迟和性能负担。
关于IndexedDB的属性和操作,推荐阅读阮一峰老师的教程
还记得我们的server.js
文件么?我们现在要实现第一个功能:注册登陆。
建立一个view
文件夹,放置我们的视图文件。在其中创建4个文件,分别命名为welcome.html
,login.html
,join.html
,todo.html
。然后开始编辑:
welcome.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="Cache-Control" content="no-siteapp">
<meta content="initial-scale=1, maximum-scale=1, width=device-width" name="viewport">
<title>Welcome</title>
</head>
<body>
<h1>Welcome to Todo List App!</h1>
</body>
</html>
login.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="Cache-Control" content="no-siteapp">
<meta content="initial-scale=1, maximum-scale=1, width=device-width" name="viewport">
<title>Welcome</title>
</head>
<body>
<h1>Login</h1>
<form method="POST" action="/login">
<label for="username">User Name</label>
<input type="text" name="username" />
<label for="pwd">Password</label>
<input type="text" name="pwd" />
<input type="submit" value="submit" />
</form>
</body>
</html>
join.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="Cache-Control" content="no-siteapp">
<meta content="initial-scale=1, maximum-scale=1, width=device-width" name="viewport">
<title>Join</title>
</head>
<body>
<h1>Join</h1>
<form method="POST" action="/join">
<label for="username">User Name</label>
<input type="text" name="username" />
<label for="pwd">Password</label>
<input type="text" name="pwd" />
<input type="submit" value="submit" />
</form>
</body>
</html>
user.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="Cache-Control" content="no-siteapp">
<meta content="initial-scale=1, maximum-scale=1, width=device-width" name="viewport">
<title>User</title>
</head>
<body>
<h1>User</h1>
</body>
</html>
然后在model/modelList.js中建立数据类型列表:
const list = [
{
name: 'user',
struct: {
_id: {type: String, unique: true},
pwd: {type: String}
}
},
{
name: 'session',
struct: {
_id: {type: String},
username: {type: String}
},
type: 'simple'
}
]
module.exports = list
打开api/server.js
,进行编辑:
const fs = require('fs')
const querystring = require('querystring')
//引入自定义模块
const app = require('./lib/core')()
const Cookie = require('./../api/lib/cookie')
const session = require('./../api/lib/session')
const Model = require('./model/model')
const modelList = require('./model/modelList')
//服务端配置
const SERVER_CONFIG = {
host: process.env.IP || 'localhost',
port: process.env.PORT || 3000
}
//加载数据库
const model = new Model()
model.setConfig({
user: 'fsn',
pwd: '0000',
db: 'fsn'
})
model.loadModelFromList(modelList)
const db = model.init()
//加载cookie和session中间件
app.use('/', (req, res, next) => {
new Cookie(req, res)
next()
})
app.use('/', (req, res, next) => {
session.filter(req, db.session, next)
})
//页面
//访问主页时,依据是否登陆展示不同内容
app.get('/', (req, res) => {
if(req.username) {
res.writeHead(200, {'Content-Type': 'text/html'})
res.end(fs.readFileSync('view/user.html'))
} else {
res.writeHead(200, {'Content-Type': 'text/html'})
res.end(fs.readFileSync('view/welcome.html'))
}
})
//访问登陆页
app.get('/login', (req, res) => {
res.writeHead(200, {'Content-Type': 'text/html'})
res.end(fs.readFileSync('view/login.html'))
})
//提交登陆信息
app.post('/login', (req, res) => {
req.on('data', (chunk) => {
const data = querystring.parse(chunk.toString())
db.user.findOne({
_id: data.username
}, (doc) => {
if(doc.pwd === data.pwd) {
session.login(req, db.session, doc._id, () => {
res.writeHead(301, {'Location': '/'})
res.end()
})
} else {
res.writeHead(200, {'Content-Type': 'text/html'})
res.write('密码错误')
res.end(fs.readFileSync('view/login.html'))
}
}, () => {
res.writeHead(200, {'Content-Type': 'text/html'})
res.write('用户不存在')
res.end(fs.readFileSync('view/login.html'))
})
})
})
//访问注册页
app.get('/join', (req, res) => {
res.writeHead(200, {'Content-Type': 'text/html'})
res.end(fs.readFileSync('view/join.html'))
})
//提交注册信息
app.post('/join', (req, res) => {
req.on('data', (chunk) => {
const data = querystring.parse(chunk.toString())
db.user.add({
_id: data.username,
pwd: data.pwd
}, (doc) => {
//TODO: 注册成功
session.login(req, db.session, doc._id, () => {
res.writeHead(301, {'Location': '/'})
res.end()
})
}, () => {
//TODO: 注册失败
res.writeHead(200, {'Content-Type': 'text/html'})
res.write('用户已存在')
res.end(fs.readFileSync('view/join.html'))
})
})
})
app.listen(SERVER_CONFIG.port, SERVER_CONFIG.host)
这样我们就跑通了注册登陆功能,运行服务器,分别检查注册、登陆、记住登陆状态等信息。