๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
์„ธ์…˜๊ณผ JWT ํ† ํฐ์˜ ์ƒ๋ช…์ฃผ๊ธฐ๋ฅผ ํšจ๊ณผ์ ์œผ๋กœ ๊ด€๋ฆฌํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ด…๋‹ˆ๋‹ค. ์„ธ์…˜ ๊ด€๋ฆฌ๋Š” ์‚ฌ์šฉ์ž ๊ฒฝํ—˜๊ณผ ๋ณด์•ˆ์— ์ง์ ‘์ ์ธ ์˜ํ–ฅ์„ ๋ฏธ์น˜๋Š” ์ค‘์š”ํ•œ ์ฃผ์ œ์ž…๋‹ˆ๋‹ค.

์„ธ์…˜ ๊ด€๋ฆฌ ๊ฐœ์š”

์„ธ์…˜ ์Šคํ† ์–ด

Redis, PostgreSQLํ™•์žฅ ๊ฐ€๋Šฅํ•œ ์ €์žฅ์†Œ

์„ธ์…˜ ๋งŒ๋ฃŒ

์ž๋™ ๋งŒ๋ฃŒ์Šฌ๋ผ์ด๋”ฉ ๋งŒ๋ฃŒ

ํ† ํฐ ๊ฐฑ์‹ 

Refresh TokenAccess Token ์žฌ๋ฐœ๊ธ‰

๋กœ๊ทธ์•„์›ƒ

์„ธ์…˜ ๋ฌดํšจํ™”ํ† ํฐ ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ

์„ธ์…˜ ๊ธฐ๋ฐ˜ ์ธ์ฆ

์„ธ์…˜์˜ ๋™์ž‘ ์›๋ฆฌ

์„ธ์…˜ ๊ธฐ๋ฐ˜ ์ธ์ฆ์€ ์„œ๋ฒ„๊ฐ€ ์‚ฌ์šฉ์ž์˜ ๋กœ๊ทธ์ธ ์ƒํƒœ๋ฅผ ์„œ๋ฒ„ ์ธก์— ์ €์žฅํ•˜๋Š” ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค. ๋™์ž‘ ๋ฐฉ์‹์„ ์ดํ•ดํ•˜๋ฉด ๋ณด์•ˆ๊ณผ ํ™•์žฅ์„ฑ ๋ฌธ์ œ๋ฅผ ๋” ์ž˜ ๋‹ค๋ฃฐ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์„ธ์…˜ ์ธ์ฆ ํ๋ฆ„:
  1. ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธํ•˜๋ฉด ์„œ๋ฒ„๊ฐ€ ์„ธ์…˜ ID๋ฅผ ์ƒ์„ฑ
  2. ์„ธ์…˜ ๋ฐ์ดํ„ฐ(์‚ฌ์šฉ์ž ID ๋“ฑ)๋ฅผ ์„ธ์…˜ ์Šคํ† ์–ด์— ์ €์žฅ
  3. ์„ธ์…˜ ID๋ฅผ ์ฟ ํ‚ค๋กœ ํด๋ผ์ด์–ธํŠธ์— ์ „์†ก
  4. ์ดํ›„ ์š”์ฒญ๋งˆ๋‹ค ์ฟ ํ‚ค์˜ ์„ธ์…˜ ID๋กœ ์‚ฌ์šฉ์ž ์ธ์ฆ
์žฅ์ :
  • ์„œ๋ฒ„๊ฐ€ ์„ธ์…˜์„ ์™„์ „ํžˆ ์ œ์–ด (์–ธ์ œ๋“  ๋ฌดํšจํ™” ๊ฐ€๋Šฅ)
  • ๋ฏผ๊ฐํ•œ ์ •๋ณด๊ฐ€ ํด๋ผ์ด์–ธํŠธ์— ๋…ธ์ถœ๋˜์ง€ ์•Š์Œ
  • ๊ตฌํ˜„์ด ๊ฐ„๋‹จํ•˜๊ณ  ๊ฒ€์ฆ๋œ ๋ฐฉ์‹
๋‹จ์ :
  • ์„œ๋ฒ„์— ์ƒํƒœ๋ฅผ ์ €์žฅํ•˜๋ฏ€๋กœ ํ™•์žฅ์„ฑ ๊ณ ๋ ค ํ•„์š”
  • ์„ธ์…˜ ์Šคํ† ์–ด๊ฐ€ ๋‹จ์ผ ์‹คํŒจ ์ง€์ (SPOF)์ด ๋  ์ˆ˜ ์žˆ์Œ

