0%

建立 GraphQL API (5)

認證 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
// authentication
appAuth('blake', 'changeonlogin');

// post new photo
const newPhoto = {
name: "範例照片從瀏覽器使用 ajax",
description: "我們資料庫的範例照片 from browser using ajax"
};

postPhoto(newPhoto);

你已經建立一個 GraphQL 伺服器了! Go! GraphQL!

接續下一篇 建立 GraphQL API (6)