메인 콘텐츠로 건너뛰기
Sonamu에서 사용자 인증을 설정하는 방법을 알아봅니다. Sonamu는 프레임워크에 종속되지 않은 유연한 인증 구조를 제공하며, Passport.js, JWT, 세션 등 다양한 인증 전략과 함께 사용할 수 있습니다.

인증 시스템 개요

Context 기반

요청마다 사용자 정보Sonamu.getContext()

Guards 시스템

선언적 권한 제어@api guards 옵션

유연한 통합

Passport, JWT, 세션원하는 방식 선택

타입 안전성

TypeScript 지원User 타입 정의

인증 시스템 이해하기

Sonamu의 인증 철학

Sonamu는 특정 인증 라이브러리를 강제하지 않습니다. 대신, 인증된 사용자 정보를 Context에 주입하는 인터페이스만 제공합니다. 이는 다음과 같은 장점이 있습니다:
  1. 유연성: Passport.js, JWT, Auth0, Firebase Auth 등 원하는 인증 시스템 사용 가능
  2. 독립성: 인증 로직이 비즈니스 로직과 분리됨
  3. 테스트 용이성: Context에 가짜 사용자를 주입하여 테스트 가능

인증 흐름

1. 클라이언트 → 서버 (로그인 요청)
2. 서버 → 인증 검증 (Passport/JWT 등)
3. 서버 → Context에 user 정보 주입
4. API 메서드 → Sonamu.getContext().user 접근
5. Guards → 권한 검증 (admin, user 등)

User 타입 정의

기본 User 인터페이스

먼저 애플리케이션에서 사용할 User 타입을 정의합니다.
// types/user.ts
export interface User {
  id: number;
  email: string;
  username: string;
  role: "admin" | "user" | "guest";
  createdAt: Date;
  updatedAt: Date;
}

// 인증되지 않은 상태를 표현
export type AuthUser = User | null;

Context 타입 확장

Sonamu의 Context에 User 타입을 추가합니다.
// types/context.ts
import type { BaseContext } from "sonamu";
import type { User } from "./user";

export interface AppContext extends BaseContext {
  user: User | null;
}
Context는 각 요청마다 생성되며, 요청 전반에서 접근 가능한 정보를 담습니다.

Passport.js 통합

Passport.js는 Node.js에서 가장 널리 사용되는 인증 미들웨어입니다. Sonamu와 함께 사용하는 방법을 알아봅니다.

Passport 설치

npm install passport passport-local
npm install -D @types/passport @types/passport-local
# or
pnpm add passport passport-local
pnpm add -D @types/passport @types/passport-local

Passport Strategy 설정

로컬 인증 전략을 설정합니다. 이는 사용자명과 비밀번호로 인증하는 가장 기본적인 방식입니다.
// auth/passport.config.ts
import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import bcrypt from "bcrypt";
import type { User } from "../types/user";

/**
 * 로컬 인증 전략 설정
 * 
 * usernameField와 passwordField는 클라이언트가 전송하는 필드명입니다.
 * 기본값은 'username'과 'password'이지만, 'email'과 'password'로 변경할 수 있습니다.
 */
passport.use(
  new LocalStrategy(
    {
      usernameField: "email", // 이메일로 로그인
      passwordField: "password",
    },
    async (email, password, done) => {
      try {
        // 데이터베이스에서 사용자 조회
        const db = getDatabase(); // 프로젝트의 DB 접근 방식 사용
        const user = await db
          .table("users")
          .where("email", email)
          .first();
        
        if (!user) {
          // 사용자를 찾을 수 없음
          return done(null, false, { message: "Incorrect email" });
        }
        
        // 비밀번호 검증
        const isPasswordValid = await bcrypt.compare(password, user.password);
        
        if (!isPasswordValid) {
          // 비밀번호 불일치
          return done(null, false, { message: "Incorrect password" });
        }
        
        // 인증 성공 - 비밀번호 필드는 제거하고 반환
        const { password: _, ...userWithoutPassword } = user;
        return done(null, userWithoutPassword as User);
      } catch (error) {
        return done(error);
      }
    }
  )
);