์„ธ์…˜ ์Šคํ† ์–ด ์„ค์ •

๋ฉ”๋ชจ๋ฆฌ ์Šคํ† ์–ด (๊ฐœ๋ฐœ์šฉ): ๋ฉ”๋ชจ๋ฆฌ ์Šคํ† ์–ด๋Š” ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋งŒ ์‚ฌ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์„œ๋ฒ„๋ฅผ ์žฌ์‹œ์ž‘ํ•˜๋ฉด ๋ชจ๋“  ์„ธ์…˜์ด ์‚ฌ๋ผ์ง€๊ณ , ์—ฌ๋Ÿฌ ์„œ๋ฒ„ ์ธ์Šคํ„ด์Šค ๊ฐ„์— ์„ธ์…˜์„ ๊ณต์œ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
// server.ts (๊ฐœ๋ฐœ ํ™˜๊ฒฝ)
import fastify from "fastify";
import fastifySession from "@fastify/session";
import fastifyCookie from "@fastify/cookie";

const app = fastify();

app.register(fastifyCookie);

app.register(fastifySession, {
  secret: process.env.SESSION_SECRET!,
  cookie: {
    secure: false, // ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋Š” HTTP ํ—ˆ์šฉ
    httpOnly: true,
    maxAge: 1000 * 60 * 60 * 24, // 1์ผ
  },
  saveUninitialized: false,
  // store๋ฅผ ์ง€์ •ํ•˜์ง€ ์•Š์œผ๋ฉด ๋ฉ”๋ชจ๋ฆฌ ์Šคํ† ์–ด ์‚ฌ์šฉ
});
๋ฉ”๋ชจ๋ฆฌ ์Šคํ† ์–ด์˜ ๋ฌธ์ œ์ :
  • ์„œ๋ฒ„ ์žฌ์‹œ์ž‘ ์‹œ ๋ชจ๋“  ์„ธ์…˜ ์†์‹ค
  • ์—ฌ๋Ÿฌ ์„œ๋ฒ„ ์ธ์Šคํ„ด์Šค ๊ฐ„ ์„ธ์…˜ ๊ณต์œ  ๋ถˆ๊ฐ€
  • ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ์ฆ๊ฐ€๋กœ ์„ฑ๋Šฅ ์ €ํ•˜ ๊ฐ€๋Šฅ
  • ํ”„๋กœ๋•์…˜์—์„œ๋Š” ์ ˆ๋Œ€ ์‚ฌ์šฉ ๊ธˆ์ง€
Redis ์Šคํ† ์–ด (ํ”„๋กœ๋•์…˜): ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ๋Š” Redis๋ฅผ ์„ธ์…˜ ์Šคํ† ์–ด๋กœ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์—…๊ณ„ ํ‘œ์ค€์ž…๋‹ˆ๋‹ค. Redis๋Š” ๋น ๋ฅด๊ณ , ํ™•์žฅ ๊ฐ€๋Šฅํ•˜๋ฉฐ, ์—ฌ๋Ÿฌ ์„œ๋ฒ„ ์ธ์Šคํ„ด์Šค ๊ฐ„ ์„ธ์…˜ ๊ณต์œ ๋ฅผ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.
npm install @fastify/redis connect-redis
# or
pnpm add @fastify/redis connect-redis
// server.ts (ํ”„๋กœ๋•์…˜)
import fastify from "fastify";
import fastifySession from "@fastify/session";
import fastifyCookie from "@fastify/cookie";
import fastifyRedis from "@fastify/redis";
import RedisStore from "connect-redis";

const app = fastify();

// Redis ์—ฐ๊ฒฐ
app.register(fastifyRedis, {
  host: process.env.REDIS_HOST || "localhost",
  port: parseInt(process.env.REDIS_PORT || "6379"),
  password: process.env.REDIS_PASSWORD,
  // ์—ฐ๊ฒฐ ํ’€ ์„ค์ •
  lazyConnect: false,
  enableReadyCheck: true,
});

app.register(fastifyCookie);

