feat: 소셜 로그인(Google/Kakao) 및 회원가입 이메일 인증 추가

- User 엔티티 확장: passwordHash nullable, provider, emailVerified, 인증 토큰 해시/만료(select:false)
- 회원가입: 자동 로그인 폐지 → 미인증 계정 생성 + 인증 메일 발송(MailService 플레이스홀더, 백엔드 로그로 링크 출력)
- 이메일 인증: GET /auth/verify-email(해시 매칭+만료 검사) → 프론트로 리다이렉트, 재발송 엔드포인트
- 로그인 게이트: 미인증 로컬 403 차단, 소셜 전용 계정은 소셜 로그인 안내
- 소셜 OAuth: passport google/kakao 전략(키 미설정 시 자동 비활성), 이메일로 계정 매칭/생성
- 기존 로컬 계정 그랜드페더링(부팅 시 인증 완료 보정)
- 프론트: 가입 후 인증 메일 안내 화면, 로그인 소셜 버튼 실연동 + 인증 결과 배너
- env(APP_API_URL/FRONTEND_URL/GOOGLE_*/KAKAO_*) 문서화, 문서·계약 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 17:04:30 +09:00
parent 7a2c70a260
commit 8766a4f5b6
21 changed files with 961 additions and 84 deletions
+18
View File
@@ -13,6 +13,24 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=14d
# --- 앱 URL (소셜 로그인/이메일 인증 리다이렉트) ---
# APP_API_URL: 백엔드 공개 주소(글로벌 prefix /api 포함) — 인증 메일 링크 생성에 사용.
# 미설정 시 http://localhost:3100/api 로 폴백.
APP_API_URL=http://localhost:3100/api
# FRONTEND_URL: 프론트 공개 주소 — 인증 완료/소셜 로그인 후 이 주소로 리다이렉트.
# 미설정 시 CORS_ORIGIN → http://localhost:5273 순으로 폴백.
FRONTEND_URL=http://localhost:5273
# --- 소셜 로그인 (OAuth) ---
# 각 제공자 콘솔에서 발급한 키. CLIENT_ID 가 비어 있으면 해당 소셜 로그인은 비활성화된다.
# 콜백 URL 은 각 콘솔에 등록한 값과 정확히 일치해야 한다(글로벌 prefix /api 포함).
KAKAO_CLIENT_ID=change-me-kakao-rest-api-key
KAKAO_CLIENT_SECRET=change-me-kakao-client-secret
KAKAO_CALLBACK_URL=http://localhost:3100/api/auth/kakao/callback
GOOGLE_CLIENT_ID=change-me-google-client-id
GOOGLE_CLIENT_SECRET=change-me-google-client-secret
GOOGLE_CALLBACK_URL=http://localhost:3100/api/auth/google/callback
# --- Gitea 연동 (외부 Gitea 인스턴스) ---
# 세 값(BASE_URL/TOKEN/ORG)이 모두 채워져야 연동 활성화. 비우면 Gitea 동기화 없이 동작한다.
# 저장소 생성/수정/삭제 시 GITEA_ORG 조직 아래 repo 가 자동 동기화된다.
+125 -11
View File
@@ -22,6 +22,7 @@
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.1",
"@types/passport-google-oauth20": "^2.0.17",
"bcryptjs": "^3.0.3",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
@@ -32,7 +33,9 @@
"ioredis": "^5.11.1",
"nest-winston": "^1.10.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-kakao": "^1.0.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@@ -3897,7 +3900,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@@ -3908,7 +3910,6 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -3965,7 +3966,6 @@
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -3978,7 +3978,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -3998,7 +3997,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
@@ -4085,16 +4083,35 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/oauth": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz",
"integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/passport": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/passport-google-oauth20": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz",
"integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==",
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/passport": "*",
"@types/passport-oauth2": "*"
}
},
"node_modules/@types/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
@@ -4106,6 +4123,17 @@
"@types/passport-strategy": "*"
}
},
"node_modules/@types/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==",
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/oauth": "*",
"@types/passport": "*"
}
},
"node_modules/@types/passport-strategy": {
"version": "0.2.38",
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
@@ -4121,21 +4149,18 @@
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -4145,7 +4170,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@@ -5589,6 +5613,15 @@
],
"license": "MIT"
},
"node_modules/base64url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.27",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
@@ -8511,7 +8544,6 @@
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
"integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ioredis/commands": "1.10.0",
"cluster-key-slot": "1.1.1",
@@ -10496,6 +10528,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/oauth": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -10778,6 +10816,18 @@
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-google-oauth20": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
"integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
"license": "MIT",
"dependencies": {
"passport-oauth2": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
@@ -10788,6 +10838,55 @@
"passport-strategy": "^1.0.0"
}
},
"node_modules/passport-kakao": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/passport-kakao/-/passport-kakao-1.0.1.tgz",
"integrity": "sha512-uItaYRVrTHL6iGPMnMZvPa/O1GrAdh/V6EMjOHcFlQcVroZ9wgG7BZ5PonMNJCxfHQ3L2QVNRnzhKWUzSsumbw==",
"license": "MIT",
"dependencies": {
"passport-oauth2": "~1.1.2",
"pkginfo": "~0.3.0"
}
},
"node_modules/passport-kakao/node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
"node_modules/passport-kakao/node_modules/passport-oauth2": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.1.2.tgz",
"integrity": "sha512-wpsGtJDHHQUjyc9WcV9FFB0bphFExpmKtzkQrxpH1vnSr6RcWa3ZEGHx/zGKAh2PN7Po9TKYB1fJeOiIBspNPA==",
"dependencies": {
"oauth": "0.9.x",
"passport-strategy": "1.x.x",
"uid2": "0.0.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
"license": "MIT",
"dependencies": {
"base64url": "3.x.x",
"oauth": "0.10.x",
"passport-strategy": "1.x.x",
"uid2": "0.0.x",
"utils-merge": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
@@ -11083,6 +11182,15 @@
"node": ">=8"
}
},
"node_modules/pkginfo": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz",
"integrity": "sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -13129,6 +13237,12 @@
"node": ">=8"
}
},
"node_modules/uid2": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
"license": "MIT"
},
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
+3
View File
@@ -33,6 +33,7 @@
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.1",
"@types/passport-google-oauth20": "^2.0.17",
"bcryptjs": "^3.0.3",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
@@ -43,7 +44,9 @@
"ioredis": "^5.11.1",
"nest-winston": "^1.10.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-kakao": "^1.0.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
+97 -17
View File
@@ -5,18 +5,23 @@ import {
HttpCode,
HttpStatus,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import type { CookieOptions, Response } from 'express';
import { AuthService } from './auth.service';
import { AuthGuard } from '@nestjs/passport';
import type { CookieOptions, Request, Response } from 'express';
import { AuthService, type SignupResult } from './auth.service';
import { SignupDto } from './dto/signup.dto';
import { LoginDto } from './dto/login.dto';
import { ResendVerificationDto } from './dto/resend-verification.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { JwtRefreshGuard } from './guards/jwt-refresh.guard';
import { CurrentUser } from './decorators/current-user.decorator';
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
import type { PublicUser } from '../user/user.service';
// 쿠키 만료 시간(ms)
@@ -34,6 +39,15 @@ export class AuthController {
private readonly config: ConfigService,
) {}
// 프론트엔드(웹) 기준 URL — 소셜 로그인/이메일 인증 후 리다이렉트 대상
private webUrl(): string {
const url =
this.config.get<string>('FRONTEND_URL') ||
this.config.get<string>('CORS_ORIGIN') ||
'http://localhost:5273';
return url.split(',')[0].trim().replace(/\/$/, '');
}
// HttpOnly·Secure 쿠키 공통 옵션
private cookieBase(): CookieOptions {
const isProd = this.config.get<string>('NODE_ENV') === 'production';
@@ -71,19 +85,45 @@ export class AuthController {
});
}
@Post('signup')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '회원가입 (가입 후 자동 로그인)' })
@ApiResponse({ status: 201, description: '가입 성공, 인증 쿠키 발급' })
@ApiResponse({ status: 409, description: '이미 가입된 이메일 (BIZ_001)' })
async signup(
@Body() dto: SignupDto,
@Res({ passthrough: true }) res: Response,
): Promise<PublicUser> {
const user = await this.authService.signup(dto);
// PublicUser 로 토큰 발급 + 쿠키 설정 (공통)
private async loginWith(res: Response, user: PublicUser): Promise<void> {
const tokens = await this.authService.issueTokens(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
return user;
}
@Post('signup')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '회원가입 (인증 메일 발송, 자동 로그인 안 함)' })
@ApiResponse({ status: 201, description: '가입 접수 — 인증 메일 발송됨' })
@ApiResponse({ status: 409, description: '이미 가입된 이메일 (BIZ_001)' })
signup(@Body() dto: SignupDto): Promise<SignupResult> {
// 이메일 인증 완료 전까지 로그인 불가 — 쿠키를 발급하지 않는다
return this.authService.signup(dto);
}
@Post('resend-verification')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '인증 메일 재발송' })
@ApiResponse({ status: 200, description: '재발송 접수(존재 여부 비노출)' })
async resend(@Body() dto: ResendVerificationDto): Promise<null> {
await this.authService.resendVerification(dto.email);
return null;
}
// 이메일 인증 링크 — 메일의 버튼에서 직접 GET 진입. 검증 후 프론트로 리다이렉트.
@Get('verify-email')
@SkipTransform()
@ApiOperation({ summary: '이메일 인증 (메일 링크)' })
async verifyEmail(
@Query('token') token: string,
@Res() res: Response,
): Promise<void> {
try {
await this.authService.verifyEmail(token);
res.redirect(`${this.webUrl()}/login?verified=1`);
} catch {
res.redirect(`${this.webUrl()}/login?verified=0`);
}
}
@Post('login')
@@ -99,11 +139,52 @@ export class AuthController {
@Res({ passthrough: true }) res: Response,
): Promise<PublicUser> {
const user = await this.authService.validateUser(dto);
const tokens = await this.authService.issueTokens(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
await this.loginWith(res, user);
return user;
}
// ── 구글 OAuth ──
@Get('google')
@SkipTransform()
@UseGuards(AuthGuard('google'))
@ApiOperation({ summary: '구글 로그인 시작 (구글 동의 화면으로 이동)' })
googleAuth(): void {
// 가드(AuthGuard)가 구글 인증 페이지로 리다이렉트 — 본문 없음
}
@Get('google/callback')
@SkipTransform()
@UseGuards(AuthGuard('google'))
@ApiOperation({ summary: '구글 로그인 콜백' })
async googleCallback(
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
await this.loginWith(res, req.user as PublicUser);
res.redirect(`${this.webUrl()}/repos`);
}
// ── 카카오 OAuth ──
@Get('kakao')
@SkipTransform()
@UseGuards(AuthGuard('kakao'))
@ApiOperation({ summary: '카카오 로그인 시작 (카카오 동의 화면으로 이동)' })
kakaoAuth(): void {
// 가드(AuthGuard)가 카카오 인증 페이지로 리다이렉트 — 본문 없음
}
@Get('kakao/callback')
@SkipTransform()
@UseGuards(AuthGuard('kakao'))
@ApiOperation({ summary: '카카오 로그인 콜백' })
async kakaoCallback(
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
await this.loginWith(res, req.user as PublicUser);
res.redirect(`${this.webUrl()}/repos`);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '로그아웃 (인증 쿠키 제거)' })
@@ -126,8 +207,7 @@ export class AuthController {
@CurrentUser() user: PublicUser,
@Res({ passthrough: true }) res: Response,
): Promise<null> {
const tokens = await this.authService.issueTokens(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
await this.loginWith(res, user);
return null;
}
+44 -3
View File
@@ -1,22 +1,63 @@
import { Module } from '@nestjs/common';
import { Logger, Module, type Provider } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { UserModule } from '../user/user.module';
import { MailModule } from '../mail/mail.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
import { GoogleStrategy } from './strategies/google.strategy';
import { KakaoStrategy } from './strategies/kakao.strategy';
// 인증 모듈 — JWT(access/refresh) + Passport 전략
// 구글 OAuth 전략 — 키가 설정된 경우에만 등록(미설정 시 null → /auth/google 비활성).
const googleStrategyProvider: Provider = {
provide: GoogleStrategy,
useFactory: (config: ConfigService, authService: AuthService) => {
if (!config.get<string>('GOOGLE_CLIENT_ID')) {
new Logger('AuthModule').warn(
'GOOGLE_CLIENT_ID 미설정 — 구글 로그인 비활성화',
);
return null;
}
return new GoogleStrategy(config, authService);
},
inject: [ConfigService, AuthService],
};
// 카카오 OAuth 전략 — 키가 설정된 경우에만 등록.
const kakaoStrategyProvider: Provider = {
provide: KakaoStrategy,
useFactory: (config: ConfigService, authService: AuthService) => {
if (!config.get<string>('KAKAO_CLIENT_ID')) {
new Logger('AuthModule').warn(
'KAKAO_CLIENT_ID 미설정 — 카카오 로그인 비활성화',
);
return null;
}
return new KakaoStrategy(config, authService);
},
inject: [ConfigService, AuthService],
};
// 인증 모듈 — JWT(access/refresh) + Passport 전략(JWT/구글/카카오) + 메일(인증)
@Module({
imports: [
UserModule,
MailModule,
PassportModule,
// 토큰 서명 옵션은 AuthService 에서 호출 단위로 지정하므로 기본 등록만 한다
JwtModule.register({}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
providers: [
AuthService,
JwtStrategy,
JwtRefreshStrategy,
googleStrategyProvider,
kakaoStrategyProvider,
],
exports: [AuthService],
})
export class AuthModule {}
+131 -10
View File
@@ -1,14 +1,19 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { JwtService, type JwtSignOptions } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { createHash, randomBytes } from 'crypto';
import * as bcrypt from 'bcryptjs';
import { UserService, type PublicUser } from '../user/user.service';
import type { User } from '../user/entities/user.entity';
import { MailService } from '../mail/mail.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { SignupDto } from './dto/signup.dto';
import { LoginDto } from './dto/login.dto';
// bcrypt 해시 라운드
const BCRYPT_ROUNDS = 10;
// 이메일 인증 토큰 유효 시간 (24시간)
const VERIFY_TTL_MS = 24 * 60 * 60 * 1000;
// 발급된 토큰 쌍
export interface TokenPair {
@@ -16,17 +21,32 @@ export interface TokenPair {
refreshToken: string;
}
// 인증 비즈니스 로직 — 가입, 로그인 검증, 토큰 발급
// 회원가입 결과 — 인증 메일 발송 후 대기 상태(자동 로그인하지 않음)
export interface SignupResult {
email: string;
verificationPending: true;
}
// 소셜 로그인 프로필 — 각 전략(google/kakao)이 공통 형태로 정규화해 전달
export interface OAuthProfile {
provider: string;
email: string | null;
name: string;
avatarUrl?: string | null;
}
// 인증 비즈니스 로직 — 가입, 로그인 검증, 이메일 인증, 소셜 로그인, 토큰 발급
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
private readonly mailService: MailService,
) {}
// 회원가입 — 이메일 중복 검사 후 비밀번호 해시하여 저장
async signup(dto: SignupDto): Promise<PublicUser> {
// 회원가입 — 이메일 중복 검사 후 미인증 계정 생성 + 인증 메일 발송(자동 로그인 X)
async signup(dto: SignupDto): Promise<SignupResult> {
const exists = await this.userService.findByEmail(dto.email);
if (exists) {
// 409 + 비즈니스 코드/문구 명시 → 필터가 그대로 노출
@@ -43,20 +63,93 @@ export class AuthService {
name: dto.name,
role: dto.role ?? null,
});
await this.sendVerification(user);
return { email: user.email, verificationPending: true };
}
// 인증 메일 재발송 — 사용자 존재/상태를 노출하지 않기 위해 항상 동일하게 응답(미인증 로컬만 실제 발송)
async resendVerification(email: string): Promise<void> {
const user = await this.userService.findByEmail(email);
if (user && user.provider === 'local' && !user.emailVerified) {
await this.sendVerification(user);
}
}
// 이메일 인증 — 토큰(원문) 해시로 사용자 조회 후 만료 검사하여 인증 완료 처리
async verifyEmail(token: string): Promise<void> {
if (!token) {
throw new BusinessException(
'BIZ_001',
'잘못된 인증 링크입니다.',
HttpStatus.BAD_REQUEST,
);
}
const tokenHash = this.hashToken(token);
const user = await this.userService.findByEmailVerifyTokenHash(tokenHash);
if (
!user ||
!user.emailVerifyExpiresAt ||
user.emailVerifyExpiresAt.getTime() < Date.now()
) {
throw new BusinessException(
'BIZ_001',
'인증 링크가 만료되었거나 유효하지 않습니다. 인증 메일을 다시 받아 주세요.',
HttpStatus.BAD_REQUEST,
);
}
await this.userService.markEmailVerified(user.id);
}
// 로그인 검증 — 자격 확인 + 소셜전용/미인증 분기
async validateUser(dto: LoginDto): Promise<PublicUser> {
const user = await this.userService.findByEmailWithPassword(dto.email);
if (!user) {
throw this.invalidCredentials();
}
// 비밀번호 미설정 = 소셜 전용 계정 → 소셜 로그인 안내
if (user.passwordHash == null) {
throw new BusinessException(
'BIZ_001',
'소셜(구글/카카오) 계정으로 가입된 이메일입니다. 소셜 로그인을 이용해 주세요.',
HttpStatus.UNAUTHORIZED,
);
}
if (!(await bcrypt.compare(dto.password, user.passwordHash))) {
throw this.invalidCredentials();
}
// 이메일 미인증 → 가입 미완료로 로그인 차단
if (!user.emailVerified) {
throw new BusinessException(
'BIZ_001',
'이메일 인증이 완료되지 않았습니다. 받은 인증 메일의 링크를 클릭해 가입을 완료해 주세요.',
HttpStatus.FORBIDDEN,
);
}
return UserService.toPublic(user);
}
// 로그인 검증 — 이메일/비밀번호 일치 확인
async validateUser(dto: LoginDto): Promise<PublicUser> {
const user = await this.userService.findByEmailWithPassword(dto.email);
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
// 401 + 비즈니스 코드/문구 명시 → 자격 불일치를 명확히 안내
// 소셜 로그인 — 이메일로 기존 계정 매칭(있으면 로그인/인증보정), 없으면 신규 생성
async validateOrCreateOAuthUser(profile: OAuthProfile): Promise<PublicUser> {
if (!profile.email) {
throw new BusinessException(
'BIZ_001',
'이메일 또는 비밀번호가 올바르지 않습니다.',
HttpStatus.UNAUTHORIZED,
'소셜 계정에서 이메일을 가져오지 못했습니다. 이메일 제공에 동의한 뒤 다시 시도해 주세요.',
HttpStatus.BAD_REQUEST,
);
}
let user = await this.userService.findByEmail(profile.email);
if (!user) {
user = await this.userService.createOAuthUser({
email: profile.email,
name: profile.name,
provider: profile.provider,
avatarUrl: profile.avatarUrl ?? null,
});
} else if (!user.emailVerified) {
// 기존(미인증 로컬) 계정과 이메일이 같으면 소셜 인증으로 인증 완료 처리하고 로그인
await this.userService.markEmailVerified(user.id);
user.emailVerified = true;
}
return UserService.toPublic(user);
}
@@ -80,4 +173,32 @@ export class AuthService {
);
return { accessToken, refreshToken };
}
// 인증 토큰 생성·저장 후 인증 메일(현재 로그) 발송
private async sendVerification(user: User): Promise<void> {
const rawToken = randomBytes(32).toString('hex');
const tokenHash = this.hashToken(rawToken);
const expiresAt = new Date(Date.now() + VERIFY_TTL_MS);
await this.userService.setEmailVerifyToken(user.id, tokenHash, expiresAt);
const apiUrl = (
this.config.get<string>('APP_API_URL') ?? 'http://localhost:3100/api'
).replace(/\/$/, '');
const verifyUrl = `${apiUrl}/auth/verify-email?token=${rawToken}`;
this.mailService.sendVerificationEmail(user.email, user.name, verifyUrl);
}
// 토큰 원문 → SHA-256 해시(원문은 메일 링크에만, DB엔 해시만 저장)
private hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
// 자격 불일치 공통 예외(이메일/비밀번호 구분 없이 동일 문구 — 계정 존재 노출 방지)
private invalidCredentials(): BusinessException {
return new BusinessException(
'BIZ_001',
'이메일 또는 비밀번호가 올바르지 않습니다.',
HttpStatus.UNAUTHORIZED,
);
}
}
@@ -0,0 +1,13 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 인증 메일 재발송 DTO — 대상 이메일
export class ResendVerificationDto {
@ApiProperty({
description: '인증 메일을 받을 이메일',
example: 'sykim@acme.co',
})
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
}
@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { Strategy, type Profile } from 'passport-google-oauth20';
import { AuthService } from '../auth.service';
import type { PublicUser } from '../../user/user.service';
// 구글 OAuth 전략 — 콜백에서 프로필을 정규화해 AuthService 로 위임.
// 키 미설정 환경에서는 모듈이 이 전략을 등록하지 않는다(AuthModule 팩토리 가드).
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
config: ConfigService,
private readonly authService: AuthService,
) {
super({
clientID: config.getOrThrow<string>('GOOGLE_CLIENT_ID'),
clientSecret: config.getOrThrow<string>('GOOGLE_CLIENT_SECRET'),
callbackURL: config.getOrThrow<string>('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'],
});
}
// 성공 시 PublicUser 반환 → @nestjs/passport 가 req.user 에 주입
async validate(
_accessToken: string,
_refreshToken: string,
profile: Profile,
): Promise<PublicUser> {
const email = profile.emails?.[0]?.value ?? null;
const name = profile.displayName || email?.split('@')[0] || '구글 사용자';
const avatarUrl = profile.photos?.[0]?.value ?? null;
return this.authService.validateOrCreateOAuthUser({
provider: 'google',
email,
name,
avatarUrl,
});
}
}
@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { Strategy, type KakaoProfile } from 'passport-kakao';
import { AuthService } from '../auth.service';
import type { PublicUser } from '../../user/user.service';
// 카카오 OAuth 전략 — 콜백 프로필을 정규화해 AuthService 로 위임.
// 키 미설정 환경에서는 모듈이 이 전략을 등록하지 않는다(AuthModule 팩토리 가드).
@Injectable()
export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
constructor(
config: ConfigService,
private readonly authService: AuthService,
) {
super({
clientID: config.getOrThrow<string>('KAKAO_CLIENT_ID'),
clientSecret: config.get<string>('KAKAO_CLIENT_SECRET') ?? '',
callbackURL: config.getOrThrow<string>('KAKAO_CALLBACK_URL'),
});
}
// 카카오 프로필(_json)에서 이메일/닉네임/프로필이미지 정규화 후 위임
async validate(
_accessToken: string,
_refreshToken: string,
profile: KakaoProfile,
): Promise<PublicUser> {
const account = profile._json?.kakao_account;
const email = account?.email ?? null;
const name =
account?.profile?.nickname ||
profile._json?.properties?.nickname ||
profile.displayName ||
email?.split('@')[0] ||
'카카오 사용자';
const avatarUrl =
account?.profile?.profile_image_url ??
profile._json?.properties?.profile_image ??
null;
return this.authService.validateOrCreateOAuthUser({
provider: 'kakao',
email,
name,
avatarUrl,
});
}
}
+50
View File
@@ -0,0 +1,50 @@
// passport-kakao 공식 타입 미제공 — 사용 범위에 맞춘 최소 선언.
declare module 'passport-kakao' {
export interface KakaoProfile {
id: number | string;
username?: string;
displayName?: string;
_json?: {
id: number;
kakao_account?: {
email?: string;
profile?: {
nickname?: string;
profile_image_url?: string;
thumbnail_image_url?: string;
};
};
properties?: {
nickname?: string;
profile_image?: string;
thumbnail_image?: string;
};
};
}
export interface StrategyOptions {
clientID: string;
clientSecret?: string;
callbackURL: string;
}
export type VerifyCallback = (
error: unknown,
user?: unknown,
info?: unknown,
) => void;
export class Strategy {
constructor(
options: StrategyOptions,
verify: (
accessToken: string,
refreshToken: string,
profile: KakaoProfile,
done: VerifyCallback,
) => void,
);
name: string;
authenticate(req: unknown, options?: unknown): void;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
// 메일 모듈 — 현재는 로그 기반 플레이스홀더(MailService) 제공.
@Module({
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
+25
View File
@@ -0,0 +1,25 @@
import { Injectable, Logger } from '@nestjs/common';
// 메일 발송 서비스 (플레이스홀더) —
// 현재는 실제 SMTP 전송 대신 백엔드 로그로 대체한다.
// 추후 nodemailer 등 도입 시 이 서비스 내부 구현만 교체하면 호출부는 그대로 유지된다.
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
// 가입 인증 메일 — 사용자가 클릭할 인증 링크를 로그로 출력한다.
sendVerificationEmail(to: string, name: string, verifyUrl: string): void {
this.logger.log(
[
'',
'──────────────── [메일 발송 - 가입 인증] ────────────────',
`받는 사람 : ${name} <${to}>`,
'제목 : [Relay] 이메일 인증을 완료해 주세요',
'본문 : 아래 링크를 클릭하면 회원가입이 완료됩니다.',
`인증 링크 : ${verifyUrl}`,
'──────────────────────────────────────────────────────',
'',
].join('\n'),
);
}
}
@@ -17,9 +17,42 @@ export class User {
@Column({ unique: true })
email!: string;
// 비밀번호 해시 — 평문 저장 금지. 조회 시 기본 제외(select: false)
@Column({ name: 'password_hash', select: false })
passwordHash!: string;
// 비밀번호 해시 — 평문 저장 금지. 조회 시 기본 제외(select: false).
// 소셜 전용 계정은 비밀번호가 없으므로 null.
@Column({
name: 'password_hash',
type: 'varchar',
nullable: true,
select: false,
})
passwordHash!: string | null;
// 가입 경로 — 'local'(이메일) | 'google' | 'kakao'. 로그인 안내·계정 연동 판별에 사용.
@Column({ type: 'varchar', default: 'local' })
provider!: string;
// 이메일 인증 완료 여부 — 로컬 가입은 인증 메일 클릭 후 true, 소셜 가입은 즉시 true.
// 미인증 로컬 계정은 로그인이 차단된다.
@Column({ name: 'email_verified', type: 'boolean', default: false })
emailVerified!: boolean;
// 이메일 인증 토큰 해시(SHA-256) — 원문은 메일 링크에만 담고 DB엔 해시만 저장. 조회 기본 제외.
@Column({
name: 'email_verify_token_hash',
type: 'varchar',
nullable: true,
select: false,
})
emailVerifyTokenHash!: string | null;
// 이메일 인증 토큰 만료 시각 — 만료 후 클릭은 무효. 조회 기본 제외.
@Column({
name: 'email_verify_expires_at',
type: 'timestamptz',
nullable: true,
select: false,
})
emailVerifyExpiresAt!: Date | null;
// 표시 이름
@Column()
+80 -4
View File
@@ -1,6 +1,10 @@
import { Injectable } from '@nestjs/common';
import {
Injectable,
Logger,
type OnApplicationBootstrap,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ILike, Repository } from 'typeorm';
import { ILike, IsNull, Repository } from 'typeorm';
import { User } from './entities/user.entity';
// 외부 노출용 사용자 형태 (passwordHash 등 민감정보 제외)
@@ -14,12 +18,33 @@ export interface PublicUser {
// 사용자 데이터 접근 계층 — 인증 모듈이 주입하여 사용
@Injectable()
export class UserService {
export class UserService implements OnApplicationBootstrap {
private readonly logger = new Logger(UserService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
// 이메일 인증 도입 이전에 만들어진 로컬 계정 그랜드페더링 —
// 인증 토큰이 없는 기존 로컬 계정은 인증 완료로 간주(미인증 진행 계정은 토큰이 있어 제외).
// 멱등(idempotent): 최초 1회만 실제로 갱신되고 이후에는 대상이 없다.
async onApplicationBootstrap(): Promise<void> {
const result = await this.userRepository.update(
{
provider: 'local',
emailVerified: false,
emailVerifyTokenHash: IsNull(),
},
{ emailVerified: true },
);
if (result.affected && result.affected > 0) {
this.logger.log(
`이메일 인증 도입 전 기존 로컬 계정 ${result.affected}건을 인증 완료로 보정`,
);
}
}
// id 단건 조회 (없으면 null)
findById(id: string): Promise<User | null> {
return this.userRepository.findOne({ where: { id } });
@@ -54,7 +79,17 @@ export class UserService {
.getOne();
}
// 신규 사용자 생성 (비밀번호는 해시된 상태로 전달받음)
// 이메일 인증 토큰 해시로 조회 — 인증 토큰 컬럼은 select:false 라 명시적으로 포함.
findByEmailVerifyTokenHash(tokenHash: string): Promise<User | null> {
return this.userRepository
.createQueryBuilder('user')
.addSelect('user.emailVerifyTokenHash')
.addSelect('user.emailVerifyExpiresAt')
.where('user.emailVerifyTokenHash = :tokenHash', { tokenHash })
.getOne();
}
// 신규 로컬 사용자 생성 (비밀번호는 해시된 상태로 전달받음, 기본 미인증)
create(payload: {
email: string;
passwordHash: string;
@@ -66,10 +101,51 @@ export class UserService {
passwordHash: payload.passwordHash,
name: payload.name,
role: payload.role ?? null,
provider: 'local',
emailVerified: false,
});
return this.userRepository.save(user);
}
// 소셜(OAuth) 사용자 생성 — 비밀번호 없음, 제공자가 이메일을 검증하므로 인증 완료 상태.
createOAuthUser(payload: {
email: string;
name: string;
provider: string;
avatarUrl?: string | null;
}): Promise<User> {
const user = this.userRepository.create({
email: payload.email,
passwordHash: null,
name: payload.name,
provider: payload.provider,
avatarUrl: payload.avatarUrl ?? null,
emailVerified: true,
});
return this.userRepository.save(user);
}
// 이메일 인증 토큰(해시)·만료 시각 저장
async setEmailVerifyToken(
userId: string,
tokenHash: string,
expiresAt: Date,
): Promise<void> {
await this.userRepository.update(userId, {
emailVerifyTokenHash: tokenHash,
emailVerifyExpiresAt: expiresAt,
});
}
// 이메일 인증 완료 처리 — 인증 플래그 true, 토큰 폐기
async markEmailVerified(userId: string): Promise<void> {
await this.userRepository.update(userId, {
emailVerified: true,
emailVerifyTokenHash: null,
emailVerifyExpiresAt: null,
});
}
// 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거)
static toPublic(user: User): PublicUser {
return {