/**
 * 세션에 사용자 정보 저장
 * 
 * serializeUser는 로그인 성공 후 호출되며, 세션에 저장할 정보를 결정합니다.
 * 보통 사용자 ID만 저장하여 세션 크기를 최소화합니다.
 */
passport.serializeUser((user: any, done) => {
  done(null, user.id);
});

/**
 * 세션에서 사용자 정보 복원
 * 
 * deserializeUser는 매 요청마다 호출되며, 세션에 저장된 ID로 사용자 정보를 조회합니다.
 * 여기서 조회한 정보가 req.user에 저장됩니다.
 */
passport.deserializeUser(async (id: number, done) => {
  try {
    const db = getDatabase();
    const user = await db
      .table("users")
      .where("id", id)
      .first();
    
    if (!user) {
      return done(new Error("User not found"));
    }
    
    const { password: _, ...userWithoutPassword } = user;
    done(null, userWithoutPassword as User);
  } catch (error) {
    done(error);
  }
});

export default passport;

Fastify 서버에 통합

Passport를 Fastify 서버에 연결합니다.
// server.ts
import fastify from "fastify";
import fastifySession from "@fastify/session";
import fastifyCookie from "@fastify/cookie";
import passport from "./auth/passport.config";

const app = fastify();

/**
 * 쿠키 파서 등록
 * 세션을 사용하려면 쿠키가 필요합니다.
 */
app.register(fastifyCookie);

/**
 * 세션 미들웨어 등록
 * 
 * secret: 세션 쿠키를 암호화하는 키 (환경변수로 관리 필수)
 * cookie: 쿠키 설정
 *   - secure: HTTPS에서만 쿠키 전송 (프로덕션에서 true)
 *   - httpOnly: JavaScript에서 쿠키 접근 불가 (XSS 방지)
 *   - maxAge: 쿠키 유효기간 (밀리초)
 */
app.register(fastifySession, {
  secret: process.env.SESSION_SECRET!, // 환경변수에서 읽기
  cookie: {
    secure: process.env.NODE_ENV === "production", // HTTPS 필수
    httpOnly: true, // XSS 공격 방지
    maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
  },
  saveUninitialized: false, // 초기화되지 않은 세션 저장 안함
  resave: false, // 변경되지 않은 세션 재저장 안함
});

/**
 * Passport 초기화
 * 
 * passport.initialize()는 Passport를 Fastify에 연결합니다.
 * passport.session()은 세션 기반 인증을 활성화합니다.
 */
app.addHook("onRequest", (request, reply, done) => {
  passport.initialize()(request.raw, reply.raw, done);
});

app.addHook("onRequest", (request, reply, done) => {
  passport.session()(request.raw, reply.raw, done);
});

// API 라우트 등록
// ...
보안 주의사항:
  • SESSION_SECRET은 절대 코드에 하드코딩하지 마세요
  • 프로덕션에서는 반드시 secure: true 설정 (HTTPS 필수)
  • httpOnly: true로 XSS 공격 방지
  • 세션 스토어는 Redis 등 외부 저장소 사용 권장 (서버 재시작 시 세션 유지)

JWT 인증

세션 대신 JWT(JSON Web Token)를 사용한 인증도 구현할 수 있습니다. JWT는 상태를 저장하지 않는(stateless) 인증 방식으로, 마이크로서비스 환경에서 유용합니다.

JWT 라이브러리 설치

npm install jsonwebtoken
npm install -D @types/jsonwebtoken
# or
pnpm add jsonwebtoken
pnpm add -D @types/jsonwebtoken

JWT 유틸리티

JWT 토큰 생성과 검증 함수를 만듭니다.
// auth/jwt.ts
import jwt from "jsonwebtoken";
import type { User } from "../types/user";

const JWT_SECRET = process.env.JWT_SECRET!;
const JWT_EXPIRES_IN = "7d"; // 7일

/**
 * JWT 페이로드 타입
 * 
 * JWT에 저장할 최소한의 정보만 포함합니다.
 * 민감한 정보(비밀번호, 카드번호 등)는 절대 포함하지 마세요.
 */
export interface JWTPayload {
  userId: number;
  email: string;
  role: string;
}