// Redis๋ฅผ ์„ธ์…˜ ์Šคํ† ์–ด๋กœ ์‚ฌ์šฉ
app.register(fastifySession, {
  secret: process.env.SESSION_SECRET!,
  store: new RedisStore({
    client: app.redis, // Fastify Redis ํด๋ผ์ด์–ธํŠธ ์‚ฌ์šฉ
    prefix: "sess:", // ์„ธ์…˜ ํ‚ค ์ ‘๋‘์‚ฌ
    ttl: 60 * 60 * 24 * 7, // 7์ผ (์ดˆ ๋‹จ์œ„)
  }),
  cookie: {
    secure: true, // HTTPS ํ•„์ˆ˜
    httpOnly: true, // XSS ๋ฐฉ์ง€
    sameSite: "strict", // CSRF ๋ฐฉ์ง€
    maxAge: 1000 * 60 * 60 * 24 * 7, // 7์ผ
  },
  saveUninitialized: false,
  resave: false,
});
Redis ์„ค์ • ์„ค๋ช…:
  • prefix: ์„ธ์…˜ ํ‚ค์— ์ ‘๋‘์‚ฌ๋ฅผ ๋ถ™์—ฌ ๋‹ค๋ฅธ ๋ฐ์ดํ„ฐ์™€ ๊ตฌ๋ถ„
  • ttl: Time To Live, ์„ธ์…˜ ์ž๋™ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ (์ดˆ ๋‹จ์œ„)
  • client: Redis ํด๋ผ์ด์–ธํŠธ ์ธ์Šคํ„ด์Šค
Redis๋Š” ํด๋Ÿฌ์Šคํ„ฐ ๋ชจ๋“œ๋ฅผ ์ง€์›ํ•˜์—ฌ ๊ณ ๊ฐ€์šฉ์„ฑ(HA)์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. AWS ElastiCache, Azure Cache for Redis ๋“ฑ์˜ ๊ด€๋ฆฌํ˜• ์„œ๋น„์Šค๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์šด์˜์ด ๋” ํŽธ๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
PostgreSQL ์Šคํ† ์–ด (๋Œ€์•ˆ): ์ด๋ฏธ PostgreSQL์„ ์‚ฌ์šฉ ์ค‘์ด๋ผ๋ฉด ๋ณ„๋„์˜ Redis ์—†์ด PostgreSQL์„ ์„ธ์…˜ ์Šคํ† ์–ด๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋‹จ, ์„ฑ๋Šฅ์€ Redis๋ณด๋‹ค ๋‚ฎ์Šต๋‹ˆ๋‹ค.
npm install connect-pg-simple
# or
pnpm add connect-pg-simple
import session from "express-session";
import connectPgSimple from "connect-pg-simple";
import pg from "pg";

const PgStore = connectPgSimple(session);
const pgPool = new pg.Pool({
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || "5432"),
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
});

app.register(fastifySession, {
  secret: process.env.SESSION_SECRET!,
  store: new PgStore({
    pool: pgPool,
    tableName: "sessions", // ์„ธ์…˜ ํ…Œ์ด๋ธ”๋ช…
  }),
  cookie: {
    secure: true,
    httpOnly: true,
    maxAge: 1000 * 60 * 60 * 24 * 7,
  },
});

์„ธ์…˜ ๋งŒ๋ฃŒ ์ „๋žต

