- Published on
tWIL 2022.11 1주차
- Authors
- Name
- eunchurn
- @eunchurn
지지난 주에 인프라를 조금 더 배포했다. Supertokens 레지스트리로 Auth 컨테이너를 띄우고 기존 Cognito관련 코드들을 모두 삭제했다. AWS ECS에서 컨테이너를 띄우는 건 기존 Terraform ECS 코드를 참고하면 될 것 같다. 이제는 Supertokens을 설정할 차례이다. 로컬에서 테스트하는 방법은 docker compose로 뛰워서 테스트 해보는 것이다.
Docker compose
API는 AWS RDS Aurora PostgreSQL Serverless를 쓴다고 가정하면 로컬 컨테이너 환경은 아래 컴포즈 파일과 같이 설정한다.
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 가 사용할 환경변수는
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 스키마는 아래와 같은 초기 상태
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
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 테이블을 사용자와 연결해서 만들어보면,
// 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를 만들기 전에 실행 스크립트는 이렇게 적는다.
{
"scripts": {
"dev": "ts-node-dev --respawn --exit-child --transpile-only src/index.ts",
"generate": "prisma generate"
}
}
Nexus schema
루트타입 만들기전에 Object 타입을 먼저 만들어서 테스트 해본다.
// 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
를 반환하는 함수를 만든다.
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
도 만들어 둔다.
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를 만든다.
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를 만든다.
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를 추가한다.
// 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",
},
});
이제 환경변수를 추가한다.
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
을 사용할 것이다.
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 로 접속한다.
이렇게 나오면 성공. 회원가입을 하기전에 세션이 있는 경우 세션을 출력하도록 해보자.
import React from "react";
import { useSessionContext } from "supertokens-auth-react/recipe/session";
export function Home() {
const session = useSessionContext();
return <>{JSON.stringify(session)}</>;
}
그리고 App.tsx
에 라우터를 추가하면,
function App() {
return (
<SuperTokensWrapper>
<BrowserRouter>
<Routes>
{getSuperTokensRoutesForReactRouterDom(reactRouterDom)}
<Route path="/" element={<Home />} />
</Routes>
</BrowserRouter>
</SuperTokensWrapper>
);
}
루트 패스에 세션 데이터가 출력된다. 이제 Apollo Client를 설정하고 세션이 잘 넘어오는지 테스트 해본다.
Apollo client
가장 심플하게 @apollo/client
와 graphql
을 설치하고 다음과 같이 Provider를 만든다.
yarn add @apollo/client graphql
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를 감싸준다.
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를 추가한다.
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가 가지고 있는 테이블을 출력해보도록 한다. (나중엔 필요없음)
// 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 클라이언트를 사용해 많은 것을 할 수 있다. 팀원들에겐 여기서부터 빅뱅이 시작된다고 얘기한다.
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
를 수정해서 쿼리를 날려보자
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 컴포넌트를 만들고 로그인, 로그아웃을 구현 그리고 비밀번호 찾기 및 새로 설정하는 등을 구현해보고 본 프로젝트에 적용할 예정이다.