EY-Office ブログ

Apollo GraphQLに入門してみた(4)

Apollo GraphQサーバーにログイン(認証、承認、セッション管理)機能を追加する Apollo GraphQLに入門してみた(3)の続きです。

このブログから見たかたは、Apollo GraphQLに入門してみた(1)Apollo GraphQLに入門してみた(2) も見てくさい。

Apollo

express-sessionでセッション管理

このサーバーはExpressの上でApolloを動かしているので、Apollo、Expressのどちらでもセッション管理を行えます。 今回はExpressの定番セッション管理ライブラリー express-sessionを使う事にしました。express-sessionはセッションIDの発生・暗号化、Cookieの設定・所得、セッションIDを使いサーバー側でデーターベース等を使ったセッションストア(セッションデータの管理)、などセッション管理に必要な機能をすべて持っています。

import session from 'express-session';
import connectPgSimple from 'connect-pg-simple';
import pgPromise from "pg-promise";

    //  ・・・

// ↓ ④
declare module 'express-session' {
  interface SessionData {
      userId: number;
  }
}

// ↓ ①
const log = (s: any) => console.log(`${(new Date()).toLocaleString('ja-JP')} ${s}`)
const pgp = pgPromise({query: (e) => log(`SQL: ${e.query}`)})
const db = pgp({
  host: process.env.DB_HOST,
  database: process.env.DB_DATABASE,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD
});

    //  ・・・

// ↓ ③
const resolvers:Resolvers = {
  Query: {

    //  ・・・

  Mutation: {
    userLogin: async (_parent, {email, password}:UserLoginArgs, context) => {
      const user = await context.db.oneOrNone(
          'SELECT * FROM users WHERE email = $1', email)
      if (user && bcrypt.compareSync(password, user.password)) {
        context.req.session.userId = user.id  // ← セッションデータへの格納
        return user
      } else {
        return null
      }
    },
    userLogout: async (_parent, _args, context) => {
      // ↓ セッションデータの廃棄
      context.req.session.destroy((err: Error) => console.log("-- logout"))
    }
  }
};

    //  ・・・

// ↓ ②
app.use(session({
  store: new (connectPgSimple(session))({pgPromise: db}),
  secret: process.env.SESSION_ID_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { maxAge: 1 * 60 * 60 * 1000 }
}));

    //  ・・・

① はセッション管理とは別物ですが、PostgreSQL接続ライブラリー pg-promiseの初期化・設定とSQLログ表示用のコードです。

② セッション管理ライブラリー express-session の初期化・設定です。

  • store: はサーバー側でのセッションデータ管理ライブラリーの設定です、今回はpg-promiseを使ったConnect PG Simpleを使いました。storeはexpress-sessionのCompatible Session StoresにあるようにRDB、NoSQLデーターベースなど多数用意されています。
  • secret: セッションIDの暗号化用キー
  • resave: セッションデータが変更されない場合は保存しない
  • saveUninitialized: 初期化されていないセッションデータは保存しない
  • cookie: セッションID用のCookeの設定

③ はログイン/ログアウトAPIの処理コードですが context.req.session というオブジェクトに代入するとセッションストア(= PostgreSQLのsessionテーブル)に格納されます。また参照や廃棄もできます。

ちなみにsessionテーブルは以下のように定義されています。

CREATE TABLE "session" (
  "sid" varchar NOT NULL COLLATE "default",
	"sess" json NOT NULL,
	"expire" timestamp(6) NOT NULL
) WITH (OIDS=FALSE);

ALTER TABLE "session" ADD CONSTRAINT "session_pkey"
    PRIMARY KEY ("sid") NOT DEFERRABLE INITIALLY IMMEDIATE;
CREATE INDEX "IDX_session_expire" ON "session" ("expire");

④ TypeScryptでプログラミングするにはセッションデータの型定義必要です、今回はログインしているユーザーのID番号のみセッションデータに持っています。

たったこれだけのコードでセッション管理が完成してしまいました。😁

graphql-sheildでAPIの承認管理

APIにはログイン済みでないとアクセス出来ないAPIもありますが、ログイン用APIのようにログイン前でもアクセスできる必要があります。このような管理はResolverのAPI処理コードなかでログイン済みかをチェックする方法もありますが煩雑です。
また、実際のシステムでは管理者ユーザーも必要になると思います、管理者ユーザーのみ読み出せるAPIも必要になります。このような判断をAPI処理コード内に分散するのは、ちょっとした間違いから脆弱性を生んでしまいます。

そこで今回はgraphql-shieldという簡単に承認管理を定義できる、承認管理ライブラリ(GraphQL Middleware)を使う事にしました。

import { shield, rule, allow } from 'graphql-shield'
import { applyMiddleware } from 'graphql-middleware'

    //  ・・・

// ↓ ①
const isLogind = rule()(
  async (_parent, _args, context, _info) => {
    const result = Boolean(context.req.session.userId)
    console.info(`isLogind:${result}`);
    return result;
  }
)

// ↓ ②
const permisions = shield({
  Query: {
    "*": isLogind
  },
  Mutation: {
    "*": isLogind,
    userLogin: allow
  }
})

// ↓ ③
const schema = applyMiddleware(
  makeExecutableSchema({
    typeDefs: [...scalarTypeDefs, typeDefs],
    resolvers: {...scalarResolvers, ...resolvers}
  }),
  permisions
)

① ログイン済みかチェックする関数の定義、セッションデータ context.req.session.userIdが存在してればログイン済みです。

② 承認の定義。判りやすいですよね!

  • Queryの全APIはログイン済の場合のみアクセスできます
  • MutationのuserLogin(ログイン)APIはいつでもアクセスできます
  • その他の全Mutation APIはログイン済の場合のみアクセスできます

graphql-shieldをApolloにミドルウエアとして組み込んでいます

apollo-clinetを使った簡単なReactアプリ

今回のログイン関連の機能をチェックするために作った簡単(手抜きな😅)なReactのクライアント側コードです

index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import { ApolloClient, InMemoryCache, ApolloProvider,
         createHttpLink } from '@apollo/client'
import { App } from './App'

const link = createHttpLink({
  uri: 'http://localhost:5000/graphql',  // ← ①
  credentials: 'include'                 // ← ②
});
const client = new ApolloClient({  // ← ③
  link,
  cache: new InMemoryCache(),      // ← ④
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'network-only',
      errorPolicy: 'all',
    },
    query: {
      fetchPolicy: 'network-only',
      errorPolicy: 'all',
    }}
});