๊ณ ์ • ๋งŒ๋ฃŒ (Fixed Expiration): ์„ธ์…˜์ด ์ƒ์„ฑ๋œ ์‹œ์ ๋ถ€ํ„ฐ ์ •ํ•ด์ง„ ์‹œ๊ฐ„์ด ์ง€๋‚˜๋ฉด ๋ฌด์กฐ๊ฑด ๋งŒ๋ฃŒ๋ฉ๋‹ˆ๋‹ค. ๋ณด์•ˆ์„ฑ์€ ๋†’์ง€๋งŒ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์ด ์ข‹์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
app.register(fastifySession, {
  secret: process.env.SESSION_SECRET!,
  cookie: {
    maxAge: 1000 * 60 * 60 * 2, // 2์‹œ๊ฐ„ ํ›„ ๋ฌด์กฐ๊ฑด ๋งŒ๋ฃŒ
  },
  rolling: false, // ์Šฌ๋ผ์ด๋”ฉ ๋งŒ๋ฃŒ ๋น„ํ™œ์„ฑํ™”
});
์Šฌ๋ผ์ด๋”ฉ ๋งŒ๋ฃŒ (Sliding Expiration): ์‚ฌ์šฉ์ž๊ฐ€ ํ™œ๋™ํ•  ๋•Œ๋งˆ๋‹ค ์„ธ์…˜ ๋งŒ๋ฃŒ ์‹œ๊ฐ„์ด ๊ฐฑ์‹ ๋ฉ๋‹ˆ๋‹ค. โ€œRemember Meโ€ ๊ธฐ๋Šฅ์— ์ ํ•ฉํ•˜๋ฉฐ, ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์ด ์ข‹์Šต๋‹ˆ๋‹ค.
app.register(fastifySession, {
  secret: process.env.SESSION_SECRET!,
  cookie: {
    maxAge: 1000 * 60 * 60 * 24 * 7, // 7์ผ
  },
  rolling: true, // ๋งค ์š”์ฒญ๋งˆ๋‹ค ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ๊ฐฑ์‹ 
  resave: false, // ์„ธ์…˜์ด ๋ณ€๊ฒฝ๋˜์ง€ ์•Š์•„๋„ ์ €์žฅ
});
ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ์ „๋žต: ์ดˆ๊ธฐ์—๋Š” ์งง์€ ๋งŒ๋ฃŒ ์‹œ๊ฐ„์„ ์„ค์ •ํ•˜๊ณ , โ€œRemember Meโ€๋ฅผ ์„ ํƒํ•˜๋ฉด ๊ธด ๋งŒ๋ฃŒ ์‹œ๊ฐ„์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.
class AuthModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async login(params: {
    email: string;
    password: string;
    rememberMe?: boolean;
  }): Promise<{ user: User }> {
    const { email, password, rememberMe } = params;
    const context = Sonamu.getContext();
    
    // ์ธ์ฆ ๋กœ์ง...
    const user = await this.authenticateUser(email, password);
    
    // Remember Me์— ๋”ฐ๋ผ ์„ธ์…˜ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์กฐ์ •
    if (rememberMe) {
      context.request.session.cookie.maxAge = 1000 * 60 * 60 * 24 * 30; // 30์ผ
    } else {
      context.request.session.cookie.maxAge = 1000 * 60 * 60 * 2; // 2์‹œ๊ฐ„
    }
    
    // ์„ธ์…˜์— ๋กœ๊ทธ์ธ ์ •๋ณด ์ €์žฅ
    await this.loginToSession(user);
    
    return { user };
  }
}

์„ธ์…˜ ๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ

์„ธ์…˜์—๋Š” ์ตœ์†Œํ•œ์˜ ์ •๋ณด๋งŒ ์ €์žฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์„ธ์…˜ ํฌ๊ธฐ๊ฐ€ ์ปค์ง€๋ฉด ์„ฑ๋Šฅ์ด ์ €ํ•˜๋˜๊ณ , ์Šคํ† ๋ฆฌ์ง€ ๋น„์šฉ์ด ์ฆ๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
// โœ… ์ข‹์€ ์˜ˆ: ์ตœ์†Œ ์ •๋ณด๋งŒ ์ €์žฅ
context.request.session.userId = user.id;
context.request.session.role = user.role;

// โŒ ๋‚˜์œ ์˜ˆ: ๋„ˆ๋ฌด ๋งŽ์€ ์ •๋ณด ์ €์žฅ
context.request.session.user = {
  id: user.id,
  email: user.email,
  username: user.username,
  bio: user.bio,
  profileImage: user.profileImage,
  preferences: user.preferences,
  // ... ์ˆ˜๋งŽ์€ ํ•„๋“œ
};
์„ธ์…˜ ๋ฐ์ดํ„ฐ ์ ‘๊ทผ:
class UserModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async getProfile(): Promise<{ user: User }> {
    const context = Sonamu.getContext();
    
    // ์„ธ์…˜์—์„œ ์‚ฌ์šฉ์ž ID ๊ฐ€์ ธ์˜ค๊ธฐ
    const userId = context.request.session.userId;
    
    if (!userId) {
      throw new Error("Not authenticated");
    }
    
    // ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์ตœ์‹  ์ •๋ณด ์กฐํšŒ
    const rdb = this.getPuri("r");
    const user = await rdb
      .table("users")
      .where("id", userId)
      .first();
    
    return { user };
  }
}

JWT ํ† ํฐ ๊ด€๋ฆฌ

JWT์˜ ๋™์ž‘ ์›๋ฆฌ

