Zijue/blog

15.cookie、session、jwt

Opened this issue · 0 comments

Zijue commented

cookie、session、jwt介绍

cookie、session、jwt都是为了解决http无状态下,服务器对客户端的身份识别设计的。它们的特点如下:

cookie

  • cookie是在http header中设置(不宜过大,设置过大可能会造成页面白屏);
  • cookie特点:可以通过浏览器添加cookie,服务端也可以设置cookie;每次请求都会携带cookie(每次请求都携带,比较浪费流量,所以需要合理设置);
  • cookie默认不能跨域(两个完全不同的域名),但是父子域名是可以设置的(子域名能拿到父域名中的数据);cookie存在前端里,所以不存在安全可言,cookie是可以被更改的;

session

  • session是基于cookie的;
  • session存储在服务器中,默认浏览器是拿不到的;session可以存放数据原则上没有上线,而且安全;
  • session默认都是存在内存中(如果服务器宕掉了,session就丢失了),所以现在基本都用redis数据库存储session;

jwt

  • jwt方案:服务器根据用户提供的信息生成一个令牌。浏览器每次请求都会带上令牌和信息,服务器会用提供的信息再次生成令牌做对比(里面不能存放隐私)。

cookie

我们使用koa创建服务器并设置cookie,代码如下:

const Koa = require('koa');
const Router = require('@koa/router');

const app = new Koa();
const router = new Router();

router.get('/write', async function (ctx) {
    ctx.res.setHeader('Set-Cookie', ['name=zijue', 'age=18']);
    ctx.body = 'ok'
})

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, function () {
    console.log('server start 3000');
});

启动服务器后,在浏览器中请求:127.0.0.1/writeF12查看cookie:

如上图所以,我们成功设置了cookie。其中domain字段可以设置子域名获取父域名的cookie。例如:

  1. 修改主机/etc/hosts文件,添加两条127.0.0.1的自定义域名映射
127.0.0.1       a.test.zijue.cn
127.0.0.1       b.test.zijue.cn
  1. 对某个cookie键值对设置domain
const Koa = require('koa');
const Router = require('@koa/router');

const app = new Koa();
const router = new Router();

router.get('/write', async function (ctx) {
    ctx.res.setHeader('Set-Cookie', ['name=zijue', 'age=18; domain=.test.zijue.cn']);
    ctx.body = 'ok'
});