/**
 * JWT 토큰 생성
 * 
 * @param user 사용자 정보
 * @returns JWT 토큰 문자열
 */
export function generateToken(user: User): string {
  const payload: JWTPayload = {
    userId: user.id,
    email: user.email,
    role: user.role,
  };
  
  return jwt.sign(payload, JWT_SECRET, {
    expiresIn: JWT_EXPIRES_IN,
  });
}

/**
 * JWT 토큰 검증
 * 
 * @param token JWT 토큰
 * @returns 검증된 페이로드 또는 null
 */
export function verifyToken(token: string): JWTPayload | null {
  try {
    const payload = jwt.verify(token, JWT_SECRET) as JWTPayload;
    return payload;
  } catch (error) {
    // 토큰이 유효하지 않거나 만료됨
    console.error("JWT verification failed:", error);
    return null;
  }
}

/**
 * Refresh Token 생성
 * 
 * Access Token보다 긴 유효기간을 가진 토큰입니다.
 * Access Token이 만료되면 Refresh Token으로 새 Access Token을 발급받습니다.
 */
export function generateRefreshToken(user: User): string {
  const payload: JWTPayload = {
    userId: user.id,
    email: user.email,
    role: user.role,
  };
  
  return jwt.sign(payload, JWT_SECRET, {
    expiresIn: "30d", // 30일
  });
}

JWT 미들웨어

요청 헤더에서 JWT를 추출하고 검증하는 미들웨어를 만듭니다.
// auth/jwt.middleware.ts
import type { FastifyRequest, FastifyReply } from "fastify";
import { verifyToken } from "./jwt";

/**
 * JWT 인증 미들웨어
 * 
 * Authorization 헤더에서 Bearer 토큰을 추출하고 검증합니다.
 * 검증에 성공하면 사용자 정보를 데이터베이스에서 조회하여 request에 추가합니다.
 */
export async function jwtAuthMiddleware(
  request: FastifyRequest,
  reply: FastifyReply
) {
  // Authorization 헤더 확인
  const authHeader = request.headers.authorization;
  
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    // JWT 토큰이 없으면 그냥 통과 (인증 선택)
    // guards에서 필요 시 차단됨
    return;
  }
  
  // Bearer 접두사 제거
  const token = authHeader.substring(7);
  
  // 토큰 검증
  const payload = verifyToken(token);
  
  if (!payload) {
    // 토큰이 유효하지 않음
    return reply.status(401).send({
      error: "Invalid or expired token",
    });
  }
  
  // 데이터베이스에서 사용자 조회
  const db = getDatabase();
  const user = await db
    .table("users")
    .where("id", payload.userId)
    .first();
  
  if (!user) {
    return reply.status(401).send({
      error: "User not found",
    });
  }
  
  // request에 사용자 정보 추가
  // 이후 Context에서 접근 가능
  request.user = user;
}

Context에 User 주입

인증된 사용자 정보를 Sonamu Context에 주입하는 방법입니다. 이는 가장 중요한 단계로, API 메서드에서 Sonamu.getContext().user로 사용자 정보에 접근할 수 있게 합니다.

Context 생성 시 User 추가

// server.ts
import { Sonamu } from "sonamu";
import type { AppContext } from "./types/context";

app.addHook("onRequest", async (request, reply) => {
  // Passport 사용 시: request.user에 사용자 정보가 있음
  // JWT 사용 시: jwtAuthMiddleware에서 request.user 설정
  
  const context: AppContext = {
    request,
    reply,
    user: request.user || null, // 인증된 사용자 또는 null
  };
  
  // Sonamu Context 설정
  Sonamu.setContext(context);
});

API에서 사용자 정보 접근

