iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 21
3
Modern Web

Think in GraphQL系列 第 21

GraphQL 設計: Autentication 與 Authorization 大全

header

雖然之前實作有提過,不過今天還是要來詳細介紹在 GraphQL 如實作 Authentication 及 Authorization !
比起 RESTful API , GraphQL 還是相對新的工具,認證與授權兩者都沒有唯一正確的實作方式,不過幸好已經有一些發展比較成熟、較多人使用的方式可以供我們參考學習,如果有更好的方式也可以在底下留言一起交流喔~

如果還不曉得 Authentication (認證)與 Authorization (授權) 兩者的差異,我簡單用一句話帶過:

Authentication 檢查發送檢查人的身份是誰、是否合格;
Authorization 則是規範使用者權限的規則,規定哪些能做、哪些不能

因此如果該 Request 未帶 token 或是 token 過期、不合法皆算是 Authentication Error ,跟 http status code 的 401 意思一樣 ; 如果 Request 的 token 認證成功但是該使用者的權限不足以執行該 Request 則是 Forbidden Error (即 因 Authorization 發生的錯誤) ,如 http status code 的 403 意思相同。

接著讓我們看如何分別實作吧

1. Authentication

目前在 Web 領域, Authentication 通常有兩種比較流行的方式,分為

  1. Session Based Authentication: Client 端登入時,Server 會製造一個 session 給 user 做未來登入使用,而這個 session id 會儲存在 user 的瀏覽器中,之後 user 每次送出的 request 都會帶上 cookie ,而 Server 會比較 cookie 中的 session id 跟儲存在 Server 的 session 來看是否符合。可參考下圖:

img
圖片來源: https://medium.com/@sherryhsu/session-vs-token-based-authentication-11a6c5ac45e4

  1. Token based Authentication: Client 端登入後, Server 會製作一個帶有 user 資訊的 JWT Token (需加上自訂 secret 加密) 並傳給 Client , Client 存下這個 JWT Token 後,會在之後每一次 request 的 header 都帶上,而 Server 就可以依此 JWT Token 來檢查登入者是否合法或是是否過期。

img
圖片來源: https://medium.com/@sherryhsu/session-vs-token-based-authentication-11a6c5ac45e4

比較: 除非有特殊需求,如需要將登入資訊存起來,不然目前而言 Token Based Authentication 都是相對簡單、安全且擴展度高的做法,想知道更多可以參考 Session vs Token Based Authentication

不過這邊兩者我都會介紹,在這邊我特別說明一下,因為大部分 project 都是將 GraphQL 當作 middleware 引入而非像前面 Apollo Server 2 一樣可以直接啟動 server ,所以通常講到 GraphQL Authentication 都是用 RESTFul API 的 endpoint 來實作,如 /singup/login 等等,也就是用 RESTful 認證取得 token 、用 GraphQL 做資料索取。

不過我們要來介紹用純 GraphQL 路線做 Authentication ,一來可以維持 API 的完整性與一致性,二來也減少額外維護 RESTful 的負擔。

1-1. Authentication - Session based

講到 Session Based ,最有名的套件當然非 passport.js 莫屬,因此這邊來示範如何透過 passport.js 來實作登入。

如果你也想知道前面說的 RESTful 認證 + GraphQL 的方式可以參考這篇 Auth Example。另外我這裡會用 apollo-server-express 來實作,所以若用 express-graphql 可參考這篇 jessedvrs/graphql-passport-example

這邊要先說明,因為 Apollo Server 2 對於安裝 Middleware 的支援度不是很好,所以我會使用 express-graphql

想做到如下圖的架構:

img

首先是使用先寫 index.js 來設定與啟動 Server

const express = require('express');
const expressGraphQL = require('express-graphql');
const session = require('express-session');
const passport = require('passport');
const schema = require('./schema');

// 建立一個 express app
const app = express();

// 設定 session ,會解析 client 所帶來的 cookie (通常只有 session id) ,
// 然後從資料庫 (沒有設定就預設記憶體) 比對 session id 是否合法
app.use(
  session({
    resave: true,
    saveUninitialized: true,
    secret: 'aaabbbccc'
  })
);