ReactDOM.render(
  <ApolloProvider client={client}>
    <React.StrictMode>
      <App />
    </React.StrictMode>
  </ApolloProvider>,
  document.getElementById('root')
)

① サーバーのURL
② 別ドメインへの通信は include を指定
③ GraphQLクライアント機能を提供するProviderの定義
④ 今回は勉強・開発目的なのでキャッシュを禁止するような設定にしました

App.tsx

import React, { useState } from 'react'
import { useLazyQuery, useMutation, gql } from '@apollo/client'
import { User } from './generated/graphql'                      // ← ①


export const App: React.FC = () => {

 // ↓ ②
  const [getUsers, { loading, error, data }] = useLazyQuery<{users: User[]}>(gql`
    query Query {
      users {
        id
        name
        email
        group {
          id
          name
        }
      }
    }
  `
  )

  return (
    <div>
      <button onClick={() => getUsers()}>load</button>  {/* ← ③ */}
      <Login />
      <Logout />
      {loading && <p>Loading...</p>}
      {error && <p>Error : {error.message}</p>}
      {data && data.users && <Users users={data.users} />}
    </div>
  )
 }

// ↓ ④
type UsersProps = {
  users: User[]
}
const Users: React.FC<UsersProps> = ({users}) => {
  console.log(users)

  const tdStyle = {border: 'solid 1px', padding: '2px 15px'}
  return (
    <table style={ {borderCollapse: 'collapse'} }>
      <tbody>
      {users.map((u,ix) => (
        <tr key={ix}>
          <td style={tdStyle}>{u.id}</td>
          <td style={tdStyle}>{u.email}</td>
          <td style={tdStyle}>{u.name}</td>
          <td style={tdStyle}>{u.group.name}</td>
        </tr>)
      )}
      </tbody>
    </table>
  )
}

// ↓ ⑤
const Login: React.FC = () => {
  const [email, setEmail] = useState("yama@mail.com")
  const [password, setPassword] = useState("test@123")

  // ↓ ⑥
  const [login, { data, loading, error }] = useMutation<{userLogin: User}>(gql`
    mutation UserLogin($email: String!, $password: String!) {
      userLogin(email: $email, password: $password) {
        id
        name
        email
      }
    }
  `)

  console.log(data?.userLogin)
  return (
    <div>
      <p>
        email: <input onChange={(e) => setEmail(e.target.value)}
                      type="text" value={email} />
      </p>
      <p>
        password: <input onChange={(e) => setPassword(e.target.value)}
                         type="text" value={password} />
      </p>
      <p>
        <button onClick={() =>  {/* ↓ ⑦ */}
          login({variables: {email: email, password: password}})}>Login</button>
      </p>
      {loading && <p>Loading...</p>}
      {error && <p>Error : {error.message}</p>}
      {data && <p>{data.userLogin?.name ?? "Login faild"}</p>}
    </div>
  )
}

// ↓ ⑧
const Logout: React.FC = () => {
  const [logout, { data, loading, error }] = useMutation(gql`
    mutation UserLogout {
      userLogout
    }
  `)
  console.log(data)
  return (
    <>
      <button onClick={() => logout()}>logout</button>
      {loading && <p>Loading...</p>}
      {error && <p>Error : {error.message}</p>}
      {data && <p>Logout Done</p>}
    </>
  )
}

