Bun 1.0 + Elysia.js Auth 따라하기
포스트
취소

Bun 1.0 + Elysia.js Auth 따라하기

Bun 런타임 기반의 Elysia.js 프레임워크를 이용한 Auth API 구현을 따라합니다.

0. 개요

  • Bun 1.0.2
  • Elysia.js + JWT + cookie
  • Drizzle ORM

출처

Add JWT Authentication in Bun API

1. elysia 프로젝트 생성

1
2
3
4
5
bun create elysia auth-api
cd auth-api
# bun install --backend=copyfile

bun dev

supabase postgresql

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
-- UUID extension 확인
select Uuid_generate_v4();

-- 테이블 생성
create table bun_users (
  id UUID DEFAULT Uuid_generate_v4(),
  name TEXT NOT NULL,
  username TEXT NOT NULL,
  email TEXT NOT NULL,
  salt varchar(256) not null,
  hash varchar(512) not null,
  summary TEXT,
  links json,
  location json,
  profile_image text not null,
  created date default now(),
  updated date default now(),
  PRIMARY KEY (id),
  unique(username, email)
)

-- 테스트 데이터
insert into bun_users(name, username, email, salt, hash, summary, profile_image) values(
  'bob', 'bob@email.com', 'bob@email.com', 'salt-key', 'hash-value', 'test user', 'bob-email-com'
)

-- 확인
select * from bun_users;

.env.local 환경변수

Bun 런타임에서는 {process 또는 Bun}.env.${변수명} 으로 읽어올 수 있다.

1
2
DATABASE_URL="postgresql://postgres:postgres@localhost:54322/postgres"
JWT_SECRET="hello-elysia-auth"

.env.local 파일의 우선순위가 .env 보다 높다 (dotenv)

tsconfig.json import path 설정

1
2
3
4
5
6
7
8
{
  "compilerOptions": {
    "paths": {
      // Re-map import paths with Bun
      "$lib/*": ["./src/lib/*"]
    }
  }
}

2. Drizzle ORM

dependency 추가

1
2
bun add drizzle-orm postgres
# bun install --backend=copyfile

schema 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/lib/db/schema.ts
import { sql } from 'drizzle-orm';
import { pgTable, uuid, date, json, text, varchar } from 'drizzle-orm/pg-core';

export const users = pgTable('bun_users', {
  id: uuid('id')
    .default(sql`Uuid_generate_v4()`)
    .primaryKey(),
  name: text('name').notNull(),
  username: text('username').notNull().unique(),
  email: text('email').notNull().unique(),
  salt: varchar('salt', { length: 256 }).notNull(),
  hash: varchar('hash', { length: 512 }).notNull(),
  summary: text('summary'),
  links: json('links'), // 인덱싱 또는 쿼리 필요시에는,
  location: json('location'), // jsonb 사용 (json binary)
  profileImage: text('profile_image').notNull(),
  createdAt: date('created').default(sql`now()`),
  updatedAt: date('updated').default(sql`now()`),
});