JWT(JSON Web Token)๋Š” ์ƒํƒœ๋ฅผ ์ €์žฅํ•˜์ง€ ์•Š๋Š”(stateless) ์ธ์ฆ ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค. ์„œ๋ฒ„๊ฐ€ ํ† ํฐ์„ ์ƒ์„ฑํ•œ ํ›„์—๋Š” ํ† ํฐ ์ž์ฒด์— ๋ชจ๋“  ์ •๋ณด๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์–ด, ์„œ๋ฒ„๊ฐ€ ๋ณ„๋„๋กœ ์ €์žฅํ•  ํ•„์š”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. JWT ๊ตฌ์กฐ:
header.payload.signature
  • Header: ํ† ํฐ ํƒ€์ž…๊ณผ ์•Œ๊ณ ๋ฆฌ์ฆ˜ (์˜ˆ: {"alg": "HS256", "typ": "JWT"})
  • Payload: ์‚ฌ์šฉ์ž ์ •๋ณด์™€ ํด๋ ˆ์ž„ (์˜ˆ: {"userId": 123, "role": "user"})
  • Signature: ํ† ํฐ์˜ ๋ฌด๊ฒฐ์„ฑ์„ ๊ฒ€์ฆํ•˜๋Š” ์„œ๋ช…
JWT์˜ ์žฅ์ :
  • ์„œ๋ฒ„๊ฐ€ ์ƒํƒœ๋ฅผ ์ €์žฅํ•˜์ง€ ์•Š์•„ ํ™•์žฅ์„ฑ์ด ์ข‹์Œ
  • ๋งˆ์ดํฌ๋กœ์„œ๋น„์Šค ํ™˜๊ฒฝ์— ์ ํ•ฉ
  • ํ† ํฐ ์ž์ฒด์— ์ •๋ณด๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์–ด ๋ณ„๋„ ์กฐํšŒ ๋ถˆํ•„์š”
JWT์˜ ๋‹จ์ :
  • ํ† ํฐ์„ ์„œ๋ฒ„์—์„œ ๊ฐ•์ œ๋กœ ๋ฌดํšจํ™”ํ•˜๊ธฐ ์–ด๋ ค์›€
  • ํ† ํฐ ํฌ๊ธฐ๊ฐ€ ์ฟ ํ‚ค๋ณด๋‹ค ํผ
  • ํ† ํฐ์ด ํƒˆ์ทจ๋˜๋ฉด ๋งŒ๋ฃŒ ์ „๊นŒ์ง€ ์‚ฌ์šฉ ๊ฐ€๋Šฅ

Access Token๊ณผ Refresh Token

Access Token์€ API ์š”์ฒญ์— ์‚ฌ์šฉ๋˜๋Š” ์งง์€ ์ˆ˜๋ช…์˜ ํ† ํฐ์ž…๋‹ˆ๋‹ค. ๋ณดํ†ต 15๋ถ„~1์‹œ๊ฐ„ ์ •๋„์˜ ์งง์€ ์œ ํšจ๊ธฐ๊ฐ„์„ ๊ฐ€์ง‘๋‹ˆ๋‹ค. ํƒˆ์ทจ๋˜๋”๋ผ๋„ ํ”ผํ•ด๋ฅผ ์ตœ์†Œํ™”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. Refresh Token์€ Access Token์„ ์žฌ๋ฐœ๊ธ‰๋ฐ›๊ธฐ ์œ„ํ•œ ๊ธด ์ˆ˜๋ช…์˜ ํ† ํฐ์ž…๋‹ˆ๋‹ค. ๋ณดํ†ต 7์ผ~30์ผ ์ •๋„์˜ ์œ ํšจ๊ธฐ๊ฐ„์„ ๊ฐ€์ง€๋ฉฐ, ์„œ๋ฒ„์— ์ €์žฅํ•˜์—ฌ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์™œ ๋‘ ๊ฐœ์˜ ํ† ํฐ์„ ์‚ฌ์šฉํ•˜๋‚˜์š”? ๋งŒ์•ฝ Access Token์˜ ์œ ํšจ๊ธฐ๊ฐ„์„ 30์ผ๋กœ ์„ค์ •ํ•˜๋ฉด:
  • ํ† ํฐ์ด ํƒˆ์ทจ๋˜๋ฉด 30์ผ ๋™์•ˆ ์•…์šฉ๋  ์ˆ˜ ์žˆ์Œ
  • ์‚ฌ์šฉ์ž ๊ถŒํ•œ ๋ณ€๊ฒฝ์ด ์ฆ‰์‹œ ๋ฐ˜์˜๋˜์ง€ ์•Š์Œ
