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 {
+24 -6
View File
@@ -59,8 +59,12 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
| 메서드 | 경로 | 요청 | 응답 | 비고 |
|---|---|---|---|---|
| POST | `/auth/signup` | `{ email, password, name }` | `{ user }` | 가입 후 자동 로그인(쿠키 set) |
| POST | `/auth/login` | `{ email, password, keepLogin? }` | `{ user }` | access/refresh 쿠키 set |
| POST | `/auth/signup` | `{ email, password, name }` | `{ email, verificationPending }` | **자동 로그인 X** — 인증 메일 발송, 쿠키 미발급 |
| POST | `/auth/resend-verification` | `{ email }` | `null` | 인증 메일 재발송(존재 여부 비노출) |
| GET | `/auth/verify-email` | `?token` | 302 redirect | 메일 링크. 검증 후 `FRONTEND_URL/login?verified=1\|0` 로 |
| POST | `/auth/login` | `{ email, password, keepLogin? }` | `{ user }` | access/refresh 쿠키 set. 미인증/소셜전용 계정은 차단 |
| GET | `/auth/google` · `/auth/kakao` | — | 302 redirect | 소셜 동의 화면으로(전체 페이지 이동) |
| GET | `/auth/google/callback` · `/auth/kakao/callback` | — | 302 redirect | 쿠키 set 후 `FRONTEND_URL/repos` 로 |
| POST | `/auth/logout` | — | `null` | 쿠키 clear |
| POST | `/auth/refresh` | — (refresh 쿠키) | `null` | access 쿠키 재발급 |
| GET | `/auth/me` | — | `User` | 부트스트랩/가드용. 미인증 401 |
@@ -72,12 +76,18 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
"id": "uuid",
"email": "sykim@acme.co",
"name": "김서연",
"role": "콘텐츠 기획" // 전사 직무(선택)
"role": "콘텐츠 기획", // 전사 직무(선택)
"avatarUrl": null // 프로필 이미지 URL (소셜 가입 시 제공자 이미지)
}
```
> 프론트 매핑: `auth.store.ts`(신규) + `useAuth` composable. 라우터 가드 TODO 를
> `me` 부트스트랩 + 401→`/login` 리다이렉트로 교체. `LoginPage`/`SignupPage` 의 `router.push` 를 실제 호출로.
### 회원가입 · 이메일 인증 · 소셜 로그인 흐름
- **이메일 가입**: `signup` → 미인증 계정 생성 + 인증 메일 발송(현재는 **백엔드 로그로 링크 출력**`MailService` 플레이스홀더). 사용자가 메일의 `verify-email` 링크 클릭 → 인증 완료 → `/login?verified=1` 로 리다이렉트. **인증 전에는 로그인 차단**(`BIZ_001` 403).
- **토큰**: 원문 토큰은 메일 링크에만, DB엔 SHA-256 해시 + 만료(24h)만 저장. 재발송은 사용자 존재/상태를 노출하지 않는다.
- **소셜 로그인**(Google/Kakao): 프론트가 `/auth/{provider}` 로 전체 페이지 이동 → 콜백에서 이메일로 계정 매칭(없으면 생성, `emailVerified=true`) → 쿠키 발급 후 `/repos` 로. 키(`*_CLIENT_ID`) 미설정 시 해당 제공자 비활성.
- **계정 모델**: `provider`('local'|'google'|'kakao'), `emailVerified`. 소셜 전용 계정은 비밀번호가 없어(`passwordHash=null`) 비밀번호 로그인 시 소셜 안내. 인증 도입 이전 로컬 계정은 부팅 시 그랜드페더링(토큰 없는 로컬 계정 → 인증 완료).
- **환경변수**: `APP_API_URL`(메일 링크용 백엔드 주소), `FRONTEND_URL`(리다이렉트 대상), `GOOGLE_*`/`KAKAO_*`(OAuth 키·콜백). `.env.development.example` 참조.
---
@@ -87,6 +97,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|---|---|---|---|
| GET | `/repos` | `?page&size` | `Repo[]`(요약) — **조직 저장소 전체**(생성 출처 무관) |
| GET | `/repos/meta` | — | `{ owner, template }` — 생성 폼 메타(`:repoId` 보다 먼저 매칭) |
| GET | `/repos/name-available` | `?slug` | `{ available: boolean }` — 이름(slug) 중복 체크(`:repoId` 보다 먼저 매칭) |
| POST | `/repos` | `CreateRepoDto` | `Repo` |
| GET | `/repos/:repoId` | — | `Repo`(상세) |
| PATCH | `/repos/:repoId` | `UpdateRepoDto` | `Repo` |
@@ -95,6 +106,9 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
`GET /repos/meta` 응답: `{ owner: string, template: { available: boolean, slug: string } }`
— 생성 전 소유자(조직) 고정 표시 + 템플릿 옵션 노출용.
`GET /repos/name-available?slug=` 응답: `{ available }` — 생성 폼의 **실시간 이름 가용성 체크**용(Relay slug 중복만 검사).
프론트는 형식(`^[a-z0-9-_]+$`)을 즉시 검사하고, 통과 시 디바운스로 이 API 를 호출한다. Gitea 이름 충돌은 생성(`POST /repos`) 시 최종 확인(`BIZ_001`).
`CreateRepoDto`: `{ slug, name, visibility('private'|'public'), description, branch?, useTemplate? }`
(**description 필수**, `useTemplate=true` 면 템플릿 저장소를 복제해 생성)
`Repo` 응답(원시값 중심):
@@ -317,9 +331,13 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done
전역 CacheModule 을 Redis(keyv)로 전환(`REDIS_*` 미설정 시 인메모리 폴백, 기본 TTL 60초). **`GET /repos`(저장소 목록)**을 버전 네임스페이스로 캐시 — 키 `repos:list:v{ver}:p{page}:s{size}`, **저장소 생성/수정/삭제·Gitea 동기화·멤버 초대/제거** 시 버전 증가로 일괄 무효화(멤버 역할 변경은 목록 응답 불변이라 제외). 버전 카운터가 공유 Redis 에 있어 **무효화가 다중 인스턴스에서도 정합**. 업무 카운트(`doneCount/totalCount`)는 ≤60초 지연 허용. 응답 계약 불변(캐시는 투명) — 프론트 영향 없음. `GET /repos/meta` 는 DB 미접근 인메모리 getter 라 캐싱 제외.
### 소셜 로그인 · 회원가입 · 이메일 인증 — **✅ 완료**
Google/Kakao OAuth(passport, 키 미설정 시 자동 비활성) + 이메일 가입 인증(미인증 로그인 차단, 그랜드페더링). 메일 발송은 `MailService` 플레이스홀더(백엔드 로그). 상세는 §1 참조.
### 그 외(미착수)
소셜 로그인(Google/Kakao OAuth), 이메일 인증, AI 에이전트 패널. (전체 페이지네이션 통일·실시간 알림·Redis 캐싱은 완료.)
AI 에이전트 패널. 메일 실제 발송(SMTP) — 현재 로그 대체. (전체 페이지네이션 통일·실시간 알림·Redis 캐싱은 완료.)
---
+24 -2
View File
@@ -265,8 +265,30 @@
> **알려진 한계**: ① 캐싱 대상은 현재 저장소 목록 1종(가장 트래픽 큰 랜딩) — 업무 목록 등은 세그먼트/검색/페이지 조합이 많아 추후 확장 후보. ② 업무 카운트 ≤60초 지연(위). ③ **의존성 추가(`@keyv/redis`)라 dev 컨테이너는 `up -d --build -V`(익명 node_modules 볼륨 갱신) 필요.**
#### 8-4. 그 외(미착수)
- 소셜 로그인(Google/Kakao OAuth), 이메일 인증, AI 에이전트 패널 실연동.
#### 8-4. 소셜 로그인 + 회원가입 이메일 인증 ✅ (2026-06-19)
**지시(2026-06-19)**: 소셜 키는 백엔드 env 에 등록됨 → 바로 연동. 회원가입은 인증 메일의 버튼 클릭 시 완료. **메일 전송은 우선 백엔드 로그로 대체.**
**백엔드**
- **User 엔티티 확장**(`modules/user/entities/user.entity.ts`): `passwordHash` **nullable**(소셜 전용 계정=null), `provider`('local'|'google'|'kakao', default 'local'), `emailVerified`(bool, default false), `emailVerifyTokenHash`/`emailVerifyExpiresAt`(둘 다 select:false). 원문 인증 토큰은 메일 링크에만 담고 DB엔 **SHA-256 해시 + 만료(24h)** 만 저장.
- **그랜드페더링**(`UserService.onApplicationBootstrap`): 인증 도입 이전 로컬 계정 락아웃 방지 — `provider='local' & 토큰 null & 미인증` 행을 인증완료로 보정(멱등 — 진행 중 미인증 계정은 토큰을 보유해 제외).
- **메일 모듈**(신규 `modules/mail/`): `MailService.sendVerificationEmail()` 플레이스홀더 — **인증 링크를 백엔드 Logger 로 출력**(추후 SMTP 도입 시 이 서비스만 교체).
- **AuthService**: `signup` **자동 로그인 폐지** → 미인증 계정 생성 + 인증 메일 발송, 응답 `{email, verificationPending}`. `verifyEmail(token)`(해시 매칭 + 만료 검사 → 인증 완료, 토큰 폐기), `resendVerification`(존재/상태 비노출), `validateUser`**로그인 게이트**(미인증 로컬=`BIZ_001` 403, 비밀번호 없는 소셜 전용=소셜 안내), `validateOrCreateOAuthUser`(이메일로 매칭→없으면 생성 emailVerified=true, 미인증 로컬과 이메일 같으면 인증 보정).
- **소셜 전략**(passport-google-oauth20·passport-kakao 신규 의존성, kakao 무타입→`auth/types/passport-kakao.d.ts` 앰비언트 선언): `GoogleStrategy`/`KakaoStrategy` 가 프로필 정규화 후 위임. **키(`*_CLIENT_ID`) 미설정 시 전략 미등록**(AuthModule `useFactory` 가드 — null 반환).
- **AuthController**: `signup`(쿠키 미발급)·`resend-verification`·**`GET verify-email`**(`@SkipTransform`+`@Res` 302 → `FRONTEND_URL/login?verified=1|0`)·**`GET google|kakao` + `/callback`**(`@SkipTransform`, 콜백서 쿠키 발급 → `FRONTEND_URL/repos`). `loginWith` 헬퍼로 토큰 발급+쿠키 공통화.
- **env**: `APP_API_URL`(메일 링크용 백엔드 주소, 폴백 `localhost:3100/api``FRONTEND_URL`(리다이렉트, 폴백 CORS_ORIGIN→5273) + `GOOGLE_*`/`KAKAO_*`(콜백 URL 은 백엔드 포트 **3100**, `/api/auth/{provider}/callback`) → `backend/.env.development.example` 문서화.
**프론트**
- `types/auth.ts` `SignupResult`, `useAuth`/`auth.store`: signup→대기(user 미갱신) + `resendVerification`.
- **SignupPage**: 가입 성공 시 자동 로그인 대신 **인증 메일 안내(verify 단계)** 표시 + 재발송 버튼 연동.
- **LoginPage**: 소셜 버튼 실연동(`window.location.href = ${VITE_API_BASE_URL}/auth/{provider}` 전체 페이지 이동) + `?verified=1|0` 결과 배너.
**검증**: 백엔드 build/lint 0 err·20 tests pass(MailService 가 AuthService 4번째 생성자 인자 — 영향 spec 없음), 프론트 type-check/lint/build 0 err.
> **알려진 한계**: ① **런타임 미검증** — DB + 소셜 콘솔에 콜백 URL 등록 필요(코드 레벨까지). ② 메일은 **로그 출력**(실발송 SMTP 미구현). ③ **의존성 추가(passport-google-oauth20/passport-kakao)라 dev 컨테이너 `up -d --build -V` 필요.** ④ 소셜 계정은 이메일 기준 매칭(별도 providerId 컬럼 없음) — 동일 이메일이면 동일인으로 연동.
#### 8-5. 그 외(미착수)
- AI 에이전트 패널 실연동, 메일 실발송(SMTP).
---
+15 -5
View File
@@ -1,13 +1,23 @@
import { useApi } from './useApi'
import type { AuthUser, LoginPayload, SignupPayload } from '@/types/auth'
import type {
AuthUser,
LoginPayload,
SignupPayload,
SignupResult,
} from '@/types/auth'
// 인증 도메인 API Composable — 인터셉터가 success/data 를 언래핑하므로 data 타입으로 캐스팅
export function useAuth() {
const api = useApi()
// 회원가입 (성공 시 백엔드가 인증 쿠키를 설정해 자동 로그인됨)
async function signup(payload: SignupPayload): Promise<AuthUser> {
return (await api.post('/auth/signup', payload)) as unknown as AuthUser
// 회원가입 — 인증 메일 발송 후 대기(자동 로그인 X). 인증 완료 전까지 로그인 불가.
async function signup(payload: SignupPayload): Promise<SignupResult> {
return (await api.post('/auth/signup', payload)) as unknown as SignupResult
}
// 인증 메일 재발송
async function resendVerification(email: string): Promise<void> {
await api.post('/auth/resend-verification', { email })
}
// 로그인
@@ -26,5 +36,5 @@ export function useAuth() {
return (await api.get('/auth/me', { silent: true })) as unknown as AuthUser
}
return { signup, login, logout, fetchMe }
return { signup, resendVerification, login, logout, fetchMe }
}
+81 -7
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
// 로그인 — 좌측 브랜드 패널 + 우측 로그인 폼
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
@@ -14,6 +15,18 @@ const showPassword = ref(false)
const keepLogin = ref(true)
const submitting = ref(false)
// 이메일 인증 결과 배너 — 백엔드 인증 링크가 /login?verified=1|0 으로 리다이렉트한다
const verifyBanner = computed<{ ok: boolean; text: string } | null>(() => {
const v = route.query.verified
if (v === '1') return { ok: true, text: '이메일 인증이 완료되었습니다. 로그인해 주세요.' }
if (v === '0')
return {
ok: false,
text: '인증 링크가 만료되었거나 유효하지 않습니다. 인증 메일을 다시 받아 주세요.',
}
return null
})
// 로그인 처리 — 성공 시 항상 저장소 목록으로 진입(이전 화면/딥링크로 복귀하지 않음)
async function onLogin() {
if (submitting.value) return
@@ -32,9 +45,10 @@ async function onLogin() {
}
}
// 소셜 로그인 — 8단계에서 구현 예정
function onSocial() {
window.alert('소셜 로그인은 준비 중입니다. 이메일로 로그인해 주세요.')
// 소셜 로그인 — 백엔드 OAuth 엔드포인트로 전체 페이지 이동(쿠키 기반 세션이라 새 창 X)
const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
function goSocial(provider: 'google' | 'kakao') {
window.location.href = `${apiBase}/auth/${provider}`
}
// 브랜드 패널 강조 포인트
@@ -103,12 +117,48 @@ const points = [
업무 지시를 시작하려면 로그인하세요.
</p>
<!-- 이메일 인증 결과 배너 -->
<div
v-if="verifyBanner"
class="verify-banner"
:class="{ err: !verifyBanner.ok }"
role="status"
>
<svg
v-if="verifyBanner.ok"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle
cx="12"
cy="12"
r="10"
/><path d="M15 9l-6 6M9 9l6 6" />
</svg>
{{ verifyBanner.text }}
</div>
<!-- 소셜 로그인 -->
<div class="socials">
<button
class="sbtn"
type="button"
@click="onSocial"
@click="goSocial('google')"
>
<span class="sico">
<svg
@@ -139,7 +189,7 @@ const points = [
<button
class="sbtn kakao"
type="button"
@click="onSocial"
@click="goSocial('kakao')"
>
<span class="sico">
<svg
@@ -439,6 +489,30 @@ const points = [
margin-top: 0.4375rem;
}
.verify-banner {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.5rem;
padding: 0.6875rem 0.875rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.45;
color: #1b7a3d;
background: var(--green-weak);
border: 1px solid var(--green-border);
}
.verify-banner.err {
color: var(--red);
background: var(--red-weak);
border-color: var(--red-border);
}
.verify-banner svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.socials {
display: flex;
flex-direction: column;
+61 -10
View File
@@ -1,10 +1,9 @@
<script setup lang="ts">
// 회원가입 — 가입 폼 (1단계: 이메일+JWT. 이메일 인증은 8단계 예정 → 가입 즉시 자동 로그인)
// 회원가입 — 가입 폼 → 인증 메일 발송 안내. 인증 메일의 링크를 눌러야 가입이 완료된다.
import { computed, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
const router = useRouter()
const authStore = useAuthStore()
// 폼 상태
@@ -17,11 +16,15 @@ const showPasswordConfirm = ref(false)
const agreeTerms = ref(true)
const submitting = ref(false)
// 화면 단계: 'form'(가입 폼) → 'verify'(인증 메일 전송됨, 8단계에서 활성화)
// 화면 단계: 'form'(가입 폼) → 'verify'(인증 메일 전송됨)
const step = ref<'form' | 'verify'>('form')
const sentEmail = computed(() => email.value.trim() || 'name@acme.co')
// 인증 메일이 실제 발송된 주소(폼 입력과 분리 — 재발송/표시에 사용)
const sentEmail = ref('')
const resending = ref(false)
const resendDone = ref(false)
const displayEmail = computed(() => sentEmail.value || email.value.trim() || 'name@acme.co')
// 가입 처리 — 검증 후 API 호출, 성공 시 자동 로그인되어 저장소 목록으로 이동
// 가입 처리 — 검증 후 API 호출, 성공 시 인증 메일 안내 화면으로 전환
async function submit() {
if (submitting.value) return
@@ -45,18 +48,35 @@ async function submit() {
submitting.value = true
try {
await authStore.signup({
const result = await authStore.signup({
name: name.value.trim(),
email: email.value.trim(),
password: password.value,
})
await router.push('/repos')
sentEmail.value = result.email
resendDone.value = false
step.value = 'verify'
} catch {
// 에러 메시지는 API 인터셉터에서 전역 처리됨
} finally {
submitting.value = false
}
}
// 인증 메일 재발송 — 존재 여부는 서버가 노출하지 않으므로 항상 완료 안내
async function resend() {
if (resending.value || !sentEmail.value) return
resending.value = true
try {
await authStore.resendVerification(sentEmail.value)
resendDone.value = true
} catch {
// 에러 메시지는 API 인터셉터에서 전역 처리됨
} finally {
resending.value = false
}
}
function backToForm() {
step.value = 'form'
}
@@ -377,15 +397,33 @@ const points = [
rx="2"
/><path d="m22 6-10 7L2 6" />
</svg>
<span>{{ sentEmail }}</span>
<span>{{ displayEmail }}</span>
</div>
<div class="vspam">
메일이 보이지 않나요? 스팸함을 확인하거나 잠시 다시 시도해주세요.
</div>
<div
v-if="resendDone"
class="vresent"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
인증 메일을 다시 보냈습니다.
</div>
<div class="vactions">
<button
class="btn-ghost"
type="button"
:disabled="resending"
@click="resend"
>
<svg
viewBox="0 0 24 24"
@@ -397,7 +435,7 @@ const points = [
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" />
</svg>
인증 메일 다시 보내기
{{ resending ? '보내는 중…' : '인증 메일 다시 보내기' }}
</button>
<button
class="vchange"
@@ -790,6 +828,19 @@ const points = [
margin-top: 1.125rem;
line-height: 1.6;
}
.verify .vresent {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin-top: 1rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--green);
}
.verify .vresent svg {
width: 0.875rem;
height: 0.875rem;
}
.verify .vactions {
margin-top: 1.625rem;
display: flex;
+31 -6
View File
@@ -1,7 +1,12 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useAuth } from '@/composables/useAuth'
import type { AuthUser, LoginPayload, SignupPayload } from '@/types/auth'
import type {
AuthUser,
LoginPayload,
SignupPayload,
SignupResult,
} from '@/types/auth'
// 인증 상태 스토어 — 현재 사용자/로그인 여부 + 인증 액션
export const useAuthStore = defineStore('auth', () => {
@@ -11,16 +16,27 @@ export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = computed<boolean>(() => user.value !== null)
const { signup: signupApi, login: loginApi, logout: logoutApi, fetchMe } = useAuth()
const {
signup: signupApi,
resendVerification: resendApi,
login: loginApi,
logout: logoutApi,
fetchMe,
} = useAuth()
// 로그인
async function login(payload: LoginPayload): Promise<void> {
user.value = await loginApi(payload)
}
// 회원가입 (성공 시 자동 로그인 상태)
async function signup(payload: SignupPayload): Promise<void> {
user.value = await signupApi(payload)
// 회원가입 — 인증 메일 발송 후 대기. 자동 로그인하지 않으므로 user 는 갱신하지 않는다.
async function signup(payload: SignupPayload): Promise<SignupResult> {
return signupApi(payload)
}
// 인증 메일 재발송
async function resendVerification(email: string): Promise<void> {
await resendApi(email)
}
// 로그아웃
@@ -44,5 +60,14 @@ export const useAuthStore = defineStore('auth', () => {
}
}
return { user, isLoggedIn, bootstrapped, login, signup, logout, bootstrap }
return {
user,
isLoggedIn,
bootstrapped,
login,
signup,
resendVerification,
logout,
bootstrap,
}
})
+6
View File
@@ -21,3 +21,9 @@ export interface SignupPayload {
name: string
role?: string
}
// 회원가입 응답 — 인증 메일 발송 후 대기 상태(자동 로그인하지 않음)
export interface SignupResult {
email: string
verificationPending: true
}