Cookie、Session、JWT在koa中的應用及實現原理

Echoyya、發表於2021-08-04

Cookie

  • HTTP協議是無狀態的,但在WEB應用中,在多個請求之間共享會話是非常必要的,所以出現了Cookie
  • cookie是為了辯別使用者身份,進行會話跟蹤而儲存在客戶端上的資料

伺服器設定cookie:客戶端第一次訪問伺服器時,會通過響應頭向客戶端傳送Cookie,屬性之間用分號空格分隔

客戶端接收並儲存cookie:客戶端再請求伺服器時,會攜帶Cookie至伺服器端,而cookie本身就是一個請求的header

重要屬性

通過修改本地hosts檔案,模擬兩個不同的域名。

# hosts
127.0.0.1   a.echoyya.com
127.0.0.1   b.echoyya.com
屬性 說明
name=value 鍵值對,可以設定要儲存的 Key/Value
Domain 針對某個域名生效 可以跨父域和子域,預設是當前域名
expires/max-age cookie存活時間 ,expires 絕對時間, max-age 相對時間 單位秒
secure 當 secure 值為 true 時,cookie 在 HTTP 中是無效,只在https下生效
Path 表示 cookie 影響到的路徑,預設是/都能被訪問到。若路徑不匹配時,瀏覽器則不傳送這個Cookie
httpOnly 表示瀏覽器無法通過程式碼來獲取,防止XSS攻擊,但是可以通過手動修改控制檯方式進行更改。

實現原理

npm install koa koa-router

const Koa = require('koa');
const Router = require('koa-router');
const querystring = require('querystring')  // 用於解析和格式化網址查詢字串

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

//  koa 操作cookie 實現原理
app.use(async (ctx, next) => {
  // 擴充套件一個設定cookie的方法
  let cookieArr = [];
  ctx.req.getCookie = function (key) {
    let cookies = ctx.req.headers['cookie']; // name=xx; age=yy => name=xx&age=yy
    let cookieObj = querystring.parse(cookies,'; ')
    return cookieObj[key] || ''
  }
  ctx.res.setCookie = function (key, value, options={}) {
    let args = []; // 每個cookie 屬性集合
    options.domain && args.push(`domain=${options.domain}`);
    options.maxAge && args.push(`max-age=${options.maxAge}`);
    options.httpOnly && args.push(`httpOnly=${options.httpOnly}`);
    options.path && args.push(`path=${options.path}`);

    cookieArr.push(`${key}=${value}; ${args.join('; ')}`);
    ctx.res.setHeader('Set-Cookie', cookieArr);  // 字串陣列
  }
  await next();
})
 
router.get('/read', async (ctx, next) => {

   // 自己封裝
   ctx.body = ctx.req.getCookie('name') || 'empty';

  // koa 實現
  // ctx.body = ctx.cookies.get('name') || 'empty';

  // 原生用法
  // ctx.body = ctx.req.headers['cookie'] || 'empty'; // 請求頭
})
router.get('/write', async (ctx, next) => {
  
  // 自己封裝
  ctx.res.setCookie('name', 'nn', {domain: '.echoyya.com'});  // 限制可訪問的域名
  ctx.res.setCookie('age', '12', {httpOnly: true,path:'/write'});  // 限制可訪問的路徑

  // koa 實現
  // ctx.cookies.set('name', 'nn', {domain: '.echoyya.com'});
  // ctx.cookies.set('age', '12', {httpOnly: true,path:'/write'});

  // 原生用法
  // ctx.res.setHeader('Set-Cookie','name=yy'); 
  // ctx.res.setHeader('Set-Cookie','age=15'); // 設定一個cookie,再次set cookie 會將上一次的覆蓋
  // ctx.res.setHeader('Set-Cookie', ['name=yy; domain=.echoyya.com', 'age=15; path=/; max-age=10; httpOnly=true']); // 設定多個cookie時,可接受一個字串陣列

  ctx.body = 'write ok'; 
})

app.use(router.routes())
app.listen(4000);

cookie簽名實現原理

cookie通常由伺服器產生,存在客戶端,隨著每次請求傳送至伺服器端,而前端儲存資料可以被使用者手動篡改,

因此可以給cookie簽名使其相對安全, 根據cookie的內容產生一個標識,保留原有內容,每次請求檢驗簽名,新增一個配置{ signed: true }

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();