① サーバー同様GraphQL Code Generatorを使い、GraphQLのオブジェクト定義に対応するTypeScript定義を生成しています
② useLazyQueryはQueryを指定したタイミングで行えるHookです
③ useLazyQueryの第1戻り値のgetUsers()を呼び出すと通信が実行されます
④ User情報をTableで表示するReactコンポーネント
⑤ ログイン用Reactコンポーネント
⑥ useMutationはMutationを指定したタイミングで行えるHookです
⑦ useMutationの第1戻り値のlogin()を呼び出すと通信が実行されます、Mutationの引数を$email,$passwordはlogin()関数の引数で指定します
⑧ ログアウト用Reactコンポーネント、コードの構造はログイン用コンポーネントと同じです

今回作ったサーバー側コード

import express from 'express';
import cors from 'cors';
import { ApolloServer, gql } from 'apollo-server-express';
import { makeExecutableSchema } from '@graphql-tools/schema'
import { typeDefs as scalarTypeDefs, resolvers as scalarResolvers } from
    'graphql-scalars';
import { applyMiddleware } from 'graphql-middleware'
import { shield, rule, allow } from 'graphql-shield'
import { Resolvers, User, Group } from './generated/graphql'
import DataLoader from 'dataloader';
import bcrypt  from 'bcrypt';
import session from 'express-session';
import connectPgSimple from 'connect-pg-simple';
import pgPromise from "pg-promise";

declare module 'express-session' {
  interface SessionData {
      userId: number;
  }
}

const log = (s: any) => console.log(`${(new Date()).toLocaleString('ja-JP')} ${s}`)

const pgp = pgPromise({query: (e) => log(`SQL: ${e.query}`)})

const db = pgp({
  host: process.env.DB_HOST,
  database: process.env.DB_DATABASE,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD
});

const typeDefs = gql`
type Query {
  user(id: Int!): User
  users: [User]
  group(id: Int!): Group
  groups: [Group]
}

type Mutation {
  userLogin(email: String!, password: String!): User
  userLogout: Void
  userRegister(user: UserInput!): User
}

type User {
  id: Int!
  name: String!
  email: String!
  group_id: Int!
  group: Group!
  created_at: DateTime!
  updated_at: DateTime!
}
input UserInput {
  name: String!
  email: String!
  password: String!
  group_id: Int!
}
type Group {
  id: Int!
  name: String!
  created_at: DateTime!
  updated_at: DateTime!
}
`;


type UserLoginArgs = {email:string, password:string}

const resolvers:Resolvers = {
  Query: {
    user: async (_parent, {id}: {id:number}, context, _info) =>
      await context.db.one('SELECT * FROM users WHERE id = $1', id),
    users: async (_parent, _args , context) =>
      await context.db.any('SELECT * FROM users'),
    group: async (_parent, {id}: {id:number}, context) =>
      await context.db.one('SELECT * FROM groups WHERE id = $1', id),
    groups: async (_parent, _args , context) =>
      await context.db.any('SELECT * FROM users')

  },
  User: {
    group: async (parent,  _args , context) =>
      await context.loaders.group.load(parent.group_id)
  },

  Mutation: {
    userLogin: async (_parent, {email, password}:UserLoginArgs, context) => {
      const user = await context.db.oneOrNone(
          'SELECT * FROM users WHERE email = $1', email)
      if (user && bcrypt.compareSync(password, user.password)) {
        context.req.session.userId = user.id
        return user
      } else {
        return null
      }
    },
    userLogout: async (_parent, _args, context) => {
      context.req.session.destroy((err: Error) => console.log("-- logout"))
    }
  }
};

const loaders = {
  group: new DataLoader<number, Group[]>(async (keys: readonly number[]) =>
     await db.any('SELECT * FROM groups WHERE id IN ($1:csv)', [keys])
)};

const isLogind = rule()(
  async (_parent, _args, context, _info) => {
    const result = Boolean(context.req.session.userId)
    console.info(`isLogind:${result}`);
    return result;
  }
)


const permisions = shield({
  Query: {
    "*": isLogind
  },
  Mutation: {
    "*": isLogind,
    userLogin: allow
  }
})


const schema = applyMiddleware(
  makeExecutableSchema({
    typeDefs: [...scalarTypeDefs, typeDefs],
    resolvers: {...scalarResolvers, ...resolvers}
  }),
  permisions
)


const corsOptions =
  {origin: 'http://localhost:3000', credentials: true}


const app = express();
app.set('trust proxy', 1);
app.use(cors(corsOptions));
app.use(session({
  store: new (connectPgSimple(session))({pgPromise: db}),
  secret: process.env.SESSION_ID_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { maxAge: 1 * 60 * 60 * 1000 }
}));


(async () => {
  const server = new ApolloServer({
    schema,
    context: ({req}) => ({
      req,
      db,
      loaders,
      log
     })
  });

  await server.start()
  server.applyMiddleware({ app, cors: false });
})()

app.listen({ port: 5000 }, () => {
  console.log('server on http://localhost:5000/graphql');
});

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