在前面一篇講解完基本的功能後,就來講一項 API Server 常見的功能: Authentication & Authorization 。
通常登入功能的實作分為 Session-based 與 Token-based 兩種 (詳細優點可見這篇問答),而對於一個簡單的服務而言, Token-based 相對而言簡單、安全且效能較佳,因此這邊我們選用 Token-based 的認證方式 (若想學習 Session-based 的朋友可參考這篇 使用 passport.js)。
那就讓我們開始 Token-based 的旅程吧 !
若還有不熟悉 Token-based Authentication 的朋友可參考以下這張超棒的解說圖:
圖片來源: https://medium.com/@sherryhsu/session-vs-token-based-authentication-11a6c5ac45e4
可以看到圖中是使用 JWT 作為 Token 生產工具,且整套流程主要就是藉由
在 JS 中,登入系統認證通常會用到兩個大名鼎鼎的 package ,分別為 bcrypt 與 jsonWebToken (jwt),前者可幫助我們加密密碼並做密碼比對,後者則是允許我們傳入自訂的資料 來生產認證用的 Token 。
題外話插播,今天的教學以簡單快速為主,使用的加密方式安全性並不是十分受保障,若是有興趣可參考這篇 Password and Credential Management in 2018 ,或參考 How Dropbox securely stores your passwords
至於如何安裝這兩項套件就請各位輸入
$ npm install --save bcrypt jsonwebtoken
簡單介紹一下兩個套件我們主要需要的 function:
bcrypt.hash(text, saltRounds)
bcrypt.compare(text, hashedText)
jwt.sign(payload, secret, options)
expiresIn
為 token 設置期限,本次範例使用 1d
代表一天期限。其他參數可自行去文件 研究jwt.verify(token, secret)
如果有人還是搞不清楚到底是誰加密誰變 token ,我用一小段話作小結: bcrypt 幫你的 password 加密,讓別人就算從 database 偷到加密後的 password 也沒辦法登入 ; jwt 幫你把使用者用帳密登入得到的資訊弄成一串稱為 token 的亂碼,Server 檢查 token 沒問題就可以得知是誰已經登入以及誰在做操作。
講了這麼多先備知識,讓我們開始將以上方法和工具應用到 GraphQL 中吧 !
接下來我們來做 Register ! 這樣就可以建立自己的帳號囉 ~ Register 的要做的事很簡單,取得 name, email, password 後創造一個新的 User ,而既然是關乎建造,那就是在 Mutation 的範疇內。
一樣先在 Schema 的 Mutation Type 定義
type Mutation {
"註冊。 email 與 passwrod 必填"
signUp(name: String, email: String!, password: String!): User
}
再來是 Resolver
// 引入外部套件
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
// 定義 bcrypt 加密所需 saltRounds 次數
const SALT_ROUNDS = 2;
// 定義 jwt 所需 secret (可隨便打)
const SECRET = 'just_a_random_secret';
...
// helper functions
...
const hash = text => bcrypt.hash(text, SALT_ROUNDS);
const addUser = ({ name, email, password }) => (
users[users.length] = {
id: users[users.length - 1].id + 1,
name,
email,
password
}
);
const resolver = {
...,
Mutation: {
...,
signUp: async (root, { name, email, password }, context) => {
// 1. 檢查不能有重複註冊 email
const isUserEmailDuplicate = users.some(user => user.email === email);
if (isUserEmailDuplicate) throw new Error('User Email Duplicate');
// 2. 將 passwrod 加密再存進去。非常重要 !!
const hashedPassword = await hash(password, SALT_ROUNDS);
// 3. 建立新 user
return addUser({ name, email, password: hashedPassword });
},
}
}
來試試看吧 ! 使用 mutation signup
看看 ~
再來試試 query users
或 user(name)
來看是否真的成功建立新 user !
有了 register 後,讓我們來看看如何使用新增的使用者登入 !
先看 Schema 部分,我們先定義一個新的 Object Type Token
,Mutaiton Type 裡需要新增 login
field 並回傳 Token
Object Type ,其實這邊要不要新增一個 Object Type 見仁見智,有些人可以直接讓 login
回傳 String 或是實作新的 Scalar Type ,不過為了簡單與做出區別故選擇新增 Object Type 。
type Token {
token: String!
}
type Mutation {
...
"登入"
login (email: String!, password: String!): Token
}
這裡我們在 token 建造時加上 expiredIn: 'id'
的參數表示 token 在一天後過期,到時候使用者要進行操作時就需要再次登入。
// helper function
const createToken = ({ id, email, name }) => jwt.sign({ id, email, name }, SECRET, {
expiresIn: '1d'
});
const resolvers = {
Mutation: {
...
login: async (root, { email, password }, context) => {
// 1. 透過 email 找到相對應的 user
const user = users.find(user => user.email === email);
if (!user) throw new Error('Email Account Not Exists');
// 2. 將傳進來的 password 與資料庫存的 user.password 做比對
const passwordIsValid = await bcrypt.compare(password, user.password);
if (!passwordIsValid) throw new Error('Wrong Password');
// 3. 成功則回傳 token
return { token: await createToken(user) };
}
}
}
其實回傳 token 這件事情也可以在 Register 時就回傳,讓會員一註冊就可以登入,這邊主要是看你的 sepc 設計。
首先使用註冊再使用登入,如下圖。
OK 有了登入成功的 token 後就來看如何讓 query 解析 token 以得知誰發出的 request 。
今天就讓我們把煩人的 meId
拿掉!
前天說過,要如何確保使用者已經登入使用?答案就在 Field Resolver 的第三個參數 context
中 !
而 context 的初始化過程其實就是一個 middleware ,在以前大多都是裝一個 middleware 來解析送來的 request 裡面 header 的 token ,不過有了 Apollo Server 2 後就直接在 Server 初始化時加入定義。
讓我們來看一個 request 進來時需要做什麼解析
x-token
取出 (x-token
只是一個好懂的命名,可自行定義)context
的內容,供之後執行的 query 或 mutation 使用new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => {
// 1. 取出
const token = req.headers['x-token'];
if (token) {
try {
// 2. 檢查 token + 取得解析出的資料
const me = await jwt.verify(token, SECRET);
// 3. 放進 context
return { me };
} catch (e) {
throw new Error('Your session expired. Sign in again.');
}
}
// 如果沒有 token 就回傳空的 context 出去
return {};
}
});
當 context
有了登入者的資料,讓我們看看如何修改原先的 Resovlers 。
首先讓我們來修改 Query.me
。
const resolvers = {
Query: {
me: (root, args, { me }) => {
if (!me) throw new Error ('Plz Log In First');
return findUserByUserId(me.id)
},
...
}
}
修改完後我們到 GraphQL Playground 試試看!但要記得在送出 Query 前要先在左下角的 HTTP HEADER 加上從 login
得到的 token
{
"x-token": "eyJh......."
}
如圖。
接著讓我們順著修改 mutation 裡面的 resolver (其實就只是第一檢查 me
第二將 meId
替換成 me.id
)
const resolvers = {
...
Mutation: {
updateMyInfo: (parent, { input }, { me }) => {
if (!me) throw new Error ('Plz Log In First');
// 過濾空值
const data = ["name", "age"].reduce(
(obj, key) => (input[key] ? { ...obj, [key]: input[key] } : obj),
{}
);
return updateUserInfo(me.id, data);
},
addFriend: (parent, { userId }, { me: { id: meId } }) => {
if (!me) throw new Error ('Plz Log In First');
const me = findUserByUserId(meId);
if (me.friendIds.include(userId))
throw new Error(`User ${userId} Already Friend.`);
const friend = findUserByUserId(userId);
const newMe = updateUserInfo(meId, {
friendIds: me.friendIds.concat(userId)
});
updateUserInfo(userId, { friendIds: friend.friendIds.concat(meId) });
return newMe;
},
addPost: (parent, { input }, { me }) => {
if (!me) throw new Error ('Plz Log In First');
const { title, body } = input;
return addPost({ authorId: me.id, title, body });
},
likePost: (parent, { postId }, { me }) => {
if (!me) throw new Error ('Plz Log In First');
const post = findPostByPostId(postId);
if (!post) throw new Error(`Post ${postId} Not Exists`);
if (!post.likeGiverIds.includes(postId)) {
return updatePost(postId, {
likeGiverIds: post.likeGiverIds.concat(me.id)
});
}
return updatePost(postId, {
likeGiverIds: post.likeGiverIds.filter(id => id === me.id)
});
},
...
}
};
就完成囉 !
在登入後輸入 mutation 來測試:
mutation ($updateMeInput: UpdateMyInfoInput!, $addPostInput:AddPostInput!) {
updateMyInfo(input: $updateMeInput) {
id
name
age
}
addPost(input: $addPostInput) {
id
title
body
author {
name
}
createdAt
}
likePost(postId: 1) {
id
}
}
---
VARIABLES
{
"updateMeInput": {
"name": "NewTestMan",
"age": 28
},
"addPostInput": {
"title": "Test ~ Hello World",
"body": "testttttinggggg"
}
}
---
HTTP HEADERS
{
"x-token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwiZW1haWwiOiJ0ZXN0QHRlc3QuY29tIiwibmFtZSI6IlRlc3RNYW4iLCJpYXQiOjE1NDA1MzgzMjksImV4cCI6MTU0MDYyNDcyOX0.ElEoRylTjjB_ACZnayABYlRDGvQSx_yQT4D7XixegFg"
}
結果如下圖。
有了認證系統後,就讓我們來介紹 Authorization (授權)!
什麼是 Authroization ? 它跟 Authentication 差別在哪裡 ? 簡單來說, Authentication 處理的是登入問題,如果登入失敗那就是 Authentication 「認證」的問題 ; Authoriaction 處理的是權限問題,如果登入者或 guest 要進行一項不屬於他權限允許的操作,那就會引發 Authorizaion 「授權」問題。
所以如果沒有帶 token 或 token 錯誤就是 Authorization Error ,但不是每一個 query/mutation 都會需要 token ,而需要 token 來取得使用者身份 (me
) 的每個 query/mutation 在 Resovler 層面都要重複一段重複的 if (!me)
檢查。
因此我們可以將這一段抽出來,並搭配 Apollo Server 提供的 Error : ForbiddenErrorError
,如下:
const { ApolloServer, gql, ForbiddenError } = require('apollo-server');
const isAuthenticated = resolverFunc => (parent, args, context) => {
if (!context.me) throw new ForbiddenError('Not logged in.');
return resolverFunc.apply(null, [parent, args, context]);
};
const reoslver = {
Query: {
...,
me: isAuthenticated((parent, args, { me }) => findUserByUserId(me.id)),
},
Mutation: {
updateMyInfo: isAuthenticated((parent, { input }, { me }) => {
...
}),
addFriend: isAuthenticated((parent, { userId }, { me: { id: meId } }) => {
...
}),
addPost: isAuthenticated((parent, { input }, { me }) => {
...
}),
likePost: isAuthenticated((parent, { postId }, { me }) => {
...
}),
...
}
};
是不是感覺清爽與易讀許多 ! 當然還有很多不同的實作方式或是使用一些 helper function 讓檢查 function 可以一直串接起來,可參考 graphql-resolvers 。
可以直接在 GraphQL Playground 輸入
query {
me {
id
}
}
如果出現如下圖中的效果就代表成功囉~
為了 demo 這項功能,讓我們在 Schema 的 Mutation Type 新增一個 field: deletePost
~ 也就是刪貼文動作。
type Mutation {
...
deletePost(postId: ID!): Post
}
而這邊 Resolver 就要實作 deletePost
,並且檢查使用者若非該貼文作者就不能刪文。
// helper functions
const deletePost = (postId) =>
posts.splice(posts.findIndex(post => post.id === postId), 1)[0];
const isPostAuthor = resolverFunc => (parent, args, context) => {
const { postId } = args;
const { me } = context;
const isAuthor = findPostByPostId(postId).authorId === me.id;
if (!isAuthor) throw new ForbiddenError('Only Author Can Delete this Post');
return resolverFunc.applyFunc(parent, args, context);
}
const resolvers = {
...,
Mutation: {
...,
deletePost: isAuthenticated(
isPostAuthor((root, { postId }, { me }) => deletePost(postId))
),
}
}
OK ! 這邊試試看用能不能以登入狀態但是刪除別人的文章,如果出現下圖就算成功!
DONE!!
除了以上的方法,Apollo 其實就比較推薦資料取得的 function 都包在 Model 裡面,像 findUserByUserId
, updateUserInfo
等等就包進 UserModel
中,而 Authorization 部分就做在 UserModel 裡,如此一來可以隱藏更多實作細節甚至減少程式碼。
或是有些人會用 GraphQL Directives 來做 Authorization ,透過在 Schema 放上 @authenticated
之類的標籤來達到更易讀的目的。
Authorization 講到這邊告一個段落,之後會有再 po 文章做更深入的介紹,包含 Role-based Authorization (身份權限)。
今天的介紹到這邊,至此也已經有一個 API Server 的架勢了 ! 但似乎又缺少了些什麼的感覺...
啊!就是少了真正的 database 所以總感覺不太正式。
OK ! 那明天就讓我們來看看如何接上 DB 吧 !
同樣整份 code 也可以直接上我的 CodeSandbox 看喔 ~~
Reference:
另外想知道更多 Authentication 比較可以參考