項目中是使用Koa的搭建服務(wù)器。
端口配置放在.env文件中,上傳時應(yīng)該忽略該文件,因為每個設(shè)備有自己的端口號。
這里使用dotenv來加載env文件。
登錄憑證
web開發(fā)中,使用最多的就是http協(xié)議,但http是一個無狀態(tài)的協(xié)議。也就是每一次http請求都是一個獨立的請求,并不知道之前的狀態(tài)。比如我們登錄成功之后,再去調(diào)用其他接口獲取信息時會發(fā)送一個新的請求,而這次的請求并不知道用戶是否已經(jīng)登錄過。
所以登錄成功之后服務(wù)器需要返回給客戶端一個登錄憑證,下次請求時拿著登錄憑證證明該用戶已經(jīng)登錄。
常見的登錄憑證:
cookie+session
Token令牌
Cookie
cookie類型為小型文本文件,某些網(wǎng)站為了辨別用戶身份而存儲在用戶本地終端的數(shù)據(jù)。瀏覽器會在特定情況下攜帶上cookie來發(fā)送網(wǎng)絡(luò)請求。
cookie總是抱存在客戶端,按照在客戶端的存儲位置可以分為內(nèi)存coolie和硬盤cookie
內(nèi)存cookie由瀏覽器維護,保存在內(nèi)存中,瀏覽器關(guān)閉時cookie會消失,存在時間是短暫的
硬盤cookie保存在硬盤中,有一個過期時間,手動清除或者到了過期時間才會消失
如何判斷一個cookie是內(nèi)存cookie還是硬盤cookie?
沒有設(shè)置過期時間,默認是內(nèi)存cookie
有設(shè)置過期時間,并且過期時間不為0或者負數(shù)的cookie是硬盤cookie,需要手動或者到期時才會刪除
cookie的生命周期
默認情況下cookie是內(nèi)存cookie,也稱之為會話cookie。可以設(shè)置expires或者max-age來設(shè)置過期時間:
expires:設(shè)置的是Date.toUTCString(),設(shè)置格式是:expires=date-in-GMTString-format
max-age:設(shè)置過期的時間:max-age=max-age-in-seconds(例如一年為60 * 60 * 24 * 365)
cookie的作用域
cookie的作用域也就是允許cookie發(fā)送給哪些URL
-
Domain:指定哪些主機可以接收cookie
如果不指定,默認為origin,不包括子域名
如果指定Domain,則包含子域名。比如,如果設(shè)置Domain=mozilla.org, 則cookie也包含在子域名中(比如develop.mozilla.org)
-
Path:指定主機下哪些路徑可以接收cookie
- 比如設(shè)置Path=/docs,則/docs、/docs/web/、/docs/web/http都會匹配到
設(shè)置cookie
cookie可以由客戶端設(shè)置,也可以由服務(wù)端設(shè)置。一般都是由服務(wù)器設(shè)置cookie,客戶端來刪除cookie
谷歌瀏覽器查看cookie的方法:F12進入控制臺 => Application => Storage => Cookies
客戶端設(shè)置cookie
// document.cookie= 'name=coderwy';
// 5s之后刪除cookie
document.cookie = 'age=18;max-age=5'
服務(wù)端設(shè)置cookie
testRouter.get('/test', (ctx, next) => {
// 設(shè)置cookie
ctx.cookies.set('name','Lucy',{
maxAge:5 * 1000 // 單位是毫秒
})
ctx.body = "test";
})
服務(wù)端獲取cookie
// domain默認為origin 所以同域名下可以獲取到cookie
testRouter.get('/demo', (ctx, next) => {
// 獲取cookie
const value = ctx.cookies.get('name')
ctx.body = '你的cookie是:' + value
})
瀏覽器在發(fā)送請求時,會自動將cookie添加到請求頭中

Session
session是基于cookie來實現(xiàn)的,在koa項目中可以借助koa-session來實現(xiàn)session認證
const session = Session({
key:'sessionid',
maxAge:10 * 1000,
signed:false // 暫時設(shè)置為不需要簽名
}, app)
app.use(session)
// 登錄接口
testRouter.get('/test', (ctx, next) => {
// 假設(shè)用戶通過name和password登錄 登錄成功之后從數(shù)據(jù)庫查詢
// 得到id和name
const id = 110
const name = 'Lucy'
// 設(shè)置session
// 因為有app.use(session)操作 所有有session這個屬性
ctx.session.user = {id, name}
ctx.body = "test";
})