app.keys = ['ya'];
// base64Url 需要特殊處理 + = /
const sign = value => crypto.createHmac('sha1',app.keys.join('')).update(value).digest('base64').replace(/\+/g,'-').replace(/\=/g,'').replace(/\//g,'_');
app.use(async (ctx, next) => {
  let cookieArr = [];   
  ctx.req.getCookie = function (key, options = {}) {
    let cookies = ctx.req.headers['cookie'];  
    let cookieObj = querystring.parse(cookies,'; ')
     
    if(options.signed){
      // 傳遞過來的簽名,和最新計算獲得的結果一直,則說明未被修改
      if(cookieObj[key + '.sig'] === sign(`${key}=${cookieObj[key]}`)){
        return cookieObj[key];
      }else {
        return ''
      }
    }
    return cookieObj[key] || ''
  }
  ctx.res.setCookie = function (key, value, options = {}) {
    let args = [];  
    let keyValue = `${key}=${value}`
    options.domain && args.push(`domain=${options.domain}`);
    options.maxAge && args.push(`max-age=${options.maxAge}`);
    options.httpOnly && args.push(`httpOnly=${options.httpOnly}`);
    options.path && args.push(`path=${options.path}`);
    options.signed && cookieArr.push(`${key}.sig=${sign(keyValue)}`);   // 是否開啟cookie簽名

    cookieArr.push(`${keyValue}; ${args.join('; ')}`);
    ctx.res.setHeader('Set-Cookie', cookieArr); // 字串陣列
  }
  await next();
})

// app.keys required for signed cookies
router.get('/visit', async (ctx, next) => {
  // Koa 實現
  // let count = ctx.cookies.get('visit',{ signed: true }) || 0;
  // let visitCount = Number(count) + 1;
  // ctx.cookies.set('visit', visitCount, { signed: true });
  // ctx.body = `you visit ${visitCount}`

  // 自己封裝
  let count = ctx.req.getCookie('visit', { signed: true }) || 0;
  let visitCount = Number(count) + 1;
  ctx.res.setCookie('visit', visitCount, { signed: true });
  ctx.body = `ya visit: ${visitCount}`

})

app.use(router.routes())
app.listen(3000);

注意事項

  • 可能被客戶端篡改,使用前驗證簽名的合法性
  • 不要儲存敏感資料,比如使用者密碼,賬戶餘額
  • 每次請求都會自動攜帶cookie,儘量減少cookie的體積
  • 設定正確的domain和path,減少資料傳輸

Session

是另一種記錄客戶狀態的機制,不同的是Cookie儲存在客戶端瀏覽器中,而session儲存在伺服器
在伺服器儲存使用者對應的資訊,伺服器可以儲存敏感資訊,而session本身是基於cookie的且比cookie安全

同時session 沒有持久化功能,需要配合資料庫或者redis使用

實現原理

npm install uuid

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

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

app.keys = ['ya']

const session = {}; // 用來儲存使用者和資訊的對映關係,對瀏覽器不可見
const cardName = 'connect_sig'; 
router.get('/cut', async (ctx, next) => {
  let id = ctx.cookies.get(cardName, {signed:true});
  if(id && session[id]){
    session[id].mny -= 20;
    ctx.body = `mny:` + session[id].mny;
  }else{
    let cardId = uuid.v4();
    session[cardId] = { mny: 500 };
    ctx.cookies.set(cardName, cardId,{httpOnly:true,signed:true});   // cookie中只存一個標識,並沒有真實的資料
    ctx.body = `mny 500`;
  }
})
app.use(router.routes())
app.listen(3000);

JWT

JSON Web Token(JWT)是目前最流行的跨域身份驗證解決方案,JWT 預設是不加密的,任何人都可以讀到,所以不要把重要資訊放在這個部分。
解決問題:session不支援分散式架構,無法支援橫向擴充套件,只能通過資料庫來儲存會話資料實現共享。如果持久層失敗會出現認證失敗。

優點:伺服器不儲存任何會話資料,即伺服器變為無狀態,使其更容易擴充套件。

使用方式

  1. HTTP 請求的頭資訊Authorization欄位裡面 Authorization: Bearer <token>
  2. 如果是post請求也可以放在請求體中,取決於後端採用哪種認證方式
  3. 通過url傳輸 http://www.xxx.com/pwa?token=xxxxx,但是一般不建議這樣使用,因為會存在連線分享導致安全隱患

組成

JWT包含了使用.分隔的三部分 Header.Payload.Signature
1. Header 頭部

{ "alg": "HS256", "typ": "JWT"}   
// algorithm => HMAC SHA256
// type => JWT

2. Payload內容 JWT 規定了7個官方欄位

iss (issuer):簽發人
exp (expiration time):過期時間
sub (subject):主題
aud (audience):受眾
nbf (Not Before):生效時間
iat (Issued At):簽發時間
jti (JWT ID):編號

3. Signature 簽名

對前兩部分的簽名,防止資料篡改 HMACSHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)

