Published on

tWIL 2022.11 1주차

Authors

지지난 주에 인프라를 조금 더 배포했다. Supertokens 레지스트리로 Auth 컨테이너를 띄우고 기존 Cognito관련 코드들을 모두 삭제했다. AWS ECS에서 컨테이너를 띄우는 건 기존 Terraform ECS 코드를 참고하면 될 것 같다. 이제는 Supertokens을 설정할 차례이다. 로컬에서 테스트하는 방법은 docker compose로 뛰워서 테스트 해보는 것이다.

Docker compose

API는 AWS RDS Aurora PostgreSQL Serverless를 쓴다고 가정하면 로컬 컨테이너 환경은 아래 컴포즈 파일과 같이 설정한다.

docker-compose.yml
version: "3.1"
services:
  postgresqldb:
    # 서울 리전 버전 체크
    # aws rds describe-db-engine-versions | jq '.DBEngineVersions[] | select(.SupportedEngineModes != null and .SupportedEngineModes[] == "serverless" and .Engine == "aurora-postgresql")'
    image: postgres:11.13
    container_name: db
    restart: always
    environment:
      POSTGRES_USER: ${POSTGRESQL_USER}
      POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD}
      POSTGRES_DB: apidb
      PGDATA: /var/lib/postgresql/data/pgdata
    ports:
      - ${POSTGRESQL_PORT}:5432
    volumes:
      - dbdata:/var/lib/postgresql/data/pgdata
    networks:
      - app_network
    healthcheck:
      test: ["CMD", "pg_isready -U supertokens_user"]
      interval: 5s
      timeout: 5s
      retries: 5
  # https://supertokens.com/blog/connect-supertokens-to-database
  auth:
    image: registry.supertokens.io/supertokens/supertokens-postgresql
    depends_on:
      - postgresqldb
    container_name: auth
    networks:
      - app_network
    healthcheck:
      test: >
        bash -c 'exec 3<>/dev/tcp/127.0.0.1/3567 && echo -e "GET /hello HTTP/1.1\r\nhost: 127.0.0.1:3567\r\nConnection: close\r\n\r\n" >&3 && cat <&3 | grep "Hello"'
      interval: 10s
      timeout: 5s
      retries: 5
    environment:
      API_KEYS: ${API_SECRET}
      POSTGRESQL_CONNECTION_URI: "postgresql://${POSTGRESQL_USER}:${POSTGRESQL_PASSWORD}@${POSTGRESQL_HOST}:${POSTGRESQL_PORT}/${POSTGRESQL_DATABASE_NAME}"
    ports:
      - 3567:3567

volumes:
  dbdata:
networks:
  app_network:
    driver: bridge

postgresqldb 컨테이너를 만들고, auth 컨테이너는 networks bridge 모드로 이 DB를 사용한다. Supertokens은 컨테이너가 실행되자마자 DB에 테이블을 만든다.

Docker compose 가 사용할 환경변수는

.env
DATABASE_URL=postgresql://postgres:password@localhost:5432/apidb?schema=public&connection_limit=5
POSTGRESQL_HOST=postgresqldb
POSTGRESQL_PORT=5432
POSTGRESQL_DATABASE_NAME=apidb
POSTGRESQL_USER=postgres
POSTGRESQL_PASSWORD=password

API

API는 Apollo Server Express를 사용하고 Express 미들웨어에 Supertokens 미들웨어를 걸어준다. 그리고 Prisma와 Nexus-Prisma를 사용한다. Nexus-Prisma는 도메인을 옮긴것 같다.

프로젝트 시작

mkdir supertokens-prisma-api & cd $_
git init
npm init -y
npx @eunchurn/init
yarn add -D prisma
yarn add @prisma/client \
         apollo-server-express \
         graphql \
         nexus \
         nexus-prisma \
         supertokens-node \
         dotenv
yarn prisma init

이제 Supertokens가 만든 DB 테이블을 Introspection 한다.

Prisma 스키마는 아래와 같은 초기 상태

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