Access Token์„ ์งง๊ฒŒ ์„ค์ •ํ•˜๊ณ  Refresh Token์œผ๋กœ ๊ฐฑ์‹ ํ•˜๋ฉด:
  • ํƒˆ์ทจ๋œ Access Token์€ ์งง์€ ์‹œ๊ฐ„๋งŒ ์œ ํšจ
  • ๊ถŒํ•œ ๋ณ€๊ฒฝ์„ ๋น ๋ฅด๊ฒŒ ๋ฐ˜์˜ ๊ฐ€๋Šฅ
  • Refresh Token์€ ์„œ๋ฒ„์— ์ €์žฅํ•˜์—ฌ ๋ฌดํšจํ™” ๊ฐ€๋Šฅ
// auth/jwt.ts
import jwt from "jsonwebtoken";

const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!;

/**
 * Access Token ์ƒ์„ฑ (์งง์€ ์ˆ˜๋ช…)
 */
export function generateAccessToken(user: User): string {
  return jwt.sign(
    {
      userId: user.id,
      email: user.email,
      role: user.role,
    },
    ACCESS_TOKEN_SECRET,
    {
      expiresIn: "15m", // 15๋ถ„
    }
  );
}

/**
 * Refresh Token ์ƒ์„ฑ (๊ธด ์ˆ˜๋ช…)
 */
export function generateRefreshToken(user: User): string {
  return jwt.sign(
    {
      userId: user.id,
      // Refresh Token์—๋Š” ์ตœ์†Œ ์ •๋ณด๋งŒ
    },
    REFRESH_TOKEN_SECRET,
    {
      expiresIn: "7d", // 7์ผ
    }
  );
}

/**
 * Access Token ๊ฒ€์ฆ
 */
export function verifyAccessToken(token: string) {
  try {
    return jwt.verify(token, ACCESS_TOKEN_SECRET);
  } catch (error) {
    return null;
  }
}

/**
 * Refresh Token ๊ฒ€์ฆ
 */
export function verifyRefreshToken(token: string) {
  try {
    return jwt.verify(token, REFRESH_TOKEN_SECRET);
  } catch (error) {
    return null;
  }
}
๋ณด์•ˆ ์ฃผ์˜์‚ฌํ•ญ:
  • Access Token๊ณผ Refresh Token์˜ Secret์„ ๋ฐ˜๋“œ์‹œ ๋‹ค๋ฅด๊ฒŒ ์„ค์ •
  • Refresh Token์€ ์„œ๋ฒ„์— ์ €์žฅํ•˜์—ฌ ๋ฌดํšจํ™” ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋งŒ๋“ค๊ธฐ
  • XSS ๊ณต๊ฒฉ์„ ๋ง‰๊ธฐ ์œ„ํ•ด ํ† ํฐ์„ localStorage ๋Œ€์‹  httpOnly ์ฟ ํ‚ค์— ์ €์žฅ ๊ถŒ์žฅ

Refresh Token ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ

CREATE TABLE refresh_tokens (
  id SERIAL PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token TEXT NOT NULL UNIQUE,
  expires_at TIMESTAMP NOT NULL,
  created_at TIMESTAMP DEFAULT NOW(),
  
  INDEX idx_user_id (user_id),
  INDEX idx_token (token),
  INDEX idx_expires_at (expires_at)
);

ํ† ํฐ ๊ฐฑ์‹  ๊ตฌํ˜„