이제 모든 API 메서드에서 사용자 정보에 접근할 수 있습니다.
class UserModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async getProfile(): Promise<{
    user: User;
  }> {
    // Context에서 사용자 정보 가져오기
    const context = Sonamu.getContext();
    
    if (!context.user) {
      throw new Error("Authentication required");
    }
    
    return {
      user: context.user,
    };
  }
  
  @api({ httpMethod: "PUT" })
  async updateProfile(params: {
    username?: string;
    bio?: string;
  }): Promise<{ user: User }> {
    const context = Sonamu.getContext();
    
    if (!context.user) {
      throw new Error("Authentication required");
    }
    
    const { username, bio } = params;
    
    // 자신의 프로필만 수정 가능
    const wdb = this.getPuri("w");
    await wdb
      .table("users")
      .where("id", context.user.id)
      .update({
        username,
        bio,
        updated_at: new Date(),
      });
    
    // 업데이트된 사용자 정보 조회
    const rdb = this.getPuri("r");
    const updatedUser = await rdb
      .table("users")
      .where("id", context.user.id)
      .first();
    
    return { user: updatedUser };
  }
}

로그인/로그아웃 API

Passport 로그인

class AuthModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async login(params: {
    email: string;
    password: string;
  }): Promise<{
    success: boolean;
    user: User;
  }> {
    const { email, password } = params;
    const context = Sonamu.getContext();
    
    // Passport 인증
    return new Promise((resolve, reject) => {
      passport.authenticate("local", (err, user, info) => {
        if (err) {
          reject(err);
          return;
        }
        
        if (!user) {
          reject(new Error(info?.message || "Login failed"));
          return;
        }
        
        // 세션에 로그인 정보 저장
        context.request.logIn(user, (err) => {
          if (err) {
            reject(err);
            return;
          }
          
          resolve({
            success: true,
            user,
          });
        });
      })(context.request.raw, context.reply.raw, () => {});
    });
  }
  
  @api({ httpMethod: "POST" })
  async logout(): Promise<{ success: boolean }> {
    const context = Sonamu.getContext();
    
    if (!context.user) {
      throw new Error("Not logged in");
    }
    
    return new Promise((resolve, reject) => {
      context.request.logOut((err) => {
        if (err) {
          reject(err);
          return;
        }
        
        resolve({ success: true });
      });
    });
  }
}

JWT 로그인

import { generateToken, generateRefreshToken } from "./auth/jwt";
import bcrypt from "bcrypt";

class AuthModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async login(params: {
    email: string;
    password: string;
  }): Promise<{
    accessToken: string;
    refreshToken: string;
    user: User;
  }> {
    const { email, password } = params;
    
    // 사용자 조회
    const rdb = this.getPuri("r");
    const user = await rdb
      .table("users")
      .where("email", email)
      .first();
    
    if (!user) {
      throw new Error("Invalid email or password");
    }
    
    // 비밀번호 검증
    const isPasswordValid = await bcrypt.compare(password, user.password);
    
    if (!isPasswordValid) {
      throw new Error("Invalid email or password");
    }
    
    // JWT 토큰 생성
    const accessToken = generateToken(user);
    const refreshToken = generateRefreshToken(user);
    
    // Refresh Token을 데이터베이스에 저장 (선택사항)
    const wdb = this.getPuri("w");
    await wdb.table("refresh_tokens").insert({
      user_id: user.id,
      token: refreshToken,
      expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일
    });
    
    const { password: _, ...userWithoutPassword } = user;
    
    return {
      accessToken,
      refreshToken,
      user: userWithoutPassword,
    };
  }
  
  @api({ httpMethod: "POST" })
  async refreshAccessToken(params: {
    refreshToken: string;
  }): Promise<{
    accessToken: string;
  }> {
    const { refreshToken } = params;
    
    // Refresh Token 검증
    const payload = verifyToken(refreshToken);
    
    if (!payload) {
      throw new Error("Invalid refresh token");
    }
    
    // 데이터베이스에서 Refresh Token 확인
    const rdb = this.getPuri("r");
    const storedToken = await rdb
      .table("refresh_tokens")
      .where("token", refreshToken)
      .where("user_id", payload.userId)
      .first();
    
    if (!storedToken) {
      throw new Error("Refresh token not found");
    }
    
    // 사용자 조회
    const user = await rdb
      .table("users")
      .where("id", payload.userId)
      .first();
    
    if (!user) {
      throw new Error("User not found");
    }
    
    // 새 Access Token 생성
    const accessToken = generateToken(user);
    
    return { accessToken };
  }
}

다음 단계