其中Value是我們設(shè)置的session.user的base64的編碼??梢钥吹絪ession本質(zhì)上還是一個cookie
獲取session:
// 獲取session
testRouter.get('/demo', (ctx, next) => {
console.log(ctx.session.user); // { id: 110, name: 'lucy' }
ctx.body = 'demo'
})
由于瀏覽器會自定在請求時帶上cookie,也就是上面的sessionid和其value,服務(wù)器這邊會對其進行解析,最終獲得登錄憑證(id: 110, name: 'lucy')
現(xiàn)在來看下使用簽名的情況:
const session = Session({
key:'sessionid',
maxAge:30 * 1000,
signed:true // 使用加密簽名
}, app)
app.keys = ['aaaaa']
app.use(session)
此時設(shè)置的session內(nèi)容為:

在我們獲取session時能正常獲取,而當我們修改了sessionid對應(yīng)的value時,就不會獲取到對應(yīng)的信息

這里是隨便修改的value,但我們知道value是根據(jù)id和name的內(nèi)容轉(zhuǎn)成的base64,如果別人也通過其他的id來生成base64來修改value的話,服務(wù)端也是能獲取到對應(yīng)的登錄憑證的,所以需要進行加密簽名
此時獲取session時:
// 獲取session
testRouter.get('/demo', (ctx, next) => {
console.log(ctx.session.user);
// 判斷session內(nèi)容
// ...
ctx.body = 'demo'
})
此時打?。?/p>
undefined
token
cookie和session的方式有很多缺點:
- cookie會附加在每個http請求中,無形中增加了流量
- cookie是明文傳輸?shù)?,所以存在安全性問題
- cookie的大小限制為4kb,對于復(fù)雜的需求是不夠的
- 對于瀏覽器其他的客戶端(比如iOS,Android)需要手動設(shè)置cookie
- 對于分布式系統(tǒng)和服務(wù)器集群中如何可以保證其他系統(tǒng)也可以正確的解析session?
所以目前的前后端分離的開發(fā)過程中,經(jīng)常使用token進行身份驗證:
token也就是令牌,在驗證了用戶賬號密碼正確的情況下,給用戶頒發(fā)一個令牌,這個令牌作為用戶訪問其他接口或者資源的憑證。
JWT實現(xiàn)Token機制
JWT實現(xiàn)Token
JWT(Json Web Token)生成的Token由三部分組成:
- header
- alg:采用的加密算法。默認是HMAC SHA256(也就是HS256,一種對稱加密算法),采用同一個密鑰進行加密解密
- typ:JWT。固定值,通常寫成JWT即可
- 會通過base64Url算法對上面兩部分進行編碼,生成一串字符串(也就是header部分了);
- payload
- 攜帶的數(shù)據(jù),比如可以講用戶的id和name放到payload中
- 默認會攜帶iat(issued at),令牌簽發(fā)時間
- 也可以設(shè)置過期時間:exp(expiration time)
- 通過base64Url算法對攜帶的數(shù)據(jù)進行編碼
- signature:因為通過進行base64Url編碼的結(jié)果很容易被反編碼的。所以除了header和payload之外還需要簽名
- 設(shè)置一個secretKey,然后將前兩個的結(jié)果和secretKey進行HS256算法的加密:
HS256(baseUrl(header) + . + baseUrl(payload), secretKey);- 所以secretKey暴露是一件非常危險的事情,因為之后就可以模擬頒發(fā)token,也可以解密token。

const Koa = require('koa')
const Router = require('koa-router')
const jwt = require('jsonwebtoken')
const app = new Koa()
const testRouter = new Router()
const SECRET_KEY = 'secretkey'
// 登錄接口
testRouter.post('/test', (ctx, next) => {
const payload = {id:119, name:'lwy'}
// 生成token
const token = jwt.sign(payload,SECRET_KEY,{
expiresIn:10
})
// 返回token
ctx.body = token
})
app.use(testRouter.routes())
app.use(testRouter.allowedMethods())
app.listen(8080, () => {
console.log('服務(wù)器啟動成功');
})
使用postman進行請求可以看到返回的token為:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTE5LCJuYW1lIjoibHd5IiwiaWF0IjoxNjA2NzI0MTg1LCJleHAiOjE2MDY3MjQxOTV9.D8q8-8zIZ5CamXi-sSZdWnPoN_yQ-A4Y8zzIYJW77yQ
可以看到,其中是以 . 分隔成三部分的,對應(yīng)的就是上面說的token的組成
接下來就可以通過postman來模擬把token傳遞給服務(wù)端。一種方式是把token放到body里,不過很少這樣使用,一般都是將token放到header中。