client 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/lib/db/index.ts
import { sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';

const connectionString = Bun.env.DATABASE_URL!;
const client = postgres(connectionString);
const db = drizzle(client, { schema });

// 테스트용 쿼리 `select count(*) from users`
const result = await db
  .select({ count: sql<number>`count(*)` })
  .from(schema.users);
const usersSize = result ? result[0].count : -1;
console.log(`users size = ${usersSize}`);

export { db };

users 쿼리

  • email 조건으로 users 조회
  • users 삽입 : 완료후 데이터 반환
  • email 또는 username 으로 users 조회
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { eq, or, and } from 'drizzle-orm';
import { db } from '$lib/db';
import { users } from '$lib/db/schema';

const emailExists = await db // result
  .select({ id: users.id })
  .from(users)
  .where(eq(users.email, email));
console.log('emailExists:', emailExists);

const newUser = await db
  .insert(users)
  .values({
    name, email, hash, salt, username, profileImage,
  })
  .returning();
console.log('newUser:', newUser);

const user = await db.query.users.findFirst({
  columns: {
    id: true,
    hash: true,
    salt: true,
  },
  where: (users, { or, eq }) =>
    or(eq(users.email, username), eq(users.username, username)),
});
console.log('user:', user);  

3. Auth API

dependency 추가

1
2
bun add @elysiajs/cookie @elysiajs/jwt
# bun install --backend=copyfile

elysia routes

  • /
    • /api ⬅ jwt, cookie 적용
      • /auth
        • /signup POST ⬅ create user
        • /login POST ⬅ select user, verify password
      • /me ⬅ isAuthenticated 적용 (cookie 로부터 userId 검사)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const app = new Elysia()
  .group('/api', (app) =>
    app
      .use(
        jwt({
          name: 'jwt',
          secret: Bun.env.JWT_SECRET!,
        })
      )
      .use(cookie())
      .use(auth)
  )
  .get('/', () => 'Hello Elysia')
  .listen(8080);

참고 : Elysia - Plugin group

1
2
3
4
5
6
7
8
9
10
11
import { Elysia } from 'elysia'

const users = new Elysia({ prefix: '/user' })
    .post('/sign-in', signIn)
    .post('/sign-up', signUp)
    .post('/profile', getProfile)

app.group('/v1', app => app
    .get('/', () => 'Using v1')
    .use(users)
)

/api/auth/signup POST

  • body 로부터 email, name, password, username 받기
  • 동일한 user 있는지 db 조회
    • 동일한 email 또는 동일한 username
    • 있으면, Bad Request(400)
  • user 데이터 생성
    • hashPassword(password) ➡ hash, salt 생성
    • profileImage URL 생성
  • db 에 user 추가 : insert returning
  • success 결과 반환
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "success": true,
  "message": "Account created",
  "data": {
    "user": [
      {
        "id": "d84ac188-ebdc-4db1-8d04-ae4965029ef7",
        "name": "test2 user",
        "username": "test2user",
        "email": "test2@email.com",
        // ...
      }
    ]
  }
}

/api/auth/login POST

  • body 로부터 username, password 받기
  • 동일한 user 있는지 db 조회
    • 동일한 email 또는 동일한 username
    • 있으면, Bad Request(400)
  • password 확인
    • comparePassword(password, user.salt, user.hash)
    • 일치하지 않으면, Bad Request(400)
  • access_token 생성 및 저장
    • access_token 생성 : userId 포함
    • setCookie 저장 : maxAge 설정 (15분)
  • success 결과 반환
1
2
3
4
5
{
  "success": true,
  "data": null,
  "message": "Account login successfully"
}

/api/auth/me GET

isAuthenticated 플러그인에 의해 보호된 경로

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "success": true,
  "message": "Fetch authenticated user details",
  "data": {
    "user": {
      "id": "8795625d-0ec6-4441-8687-ef9e9a826e96",
      "name": "test1 user",
      "username": "test1user",
      "email": "test1@email.com",
      // ...
    }
  }
}

isAuthenticated 플러그인

  • cookie.access_token 확인
    • 없으면, Unauthorized(401)
  • user 확인
    • access_token 에서 userId 추출
    • db 에서 userId 로 user 조회
    • 없으면, Unauthorized(401)
  • user 정보 반환

Password 처리

Bun 내장 API 에도 Bun.CryptoHasher 기능이 있다. (“blake2b256”, “sha512” 등등..)

구현된 함수

  • hashPassword(password) : 암호화
    • salt = randomBytes(16)
    • password = pbkdf2(password, salt, …, ‘sha512’)
  • comparePassword(password, salt, hash) : 암호화 값 비교
    • pbkdf2(password, salt, …, ‘sha512’) 재생성
    • db 에 저장된 password 와 비교
  • md5hash(email) : 이메일 난독화
    • createHash(‘md5’) 로 text 변환

9. Review

  • 익숙해질 때까지 반복 숙달할 목적으로 auth 구현 코드를 살펴보았다.

  • vscode 에서 플러그인으로 삽입된 jwt, setCookie 에 대해 빨간줄이 뜬다.
    • 빨간줄이 없어지지 않는게 많이 거슬린다. (TS 가 싫어지는 이유)
  • elysia 설정은 함수형 프로그래밍 스타일이라 코드가 어색하다.
    • 함수 파라미터로 함수를 넣는데, 나름의 스타일로 정리가 필요하다.
    • zod 같은 타입 가드 기능(t)이 있어 편리하다.
  • 원본의 prisma 대신에 drizzle 을 사용해 보았다. 아주 좋다.
    • 그냥 sql 적용하고, 그에 맞춰서 수작업으로 schema 작성하는게 편하다.

 
 

끝!   읽어주셔서 감사합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.