認證 Authentication GraphQL 中的身份驗證是一個熱門話題。沒有固定的做法,但許多應用程序都有這樣的需求。GraphQL 本身並不反對認證,因為它只是一種查詢語言。如果您想在 GraphQL 中進行身份驗證,請考慮使用 GraphQL mutations。
接下來,我們要使用簡約方法向 GraphQL 伺服務器添加身份驗證,這可以讓使用者登錄 (sign in) 到您的應用程序。
首先我們要新增一個使用者認證服務,並在原來的使用者資料庫中新增一個 password 欄位用來做認證用與一個 roles 欄位做授權。
新增一個子目錄 services 並新增一個程式檔案 user.service.js。這裡須要使用兩個 Node.js 套件,分別用來做密碼與權杖的加密與驗證:
npm install 1 2 npm install bcryptjs --save npm install jsonwebtoken --save
services/user.service.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 const bcrypt = require ("bcryptjs" );const jwt = require ("jsonwebtoken" );const jwtSecretKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" ;const { users } = require ("../models/dbs" );function authenticate (username, password ) { if (typeof username !== "string" || typeof password !== "string" ) { return Promise .reject(new Error ("Username or password is incorrect" )); } return users.findOne(username) .then(result => { if (result.appLogin === username) { return passwordVerify(result, password) .then(result => { const token = jwt.sign( { appLogin: result.appLogin, name: result.name }, jwtSecretKey, { expiresIn : "12h" } ); return { user: { appLogin: result.appLogin, name: result.name, roles: result.roles }, token: token }; }) .catch(err => { throw new Error ("Username or password is incorrect" ); }); } }) .catch(err => { throw new Error ("Username or password is incorrect" ); }); } function passwordVerify (user, password ) { return new Promise ((resolve, reject ) => { if (!user) { return reject(new Error ("Parameters error" )); } if (typeof password !== "string" ) { return Promise .reject(new Error ("Parameters error" )); } if (validPassword(password, user.password)) { return resolve(user); } else { return reject(new Error ("Username or password is incorrect" )); } }); } function validPassword (password, hash ) { return bcrypt.compareSync(password, hash); } function jwtVerify (token ) { let payload; if (!token) { throw new Error ("Authentication failed" ); } try { payload = jwt.verify(token, jwtSecretKey); } catch (err) { if (err.name === "TokenExpiredError" ) { throw new Error ("Token Expired" ); } else { throw new Error ("Authentication failed" ); } } return payload; } function resetPassword (id, password ) { if (!id || !password) { return Promise .reject(new Error ("Parameters error" )); } return users.findOne(id) .then(result => { result["password" ] = setPassword(password); return users.update(result.appLogin, result); }) .then(() => "Password changed" ) .catch(err => { throw err; }); } function setPassword (password ) { return bcrypt.hashSync(password, 10 ); } module .exports = { authenticate, jwtVerify, setPassword, resetPassword }
現在可以使用這個服務來將資料庫中的使用者新增一個 password 欄位與 roles 欄位。
因為我們的 LevelDB 是崁入式的資料庫,不能同時由兩個 Node.js 行程開啟資料庫,所以必須先停下 Apollo Server,再執行下面程式碼更新資料庫 users 資料庫:
models/useful/dbs-update-users.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const { users } = require ('../dbs' );const { setPassword } = require ('../../services/user.service' );const data = { blake: { password : 'changeonlogin' , roles : ['developer' , 'demo' ]}, james: { password : 'changeonlogin' , roles : ['user' , 'demo' ]}, scott: { password : 'tiger' , roles : ['admin' ]}, } users.findAll().then(result => { result.map(value => { const user = { ...value, password: setPassword(data[value.appLogin].password), roles: data[value.appLogin].roles } return user; }) .forEach(value => { users.update(value.appLogin, value) .then(console .log) .catch(console .log); }); });
第 7 行,新增一個 password 欄位,預設值設為 “changeonlogin”,加密儲存回資料庫。
現在資料庫裡的資料應該如下:
LevelDB usersDb 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 [ { id: 'blake' , appLogin: 'blake' , name: '葛蘭特 Blake' , password: '$2a$10$OqyTUxbgA.qds4Cn8tUXa.xgK2OtYUTuombk/Mu.bEXti3LkTG3bu' , roles: [ 'developer' , 'demo' ] }, { id: 'james' , appLogin: 'james' , name: '麥克 James' , password: '$2a$10$v8ee6guXTOgMWkz6oy0Nr.F1G8Io4o0vk1V2BB5WEDka/nudj31nK' , roles: [ 'user' , 'demo' ] }, { id: 'scott' , appLogin: 'scott' , name: '老虎 Scott' , password: '$2a$10$o1cpCMZJvNyeO63hFqGvC.plFrNUA9pigbWmOTdijVOLN4z2DC5p6' , roles: [ 'admin' ] } ]
因為我們在資料庫中加入了 roles 欄位,因此也需要修改 User 使用者型態將 roles 欄位加入,roles 欄位是一個內涵 String 的陣列,而我們就用他預設的解析函式就可以了:
schema/user.js 1 2 3 4 5 6 7 8 9 10 ... type User { appLogin: ID! name: String avatar: String roles: [String !]! postedPhotos: [Photo!]! inPhotos: [Photo!]! } ...
現在我們可以來加入認證的機制,首先將認證服務 userService 加入 context 中,這要修改專案根目錄下的 index.js:
index.js 1 2 3 4 5 6 7 8 ... const userService = require ('./services/user.service' );... const context = { db, userService }; ...
我們要用 mutation 來處裡使用者認證,在 User schema 中設計一個自訂的承載類型 (payload type) AuthPayload 與 appAuth mutation:
schema/user.js 1 2 3 4 5 6 7 8 9 10 11 12 13 ... extend type Mutation { appAuth(login: String ! password: String ): AuthPayload! } ... type AuthPayload { user: User! token: String ! } ...
在對應的解析函式加入實際的執行碼:
resolves/user.js 1 2 3 4 5 6 7 8 ... Mutation: { appAuth(parent, { login : username, password }, { userService }) { return userService.authenticate(username, password) .then(results => results); } }, ...
現在可從 GraphQL Playground 客戶端測試看看:
GraphQL Playground appAuth mutation 1 2 3 4 5 6 7 8 9 mutation authentication { appAuth(login: "blake" password: "changeonlogin" ) { user { appLogin name } token } }
GraphQL Playground results 1 2 3 4 5 6 7 8 9 10 11 { "data" : { "appAuth" : { "user" : { "appLogin" : "blake" , "name" : "葛蘭特 Blake" }, "token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBMb2dpbiI6ImJsYWtlIiwibmFtZSI6IuiRm-iYreeJuSBCbGFrZSIsImlhdCI6MTU3MzE3MDgwMSwiZXhwIjoxNTczMjE0MDAxfQ.eqrwtifdL-ZdOLL8deS547HhirWK59UbDOTOYUshgTc" } } }
我們將使用認證成功後返回的資料中所含的權杖 token 來做驗證與授權。
在未來的請求中證明自己的身分,你必須在每個請求的 HTTP Headers 的 Authorization 中傳送你自己的權杖,這個權杖會被用來查看使用者的資料庫紀錄來辨識與授權。
GraphQL Playground 有個地方可讓你為每個請求加入標頭 (HTTP Headers)。在左下方角落的 “QUERY VARIABLES” 旁邊有個 “HTTP HEADERS” 標籤,你可以使用這個標籤在請求中加入 HTTP Headers。用 JSON 來傳送標頭:
HTTP Headers 1 2 3 { "Authorization" : "Bearer <YOUR_TOKEN>" }
請將 <YOUR_TOKEN> 換成 appAuth mutation 回傳的權杖。現在你可以連同每一個 GraphQL 請求一起來識別你。GraphQL 伺服器會使用權杖的資料從資料庫中找到你的帳號並將它加入 GraphQL 的 context 中,解析函式就可以用來判斷使用者的授權權限。
接下來,我們要建立一個參考我們自己的使用者資訊的 query: me query。這個 query 會根據 HTTP 標頭傳送的權杖來回傳當前登入的使用者。如果目前沒有使用者登入,這個 query 會回傳 null。這將會是代表經過身份驗證的用戶。
在這個程序一開始,用戶端同時傳送 GraphQL query me 和保護使用者資訊的 Authorization: token。接下來 API 會捕捉 Authorization 標頭,並使用權杖在資料庫中查看當前使用者的紀錄資料,也會將當前使用者帳號加入 context。帳號被加入 context 後,所有的解析函式就可以讀取當前的使用者了。
我們要改變 context 物件的建構方式,使用函式 來處理 context,而不是直接使用物件。
index.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 ... const context = async ({ req }) => { let currentUser = null ; try { let token = req.headers.authorization.split(" " )[1 ]; let payload = token && userService.jwtVerify(token); currentUser = payload && (await db.users.findOne(payload.appLogin)); } catch { currentUser = null ; } return { db, userService, currentUser }; }; const server = new ApolloServer({ typeDefs, resolvers, context }); ...
context 可以是物件或函式,它會在每一次有請求時設定 context。當 context 是函式時,每當有 GraphQL 請求時,它都會被呼叫。這個函式回傳的物件是被送給解析函式的 context。
context 設定好之後,就可加入 me query 與對映的解析函式:
schema/user.js 1 2 3 4 5 6 7 ... extend type Query { me: User ... } } ...
me 的解析函式:
resolvers/user.js 1 2 3 4 5 6 ... Query: { me: (parent, args, { currentUser } ) => currentUser, ... }, ...
現在可來使用 me query 傳送一個請求來取得關於你自己的資料,記得 HTTP Headers 裡面要有正確的權杖,使用錯誤的權杖或沒有授權標頭時,你會看到 me query 是 null。
GraphQL Playground query me 1 2 3 4 5 6 query currentUser { me { appLogin name } }
GraphQL Playground HTTP HEADERS 1 2 3 { "Authorization" : "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBMb2dpbiI6ImJsYWtlIiwibmFtZSI6IuiRm-iYreeJuSBCbGFrZSIsImlhdCI6MTU3MzE4Mzg3MSwiZXhwIjoxNTczMjI3MDcxfQ.n8msSCdRNlj9lM99Iqw7Mey-wec_zroqBEUKYojC8-A" }
GraphQL Playground query me result 1 2 3 4 5 6 7 8 { "data" : { "me" : { "appLogin" : "blake" , "name" : "葛蘭特 Blake" } } }
你甚至可以查你自己其它的資料:
GraphQL Playground query me 1 2 3 4 5 6 7 8 9 10 11 12 query currentUser { me { appLogin name postedPhotos { name } inPhotos { name } } }
GraphQL Playground query me result 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 { "data" : { "me" : { "appLogin" : "blake" , "name" : "葛蘭特 Blake" , "postedPhotos" : [ { "name" : "Dropping the Heart Chute" } ], "inPhotos" : [ { "name" : "Dropping the Heart Chute" }, { "name" : "Enjoying the sunshine" } ] } } }
postPhoto mutation 使用者必須先登入才能將照片貼到我們的 app,postPhoto mutation 可以藉由檢查 context 來確定登入的是誰。我們來修改 postPhoto mutation:
resolvers/photo.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 ... Mutation: { async postPhoto(parent, args, { db : { photos }, currentUser }) { if (!currentUser) { throw new Error ("only an authorized user can post a photo" ); } let newPhoto = { ...args.input, appUser: currentUser.appLogin, created: new Date () }; const insertedPhoto = await photos.insert("" , newPhoto); return insertedPhoto; } }, ...
第 4 行我們判斷是不是有效的使用者,如果不是,拋出一個錯誤,程序即會中斷執行,這是最簡單的授權驗證,可加入角色 (roles) 做更細微的控管。
最後的 resolvers/photo.js 如下:
resolvers/photo.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 const { GraphQLScalarType } = require ("graphql" );module .exports = { Query: { totalPhotos: (parent, args, { db: { photos } } ) => photos.allCount().then(value => value), allPhotos: (parent, args, { db: { photos } } ) => photos.findAll() }, Mutation: { async postPhoto(parent, args, { db : { photos }, currentUser }) { if (!currentUser) { throw new Error ("only an authorized user can post a photo" ); } let newPhoto = { ...args.input, appUser: currentUser.appLogin, created: new Date () }; const insertedPhoto = await photos.insert("" , newPhoto); return insertedPhoto; } }, Photo: { url: parent => `https://fakeimg.pl/120x160/?text=${parent.id} ` , postedBy: (parent, args, { db: { users } } ) => { return users.findOne(parent.appUser).then(value => value); }, taggedUsers: (parent, args, { db: { users, tags } } ) => { return tags.findAll().then(result => { return result .filter( tag => tag.photoID === parent.id) .map(tag => tag.userID) .map(userID => users.findOne(userID)); }); } }, DateTime: new GraphQLScalarType({ name: "DateTime" , description: "有效的日期時間值" , parseValue: value => new Date (value), serialize: value => new Date (value).toISOString(), parseLiteral: ast => ast.value }) };
現在可以將新照片貼到 GraphQL 服務了。
GraphQL Playground mutation newPhoto 1 2 3 4 5 6 7 8 9 10 11 12 13 mutation newPhoto($input: PostPhotoInput!) { postPhoto(input: $input) { id name url description category created postedBy { name } } }
我們也必須在 Query Variables 面板中傳送對應的 PostPhotoInput JSON:
GraphQL Playground VARIABLES mutation newPhoto input 1 2 3 4 5 6 { "input" : { "name" : "範例照片 ABC" , "description" : "我們資料庫的範例照片" } }
從客戶端使用 GraphQL API 到目前為止我們都是從 GraphQL Playground 測試 GraphQL 伺服器,現在看看如何從 Web app 將新照片貼到 GraphQL 服務。
首先需要認證,這是一段認證的範例函式:
authentication function appAuth 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 function appAuth (username, password ) { const query = `mutation login { appAuth(login: "${username} " password: "${password} ") { user { name appLogin } token } }` ; const endpoint = "http://localhost:4000/graphql" ; const opts = { method: "POST" , headers: { "Content-Type" : "application/json" , "Accept" : "application/json" }, body: JSON .stringify({ query }) }; fetch(endpoint, opts) .then(res => res.json()) .then(({ data: { appAuth } } ) => localStorage.setItem('token' , appAuth.token)) .catch(console .error); }
如果認證成功,會將權杖存到 localStorage。然後就可以使用此權杖執行 mutation postPhoto 貼上新照片。
以下則是一段貼上新照片的範例函式:
post new Photo function 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 function postPhoto (newPhoto ) { const query = `mutation newPhoto($input: PostPhotoInput!) { postPhoto(input: $input) { id name url description category created postedBy { name } } }` ; const url = "http://localhost:4000/graphql" ; const opts = { method: "POST" , headers: { "Content-Type" : "application/json" , "Accept" : "application/json" , "Authorization" : `Bearer ${localStorage.getItem('token' )} ` }, body: JSON .stringify({ query: query, variables: { input : newPhoto } }) }; fetch(url, opts) .then(res => res.json()) .then(console .log) .catch(console .error); }
現在可以執行這兩段函式:
post photo 1 2 3 4 5 6 7 8 9 10 appAuth('blake' , 'changeonlogin' ); const newPhoto = { name: "範例照片從瀏覽器使用 ajax" , description: "我們資料庫的範例照片 from browser using ajax" }; postPhoto(newPhoto);
你已經建立一個 GraphQL 伺服器了! Go! GraphQL!
接續下一篇 建立 GraphQL API (6)