可以看到有很多的認證方式,常用的是Bearer(送信人) Token方式
獲取token:
testRouter.get('/demo', (ctx, next) => {
// 獲取token
console.log(ctx.headers.authorization);
})
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTE5LCJuYW1lIjoibHd5IiwiaWF0IjoxNjA2NzI0MTg1LCJleHAiOjE2MDY3MjQxOTV9.D8q8-8zIZ5CamXi-sSZdWnPoN_yQ-A4Y8zzIYJW77yQ
接下來可以獲取到token:
testRouter.get('/demo', (ctx, next) => {
// 獲取token
console.log(ctx.headers.authorization);
const auth = ctx.header.authorization
const token = auth.replace('Bearer ', '')
try {
// 驗證token verify驗證失敗之后會拋出異常
const result = jwt.verify(token, SECRET_KEY)
console.log(result);
ctx.body = result
} catch (error) {
console.log('驗證失?。?, error);
ctx.body = 'token 無效'
}
})
token驗證成功之后的返回:
{
? "id": 119,
? "name": "lwy",
? "iat": 1606874354,
? "exp": 1606884354
}
非對稱加密
前面用到的HS256加密算法是一種對稱加密(維基百科),一旦密鑰暴露是一種很危險的事情。比如在分布式系統(tǒng)中,每一個子系統(tǒng)都需要獲取到密鑰,那么每個子系統(tǒng)既可以發(fā)布令牌,也可以驗證令牌,但對一些資源服務(wù)器來說,只需要有驗證令牌的功能即可。
這個時候就可以使用非對稱加密(維基百科):
私鑰(private key):用于發(fā)布令牌
公鑰(public key):用于驗證令牌
通常我們使用公鑰加密,用私鑰解密。而在數(shù)字簽名中,我們使用私鑰加密(相當于生成簽名),公鑰解密(相當于驗證簽名)
我們可以使用openssl來生成私鑰,然后根據(jù)私鑰生成對應(yīng)的公鑰。mac中自帶的有openssl工具。
比如生成一個密鑰到指定的目錄中:
cd 指定目錄-
openssl:進入到ssl的命令行交互頁面` -
genrsa -out private.key 1024: 生成私鑰-
genrsa: gen是generate的縮寫,rsa是常用的非對稱加密算法 -
-out:代表我們要導(dǎo)出 -
private.key: 導(dǎo)出的文件名(代表我們生成的是私鑰) -
1024:生成的私鑰的長度,也可以搞成其他長度
-

接下來還要生成用于驗證簽名的公鑰:
rsa -in private.key -pubout -out public.key
-
-in private.key:表示將剛剛生成的密鑰作為輸入 -
-pubout:表示這次生成的是公鑰 -
-out:表示我們要導(dǎo)出 -
public.key:導(dǎo)出的文件名

使用非對稱加密生成token:
const PRIVATE_KEY = fs.readFileSync('./keys/private.key')
const PUBLIC_KEY = fs.readFileSync('./keys/public.key')
// 使用非對稱加密
testRouter.post('/test', (ctx, next) => {
const payload = {id:110, name:'lwy'}
// 私鑰用來生成簽名
const token = jwt.sign(payload, PRIVATE_KEY,{
expiresIn:10 * 1000,
algorithm:"RS256" // 指明用到的算法
})
ctx.body = token
})
testRouter.get('/demo', (ctx, next) => {
const auth = ctx.headers.authorization
const token = auth.replace('Bearer ', '')
try {
const res = jwt.verify(token, PUBLIC_KEY,{
algorithms:["RS256"]
})
ctx.body = res
} catch (error) {
console.log('token驗證錯誤:', error);
ctx.body = 'token失效'
}
})
ps: 項目中相對路徑是相對于process.cwd()的,也就是當前項目啟動的目錄