// 將 passport 以 middleware 方式導入 app ,每當 request 進來就會檢查 request.session
// 並從中解析出 user 並放到 'req.user' 中
app.use(passport.initialize());
app.use(passport.session());

app.use(
  '/graphql',
  expressGraphQL({
    schema,
    graphiql: true
  })
);

app.listen(4000, () => {
  console.log('Listening');
});

接著設定 Schema 部分:

const { gql, makeExecutableSchema } = require('apollo-server');
const AuthService = require('./auth');

const typeDefs = gql`
  type User {
    id: ID
    email: String
  }

  type Query {
    user: User
  }

  type Mutation {
    signup(email: String!, password: String!): User
    login(email: String!, password: String!): User
    logout: User
  }
`;

const resolvers = {
  Query: {
    user: (parentValue, args, req) => {
      return req.user;
    }
  },
  Mutation: {
    signup: (parentValue, { email, password }, req) => {
      // request also called 'context'
      return AuthService.signup({ email, password, req });
    },
    login: (parentValue, { email, password }, req) => {
      return AuthService.login({ email, password, req });
    },
    logout: (parentValue, args, req) => {
      const { user } = req;
      req.logout();
      return user;
    }
  }
};

module.exports = makeExecutableSchema({
  typeDefs,
  resolvers
});

最後設定 passport 作 helper funciton:

const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;

// 假造的 Model
const User = (() => {
  const users = [
    { id: 1, email: 'fong@test.com', password: '123456' },
    { id: 2, email: 'kevin@test.com', pasword: '123456' }
  ];
  return {
    findUserById: id => users.find(user => user.id === id),
    findUserByEmail: email => users.find(user => user.email === email),
    addUser: ({ email, password }) => {
      const newUser = {
        id: users.length + 1,
        email,
        password
      };
      users.push(newUser);
      return newUser;
    }
  };
})();

// 用於設定 user 資料以何種方式存入 session 。傳統上用 user.id
passport.serializeUser((user, done) => {
  done(null, user.id);
});

// The counterpart of 'serializeUser'.  Given only a user's ID, we must return
// the user object.  This object is placed on 'req.user'.
passport.deserializeUser((id, done) => {
  const user = User.findUserById(id);
  if (!user) return done('User Not Exist');
  return done(null, user);
});

// 設定 passport 如何認證
passport.use(
  new LocalStrategy({ usernameField: 'email' }, (email, password, done) => {
    const user = User.findUserByEmail(email);
    if (!user) {
      return done(null, false, 'Invalid Credentials');
    }
    if (user.password !== password) {
      return done(null, false, 'Invalid credentials');
    }
    return done(null, user);
  })
);

function signup({ email, password, req }) {
  if (!email || !password) {
    throw new Error('You must provide an email and password.');
  }

  // 為 demo 方便省略加密密碼
  if (User.findUserByEmail(email)) {
    throw new Error('Email in use');
  }
  return User.addUser({ email, password });
}

// 會觸發 'local-storage' 做驗證,驗證成功會回傳 user
function login({ email, password, req }) {
  return new Promise((resolve, reject) => {
    passport.authenticate('local', (err, user) => {
      if (err) {
        return reject(err);
      }
      if (!user) {
        return reject(new Error('Invalid credentials.'));
      }

      return req.login(user, () => resolve(user));
    })({ body: { email, password } });
  });
}

module.exports = { signup, login };

如此一來就可以打開 browser 輸入 localhost:4000/graphql 來測試。

登入:

img

輸入 user query 來測試是否成功認證:

img