實際應用

npm install koa-bodyparser jwt-simple

const Koa = require('koa');
const Router = require('koa-router');
const bodyParser = require('koa-bodyparser');
const jwt = require('jwt-simple');
const app = new Koa();
const router = new Router();
app.use(bodyParser())

// 登陸
router.post('/login', async (ctx, next) => {
  let { username,  password } = ctx.request.body;
  if (username == 'admin' && password == 'admin') {
    // let token = jwt.encode(username,'ya');  // jwt-simple 實現
    let token = myJwt.encode(username,'ya');   // 自己實現
    ctx.body = {
      code: 200,
      data: {
        token,
        username
      }
    }
  }
})
// 驗證是否有許可權
router.get('/validate', async (ctx, next) => {
  let authorization = ctx.get('authorization');
  if(authorization){
    let [,token] = authorization.split(' ');
    try{
      // let r = jwt.decode(token,'ya');   // jwt-simple 實現
      let r = myJwt.decode(token,'ya');    // 自己實現
      ctx.body = {
        code:200,
        data:{
          username:r
        }
      }
    }catch{
      ctx.body = {
        code:401,
        data:'token已失效'
      }
    }
  }
})

app.use(router.routes())
app.listen(3000);

實現原理

// token組成部分為為三段,1,固定格式表示型別  2,內容 3 簽名
// 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.ImFkbWluIg.xJ0xCP2SXSaJSC-Q1PXuByHdJlBUHCNjdGRU4XW0abU'
const myJwt = {
  sign(str,secret){
    str = require('crypto').createHmac('sha256',secret).update(str).digest('base64');
    return this.toBase64Escape(str);
  },
  toBase64(content){   // 物件轉base64 需要先轉為buffer => base64
    let source = typeof content === 'string' ? content : JSON.stringify(content);
    return this.toBase64Escape(Buffer.from(source).toString('base64'));
  },
  toBase64Escape(base64){
    return base64.replace(/\+/g,'-').replace(/\//g,'_').replace(/\=/g,'');
  },
  encode(username, secret){   // 轉為base64並不是為了安全,只是為了可以在網路中傳輸
    let header = this.toBase64({typ:'JWT',alg:'HS256'});
    let content = this.toBase64(username);
    let sign = this.sign([header, content].join('.'),secret)
    return header + '.' + content  + '.' + sign
  },
  base64urlUnescape(str){
    str += new Array(5 - str.length % 4).join('=');
    return str.replace(/\-/g, '+').replace(/_/g, '/');
  },
  // 相同的內容生成的簽名相同,可以新增一些過期時間等資訊 
  // 通過內容生成了一個簽名,反之通過校驗簽名。即可得知內容是否發生改變
  decode(token,secret){
    let [header, content, sign] = token.split('.');
    let newSign = this.sign([header, content].join('.'),secret);
    if(sign === newSign){   // 此時內容line2中的資料一定是可靠的
      return Buffer.from(this.base64urlUnescape(content),'base64').toString();
    }else{
      throw new Error('token已失效')
    }
  }
}

前端儲存方式 cookie session localStorage sessionStorage token 區別

  • cookie特點可以每次請求的時候自動攜帶,可以實現使用者登入功能. 使用cookie來識別使用者,1.如果單純的使用cookie,不建議存放敏感資訊,如果被劫持到。(cookie是存在客戶端,並不安全,使用者可以自行篡改)2.每個瀏覽器一般對請求頭都有大小限制 cookie 不能大於4k,如果cookie過大,會導致頁面白屏。 每次訪問伺服器都會浪費流量(合理設定cookie);
  • session:在伺服器儲存使用者對應的資訊,伺服器可以儲存敏感資訊,而session本身是基於cookie的且比cookie安全;
  • localStorage:關掉瀏覽器資料依然存在,除非手動清楚,有大小限制約5M,傳送請求不會攜帶;
  • sessionStorage:頁面不關閉就不會銷燬 (用途:如單頁應用訪問時儲存滾動條地址)
  • token -> jwt -> jsonwebtoken 不需要伺服器儲存,沒有跨域限制,不建議儲存敏感資訊

相關文章