147 lines
4.8 KiB
TypeScript
147 lines
4.8 KiB
TypeScript
import bcrypt from "bcryptjs";
|
|
import jwt, { JwtPayload } from "jsonwebtoken";
|
|
import { randomBytes, createCipheriv, createDecipheriv, createHash } from "crypto";
|
|
|
|
// 인증/보안 관련 공통 유틸리티 함수들을 제공한다.
|
|
// - 비밀번호 해시/검증 (bcrypt 기반)
|
|
// - JWT 액세스 토큰 발급/검증
|
|
// - 민감정보 JSON 암호화/복호화 (AES-256-GCM)
|
|
|
|
export interface AccessTokenPayload extends JwtPayload {
|
|
sub: string;
|
|
email: string;
|
|
tokenVersion: number;
|
|
}
|
|
|
|
export interface JwtUserLike {
|
|
id: string;
|
|
email: string;
|
|
tokenVersion: number;
|
|
}
|
|
|
|
// 내부에서 사용할 JWT 시크릿을 가져온다.
|
|
// - 설정되지 않은 경우에는 명확한 에러를 던져 조기에 문제를 발견한다.
|
|
function getJwtSecret(): string {
|
|
const secret = process.env.AUTH_JWT_SECRET;
|
|
if (!secret || secret.length === 0) {
|
|
throw new Error("AUTH_JWT_SECRET 환경변수가 설정되지 않았습니다.");
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
// 암호화 키 원본 문자열로부터 32바이트 AES 키를 파생한다.
|
|
// - 키 문자열 길이에 관계없이 SHA-256 해시를 사용해 32바이트를 만든다.
|
|
function deriveEncryptionKey(): Buffer {
|
|
const raw = process.env.AUTH_ENCRYPTION_KEY;
|
|
if (!raw || raw.length === 0) {
|
|
throw new Error("AUTH_ENCRYPTION_KEY 환경변수가 설정되지 않았습니다.");
|
|
}
|
|
return createHash("sha256").update(raw).digest();
|
|
}
|
|
|
|
// 최소 비밀번호 길이 (보안 요구사항: 8자 이상)
|
|
const MIN_PASSWORD_LENGTH = 8;
|
|
|
|
// 비밀번호를 bcrypt 해시로 변환한다.
|
|
export async function hashPassword(plain: string): Promise<string> {
|
|
if (typeof plain !== "string" || plain.length < MIN_PASSWORD_LENGTH) {
|
|
throw new Error("비밀번호는 최소 8자 이상이어야 합니다.");
|
|
}
|
|
|
|
// bcryptjs 기본 라운드 수(10)를 사용한다. 필요 시 환경에 맞게 조정 가능.
|
|
const saltRounds = 10;
|
|
return await bcrypt.hash(plain, saltRounds);
|
|
}
|
|
|
|
// 평문 비밀번호와 저장된 해시가 일치하는지 검증한다.
|
|
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
|
if (!hash) return false;
|
|
if (!plain || plain.length < MIN_PASSWORD_LENGTH) {
|
|
// 너무 짧은 비밀번호는 즉시 false 를 반환해 타이밍 공격을 줄인다.
|
|
return false;
|
|
}
|
|
|
|
return await bcrypt.compare(plain, hash);
|
|
}
|
|
|
|
// 유저 정보를 기반으로 JWT 액세스 토큰을 발급한다.
|
|
export async function signAccessToken(user: JwtUserLike): Promise<string> {
|
|
const secret = getJwtSecret();
|
|
|
|
const payload: AccessTokenPayload = {
|
|
sub: user.id,
|
|
email: user.email,
|
|
tokenVersion: user.tokenVersion,
|
|
};
|
|
|
|
// 만료 시간은 기본 7일으로 설정한다.
|
|
const token = jwt.sign(payload, secret, {
|
|
algorithm: "HS256",
|
|
expiresIn: "7d",
|
|
});
|
|
|
|
return token;
|
|
}
|
|
|
|
// JWT 액세스 토큰을 검증하고, 유효하면 페이로드를 반환한다.
|
|
// - 검증 실패(시그니처 오류, 만료 등) 시 null 을 반환한다.
|
|
export async function verifyAccessToken(token: string): Promise<AccessTokenPayload | null> {
|
|
const secret = getJwtSecret();
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, secret) as AccessTokenPayload;
|
|
if (!decoded || typeof decoded.sub !== "string") {
|
|
return null;
|
|
}
|
|
return decoded;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// JSON 객체를 AES-256-GCM 으로 암호화한다.
|
|
// - 결과는 iv.tag.ciphertext 형태의 base64 조합 문자열이다.
|
|
export async function encryptJson<T extends object>(value: T): Promise<string> {
|
|
const key = deriveEncryptionKey();
|
|
const iv = randomBytes(12); // GCM 권장 IV 길이 96bit
|
|
|
|
const plain = JSON.stringify(value);
|
|
|
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
let encrypted = cipher.update(plain, "utf8", "base64");
|
|
encrypted += cipher.final("base64");
|
|
const authTag = cipher.getAuthTag();
|
|
|
|
const ivB64 = iv.toString("base64");
|
|
const tagB64 = authTag.toString("base64");
|
|
|
|
return `${ivB64}.${tagB64}.${encrypted}`;
|
|
}
|
|
|
|
// AES-256-GCM 으로 암호화된 JSON 문자열을 복호화한다.
|
|
export async function decryptJson<T = unknown>(ciphertext: string): Promise<T> {
|
|
const key = deriveEncryptionKey();
|
|
|
|
try {
|
|
const parts = ciphertext.split(".");
|
|
if (parts.length !== 3) {
|
|
throw new Error("잘못된 암호문 형식입니다.");
|
|
}
|
|
|
|
const [ivB64, tagB64, dataB64] = parts;
|
|
const iv = Buffer.from(ivB64, "base64");
|
|
const authTag = Buffer.from(tagB64, "base64");
|
|
|
|
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
decipher.setAuthTag(authTag);
|
|
|
|
let decrypted = decipher.update(dataB64, "base64", "utf8");
|
|
decrypted += decipher.final("utf8");
|
|
|
|
return JSON.parse(decrypted) as T;
|
|
} catch (error) {
|
|
// 복호화 실패 시 명시적인 에러를 던져 상위에서 처리할 수 있게 한다.
|
|
throw new Error("민감정보 복호화에 실패했습니다.");
|
|
}
|
|
}
|