class AuthModel extends BaseModelClass {
  /**
   * Access Token ๊ฐฑ์‹ 
   * 
   * Refresh Token์„ ๋ฐ›์•„ ์ƒˆ๋กœ์šด Access Token์„ ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค.
   * Refresh Token์˜ ์œ ํšจ์„ฑ๊ณผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ €์žฅ ์—ฌ๋ถ€๋ฅผ ๋ชจ๋‘ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.
   */
  @api({ httpMethod: "POST" })
  async refreshToken(params: {
    refreshToken: string;
  }): Promise<{
    accessToken: string;
    refreshToken?: string; // Refresh Token Rotation ์‹œ
  }> {
    const { refreshToken } = params;
    
    // 1. Refresh Token ๊ฒ€์ฆ
    const payload = verifyRefreshToken(refreshToken);
    
    if (!payload || typeof payload === "string") {
      throw new Error("Invalid refresh token");
    }
    
    // 2. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ Refresh Token ํ™•์ธ
    const rdb = this.getPuri("r");
    const storedToken = await rdb
      .table("refresh_tokens")
      .where("token", refreshToken)
      .where("user_id", payload.userId)
      .where("expires_at", ">", new Date())
      .first();
    
    if (!storedToken) {
      throw new Error("Refresh token not found or expired");
    }
    
    // 3. ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ
    const user = await rdb
      .table("users")
      .where("id", payload.userId)
      .first();
    
    if (!user) {
      throw new Error("User not found");
    }
    
    // 4. ์ƒˆ Access Token ์ƒ์„ฑ
    const accessToken = generateAccessToken(user);
    
    // 5. Refresh Token Rotation (์„ ํƒ์‚ฌํ•ญ)
    // ๋ณด์•ˆ ๊ฐ•ํ™”๋ฅผ ์œ„ํ•ด Refresh Token๋„ ํ•จ๊ป˜ ๊ฐฑ์‹ 
    const newRefreshToken = generateRefreshToken(user);
    
    const wdb = this.getPuri("w");
    
    // ๊ธฐ์กด Refresh Token ์‚ญ์ œ
    await wdb
      .table("refresh_tokens")
      .where("token", refreshToken)
      .delete();
    
    // ์ƒˆ Refresh Token ์ €์žฅ
    await wdb.table("refresh_tokens").insert({
      user_id: user.id,
      token: newRefreshToken,
      expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7์ผ
    });
    
    return {
      accessToken,
      refreshToken: newRefreshToken,
    };
  }
}
Refresh Token Rotation์€ Refresh Token์„ ํ•œ ๋ฒˆ ์‚ฌ์šฉํ•˜๋ฉด ์ƒˆ๋กœ์šด Refresh Token์„ ๋ฐœ๊ธ‰ํ•˜์—ฌ ๋ณด์•ˆ์„ ๊ฐ•ํ™”ํ•˜๋Š” ๊ธฐ๋ฒ•์ž…๋‹ˆ๋‹ค. ํ† ํฐ์ด ํƒˆ์ทจ๋˜๋”๋ผ๋„ ํƒˆ์ทจ์ž์™€ ์ •์ƒ ์‚ฌ์šฉ์ž ์ค‘ ํ•œ ์ชฝ๋งŒ ์ƒˆ ํ† ํฐ์„ ๋ฐ›์„ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ, ํƒˆ์ทจ๋ฅผ ๋น ๋ฅด๊ฒŒ ๊ฐ์ง€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํ† ํฐ ๋ฌดํšจํ™” (Logout)

JWT๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ๋งŒ๋ฃŒ ์ „๊นŒ์ง€ ์œ ํšจํ•˜์ง€๋งŒ, ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ฐ•์ œ๋กœ ๋ฌดํšจํ™”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
class AuthModel extends BaseModelClass {
  /**
   * ๋กœ๊ทธ์•„์›ƒ
   * 
   * Refresh Token์„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์‚ญ์ œํ•˜๊ณ ,
   * Access Token์„ ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
   */
  @api({ httpMethod: "POST" })
  async logout(): Promise<{ success: boolean }> {
    const context = Sonamu.getContext();
    
    if (!context.user) {
      throw new Error("Not authenticated");
    }
    
    const wdb = this.getPuri("w");
    
    // 1. ๋ชจ๋“  Refresh Token ์‚ญ์ œ
    await wdb
      .table("refresh_tokens")
      .where("user_id", context.user.id)
      .delete();
    
    // 2. ํ˜„์žฌ Access Token์„ ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ์— ์ถ”๊ฐ€
    const authHeader = context.request.headers.authorization;
    if (authHeader && authHeader.startsWith("Bearer ")) {
      const token = authHeader.substring(7);
      
      // Access Token์˜ ๋‚จ์€ ์œ ํšจ์‹œ๊ฐ„ ๊ณ„์‚ฐ
      const decoded = jwt.decode(token) as any;
      const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);
      
      // Redis์— ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ ์ถ”๊ฐ€ (TTL์€ ํ† ํฐ ๋‚จ์€ ์‹œ๊ฐ„)
      const redis = getRedisClient();
      await redis.setex(`blacklist:${token}`, expiresIn, "1");
    }
    
