GraphQL/REST における認証/認可
https://cdn-images-1.medium.com/max/1600/1*WhV_V9YFa7d5XiKolRWEbQ.png
REST API
Authentication (認証)
Authentication には JWT を利用する。JWT を生成するコードは、以下のようになる。読み込み権限を与えることを示す "read-post" を claim として保持させ、暗号化には HS256 を利用し、この暗号化時のシークレットキーとして "secret" という文字列を利用する。 code:js
import * as jwt from "jsonwebtoken";
const token = jwt.sign({ claims: "read-post" }, "secret", {
algorithm: "HS256"
});
console.log(token);
// header - eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
// payload - eyJkYXRhIjoiZm9vYmFyIiwiaWF0IjoxNTQ0NjQ0ODI0fQ.
// signature - IzNv-vRptcbU0S_qfqqKusEnwLzdUT_IsYjIcQl4KHk
サーバサイドでこれを認可する場合、以下のようになる。jwt.verify は JWT とそのシークレットキー、もしくは公開鍵を引数にとり、JWT の検証を行う。express では、ある middleware は、それが追加された以降に定義されたリソースに対してのみ有効となる。ので、下記の例では posts は認可がなくともアクセスできるが、protected-posts には JWT によるが通らないとアクセスできない。 code:js
import * as express from "express";
import * as jwt from "jsonwebtoken";
const app = express();
app.get("/posts", (req, res) => {
res.send("posts");
});
app.use((req, res, next) => {
const { authorization } = req.headers;
jwt.verify(authorization, "secret", (err, decodedToken) => {
if (err || !decodedToken) {
res.status(401).send("not authorized");
return;
}
next();
});
});
app.get("/protected-posts", (req, res) => {
res.send("protected posts");
});
app.listen(3000, () => {
console.log("listening on port 3000");
});
Authorization (認可)
以下のように書ける。Authorization なので、認可に必要な情報を claim から取得する。そして、リソース (protected-posts) アクセス時に、認可できるかどうかを判定する。 code:js
import * as express from "express";
import * as jwt from "jsonwebtoken";
import { Request } from "express";
const app = express();
app.get("/posts", (req, res) => {
res.send("posts");
});
interface ReqClaims extends Request {
claims?: string;
}
app.use((req: ReqClaims, res, next) => {
const { authorization } = req.headers;
jwt.verify(authorization, "secret", (err, decodedToken) => {
if (err || !decodedToken) {
res.status(401).send("not authorized");
return;
}
req.claims = decodedToken.claims;
next();
});
});
app.get("/protected-posts", (req: ReqClaims, res) => {
if (req.claims !== "read-posts") {
res.status(401).send("not authorized");
return;
}
res.send("protected posts");
});
app.listen(3000, () => {
console.log("listening on port 3000");
});
GraphQL (graphql-yoga)
Authentication (認証)
しかし、下記の例のままだと問題がある。なぜなら、HTTP ステータスコードを利用してしまっている (GraphQL では常に 200 で返り、エラーはレスポンスオブジェクト内に含める) ためである。
code:js
import { GraphQLServer } from "graphql-yoga";
import * as jwt from "jsonwebtoken";
const typeDefs = type Query { hello(name: String): String! };
const resolvers = {
Query: {
hello: (_, { name }) => Hello ${name || "World"}
}
};
const server = new GraphQLServer({ typeDefs, resolvers });
server.express.use((req, res, next) => {
const { authorization } = req.headers;
jwt.verify(authorization, "secret", (err, decodedToken) => {
if (err || !decodedToken) {
res.status(401).send("not authorized");
return;
}
next();
});
});
server.start(() => console.log("Server is running on localhost:4000"));
これを改善するために、graphql-yoga に含まれるGraphQLMiddlewares を利用できる。さらに、例外をスローする場合には apollo-server-core に含まれる AuthenticationError を利用できる。 code:js
import { GraphQLServer } from "graphql-yoga";
import * as jwt from "jsonwebtoken";
import { AuthenticationError } from "apollo-server-core";
const typeDefs = type Query { hello(name: String): String! };
const resolvers = {
Query: {
hello: (_, { name }) => Hello ${name || "World"}
}
};
// resolve にリゾルバー、root に GraphQL のルートオブジェクト、args には GraphQL クエリの引数、等
const authenticate = async (resolve, root, args, context, info) => {
let token;
try {
token = jwt.verify(context.request.get("Authorization"), "secret");
} catch (e) {
return new AuthenticationError("Not authorised");
}
const result = await resolve(root, args, context, info);
return result;
};
const server = new GraphQLServer({
typeDefs,
resolvers,
context: req => ({ ...req }),
});
Authorization (認可)
下記のように記述できる。context に claim を渡し、リゾルバー hello 内でそれを参照する。このアプローチの問題点は、各種リゾルバーを claim で保護する、つまり、認可することを忘れないようにしなければならない、という点である。claim が1つの場合は良いが、数が多くなってくると手に負えなくなる。
code:js
import { GraphQLServer } from "graphql-yoga";
import * as jwt from "jsonwebtoken";
import { AuthenticationError } from "apollo-server-core";
const typeDefs = type Query { hello(name: String): String! };
const resolvers = {
Query: {
hello: (_, { name }, ctx) => {
if (ctx.claims !== "read-posts") {
return new AuthenticationError("not authorized");
}
return Hello ${name || "World"};
}
}
};
const authenticate = async (resolve, root, args, context, info) => {
let token;
try {
token = jwt.verify(context.request.get("Authorization"), "secret");
} catch (e) {
return new AuthenticationError("Not authorised");
}
context.claims = token.claims;
const result = await resolve(root, args, context, info);
return result;
};
const server = new GraphQLServer({
typeDefs,
resolvers,
context: req => ({ ...req }),
});
上記問題を解決するには、graphql-sheld を利用する。これは、Web アプリケーションのパーミッションのレイヤーを作成するツールである。これを使用する場合、スキーマ内の各スキーマについていくつかのルールを提供する必要がある。ルールは、ユーザが認可されるかどうかを判定するのに利用される。 code:js
import { GraphQLServer } from "graphql-yoga";
import { rule, shield, and, or, not } from "graphql-shield";
import * as jwt from "jsonwebtoken";
const typeDefs = type Query { hello(name: String): String! };
const resolvers = {
Query: {
hello: (_, { name }) => Hello ${name || "World"}
}
};
// Auth
function getClaims(req) {
let token;
try {
token = jwt.verify(req.request.get("Authorization"), "secret");
} catch (e) {
return null;
}
return token.claims;
}
// Rules
const isAuthenticated = rule()(async (parent, args, ctx, info) => {
return ctx.claims !== null;
});
const canReadposts = rule()(async (parent, args, ctx, info) => {
return ctx.claims === "read-posts";
});
// Permissions
const permissions = shield({
Query: {
hello: and(isAuthenticated, canReadposts)
}
});
const server = new GraphQLServer({
typeDefs,
resolvers,
context: req => ({
...req,
claims: getClaims(req)
})
});
GraphQL (Apollo Server)
Authentication (認証)
Apollo Server を利用する場合、以下のように書ける。graphql-yoga の場合は、context にクライアントからのリクエストオブジェクトをわたし、middleware がそれを参照して認証を行い、resolver を実行する、という流れだった。一方、Apollo Server の例では、middleware を介さずに直接リクエストオブジェクトを認証し、その結果を context に渡している。getUser でトークンの認証を行なっている。 code:js
// using apollo-server 2.x
const { ApolloServer } = require('apollo-server');
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => {
// get the user token from the headers
const token = req.headers.authorization || '';
// try to retrieve a user with the token
const user = getUser(token);
// add the user to the context
return { user };
},
});
server.listen().then(({ url }) => {
console.log(🚀 Server ready at ${url})
});
Authorization (認可)
Schema 単位の認可
一番シンプルなアプローチ。all-or-nothing アプローチとも呼ばれる。ロールに基づいて、リクエストしてきたユーザが全クエリの実行を拒否するか、許可するか、を決定する。非常に厳しく制限された環境であり得る。
code:js
context: ({ req }) => {
// get the user token from the headers
const token = req.headers.authorization || '';
// try to retrieve a user with the token
const user = getUser(token);
// optionally block the user
// we could also check user roles/permissions here
if (!user) throw new AuthorizationError('you must be logged in');
// add the user to the context
return { user };
},
Resolver 単位の認可
code:js
users: (parent, args, context) => {
// In this case, we'll pretend there is no data when
// we're not logged in. Another option would be to
// throw an error.
if (!context.user) return [];
}
あるいは、admin 権限を持っている場合にのみ許可したい場合には、以下のように記述できる。
code:js
users: (parent, args, context) => {
if (!context.user || !context.user.roles.includes('admin')) return null;
return context.models.User.getAll();
}
データモデルにおける認可
Resolver のロジックは、Resolver 自体ではなくデータモデルに移譲することが推奨されている。そして、そのデータモデルは context を通じて Resolver に渡すことができる。下記の例では、context に models というフィールドを用意し、そこにデータモデルを格納しておく。Resolver ではこれを参照し、実際にデータモデル上の処理を実行する。
code:js
context: ({ req }) => {
// get the user token from the headers
const token = req.headers.authentication || '';
// try to retrieve a user with the token
const user = getUser(token);
// optionally block the user
// we could also check user roles/permissions here
if (!user) throw new AuthorizationError('you must be logged in to query this schema');
// add the user to the context
return {
user,
models: {
User: generateUserModel({ user }),
...
}
};
},
カスタムディレクティブを利用する
GraphQL 外での認可
Resolver が外部の REST API を呼び出す場合には、トークンを素通りさせてそのまま受け渡してしまえば良い。