generator nexusPrisma {
  provider = "nexus-prisma"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

DB Pull 을 하면,

yarn prisma db pull
prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

generator nexusPrisma {
  provider = "nexus-prisma"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model all_auth_recipe_users {
  user_id        String          @id @db.Char(36)
  recipe_id      String          @db.VarChar(128)
  time_joined    BigInt
  userid_mapping userid_mapping?

  @@index([time_joined(sort: Desc), user_id(sort: Desc)], map: "all_auth_recipe_users_pagination_index")
}

model emailpassword_pswd_reset_tokens {
  user_id             String              @db.Char(36)
  token               String              @unique @db.VarChar(128)
  token_expiry        BigInt
  emailpassword_users emailpassword_users @relation(fields: [user_id], references: [user_id], onDelete: Cascade)

  @@id([user_id, token])
  @@index([token_expiry], map: "emailpassword_password_reset_token_expiry_index")
}

model emailpassword_users {
  user_id                         String                            @id @db.Char(36)
  email                           String                            @unique @db.VarChar(256)
  password_hash                   String                            @db.VarChar(256)
  time_joined                     BigInt
  emailpassword_pswd_reset_tokens emailpassword_pswd_reset_tokens[]
}

model emailverification_tokens {
  user_id      String @db.VarChar(128)
  email        String @db.VarChar(256)
  token        String @unique @db.VarChar(128)
  token_expiry BigInt

  @@id([user_id, email, token])
  @@index([token_expiry], map: "emailverification_tokens_index")
}

model emailverification_verified_emails {
  user_id String @db.VarChar(128)
  email   String @db.VarChar(256)

  @@id([user_id, email])
}

model jwt_signing_keys {
  key_id     String  @id @db.VarChar(255)
  key_string String
  algorithm  String  @db.VarChar(10)
  created_at BigInt?
}

model key_value {
  name            String  @id @db.VarChar(128)
  value           String?
  created_at_time BigInt?
}

model passwordless_codes {
  code_id              String               @id @db.Char(36)
  device_id_hash       String               @db.Char(44)
  link_code_hash       String               @unique @db.Char(44)
  created_at           BigInt
  passwordless_devices passwordless_devices @relation(fields: [device_id_hash], references: [device_id_hash], onDelete: Cascade)

  @@index([created_at], map: "passwordless_codes_created_at_index")
  @@index([device_id_hash], map: "passwordless_codes_device_id_hash_index")
}

model passwordless_devices {
  device_id_hash     String               @id @db.Char(44)
  email              String?              @db.VarChar(256)
  phone_number       String?              @db.VarChar(256)
  link_code_salt     String               @db.Char(44)
  failed_attempts    Int
  passwordless_codes passwordless_codes[]

  @@index([email], map: "passwordless_devices_email_index")
  @@index([phone_number], map: "passwordless_devices_phone_number_index")
}

model passwordless_users {
  user_id      String  @id @db.Char(36)
  email        String? @unique @db.VarChar(256)
  phone_number String? @unique @db.VarChar(256)
  time_joined  BigInt
}

model role_permissions {
  role       String @db.VarChar(255)
  permission String @db.VarChar(255)
  roles      roles  @relation(fields: [role], references: [role], onDelete: Cascade, onUpdate: NoAction)

  @@id([role, permission])
  @@index([permission], map: "role_permissions_permission_index")
}

model roles {
  role             String             @id @db.VarChar(255)
  role_permissions role_permissions[]
  user_roles       user_roles[]
}

model session_access_token_signing_keys {
  created_at_time BigInt  @id
  value           String?
}

model session_info {
  session_handle       String  @id @db.VarChar(255)
  user_id              String  @db.VarChar(128)
  refresh_token_hash_2 String  @db.VarChar(128)
  session_data         String?
  expires_at           BigInt
  created_at_time      BigInt
  jwt_user_payload     String?
}

model thirdparty_users {
  third_party_id      String @db.VarChar(28)
  third_party_user_id String @db.VarChar(256)
  user_id             String @unique @db.Char(36)
  email               String @db.VarChar(256)
  time_joined         BigInt

  @@id([third_party_id, third_party_user_id])
}

model user_metadata {
  user_id       String @id @db.VarChar(128)
  user_metadata String
}

model user_roles {
  user_id String @db.VarChar(128)
  role    String @db.VarChar(255)
  roles   roles  @relation(fields: [role], references: [role], onDelete: Cascade, onUpdate: NoAction)

  @@id([user_id, role])
  @@index([role], map: "user_roles_role_index")
}

model userid_mapping {
  supertokens_user_id   String                @unique @db.Char(36)
  external_user_id      String                @unique @db.VarChar(128)
  external_user_id_info String?
  all_auth_recipe_users all_auth_recipe_users @relation(fields: [supertokens_user_id], references: [user_id], onDelete: Cascade, onUpdate: NoAction)

  @@id([supertokens_user_id, external_user_id])
}

Introspection 결과를 보면 스키마가 꽤 많다. API로는 모든 스키마를 노출시킬 필요가 없다. Supertokens가 제공해주는 유저에 대한 Recipe가 많기 때문에 우리가 사용할 User는 all_auth_recipe_users 테이블이 될 것 같다. 이 테이블을 User라고 생각하고, Post 테이블을 사용자와 연결해서 만들어보면,

prisma/schema.prisma
// omitted...
/// Post
model Post {
  id        Int                    @id @default(autoincrement())
  createdAt DateTime               @default(now())
  updatedAt DateTime               @updatedAt
  published Boolean                @default(false)
  title     String                 @db.VarChar(255)
  author    all_auth_recipe_users? @relation(fields: [authorId], references: [user_id])
  authorId  String?
}

/// Supertokens User
model all_auth_recipe_users {
  user_id        String          @id @db.Char(36)
  recipe_id      String          @db.VarChar(128)
  time_joined    BigInt
  userid_mapping userid_mapping?
  Post           Post[]

  @@index([time_joined(sort: Desc), user_id(sort: Desc)], map: "all_auth_recipe_users_pagination_index")
}
// omitted...

이렇게 스키마를 같이 유지 시켰다. 생각해보면 all_auth_recipe_users 테이블은 마음대로 required 필드를 추가할 순 없다. Supertokens는 우리가 추가한 필드를 알 수 없기 때문에... 고도화 할 경우 별도의 User 테이블이 필요할 수 도 있을 것 같다. 여기서는 일단 고려하지 않고 만들기도 한다.

이제 API를 만들기 전에 실행 스크립트는 이렇게 적는다.

package.json
{
  "scripts": {
    "dev": "ts-node-dev --respawn --exit-child --transpile-only src/index.ts",
    "generate": "prisma generate"
  }
}

Nexus schema

루트타입 만들기전에 Object 타입을 먼저 만들어서 테스트 해본다.

schema.ts
// https://graphql-nexus.github.io/nexus-prisma
import { all_auth_recipe_users, Post } from "nexus-prisma";
import { makeSchema, objectType } from "nexus";

export const schema = makeSchema({
  types: [
    objectType({
      name: all_auth_recipe_users.$name,
      description: all_auth_recipe_users.$description,
      definition(t) {
        t.field(all_auth_recipe_users.user_id);
      },
    }),
    objectType({
      name: Post.$name,
      description: Post.$description,
      definition(t) {
        t.field(Post.id);
      },
    }),
  ],
});

Prisma는 PascalCase를 선호하도록 되어있다. 그리고 복수형태는 지양하도록 되어있다. Post가 정형화된 형식인데 Supertokens는 snake_case를 사용한다. 그렇다면 개발자가 만들어내는 테이블은 PascalCase로 Supertokens는 snake_case로 컨벤션을 지정하면 될 것 같다.

Server

우선 Express app을 만들자. 여기서는 추후 비동기 함수들이 실행될 것이라 비동기로 app를 반환하는 함수를 만든다.

app.ts
import express from "express";
import "./supertokens";
import { middleware as supertokensMiddleware } from "supertokens-node/framework/express";

export async function getApp() {
  const app = express();
  app.use(express.json());
  app.disable("x-powered-by");
  app.use(supertokensMiddleware());
  return app;
}

그리고 Supertokens를 init하도록 하자. Recipe는 emailpassword, jwt 그리고 userroles를 사용할 것이며, dashboard도 만들어 둔다.

supertokens.ts
import supertokens from "supertokens-node";
import jwt from "supertokens-node/recipe/jwt";
import Session from "supertokens-node/recipe/session";
import EmailPassword from "supertokens-node/recipe/emailpassword";
import UserRoles from "supertokens-node/recipe/userroles";
import Dashboard from "supertokens-node/recipe/dashboard";

supertokens.init({
  framework: "express",
  supertokens: {
    connectionURI: process.env.AUTH_DOMAIN,
    apiKey: process.env.API_SECRET,
  },
  appInfo: {
    appName: "auth",
    apiDomain: process.env.API_DOMAIN,
    websiteDomain: process.env.WEB_DOMAIN,
    apiBasePath: "/auth",
    websiteBasePath: "/auth",
  },
  recipeList: [
    jwt.init(),
    EmailPassword.init({
      override: {
        functions: (originalImplementation) => {
          return {
            ...originalImplementation,

            // here we are only overriding the function that's responsible
            // for signing in a user.
            signIn: async function (input) {
              console.log("supertokens: signin");
              console.log(input);
              console.log(input.userContext._default);
              return await originalImplementation.signIn(input);
            },
            signUp: async function (input) {
              console.log("supertokens: signup");
              // TODO: some custom logic

              // or call the default behaviour as show below
              return await originalImplementation.signUp(input);
            },
            // ...
            // TODO: override more functions
          };
        },
      },
    }),
    UserRoles.init(),
    Session.init({
      jwt: {
        enable: true,
        // issuer: "",
      },
      cookieSameSite: "none",
    }), // initializes session features
    Dashboard.init({
      apiKey: "hello",
    }),
  ],
});

이제 Apollo Server를 만든다.

server.ts
import { ApolloServer } from "apollo-server-express";
import { getApp } from "./libs";
import { schema } from "./schema";
import { context } from "./context";

const port = process.env.PORT || "8000";

const server = new ApolloServer({
  schema,
  context,
});

export async function runServer() {
  await server.start();
  const app = await getApp();
  server.applyMiddleware({ app, path: "/" });
  await new Promise<void>((resolve) => app.listen(Number(port), resolve));
  console.info(
    `🚀 GraphQL service ready at http://localhost:${port}${server.graphqlPath}`,
  );
}

Context는 Session을 담을 예정이다. 우선 로그를 찍기 위해 Context를 만든다.

context.ts
import { ExpressContext } from "apollo-server-express";
import { Request, Response } from "express";
import { getSession, SessionContainer } from "supertokens-node/recipe/session";

export type Context = {
  req: Request;
  res: Response;
  session: SessionContainer;
};

export async function context({ req, res }: ExpressContext): Promise<Context> {
  const session = await getSession(req, res);
  console.log(session);
  return {
    req,
    res,
    session,
  };
}

이제 schema.ts에도 Context를 추가한다.

schema.ts
// https://graphql-nexus.github.io/nexus-prisma
import { all_auth_recipe_users, Post } from "nexus-prisma";
import { makeSchema, objectType } from "nexus";

export const schema = makeSchema({
  types: [
    objectType({
      name: all_auth_recipe_users.$name,
      description: all_auth_recipe_users.$description,
      definition(t) {
        t.field(all_auth_recipe_users.user_id);
      },
    }),
    objectType({
      name: Post.$name,
      description: Post.$description,
      definition(t) {
        t.field(Post.id);
      },
    }),
  ],
  contextType: {
    module: require.resolve("./context"),
    export: "Context",
  },
});

이제 환경변수를 추가한다.

.env
API_SECRET=apisecret
AUTH_DOMAIN=http://localhost:3567
API_DOMAIN=http://localhost:8000
WEB_DOMAIN=http://localhost:3000

여기까지의 commit은 여기에 남겨둔다.

Client

이제 React 앱으로 Supertokens를 설정해본다.

npx create-react-app supertokens-client --template=typescript
cd supertokens-client
yarn add supertokens-auth-react react-router-dom

react-router-dom을 사용할 것이다.

App.tsx
import React from "react";
import { BrowserRouter, Routes } from "react-router-dom";
import * as reactRouterDom from "react-router-dom";
import SuperTokens, {
  SuperTokensWrapper,
  getSuperTokensRoutesForReactRouterDom,
} from "supertokens-auth-react";
import EmailPassword from "supertokens-auth-react/recipe/emailpassword";
import Session from "supertokens-auth-react/recipe/session";

SuperTokens.init({
  appInfo: {
    appName: "auth",
    apiDomain: "http://localhost:8000",
    websiteDomain: "http://localhost:3000",
    apiBasePath: "/auth",
    websiteBasePath: "/auth",
  },
  recipeList: [EmailPassword.init(), Session.init()],
});

function App() {
  return (
    <SuperTokensWrapper>
      <BrowserRouter>
        <Routes>
          {/*This renders the login UI on the /auth route*/}
          {getSuperTokensRoutesForReactRouterDom(reactRouterDom)}
          {/*Your app routes*/}
        </Routes>
      </BrowserRouter>
    </SuperTokensWrapper>
  );
}

export default App;

앱을 실행하고 http://localhost:3000 으로 접속하면 오류가 나고, http://localhost:3000/auth 로 접속한다.

signin

이렇게 나오면 성공. 회원가입을 하기전에 세션이 있는 경우 세션을 출력하도록 해보자.

Home.tsx
import React from "react";
import { useSessionContext } from "supertokens-auth-react/recipe/session";

export function Home() {
  const session = useSessionContext();
  return <>{JSON.stringify(session)}</>;
}

그리고 App.tsx에 라우터를 추가하면,

App.tsx
function App() {
  return (
    <SuperTokensWrapper>
      <BrowserRouter>
        <Routes>
          {getSuperTokensRoutesForReactRouterDom(reactRouterDom)}
          <Route path="/" element={<Home />} />
        </Routes>
      </BrowserRouter>
    </SuperTokensWrapper>
  );
}

루트 패스에 세션 데이터가 출력된다. 이제 Apollo Client를 설정하고 세션이 잘 넘어오는지 테스트 해본다.

Apollo client

가장 심플하게 @apollo/clientgraphql을 설치하고 다음과 같이 Provider를 만든다.

yarn add @apollo/client graphql
ApolloProvider.tsx
import React from "react";
import { ApolloProvider } from "@apollo/client";
import { InMemoryCache, ApolloClient } from "@apollo/client";

export const client = new ApolloClient({
  uri: "http://localhost:8000",
  cache: new InMemoryCache(),
});

export function ApolloClientProvider(props: { children: React.ReactNode }) {
  return <ApolloProvider client={client}>{props.children}</ApolloProvider>;
}

이제 App.tsx에 Provider를 감싸준다.

App.tsx
import React from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import * as reactRouterDom from "react-router-dom";
import SuperTokens, {
  SuperTokensWrapper,
  getSuperTokensRoutesForReactRouterDom,
} from "supertokens-auth-react";
import EmailPassword from "supertokens-auth-react/recipe/emailpassword";
import Session from "supertokens-auth-react/recipe/session";
import { ApolloClientProvider } from "./ApolloProvider";
import { Home } from "./Home";

SuperTokens.init({
  appInfo: {
    appName: "auth",
    apiDomain: "http://localhost:8000",
    websiteDomain: "http://localhost:3000",
    apiBasePath: "/auth",
    websiteBasePath: "/auth",
  },
  recipeList: [EmailPassword.init(), Session.init()],
});

function App() {
  return (
    <SuperTokensWrapper>
      <ApolloClientProvider>
        <BrowserRouter>
          <Routes>
            {getSuperTokensRoutesForReactRouterDom(reactRouterDom)}
            <Route path="/" element={<Home />} />
          </Routes>
        </BrowserRouter>
      </ApolloClientProvider>
    </SuperTokensWrapper>
  );
}

export default App;

API로 가서 Context와 Object 타입을 변경하고 Query를 추가한다.

context.ts
import { ExpressContext } from "apollo-server-express";
import { Request, Response } from "express";
import { PrismaClient } from "@prisma/client";
import prisma from "./libs/prisma/client";
import { getSession, SessionContainer } from "supertokens-node/recipe/session";

export type Context = {
  req: Request;
  res: Response;
  session: SessionContainer | null;
  prisma: PrismaClient;
};

export async function context({ req, res }: ExpressContext): Promise<Context> {
  let session: SessionContainer | null = null;
  try {
    session = await getSession(req, res);
  } catch (e) {
    console.log(e);
  }
  return {
    req,
    res,
    session,
    prisma,
  };
}

여기서 세션 데이터가 있는 경우 세션을 담아 컨텍스트에 내려주며, Prisma 클라이언트도 추가하였다.

Object 타입은 Supertokens가 가지고 있는 테이블을 출력해보도록 한다. (나중엔 필요없음)

objects.ts
// https://graphql-nexus.github.io/nexus-prisma
import { all_auth_recipe_users, Post } from "nexus-prisma";
import { objectType } from "nexus";

export const allAuthRecipeUsers = objectType({
  name: all_auth_recipe_users.$name,
  description: all_auth_recipe_users.$description,
  definition(t) {
    t.field(all_auth_recipe_users.user_id);
    t.field(all_auth_recipe_users.recipe_id);
    t.field(all_auth_recipe_users.Post);
  },
});
export const PostType = objectType({
  name: Post.$name,
  description: Post.$description,
  definition(t) {
    t.field(Post.id);
  },
});

마지막으로 로그인한 사용자가 누구인지 getMe 쿼리를 통해 알 수 있다. 이제부터 Prisma 클라이언트를 사용해 많은 것을 할 수 있다. 팀원들에겐 여기서부터 빅뱅이 시작된다고 얘기한다.

queries.ts
export const getMe = queryField("getMe", {
  type: all_auth_recipe_users.$name,
  resolve(_root, _args, { session, prisma }) {
    if (!session) return null;
    const userId = session.getUserId();
    const user = prisma.all_auth_recipe_users.findUnique({
      where: { user_id: userId },
    });
    return user;
  },
});

이제 Home.tsx를 수정해서 쿼리를 날려보자

Home.tsx
import React from "react";
import { useSessionContext } from "supertokens-auth-react/recipe/session";
import { useQuery, gql } from "@apollo/client";

const query = gql`
  query me {
    getMe {
      user_id
      recipe_id
      Post {
        id
      }
    }
  }
`;

export function Home() {
  const { data, loading, error } = useQuery(query, {
    fetchPolicy: "network-only",
  });
  console.log({ data, loading, error });
  const session = useSessionContext();
  return (
    <div>
      <div>User ID: {data?.getMe!.user_id}</div>
      <div>Recipe: {data?.getMe!.recipe_id}</div>
      <div>From Session: {JSON.stringify(session)}</div>
    </div>
  );
}

클라이언트에서 내가 누구인지는 Session을 통해 알 수 있고 Backend도 내가 누구인지 알 수 있는 상태가 되었다.

지금까지의 API 코드는 여기 그리고 클라이언트 코드는 여기

다음주는 Headless 컴포넌트를 만들고 로그인, 로그아웃을 구현 그리고 비밀번호 찾기 및 새로 설정하는 등을 구현해보고 본 프로젝트에 적용할 예정이다.