    return { success: true };
  }
  
  /**
   * ๋ชจ๋“  ์„ธ์…˜ ๋กœ๊ทธ์•„์›ƒ (๋‹ค๋ฅธ ๊ธฐ๊ธฐ ํฌํ•จ)
   */
  @api({ httpMethod: "POST" })
  async logoutAll(): Promise<{ success: boolean }> {
    const context = Sonamu.getContext();
    
    if (!context.user) {
      throw new Error("Not authenticated");
    }
    
    const wdb = this.getPuri("w");
    
    // ๋ชจ๋“  Refresh Token ์‚ญ์ œ
    await wdb
      .table("refresh_tokens")
      .where("user_id", context.user.id)
      .delete();
    
    return { success: true };
  }
}

ํ† ํฐ ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ ๋ฏธ๋“ค์›จ์–ด

// auth/jwt.middleware.ts
import type { FastifyRequest, FastifyReply } from "fastify";

export async function checkTokenBlacklist(
  request: FastifyRequest,
  reply: FastifyReply
) {
  const authHeader = request.headers.authorization;
  
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return;
  }
  
  const token = authHeader.substring(7);
  
  // Redis์—์„œ ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ ํ™•์ธ
  const redis = getRedisClient();
  const isBlacklisted = await redis.exists(`blacklist:${token}`);
  
  if (isBlacklisted) {
    return reply.status(401).send({
      error: "Token has been revoked",
    });
  }
}

์‹ค์ „ ํŒจํ„ด

๋™์‹œ ์ ‘์† ์ œํ•œ

ํ•œ ๊ณ„์ •์œผ๋กœ ์—ฌ๋Ÿฌ ๊ธฐ๊ธฐ์—์„œ ๋™์‹œ ์ ‘์†์„ ์ œํ•œํ•˜๋Š” ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค.
class AuthModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async login(params: {
    email: string;
    password: string;
  }): Promise<{ accessToken: string }> {
    const { email, password } = params;
    
    // ์ธ์ฆ...
    const user = await this.authenticateUser(email, password);
    
    const wdb = this.getPuri("w");
    
    // ๊ธฐ์กด ์„ธ์…˜ ๋ชจ๋‘ ์‚ญ์ œ (๊ฐ•์ œ ๋กœ๊ทธ์•„์›ƒ)
    await wdb
      .table("refresh_tokens")
      .where("user_id", user.id)
      .delete();
    
    // ์ƒˆ ํ† ํฐ ๋ฐœ๊ธ‰
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);
    
    await wdb.table("refresh_tokens").insert({
      user_id: user.id,
      token: refreshToken,
      expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    });
    
    return { accessToken };
  }
}

๋งŒ๋ฃŒ๋œ ์„ธ์…˜ ์ •๋ฆฌ

์ฃผ๊ธฐ์ ์œผ๋กœ ๋งŒ๋ฃŒ๋œ Refresh Token์„ ์‚ญ์ œํ•˜์—ฌ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํฌ๊ธฐ๋ฅผ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
// tasks/cleanup-tokens.ts
import { task } from "sonamu";

/**
 * ๋งค์ผ ์ž์ •์— ๋งŒ๋ฃŒ๋œ Refresh Token ์‚ญ์ œ
 */
@task({ cron: "0 0 * * *" })
export async function cleanupExpiredTokens() {
  const db = getDatabase();
  
  const result = await db
    .table("refresh_tokens")
    .where("expires_at", "<", new Date())
    .delete();
  
  console.log(`Deleted ${result} expired refresh tokens`);
}

์ฃผ์˜์‚ฌํ•ญ

์„ธ์…˜/ํ† ํฐ ๊ด€๋ฆฌ ์ฃผ์˜์‚ฌํ•ญ:
  1. ์„ธ์…˜ Secret๊ณผ JWT Secret์€ ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ๊ด€๋ฆฌ
  2. ํ”„๋กœ๋•์…˜์—์„œ๋Š” Redis ๋“ฑ ์™ธ๋ถ€ ์„ธ์…˜ ์Šคํ† ์–ด ํ•„์ˆ˜
  3. Refresh Token์€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅํ•˜์—ฌ ๋ฌดํšจํ™” ๊ฐ€๋Šฅํ•˜๊ฒŒ
  4. Access Token์€ ์งง๊ฒŒ, Refresh Token์€ ๊ธธ๊ฒŒ ์„ค์ •
  5. HTTPS ์‚ฌ์šฉ ์‹œ์—๋งŒ secure: true ์„ค์ •
  6. XSS ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด httpOnly: true ํ•„์ˆ˜
  7. ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ๋Š” Redis์˜ TTL ๊ธฐ๋Šฅ ํ™œ์šฉ

๋‹ค์Œ ๋‹จ๊ณ„