Restful API
REST - 表現層狀態轉換 (Representational State Transfer) 是 Roy Thomas Fielding 博士於 2000 年在他的 UC Irvine 博士論文中提出來的一種全球資網軟體架構風格,目的是方便於不同軟體、程式在網路
(WWW) 中互相傳遞資訊。
REST 是根基於超文字傳輸協定 (HTTP) 之上而確定的一組約束和屬性,也就是說 REST 本身並不是一套標準,而是一套設計風格。現代 API 在製作時,通常採用的設計風格就是 REST。因此,這類型的 API 被稱為是 RESTful APIs。
符合或相容於這種架構風格 (簡稱為 REST 或 RESTful )的網路服務,允許使用者端發出以統一資源標識符 ( URI ) 存取和操作網路資源的請求,而與預先定義好的無狀態操作集一致化。相對於其它種類的網路服務,例如:SOAP 服務,則是以本身所定義的操作集,來存取網路上的資源。
如果我們要將伺服器架構成一種服務 (API),讓任何使用者都可以存取資料,則可以將製作一個 RESTful API:
HTTP Verb
Path
用途
GET
/students
獲得所有學生的資料,
GET
/students/:id
獲得特定的學生資料。
POST
/students
創建一個新的學生。
PUT
/students/:id
修改特定的學生資料。使用者提供的資料會被變成資料庫內的完整新資料。
PATCH
/students/:id
修改特定的學生資料。使用者只需要提供要被修改的資料即可。
DELETE
/students/:id
刪除特定的學生。
*. 因為網頁瀏覽器只能夠送出 GET 以及 POST 這兩種 Request,所以我們可以用 Post man 來測試寄出其他種類的 request。
下載好 Post man 之後,創建一個 working directory:Restful API,並於 Vscode 中開啟。
執行 npm init
執行 npm install express ejs mongoose
在 working directory 中新增一個 models 資料夾存放 student.js 。
const express = require("express");
const app = express();
const mongoose = require("mongoose");
const Student = require("./models/student");
// const cors = require("cors");
mongoose
.connect("mongodb://localhost:27017/examleDB")
.then(() => {
console.log("成功連結 mongoDB....");
})
.catch((e) => {
console.log(e);
});
app.set("view engine", "ejs");
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// app.use(cors());
app.get("/students", async (req, res) => {
try {
let studentData = await Student.find({}).exec();
return res.send(studentData);
} catch (e) {
return res.status(500).send("尋找資料時發生錯誤。。。");
}
});
app.get("/students/:_id", async (req, res) => {
let { _id } = req.params;
try {
let foundStudent = await Student.findOne({ _id }).exec();
return res.send(foundStudent);
} catch (e) {
return res.status(500).send(e);
}
});
app.post("/students", async (req, res) => {
try {
let { name, age, major, merit, other } = req.body;
let newStudent = new Student({
name,
age,
major,
scholarship: { merit, other },
});
let saveStudent = await newStudent.save();
return res.send({
msg: "資料儲存成功",
saveObject: saveStudent,
});
} catch (e) {
return res.status(400).send(e.message);
}
});
app.put("/students/:_id", async (req, res) => {
try {
let { _id } = req.params;
let { name, age, major, merit, other } = req.body;
let newData = await Student.findOneAndUpdate(
{ _id },
{ name, age, major, scholarship: { merit, other } },
{ new: true, runValidators: true, overwrite: true }
// 因為 HTTP put request 要求客戶端提供所有數據,所以
// 我們需要根據客戶端提供的數據,來更新資料庫內的資料
);
res.send({ msg: "成功更新學生資料!", updateData: newData });
} catch (e) {
res.status(400).send(e.message);
}
});
class NewData {
constructor() {}
setProperty(key, value) {
if (key !== "merit" && key !== "other") {
this[key] = value;
} else {
this[`scholarship.${key}`] = value;
}
}
}
app.patch("/students/:_id", async (req, res) => {
try {
let { _id } = req.params;
let newObject = new NewData();
for (let property in req.body) {
newObject.setProperty(property, req.body[property]);
}
let newData = await Student.findByIdAndUpdate({ _id }, newObject, {
new: true,
runValidators: true,
// 不能寫overwrite: true
});
res.send({ msg: "成功更新學生資料!", updatedData: newData });
} catch (e) {
return res.status(400).send(e.message);
}
});
app.delete("/students/:_id", async (req, res) => {
try {
let { _id } = req.params;
let deleteResult = await Student.deleteOne({ _id });
return res.send(deleteResult);
} catch (e) {
console.log(e);
return res.status(500).send("無法刪除學生資料");
}
});
app.listen(3000, () => {
console.log("伺服器正在聆聽 port 3000");
});
const mongoose = require("mongoose");
const { Schema } = mongoose;
const studentSchema = new Schema({
name: {
type: String,
required: true,
minlength: 2,
},
age: {
type: Number,
default: 18,
max: [80, "可能有點太老了喔..."],
min: [0, "年齡不能小於0"],
},
major: {
type: String,
enum: [
"Chemistry",
"Computer Science",
"Mathematics",
"Civil Engineering",
"undecide",
],
},
scholarship: {
merit: {
type: Number,
min: 0,
max: [5000, "學生 merit scholarship太多了"],
default: 0,
},
other: {
type: Number,
min: 0,
default: 0,
},
},
});
const Student = mongoose.model("Student", studentSchema);
module.exports = Student;
RESTful Routing
如果我們的伺服器不提供服務,而是網頁功能,還是可以將網頁伺服器內部的 routing 做成 REST 風格,稱為 RESTful Routing。通常來說,RESTful Routing 遵守以下的表格。當然,每個網站也可以設定自己的 Restful Routing。以下的表格與 Ruby on Rails 框架相同:
HTTP Verb
Path
用途
GET
/students
獲得所有學生的資料。
GET
/students/new
回傳一個包含可以用來新增新學生的表格的網頁。
POST
/students
創建一個新的學生。
GET
/students/:id
獲得特定的學生資料。
GET
/students/:id/edit
回傳一個包含可以用來修改學生資料的表格的網頁。
PUT/PATCH
/students/:id
修改特定的學生資料。
DELETE
/students/:id
刪除特定的學生。
npm install cors
npm install method-override
Middlewares, Cookies, Sessions
Express Middlewares
Express 中的 Middleware (中介軟體) 除了可以放在所有的 routes 之前,也可以放在 route 內部的 path 以及 callbackFn之間。語法是:
app.METHOD(path, middlewareFn, callbackFn);
範例:使用者進入 rout 之後會開始執行 middleware,接著執行 callbackFn
app.get(
"/students",
(req, res, next) => {
console.log("正在執行myMiddleware。。");
next();
},
async (req, res) => {
try {
let studentData = await Student.find({}).exec();
// return res.send(studentData);
return res.render("students", { studentData });
} catch (e) {
return res.status(500).send("尋找資料時發生錯誤。。。");
}
}
);
app.METHOD(path, [middlewareFn, middlewareFn2], callbackFn);
範例:使用者進入 rout 之後會開始執行兩個 middleware,接著執行 callbackFn
app.get(
"/students",
[
(req, res, next) => {
console.log("正在執行myMiddleware。。");
next();
},
(req, res, next) => {
console.log("正在執行myMiddleware2。。");
next();
},
],
async (req, res) => {
try {
let studentData = await Student.find({}).exec();
// return res.send(studentData);
return res.render("students", { studentData });
} catch (e) {
return res.status(500).send("尋找資料時發生錯誤。。。");
}
}
);
Middleware 中的 callbackFn 內可以有三個參數,分別為 req, res, 以及 next。 若我們希望用 middleware來處理錯誤,則可以改用包含四個參數的 callbackFn。四個參數分別為:err, req, res, next (順序不能換)。
在 try catch block 內部,我們可以把 catch() 到的錯誤,用 next() 往 middleware 的方向傳送。此時,我們在 express 的 app.use() 所使用的 callbackFn 則需要四個參數:err, req, res 以及 next。
範例:
app.get("/students", async (req, res, next) => {
try {
let studentData = await Student.find({}).exec();
// return res.send(studentData);
return res.render("students", { studentData });
} catch (e) {
// return res.status(500).send("尋找資料時發生錯誤。。。");
next(e);
}
});
app.use((err, req, res, next) => {
return res.status(400).send(err);
});
express.Router
隨著伺服器的擴大,routes 的數量可能變得非常巨大。此時,將 routes 根據功能分類就變得相當重要。Express.js 提供了express. Router 的功能,讓我們可以將 routes 分門別類。express.Router 的語法為:
const express = require('express');
const router = express. Router();
router.use(....);
router.get('/', (req, res) => { res.send('Birds home page') });
module.exports = router;
首先建立一個資料夾:routes,在 routes 資料夾裡面建立和 route 相關的 js 檔案,例如:student-routes.js、faculty-routes.js。
將 app.js 裡面和 /students 路徑相關的程式碼移至 student-routes.js。
將 app.js 裡面和 /faculty 路徑相關的程式碼移至 faculty-routes.js。
將 student-routes.js 裡面所有的 /students 刪除、app.method 改成 router.method ...。
範例:
const express = require("express");
const router = express.Router();
const Student = require("../models/student");
router.get("/", async (req, res, next) => {
try {
let studentData = await Student.find({}).exec();
// return res.send(studentData);
return res.render("students", { studentData });
} catch (e) {
// return res.status(500).send("尋找資料時發生錯誤。。。");
next(e);
}
});
router.get("/new", (req, res) => {
return res.render("new-student-form");
});
router.get("/:_id", async (req, res, next) => {
let { _id } = req.params;
try {
let foundStudent = await Student.findOne({ _id }).exec();
// return res.send(foundStudent);
if (foundStudent != null) {
return res.render("student-page", { foundStudent });
} else {
return res.status(400).render("student-not-found");
}
} catch (e) {
// return res.status(400).render("student-not-found");
next(e);
}
});
router.get("/:_id/edit", async (req, res) => {
let { _id } = req.params;
try {
let foundStudent = await Student.findOne({ _id }).exec();
// return res.send(foundStudent);
if (foundStudent != null) {
return res.render("edit-student", { foundStudent });
} else {
return res.status(400).render("student-not-found");
}
} catch (e) {
return res.status(400).render("student-not-found");
}
});
router.post("/", async (req, res) => {
try {
let { name, age, major, merit, other } = req.body;
let newStudent = new Student({
name,
age,
major,
scholarship: { merit, other },
});
let saveStudent = await newStudent.save();
//
return res.render("student-save-success", { saveStudent });
} catch (e) {
return res.status(400).render("student-save-fail");
}
});
router.put("/:_id", async (req, res) => {
try {
let { _id } = req.params;
let { name, age, major, merit, other } = req.body;
let newData = await Student.findOneAndUpdate(
{ _id },
{ name, age, major, scholarship: { merit, other } },
{ new: true, runValidators: true, overwrite: true }
// 因為 HTTP put request 要求客戶端提供所有數據,所以
// 我們需要根據客戶端提供的數據,來更新資料庫內的資料
);
// res.send({ msg: "成功更新學生資料!", updateData: newData });
return res.render("student-update-success", { newData });
} catch (e) {
res.status(400).send(e.message);
}
});
class NewData {
constructor() {}
setProperty(key, value) {
if (key !== "merit" && key !== "other") {
this[key] = value;
} else {
this[`scholarship.${key}`] = value;
}
}
}
router.patch("/:_id", async (req, res) => {
try {
let { _id } = req.params;
let newObject = new NewData();
for (let property in req.body) {
newObject.setProperty(property, req.body[property]);
}
let newData = await Student.findByIdAndUpdate({ _id }, newObject, {
new: true,
runValidators: true,
// 不能寫overwrite: true
});
res.send({ msg: "成功更新學生資料!", updatedData: newData });
} catch (e) {
return res.status(400).send(e.message);
}
});
router.delete("/:_id", async (req, res) => {
try {
let { _id } = req.params;
let deleteResult = await Student.deleteOne({ _id });
return res.send(deleteResult);
} catch (e) {
console.log(e);
return res.status(500).send("無法刪除學生資料");
}
});
module.exports = router;
const express = require("express");
const router = express.Router();
router.get("/", (req, res) => {
return res.send("歡迎來到教職員頁面");
});
router.get("/new", (req, res) => {
return res.send("這是新增教職員數據的頁面");
});
module.exports = router;
在主要的 controller (app.js) 內部,則可以寫:
const birds = require('./birds');
// ...
app.use('/birds', birds);
範例:
const express = require("express");
const app = express();
const mongoose = require("mongoose");
// const cors = require("cors");
const methodOverride = require("method-override");
const studentRoutes = require("./routes/student-routes");
const facultyRoutes = require("./routes/faculty-routes");
mongoose
.connect("mongodb://localhost:27017/examleDB")
.then(() => {
console.log("成功連結 mongoDB....");
})
.catch((e) => {
console.log(e);
});
app.set("view engine", "ejs");
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// app.use(cors());
app.use(methodOverride("_method"));
app.use("/students", studentRoutes);
app.use("/faculty", facultyRoutes);
app.use((err, req, res, next) => {
return res.status(400).send(err);
});
app.listen(3000, () => {
console.log("伺服器正在聆聽 port 3000");
});
Cookies
Cookies 是伺服器傳送給瀏覽器,並在客戶端下次訪問同一網站時一同發回的一小段文字。
它幫助該網站保留使用者的偏好設置 (例如:登入帳號、語言、字體大小及其他設定),以便使用者再次訪問該網站或瀏覽該網站的不同網頁時,無需重新填寫那些資料。
Cookie 會被放在客戶端的瀏覽器內部 (例如:在 Chrome 瀏覽器內,點選 Settings,點擊 Privacy and security,再點擊 Cookies and other site data,就可以看到所有的cookies )。
Cookies 是以 key-value pair 的形式儲存於瀏覽器內的。每個 Cookies 都有綁定特定的網站。若網站 A 給我們一個 cookie,則下次我們訪問網站 A 時,這組 cookie 也會被傳送到網站 A 的伺服器。在 Express 的伺服器程式碼當中,設定 cookie 的語法是:
res.cookie(key, value);
下次同個瀏覽器傳送 HTTP request 到我們的伺服器時,我們可以用 cookieParser() 這個 middleware (如果沒使用 cookieParser() 則會獲得 undefined),之後就可以透過 req.cookies 這個屬性來獲取我們的伺服器曾經存在客戶端的資料。
working directory:cookie and session practice
npm init
npm express
獲得 cookieParser 套件:
npm install cookie-parser
範例:
const express = require("express");
const app = express();
const cookieParser = require("cookie-parser");
app.use(cookieParser());
app.get("/", (req, res) => {
return res.send("這是首頁。");
});
app.get("/setCookie", (req, res) => {
res.cookie("yourCookie", "Oreo");
return res.send("已經設置好 Cookie。。。");
});
app.get("/seeCookie", (req, res) => {
return res.send("看一下已經設置好的 Cookie。。。" + req.cookies.yourCookie);
});
app.listen(3000, () => {
console.log("Sever running on port 3000。。。");
});
Cookie 簽名
由於 cookies 可在客戶端的瀏覽器內被自由修改,因此,我們可以在傳送 cookie 之前,幫 cookie 做簽名(sign)。簽名後的 cookie 被稱為 signed cookie。若客戶端對 signed cookie 做修改的話,我們的 Express伺服器可以抓到這個錯誤,並且確認修改過的 cookie 為無效的 cookie。
在 Express 當中,若要對 cookie 做簽名的話,我們需要先下載 cookie parser 並且在 cookieParser() 這個function 內部提供一個參數。此參數為某個秘密 String。在寄送 cookie 之前,我們需要設定 signed 屬性為 true:
res.cookie(key, value, {signed: true});
下次同個瀏覽器傳送 HTTP request 到我們的伺服器時,我們可以用 req.signedCookies 這個屬性獲得cookies 的 key-value pair。
範例:
const express = require("express");
const app = express();
const cookieParser = require("cookie-parser");
app.use(cookieParser("熊貓很可愛")); // function 內部提供一個參數。此參數為某
個秘密 String
app.get("/", (req, res) => {
return res.send("這是首頁。");
});
app.get("/setCookie", (req, res) => {
res.cookie("yourCookie", "Oreo", {signed: true});
return res.send("已經設置好 Cookie。。。");
});
app.get("/seeCookie", (req, res) => {
console.log(req.signedCookies); // { yourCookie: 'Oreo' }
return res.send("看一下已經設置好的 Cookie。。。" + req.signedCookies.yourCookie);
});
app.listen(3000, () => {
console.log("Sever running on port 3000。。。");
});
Cookies and Storage
Cookies 以及 storage (local storage、session storage 的統稱) 的差別在於:
Cookies
Storage
Purpose
伺服器端讀取資料、保留使用者的偏好設置。
運行在用戶端的儲存空間。
HTTP
會隨著 HTTP 请求寄送到伺服器端。
不會隨著 HTTP 請求寄送到伺服器端。
Data Size
對每個網站來說,最大4095 Bytes。
最大5 MB。
Expiration
有可能會過期。
不會過期。
Session
由於 Cookies 能夠儲存的資料量有限,最多不能超過4095 bytes。而且,因為 Cookies 可以被輕易修改,會引發安全性問題。為了解決這些問題,我們可以在伺服器端使用 Sessions。
Session 是在網頁伺服器上的儲存空間。當使用者登入網頁時,伺服器會製作一個 session id,,以及此session id 所相對應的資料。 Session id 會被當作 cookie 送到用戶端。下次用戶端造訪同一網時,Session id 會被以 cookie 的形式送到伺服器,而伺服器使用 session id 找到所相對應的資料,來確認使用者的身份。
如此一來,我們可以解決兩個 cookie 的隱患:
1. 伺服器上的儲存空間不受 4095 bytes 的容量限制。
2. 如果用戶篡改了自己的 session id,有沒有可能冒充其他使用者呢?有可能,但是 session id 通常是非常長的 String,可能性非常的多,比密碼更難猜。
隨意修改 session id 只會造成伺服器無法辨認 session id。若要靠修改 session id 來冒稱他人,與嘗試猜測 session_id 相比,他們嘗試猜測其他人的密碼會更有可能達成冒充他人的意圖。
另外,在發出 session id 之前,我們可以對 session id 簽名。若有人篡改 cookie 內的 session id,我們可以快速識別出來。
在 Express 所架設的伺服器內,若要使用 sessions,則可以使用套件 express-sessions。
npm install express-session
express-sessions的語法:
app.use(session({
secret: 'keyboard cat',
resave: false,
save Uninitialized: false,
cookie: {secure: true}
}))
• secret - 用來幫 session ID 做成的 cookie 簽名。
• resave - 強制將此 session 重新保存回伺服器上的 session 存儲區,即使在上次到本次的 HTTP request 期間,從未修改過此 session。預設值為 true,但建議使用 false。使用 true 的話,可能會在伺服器上導致 race condition。例如:客戶端向伺服器發出兩個並行的 request,則某個 request 對 session 所做的改變會在另一個 request 結束時被覆蓋。
• saveUninitialized - 當 request 送到伺服器時,如果 request 的 header 內部沒有包含 session id 的cookie 的話,伺服器會:(1) 生成獨特的session id、(2) 將 session id 存到 cookie 內,寄給用戶端。(3) 創建一個 empty session object。(4) 根據 saveUninitialized 的值,在 request 結束時,session object 可能會被儲存在伺服器內。
若在 request 的整個生命週期內,session object 都沒有被修改的話,那麼在請求結束時,如果saveUninitialized 為 false 時,則 session object 不會被存在資料庫內。 Uninitialized的意思是指new but not modified。
saveUninitialized 的預設值是 true,但建議使用 false。使用 false 的好處在於,伺服器可以防止在系統中存儲大量 empty session object。由於沒有任何有用的資訊需要用 session 來儲存,session object request 結束時被刪除。
何時會 modify session object 呢?例如:使用者登入系統時, session object 會更新最近一次登入的時間。如果某個使用我們網站的人,只是走走逛逛沒有登入,那麼 session object 從被創建到 request 結束都不會被更改,所以屬於new but not modified,也就是 Uninitialized。
此外,在 saveUninitialized 使用 false 也可以降低伺服器出現 race condition 的情況。
• cookie: { secure: true } - 若設定 secure 為 true,則 cookies 只有在 HTTPs 的協議下才會進行傳輸。任何不安全的傳輸通道上,cookie 都不會被傳遞。
如果我們想要獲得 session id 所相對應的 session data,我們只需要在 Express 當中取得 request object的 session 屬性即可:
req.session
另外,express-sessions 給客戶端設定的 cookie 名稱是 connect.sid,而 value 則是簽名過的 session id。
範例:
const express = require("express");
const app = express();
const cookieParser = require("cookie-parser");
const session = require("express-session");
app.use(cookieParser("熊貓很可愛")); // function 內部提供一個參數。此參數為某個秘密 String
app.use(
session({
secret: "熊貓和貓熊都很可愛",
resave: false,
saveUninitialized: false,
cookie: { secure: false }, // 因為在此使用 localhost,所以設定為 false。設定 secure 為 true,則 cookies 只有在 HTTPs 的協議下才會進行傳輸。
})
);
const checkUser = (req, res, next) => {
if (!req.session.isVerified) {
return res.send("請先登入系統,才能看到秘密。");
} else {
next();
}
};
app.get("/", (req, res) => {
return res.send("這是首頁。");
});
app.get("/setCookie", (req, res) => {
// res.cookie("yourCookie", "Oreo");
res.cookie("yourCookie", "Oreo", { signed: true });
return res.send("已經設置好 Cookie。。。");
});
app.get("/seeCookie", (req, res) => {
console.log(req.signedCookies);
return res.send(
"看一下已經設置好的 Cookie。。。" + req.signedCookies.yourCookie
);
});
app.get("/setSessionData", (req, res) => {
req.session.example = "something not important...";
// console.log(req.session);
return res.send("在伺服器設置 session 資料,在瀏覽器設置簽名後的 session id");
});
app.get("/seeSessionData", (req, res) => {
console.log(req.session); // connect.sid => session id
return res.send("看一下已經設置好的 Session資料。。。");
});
app.get("/verifyUser", (req, res) => {
req.session.isVerified = true;
return res.send("你已經被驗證了。。。");
});
app.get("/secret", checkUser, (req, res) => {
return res.send("秘密是柴犬很可愛。");
});
app.get("/secret2", checkUser, (req, res) => {
return res.send("秘密是駱駝很可愛。");
});
app.listen(3000, () => {
console.log("Sever running on port 3000。。。");
});
環境變數 (environment variable)
直接在程式碼內儲存秘密是一個不好的習慣。通常來說,我們會把秘密存在環境變數內部。環境變數(environment variable) 是一個動態的值,可以影響電腦上運行的程式。它們是正在運行程式的一部分。
例如:一個正在運行的程式可以查詢 TEMP 環境變量的值來發現一個合適的位置,來存儲臨時的文件,或者查詢 HOME 變量來找到運行該程式的用戶所擁有的目錄結構。
在 Node.js 當中,我們使用 dotenv 套件,透過 process 物件的 env 屬性,來獲得環境變數。(除此之外,如果我們在雲端上部署伺服器,通常雲端提供商應該有某種秘密管理工具,例如:AWS Secrets Manager。)
下載 dotenv 套件:
npm install dotenv
在目前工作目錄中創建一個 .env 檔案
在目前工作目錄中創建一個 .gitignore 檔案,將 .env 放進去 (不讓 git 追蹤)
範例:
require("dotenv").config();
const express = require("express");
const app = express();
const cookieParser = require("cookie-parser");
const session = require("express-session");
app.use(cookieParser(process.env.MYCOOKIESECRETKEY)); // function 內部提供一個參數。此參數為某個秘密 String
app.use(
session({
secret: process.env.MYSESSIONSECRETKEY,
resave: false,
saveUninitialized: false,
cookie: { secure: false }, // 因為在此使用 localhost,所以設定為 false。設定 secure 為 true,則 cookies 只有在 HTTPs 的協議下才會進行傳輸。
})
);
const checkUser = (req, res, next) => {
if (!req.session.isVerified) {
return res.send("請先登入系統,才能看到秘密。");
} else {
next();
}
};
app.get("/", (req, res) => {
return res.send("這是首頁。");
});
app.get("/setCookie", (req, res) => {
// res.cookie("yourCookie", "Oreo");
res.cookie("yourCookie", "Oreo", { signed: true });
return res.send("已經設置好 Cookie。。。");
});
app.get("/seeCookie", (req, res) => {
console.log(req.signedCookies);
return res.send(
"看一下已經設置好的 Cookie。。。" + req.signedCookies.yourCookie
);
});
app.get("/setSessionData", (req, res) => {
req.session.example = "something not important...";
// console.log(req.session);
return res.send("在伺服器設置 session 資料,在瀏覽器設置簽名後的 session id");
});
app.get("/seeSessionData", (req, res) => {
console.log(req.session); // connect.sid => session id
return res.send("看一下已經設置好的 Session資料。。。");
});
app.get("/verifyUser", (req, res) => {
req.session.isVerified = true;
return res.send("你已經被驗證了。。。");
});
app.get("/secret", checkUser, (req, res) => {
return res.send("秘密是柴犬很可愛。");
});
app.get("/secret2", checkUser, (req, res) => {
return res.send("秘密是駱駝很可愛。");
});
app.listen(3000, () => {
console.log("Sever running on port 3000。。。");
});
MYCOOKIESECRETKEY = "熊貓很可愛"
MYSESSIONSECRETKEY = "熊貓和貓熊都很可愛"
.env
Flash
Flash 是在 session 當中一個特別的儲存空間,可以用來儲存一些簡短的訊息。例如:登入成功或是登入失敗的資訊。如果要使用 flash,可以使用 connect-flash 套件。
安裝套件:
npm install connect-flash
範例:
const flash = require("connect-flash");
app.use(flash());
app.get("/", (req, res) => {
req.flash("message", "歡迎來到網頁。。。"); // key, value
return res.send("這是首頁。" + req.flash("message"));
});
Authentication and Authorization
Authentication
Authentication (身分驗證) 是指通過一定的手段,完成對使用者身分的確認。如果我們希望我們的應用程序被世界上的任何人使用,我們需要創建一個用戶身份驗證系統。這將是一個用戶可以註冊帳戶和登錄的系統。
Authentication 可以透過以下的幾種方式來完成:
1. 要求使用者給予系統已經儲存過的帳號密碼。
2. 基於共享金鑰的身分驗證。當使用者需要進行身分驗證時,使用者通過輸入或通過保管有密碼的裝置提交由使用者和伺服器共同擁有的密碼。伺服器在收到使用者提交的密碼後,檢查使用者所提交的密碼是否與伺服器端儲存的密碼一致,如果一致,就判斷使用者為合法使用者。如果使用者提交的密碼與伺服器端所儲存的密碼不一致時,則判定身分驗證失敗。
3. 基於公開金鑰加密演算法的身分驗證。通信中的雙方分別持有公開金鑰和私有金鑰,由其中的一方採用私有金鑰對特定資料進行加密,而對方採用公開金鑰對資料進行解密,如果解密成功,就認為使用者是合法使用者,否則就認為是身分驗證失敗。使用基於公開金鑰加密演算法的身分驗證的服務有:SSL、數位簽章等等。
4. 基於生物學特徵的身份驗證,使用每個人身體上獨一無二的特徵,如指紋、虹膜等等。
5. 多重要素驗證 (Multi-factor authentication,縮寫為 MFA)。例如,使用銀行卡時,需要另外輸入個人識別碼,確認之後才能使用其轉帳功能。登入校園網路系統時,通過手機簡訊或學校指定的手機軟體進行驗證。
6. 開放授權 (OAuth, Open Authentication) 是一個開放標準,允許使用者讓第三方應用存取該使用者在某一網站上儲存的私密的資源 (如:相片,影片,聯絡人列表) 而無需將使用者名稱和密碼提供給第三方應用。
Authorization
既然我們知道正在使用系統的用戶是誰,並且已經做了身份驗證,伺服器仍需要檢查該用戶是否有權執行他們嘗試執行的操作。這稱為授權 (Authorizations)。例如:在 Udemy上面,只有講師可以看到每個學生的學習進度與後台資料,而學生無法看到其他學生的資訊。若有學生嘗試存取其他學生的後台資料,伺服器應該加以阻擋。
密碼學導論與密碼加鹽
在我們的數據庫中以純文字的形式存儲密碼是非常危險的。若駭客駭入系統內部,就可以馬上看到所有用戶的密碼。另外,員工也可以訪問數據庫,看到所有用戶的密碼,可能會有資安疑慮。由於很多人在多個網站上都會重複使用相同的密碼 (並不是一個非常好的習慣),若我們的數據庫內的密碼外洩,受影響的用戶的 Google、Facebook、銀行等帳戶可能都會同時遭到入侵。
因此,我們希望在將密碼存儲到數據庫之前對其進行加密或轉換。 我們實際上不需要加密(encrypt) 密碼,我們只需要對它們進行雜湊(hash)處理。加密通常意味著我們可以將我們加密的內容進行解密處理,以獲取原始文字。雜湊處理則代表沒有可逆選項。(不可逆性與雪崩效應)
如果我們將密碼的雜湊值 (hash values) 存儲在數據庫中,當用戶端給我們密碼時,我們只需要將密碼拿去做雜湊處理,得到雜湊值後,再去跟數據庫中的雜湊值去比較是否相同。
在雜湊函數的選擇上需要特別注意。 我們可以使用 SHA 家族的演算法對密碼進行雜湊處理,但因為 SHA 家族演算法產生雜湊值的速度非常快,並不適合當作使用者密碼的雜湊函數。這是因為,如果我們使用一個非常快的演算法,駭客可以非常快速地對使用者的密碼做出很多猜測。駭客可以不斷的猜測不同可能的密碼來嘗試登入系統。通常來說,駭客會參考「一萬
種最常見的密碼」列表來猜測使用者的密碼。像這樣的攻擊,被稱為字典攻擊 (dictionary attack)。
另一個資安問題是駭客可以創建彩虹表 (rainbow table)。彩虹表是一個包含許多密碼 (可能超過1億個) 及其雜湊值的列表。駭客如果進入我們的資料庫,可以看到許多以雜湊值的形式所儲存的密碼。此時駭客只需要拿出彩虹表對照,就可以反推原本的使用者密碼。
如果幫單一密碼算出雜湊值所需要的時間越長,對駭客來說,創建彩虹表的時間成本就越高。因此,我們需要使用速度慢的雜湊函數。在市面上,儲存密碼用的雜湊函數最有名的就Bcrypt。跟 SHA 家族相比 ,Bcrypt 的速度很慢。此外,我們也可以做設定讓 Bcrypt 完成的速度減慢。
密碼加鹽
即使我們在將密碼保存到數據庫之前對密碼進行了雜湊函數轉換,仍然不安全。駭客仍然可以用彩虹表對照出雜湊函數轉換前的密碼。因此,我們通常會在對密碼『加鹽」處理。在我們對密碼做雜湊處理之前,我們在密碼中添加一些鹽 (salt),再拿去做雜湊處理,這樣相同的密碼在數據庫中看起來會有所不同,因為相同的密碼會有不同的鹽,所以雜湊函數算出來的雜湊值也會不同。例如:
在數據庫中,會存儲雜湊值和鹽兩個部分。下次當使用者給伺服器密碼時,伺服器可以將使用者給的密碼配上資料庫內儲存的鹽,兩者拿去算出雜湊值。若算出的值與資料庫內的雜湊值相符合,則驗證使用者。
這就是為什麼大多數網站不會在我們忘記密碼時,告訴我們密碼的原因。網站伺服器真的沒有辦法恢復密碼,因為網站伺服器根本就沒有儲存過使用者的密碼。伺服器所能做的就是讓我們創建一個新密碼。
Bcrypt 密碼處理
Bcrypt 是根據 Blowfish 加密演算法所設計的密碼雜湊函式。在使用 Bcrypt 時,我們可以客製化salt round 。 salt round 的數字越大,Bcrypt 做雜湊運算所需要完成的時間就越久,且成2 的 salt round 次方倍數成長。也就是說,salt round 設定為 10,會比設定為 1需要花上的時間多 2 的 10 次方 = 1024倍。
使用 Bcryp t時,輸入是密碼、salt round、一個鹽巴,而輸出是雜湊值。雜湊值的形式是:
$2<a/b/x/y>$[cost]$[22 character salt][31 character hash]
Bcrypt 套件下載:
npm install bcrypt
Bcrypt 語法:
bcrypt.hash(myPlaintextPassword, saltRounds, function(err, hash) {
// Store hash in your password DB.
});
要確認使用者給的密碼與資料庫內儲存的 Bcrypt 雜湊值是否相同,可以使用 Bcrypt 套件內部內建的 bcrypt.compare 語法:
// Load hash from your password DB.
bcrypt.compare(myPlaintextPassword, hash).then(function(result) {
// result == true
});
bcrypt.compare(someOtherPlaintextPassword, hash).then(function(result) {
// result == false
});
開放授權 (OAuth, Open Authentication)
我們可以依靠另一方 (如:Facebook) 來驗證某人的真實性,而不是使用密碼。大多數網站讓用戶在本地身份驗證 (local authentication) 或使用其他服務之間進行選擇。我們可以使用 OAuth 以幫助新舊用戶簡化註冊/登錄過程。
假設用戶已經在瀏覽器中登入 Facebook,則用戶只需單擊一個按鈕即可登入我們的網站,而不需要填寫個人資料表格或是註冊新的密碼。大多數網站都會使用 OAuth 來提高轉化率,即訪問網站者中的註冊百分比。若用戶覺得註冊帳戶非常容易,更多的用戶會傾向註冊帳戶。每個帳戶都有電子郵件地址,網站也就可以開始通過電子郵件向用戶進行營銷。
OAuth 2.0 是一種安全協議,協議規範能讓第三方應用程式以有限的權限,透過構建資源擁有者與網路伺服器間的許可交互機制,讓第三方應用程式代表資源擁有者訪問伺服器。OAuth 常見名詞:
• Resource Owner - 資源擁有者,即網頁的使用者。資源是指網頁使用者的個人資料與授權。
• Client - 客戶端,指的是第三方應用程式網站本身。
• Authorization Server - 授權伺服器,指的是 Google, Facebook 等大型系統,也就是給予授權的伺服器。
• Resource Server - 資源伺服器,指的是 Google, Facebook 等大型系統中,存放資源擁有者的被保護資訊的位置。
OAuth 詳細流程
1. 架設網站的 Client 需要先到 Google 註冊,使用 Google OAuth,而 Google 會給 Client 兩組英數字碼: client id 以及 secret。也需要設定 redirect URL,這是 Google 驗證使用者完成後,需要將使用者導向到 Website 的地方。
2. 網站需要製作一個 anchor tag,連結到 /auth/google。網頁使用者點擊連結,就會被送到 Google 登入頁面。
Google client id 與 secret
申請的網址:console.developers.google.com
新增專案 => API 和服務 => 憑證 => 建立憑證 => OAutj 用戶端 ID => 設定同意畫面 => User Type 選擇外部 => 建立 => 應用程式名稱 => 使用者支援電子郵件 => 應用程式網域 => 應用程式首頁:http://localhost:8080 => 開發人員聯絡資訊
憑證 => 建立憑證 => OAutj 用戶端 ID => 應用程式類型:網頁應用程式 => 已授權的重新導向 URI:新增 URI (http://localhost:8080/auth/google/redirect)
Passport 套件語法
Passport.js 是適用於 Node.js 中,用來做身份驗證的 middleware。使用 Passport.js,我們可以將 OAuth 身份驗證的功能輕鬆集成到任何基於 Node 和 Express 的應用程序中。
Passport 庫提供了 500 多種身份驗證機制,包括本地身份驗證、Google、Facebook、Twitter、GitHub、LinkedIn、Instagram 登入等等功能。
因為 Passport 把所有跟 OAuth 有關的步驟都自動完成了,所以我們的程式碼是從獲得 token與 resource owner 的資料後,以及 redirect 的部分開始撰寫。
Passport 套件下載
npm install passport
npm install passport-google-oauth20
working directory:project7
npm init
npm install express mongoose ejs dotenv
index,js
1. 先設定 Google Strategy 的登入方式。 Google Strategy 需要兩個 parameter,第一個parameter 是一個物件,內部含有 client id、client secret 以及一個 callback URL。第二個parameter 是一個 function。
2. 用戶端在 Google 登入頁面按下登入後,Passport 會自動完成 Oauth 的步驟,取得用戶的資料。取得用戶的資料後,Passport 會自動調用 Google Strategy 第二個 parameter 內部的function。此 function 的參數為 accessToken、refreshToken、 profile、done。其中,profile代表 Passport 從 Google 取得的用戶資料。
3. 我們可以在此 function 內部判斷,若此用戶為第一次登入系統,則將從 Google 取得的用戶資料存入我們系統的資料庫內。
4. 在此 function 的第四個參數 done 是一個function。我們可以將使用者資訊放入 done 的第二個參數內,並且執行done()。
在程式開發當中,Serialization 是指,將數據 (或是物件) 傳輸或儲存之前,將其轉換為 bytes 的過程。Deserialization 則是指將 bytes 轉換回到物件。
Passport 將這部分的實作留給開發者自己決定怎麼實踐 Serialization 與 Deserialization 的功能。傳統上來說,Serialization 的做法,是簡單的將用戶端的 id 存入 session。而Deserialization 的做法是將 session 內部的 id 拿去資料庫查看資料,將 id 所指向的資料取回。
在Passport當中,serialization 與 deserialization 的功能名稱叫做 serializeUser 與
deserializeUser。我們實作這兩個功能之前,需要先使用 express-session 這個套件的功能,幫 session 做簽名等功能。(npm install express-session)
以上的功能都設定好後,在 Google Strategy 內部的第二個參數的 function 所使用的第四個參數 done 被我們執行時,Passport 會透過 express-session 套件去自動執行passport.serializeUser()。 serializeUser() 參數為 user 與done。user 會被自動帶入 Google Strategy 的done 的第二個參數。 passport.serializeUser() 也會自動帶入以下的兩個功能 (當內部的 done() 被執行時):
1. 內部的 done() 執行時,將參數的值放入 session 內部,並且在用戶端設置 cookie
2. 設定 req.isAuthenticated() 為 true。
serializeUser 完成後,Passport 會執行 callback URL的route。進入此 route之後,Passport 會執行 deserializeUser()。
Passport 在 deserializeUser() 額外附加的一個功能,就是當 deserializeUser() 內部的 done()被執行時,第二個參數會被設定在 req.user 內部。為何要這樣做?
這是因為,從使用者登入後,我們目前只有執行過 serializeUser,也就是將使用者的登入資訊存入 session 內部。但使用者或許曾經登入過系統,是個舊用戶,以前曾在系統內存過其他資料。
我們讓使用者開始使用網站之前,最好可以把這些資料放在一個方便存取的地方。這就是為Passport 會提供「deserializeUser() 內部自動設定 req.user 的值是 done() 的第二個參數的值」這個功能了。
最後,callback URL 內部會將使用者導向到網頁的其他地方。在這些 route 內部,我們就可以使用 req.user 這個屬性來客製化網頁的內容了。
以下幾個為 Passport 內建的 methods:
req.logout() - 登出使用者。Passport會自動刪除session。
req.isAuthenticated() - 給定 boolean 的值,代表使用者是否被認證過。