router.get('/read', async function(ctx){
    ctx.body = ctx.req.headers['cookie'] || 'empty';
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, function () {
    console.log('server start 3000');
});

首先请求a.test.zijue.cn:3000/write路径设置cookie,可以看见age字段的domain值为.test.zijue.cn

接下来我们访问b.test.zijue.cn:3000/read,发现虽然a.test.zijue.cnb.test.zijue.cn域名不同,但是b.test.zijue.cn.test.zijue.cn的子域,所以可以拿到age字段的cookie。


cookie中httpOnly字段设置为true表示只能由浏览器获取,不能通过js脚本获得(document.cookie);


cookie中exipres/max-age字段表示cookie的存活时间,默认为session(浏览器关闭就销毁)。

将cookie操作封装成中间件(Koa自带,此处自定义理解原理)

const Koa = require('koa');
const Router = require('@koa/router');
const querystring = require('querystring');

const app = new Koa();
const router = new Router();

app.use(async (ctx, next) => {
    const cookies = [];
    ctx.myCookies = {
        set(key, value, options = {}) {
            let opts = [];
            if (options.domain) {
                opts.push(`domain=${options.domain}`)
            }
            if (options.httpOnly) {
                opts.push(`httpOnly=${options.httpOnly}`)
            }
            if (options.maxAge) {
                opts.push(`max-age=${options.maxAge}`)
            }

            cookies.push(`${key}=${value}; ${opts.join('; ')}`);
            ctx.res.setHeader('Set-Cookie', cookies)
        },
        get(key) {
            let cookieObj = querystring.parse(ctx.req.headers['cookie'], '; ');
            return cookieObj[key];
        }
    }
    return next();
})

router.get('/write', async function (ctx) {
    ctx.myCookies.set('name', 'zijue', {
        domain: '.test.zijue.cn',
        httpOnly: true
    });
    ctx.myCookies.set('age', '18');

    ctx.body = 'ok'
});

router.get('/read', async function (ctx) {
    ctx.body = ctx.myCookies.get('name');
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, function () {
    console.log('server start 3000');
});

我们知道cookie是可以改的,那么就存在很大的安全风险。所以为了校验cookie中的数据是否被修改,我们会采用加盐的方式校验。思路就是对需要校验的数据做加盐处理生成一个签名摘要并返回,等下一次请求来时进行验证,对比签名摘要确定数据是否修改,具体实现如下:

const Koa = require('koa');
const Router = require('@koa/router');
const querystring = require('querystring');
const crypto = require('crypto');

const app = new Koa();
const router = new Router();

const secret = 'zijue-secret'; // 秘钥,也就是加的盐

// 当浏览器请求时,会处理cookie中传递的值,将base64中的= + /忽略掉,所以我们传递前需要对这些值进行处理
const toBase64URL = (str) => {
    return str.replace(/\=/g, '').replace(/\+/g, '-').replace(/\//, '_');
}

app.use(async (ctx, next) => {
    const cookies = [];
    ctx.myCookies = {
        set(key, value, options = {}) {
            let opts = [];
            if (options.domain) {
                opts.push(`domain=${options.domain}`)
            }
            if (options.httpOnly) {
                opts.push(`httpOnly=${options.httpOnly}`)
            }
            if (options.maxAge) {
                opts.push(`max-age=${options.maxAge}`)
            }
            if (options.signed) {
                let sign = crypto.createHmac('sha1', secret).update([key, value].join('=')).digest('base64');
                sign = toBase64URL(sign);
                cookies.push(`${key}-sign=${sign}`);
            }

            cookies.push(`${key}=${value}; ${opts.join('; ')}`);
            ctx.res.setHeader('Set-Cookie', cookies)
        },
        get(key, options = {}) {
            let cookieObj = querystring.parse(ctx.req.headers['cookie'], '; ');
            if (options.signed) {
                // 先获取上一次的签名
                let lastSign = cookieObj[`${key}-sign`];
                // 再次摘要传递的键值对获取新的签名
                let sign = toBase64URL(crypto.createHmac('sha1', secret).update([key, cookieObj[key]].join('=')).digest('base64'));
                if (sign == lastSign) {
                    return cookieObj[key];
                } else {
                    throw new Error('cookie被篡改')
                }
            }
            return cookieObj[key] || '';
        }
    }
    return next();
})

router.get('/write', async function (ctx) {
    ctx.myCookies.set('name', 'zijue', {
        domain: '.test.zijue.cn',
        httpOnly: true
    });
    ctx.myCookies.set('age', '12', { signed: true });

    ctx.body = 'ok'
});

router.get('/read', async function (ctx) {
    ctx.body = ctx.myCookies.get('age', { signed: true });
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, function () {
    console.log('server start 3000');
});

其中需要注意的是:浏览器请求时,会将传递的数据中base64= + /忽略(Base64URL 算法),所以我们需要进行处理。

session

虽然我们对cookie数据签名验证,但是依旧有可能被找到规律破解,同时大量敏感数据存储在前端也不太好,为了解决这个问题就需要用到session了。session可以类比于一家店,第一次请求店家会给你发放一个会员卡,之后每次请求操作都通过会员卡核实身份消费,这样就大大的提高了安全性。原理代码如下:

const Koa = require('koa');
const Router = require('@koa/router');
const uuid = require('uuid');

const app = new Koa();
const router = new Router();

let session = {}; //session可以理解为一个服务器记账的本子,为了稍后能通过这个本子找到具体信息

router.get('/consume', async function (ctx) {
    let hasVisit = ctx.cookies.get(cardName, { signed: true });
    if (hasVisit && session[hasVisit]) {
        session[hasVisit].mny -= 100;
        ctx.body = '恭喜你消费了 ' + session[hasVisit].mny
    } else {
        const id = uuid.v4();
        session[id] = { mny: 500 };
        ctx.cookies.set(cardName, id, { signed: true });
        ctx.body = '恭喜你已经是本店会员了 有500元'
    }
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, function () {
    console.log('server start 3000');
});

Koa本身并没有实现session,需要通过安装第三方包实现。

jwt

使用session虽然安全性得到了保障,但是扩展性不行。当代码运行在服务器集群上时,session共享就是一个问题,而且因为session基于cookie,无法跨域,所以解决单点登录问题也很麻烦。为了解决这些问题,我们有一种更好的方式JWT。服务器不保存数据,所有数据都保存在客户端,每次请求都发回服务器。

  • jwt的原理
    JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样:
{
  "name": "zijue",
  "role": "admin",
  "expires": "xxxx-xx-xx"
}

以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(详见后文)。

  • jwt的构成
    jwt由Header(头部)、Payload(负载)、Signature(签名)三部分通过.连接组成的字符串Header.Payload.Signature,实际样式如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
  1. Header
{
  "typ": "JWT",
  "alg": "HS256"
}

alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT。最后,将上面的 JSON 对象使用 Base64URL 算法转成字符串;

  1. Payload
    Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段:
iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号

除了官方字段,你还可以在这个部分定义私有字段,如下:

{
  "name": "zijue",
  "role": "admin"
}

这个 JSON 对象也要使用 Base64URL 算法转成字符串。注意:base64是可以反解的,所以不要把敏感信息放到此处。

  1. Signature
    Signature 部分是对前两部分的签名,防止数据篡改。通过Header里面提供的签名算法(默认是 HMAC SHA256)和服务器指定的secret(保证只有服务器知道,不能泄露给用户),采用如下的计算公式得到签名:
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

搞清楚JWT的组成和原理后,我们自己来实现一下此逻辑:

const Koa = require('koa');
const Router = require('@koa/router');
const crypto = require('crypto');

const app = new Koa();
const router = new Router();
const secret = 'zijue-secret';

const jwt = { // 仅展示原理,部分逻辑写死
    header: {
        'typ': 'JWT',
        'alg': 'HS256',
    },
    toBase64Url(str) { // 将base64中的=、+、\替换成base64Url规定值
        return str.replace(/\=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
    },
    toBase64(content) {
        return this.toBase64Url(Buffer.from(JSON.stringify(content)).toString('base64'));
    },
    base64UrlUnescape(str) { // 将base64Url反解为base64
        /**
         * new Array(2) ==> [empty*3]
         * [empty*3].join('=') ==> '=='
         */
        str += new Array(5 - str.length % 4).join('='); // 末尾补=
        return str.replace(/-/g, '+').replace(/_/g, '/');
    },
    sign(content, secret) { // 将header、payload生成签名的方法
        return this.toBase64Url(crypto.createHmac('sha256', secret).update(content).digest('base64'));
    },
    encode(payload, secret) { // 生成token方法
        let header = this.toBase64(this.header);
        let content = this.toBase64(payload);
        let sign = this.sign([header, content].join('.'), secret);
        return [header, content, sign].join('.');
    },
    decode(token, secret) { // 解析token方法,未做过期时间校验
        let [header, payload, sign] = token.split('.');
        let newSign = this.sign([header, payload].join('.'), secret);
        if (newSign == sign) {
            return JSON.parse(Buffer.from(this.base64UrlUnescape(payload), 'base64').toString());
        } else {
            throw new Error('数据被篡改');
        }
    }
}

router.get('/login', async (ctx, next) => {
    let payload = {
        'id': '31914',
        'name': 'zijue',
        'exp': new Date(Date.now() + 15 * 60 * 1000).toUTCString() // 令牌过期时间
    }
    // 生成令牌token;数据不宜过大,一般情况下放id即可
    let token = jwt.encode(payload, secret);
    ctx.body = {
        err: 0,
        data: {
            payload,
            token
        }
    }
});

router.get('/validate', async (ctx, next) => {
    try {
        // jwt规范 Authorization: Bearer <token>;此处直接 Authorization: <token> 替代
        let token = ctx.get('Authorization'); // 将token放在请求头中,有效避免跨域的问题,是一种优雅的方式
        let payload = jwt.decode(token, secret);
        ctx.body = {
            err: 0,
            data: {
                payload
            }
        }
    } catch (e) {
        ctx.body = {
            err: 1
        }
    }
});

app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, function () {
    console.log('server start at 3000')
});

引用