(程式碼參考 : https://github.com/jaredhanson/passport/issues/454#issuecomment-278161842)

1-2. Authentication - Token based

接著簡單介紹一下 Token based:

詳細程式碼可以直接參考我之前的 打造一個 GraphQL API Server 應用:部落格社交軟體 - 2 (Authentication & Authorization)

這邊只說個大概。

首先是 Schema 定義,我們需要

  1. User type 定義使用者資料,這邊只寫出 idemail
  2. Query.user field 代表登入者的資料
  3. Token type 將 token 資訊包起來放在 token field 裡
  4. Mutationsignup, login, logout
type User {
  id
  email
}

type Query {
  user: User
}

type Token {
  token: String
}

type Mutation {
  signup(email: String!, password: String!): User
  login(email: String!, password: String!): Token
  logout: User
}

接著程式方面我們會使用 jwt 套件來製作 token , jwt 的強大在於可以自定義過期時間以及將使用者資料塞進去,待未來取出使用。

這邊最主要就是 Mutation 的 login resolver 以及 Apollo Server 的 context 設定:

const createToken({ id, email }, secret) => jwt.sign({ id, email}, secret, { expiresIn: '1d' });

const resolvers = {
  Mutation: {
     login: async (root, { email, password }, { userModel, SECRET }) => {
       /* 檢查登入資訊... */
       ...
       // 認證成功回傳 token
       return {
         token: createToken(user, SECRET)
       }
     }
  }
}

前端取得 token 後,將 token 帶入之後每次 request 的 header (這邊是放在 header 'x-token' 位址),就可以靠 Apollo Server 的 context 做 jwt 認證並解析 user 。

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: async ({ req }) => {
    const token = req.headers['x-token'];
    const secret = process.env.SECRET;
    const context = { userModel, secret };
    if (token) {
      try {
        const me = await jwt.verify(token, SECRET);
        return { ...context, me };
      } catch (e) {
        throw new Error('Your session expired. Sign in again.');
      }
    }
    return context;
  }
});

如此一來只要有帶入合法 token ,之後的 query 都可以解析出是哪位 user 發出 request 再做權限檢查。另外這裏如果要 logout 的話就直接讓前端丟掉 token 即可。

這邊建議如果你確定你的 GraphQL Server 僅供認證者或是系統內部使用,那在 context 檢查 token 的規則變成一定要帶 token 以避免任何未經認證的 request 進入。

2. Authorization

接著來講 Authorization ,首先我們得先思考三點:

  1. 這個 request data 是公開的還是私有的 ?
  2. 這個 request 有含有 Authentication 資訊嗎 ?
  3. 這個 request 帶的資料正確嗎 ?

在 RESTful API 我們可以用 middleware 形式來將一個個 endpoint 做權限檢查如下:

img

但在 GraphQL 做權限檢查其實難度更高,因為整個 GraphQL 只有一個 endpoint 呀! 因此我們需要更進階的思考:

  1. 這支 query 要的是全部的 field 嗎?
  2. 這支 query 要求的 field 的權限為何 ?
  3. 要如何適當地處理錯誤 ?

2-1. Authorization - In Resolver

首先最簡單的就是直接寫進 Resolver 中,如下

const { AuthenticationError, ForbiddenError } = require('apollo-server');

const resolvers = {
  Mutation: {
    deletePost: (root, { postId }, { postModel, me }) => {
      // 檢查有無 user
      if (!me) throw new AuthenticationError('Not logged in');

      // 檢查其他權限如是否作者、角色層級夠不夠高
      if (!await postModel.isAuthor(postId, me)) throw new ForbiddenError('Not Allowed')

      // 開始執行
      return postModel.delete({ id: postId });
    }
  }
}

在這邊可以用一些 higher order function 的方式來簡化 resolver 如下:

const resolvers = {
  Mutation: {
    deletePost: isAuthenticated(isPostAuthor((root, { postId }, { postModel, me }) => {
      // 直接開始執行
      return postModel.delete({ id: postId });
    }))
  }

這樣就可以大幅簡化程式並且減少重複的程式碼,也讓 Resolver 可以專注在自己的商業邏輯。

2-2. Authorization - In Resolver but Delegate to Others

但上述方法做 Authentication 很容易,但是要做到更複雜的權限檢查或是 field 層級的檢查 (如會員生日只能給好友以上層級看到) ,就得要將這段邏輯抽出。

在這裡 Facebook 是推薦將權限管理代理到商業邏輯層來實作以保持程式碼不會四處重複,如下圖所示:

img
(圖片來源: https://graphql.org/learn/thinking-in-graphs/#business-logic-layer)

這種做法會比較像這段程式碼:

const { AuthenticationError, ForbiddenError } = require('apollo-server');

const resolvers = {
  Mutation: {
    deletePost: isAuthenticated((root, { postId }, { postModel, me }) => {
      return usecase.post.deletePost({
        postId,
        user: me,
        isPostAuthor: postModel.isAuthor,
        deletePost: postModel.delete,
      })
    })
  }
}


/// usecase.post
module.exports = {
  deletePost: ({ postId, user, isAuthor, deletePost }) => {
    // 檢查其他權限如是否作者、角色層級夠不夠高
    if (!await isAuthor(postId, me)) throw new ForbiddenError('Not Allowed')

    // 開始執行
    return deletePost({ id: postId });
  }
}

而 Apollo Server 也鼓勵將權限管理抽出來,不過他們是建議放進資料存取層 (Model Layer) 可參考程式碼如下:

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: async ({ req }) => {
    const token = req.headers['x-token'];
    const secret = process.env.SECRET;
    const context = { secret };
    if (token) {
      try {
        const me = await jwt.verify(token, SECRET);
        return { ...context, postModel: genPostModel(user), me };
      } catch (e) {
        throw new Error('Your session expired. Sign in again.');
      }
    }
    return context;
  }
});


const resolvers = {
  Mutation: {
    deletePost: isAuthenticated((root, { postId }, { postModel, me }) => {
      return postModel.delete({ id: postId });
    })
  }
}


// PostModel

module.exports = (user) {
  delete: (id) => {
    if (!await isAuthor(id, suer)) throw new ForbiddenError('Not Allowed')
    return db.query ('DELTE FROM post WHERE id = $1', [id]);
  }
}

2-3. Authorization - Using Directive

當然也可以使用之前介紹過的 Directive 功能做權限檢查,詳情見之前這篇 GraphQL 入門: 給我更多的彈性! 建立自己的 Directives

可以做到如下的效果:

Query {
  @isAuthenticated
  me: User
}

type Mutation {
  @auth(requires: "ADMIN")
  deleteUser(userId: ID!): User
  @authorOnly
  deletePost(postId: ID!): Post
}

不過 Directive 雖然可讀性高,對於一般 Authentication 檢查與 role-based 的權限檢查也還算適合,但仍不適合一些高複雜度的權限檢查。

2-4. Authorization 的 Viewer 模式

最後要來介紹一個 Facebook 推薦,在 Schema 上做權限管理的 Viewer 模式。這個模式可以大大降低權限管理在架構上的複雜度。簡單來說就是在 Query 實作一個 viewer field ,而今天你是什麼身份的人進去 viewer 就只會得到屬於你身份可以接觸到的資料。

舉個實際的例子,今天一個電商系統至少會有三種使用者身份,分別為商家管理員 (admin)、消費者 (shopper)以及路人 (guest)。而電商系統最重要的就是訂單資料,但是三者對於訂單的權限也不同。

  1. admin: 可以看到該商家所有 shopper 下的訂單
  2. shopper: 僅可以看到自己下的訂單
  3. guest: 完全看不到訂單

在傳統的設計中, 如 Query.productsQuery.ordersQuery.friends 等等每個都要處理權限管理,隨著 Schema 增長會越難以管理,因此 viewer 模式就為 Schema 提供一個入口,讓我們可以順著 viewer 去設計權限管理。

type Query {
  # 通常 viewer 都是針對已登入使用者
  viewer: Viewer
}

type Viewer {
  "產品。 shopper 可以看見自己的 ; admin 可以看見全部的"
  orders: [Order]
  "好友。 僅 shopper 有"
  friends: [User]
  "產品。 shopper 可以看見一般商品 ; admin 可以看見隱藏商品"
  products: [Product]
}

比如在 GitHub API Explorer 也有用到這樣的模式,可以使用 APIs-guru/graphql-voyager 來視覺化呈現 Schema 。

--

Reference:


上一篇
GraphQL Design: 關於 Security 的二三事
下一篇
Think in GraphQL: Schema Query 設計守則 - 1
系列文
Think in GraphQL30

尚未有邦友留言

立即登入留言