From 8766a4f5b6fe26d76e3aac22933ffc22c403464f Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 19 Jun 2026 17:04:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8(Google/Kakao)=20=EB=B0=8F=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/.env.development.example | 18 +++ backend/package-lock.json | 136 +++++++++++++++-- backend/package.json | 3 + backend/src/modules/auth/auth.controller.ts | 114 +++++++++++--- backend/src/modules/auth/auth.module.ts | 47 +++++- backend/src/modules/auth/auth.service.ts | 141 ++++++++++++++++-- .../auth/dto/resend-verification.dto.ts | 13 ++ .../auth/strategies/google.strategy.ts | 40 +++++ .../modules/auth/strategies/kakao.strategy.ts | 48 ++++++ .../modules/auth/types/passport-kakao.d.ts | 50 +++++++ backend/src/modules/mail/mail.module.ts | 9 ++ backend/src/modules/mail/mail.service.ts | 25 ++++ .../src/modules/user/entities/user.entity.ts | 39 ++++- backend/src/modules/user/user.service.ts | 84 ++++++++++- docs/api-contract.md | 30 +++- docs/integration-progress.md | 26 +++- frontend/src/composables/useAuth.ts | 20 ++- frontend/src/pages/relay/LoginPage.vue | 88 ++++++++++- frontend/src/pages/relay/SignupPage.vue | 71 +++++++-- frontend/src/stores/auth.store.ts | 37 ++++- frontend/src/types/auth.ts | 6 + 21 files changed, 961 insertions(+), 84 deletions(-) create mode 100644 backend/src/modules/auth/dto/resend-verification.dto.ts create mode 100644 backend/src/modules/auth/strategies/google.strategy.ts create mode 100644 backend/src/modules/auth/strategies/kakao.strategy.ts create mode 100644 backend/src/modules/auth/types/passport-kakao.d.ts create mode 100644 backend/src/modules/mail/mail.module.ts create mode 100644 backend/src/modules/mail/mail.service.ts diff --git a/backend/.env.development.example b/backend/.env.development.example index cc2b3ae..9c6fd07 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -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 가 자동 동기화된다. diff --git a/backend/package-lock.json b/backend/package-lock.json index 6eae8d8..61e7d89 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 0469b0e..42e1c56 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index fdf32b6..f496097 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -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('FRONTEND_URL') || + this.config.get('CORS_ORIGIN') || + 'http://localhost:5273'; + return url.split(',')[0].trim().replace(/\/$/, ''); + } + // HttpOnly·Secure 쿠키 공통 옵션 private cookieBase(): CookieOptions { const isProd = this.config.get('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 { - const user = await this.authService.signup(dto); + // PublicUser 로 토큰 발급 + 쿠키 설정 (공통) + private async loginWith(res: Response, user: PublicUser): Promise { 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 { + // 이메일 인증 완료 전까지 로그인 불가 — 쿠키를 발급하지 않는다 + return this.authService.signup(dto); + } + + @Post('resend-verification') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '인증 메일 재발송' }) + @ApiResponse({ status: 200, description: '재발송 접수(존재 여부 비노출)' }) + async resend(@Body() dto: ResendVerificationDto): Promise { + 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 { + 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 { 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 { + 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 { + 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 { - const tokens = await this.authService.issueTokens(user); - this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken); + await this.loginWith(res, user); return null; } diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index f08dfa6..50f905a 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -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('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('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 {} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 9856700..b4b712c 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -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 { + // 회원가입 — 이메일 중복 검사 후 미인증 계정 생성 + 인증 메일 발송(자동 로그인 X) + async signup(dto: SignupDto): Promise { 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 { + const user = await this.userService.findByEmail(email); + if (user && user.provider === 'local' && !user.emailVerified) { + await this.sendVerification(user); + } + } + + // 이메일 인증 — 토큰(원문) 해시로 사용자 조회 후 만료 검사하여 인증 완료 처리 + async verifyEmail(token: string): Promise { + 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 { + 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 { - const user = await this.userService.findByEmailWithPassword(dto.email); - if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) { - // 401 + 비즈니스 코드/문구 명시 → 자격 불일치를 명확히 안내 + // 소셜 로그인 — 이메일로 기존 계정 매칭(있으면 로그인/인증보정), 없으면 신규 생성 + async validateOrCreateOAuthUser(profile: OAuthProfile): Promise { + 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 { + 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('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, + ); + } } diff --git a/backend/src/modules/auth/dto/resend-verification.dto.ts b/backend/src/modules/auth/dto/resend-verification.dto.ts new file mode 100644 index 0000000..2768ca0 --- /dev/null +++ b/backend/src/modules/auth/dto/resend-verification.dto.ts @@ -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; +} diff --git a/backend/src/modules/auth/strategies/google.strategy.ts b/backend/src/modules/auth/strategies/google.strategy.ts new file mode 100644 index 0000000..eb889c0 --- /dev/null +++ b/backend/src/modules/auth/strategies/google.strategy.ts @@ -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('GOOGLE_CLIENT_ID'), + clientSecret: config.getOrThrow('GOOGLE_CLIENT_SECRET'), + callbackURL: config.getOrThrow('GOOGLE_CALLBACK_URL'), + scope: ['email', 'profile'], + }); + } + + // 성공 시 PublicUser 반환 → @nestjs/passport 가 req.user 에 주입 + async validate( + _accessToken: string, + _refreshToken: string, + profile: Profile, + ): Promise { + 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, + }); + } +} diff --git a/backend/src/modules/auth/strategies/kakao.strategy.ts b/backend/src/modules/auth/strategies/kakao.strategy.ts new file mode 100644 index 0000000..85a0c46 --- /dev/null +++ b/backend/src/modules/auth/strategies/kakao.strategy.ts @@ -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('KAKAO_CLIENT_ID'), + clientSecret: config.get('KAKAO_CLIENT_SECRET') ?? '', + callbackURL: config.getOrThrow('KAKAO_CALLBACK_URL'), + }); + } + + // 카카오 프로필(_json)에서 이메일/닉네임/프로필이미지 정규화 후 위임 + async validate( + _accessToken: string, + _refreshToken: string, + profile: KakaoProfile, + ): Promise { + 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, + }); + } +} diff --git a/backend/src/modules/auth/types/passport-kakao.d.ts b/backend/src/modules/auth/types/passport-kakao.d.ts new file mode 100644 index 0000000..be0b887 --- /dev/null +++ b/backend/src/modules/auth/types/passport-kakao.d.ts @@ -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; + } +} diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts new file mode 100644 index 0000000..6138e87 --- /dev/null +++ b/backend/src/modules/mail/mail.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { MailService } from './mail.service'; + +// 메일 모듈 — 현재는 로그 기반 플레이스홀더(MailService) 제공. +@Module({ + providers: [MailService], + exports: [MailService], +}) +export class MailModule {} diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts new file mode 100644 index 0000000..8810637 --- /dev/null +++ b/backend/src/modules/mail/mail.service.ts @@ -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'), + ); + } +} diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index c61bbf6..cf7ed7c 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -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() diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index f3696d0..c69e34e 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -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, ) {} + // 이메일 인증 도입 이전에 만들어진 로컬 계정 그랜드페더링 — + // 인증 토큰이 없는 기존 로컬 계정은 인증 완료로 간주(미인증 진행 계정은 토큰이 있어 제외). + // 멱등(idempotent): 최초 1회만 실제로 갱신되고 이후에는 대상이 없다. + async onApplicationBootstrap(): Promise { + 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 { return this.userRepository.findOne({ where: { id } }); @@ -54,7 +79,17 @@ export class UserService { .getOne(); } - // 신규 사용자 생성 (비밀번호는 해시된 상태로 전달받음) + // 이메일 인증 토큰 해시로 조회 — 인증 토큰 컬럼은 select:false 라 명시적으로 포함. + findByEmailVerifyTokenHash(tokenHash: string): Promise { + 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 { + 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 { + await this.userRepository.update(userId, { + emailVerifyTokenHash: tokenHash, + emailVerifyExpiresAt: expiresAt, + }); + } + + // 이메일 인증 완료 처리 — 인증 플래그 true, 토큰 폐기 + async markEmailVerified(userId: string): Promise { + await this.userRepository.update(userId, { + emailVerified: true, + emailVerifyTokenHash: null, + emailVerifyExpiresAt: null, + }); + } + // 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거) static toPublic(user: User): PublicUser { return { diff --git a/docs/api-contract.md b/docs/api-contract.md index 3c1dfb5..a98e562 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -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 캐싱은 완료.) --- diff --git a/docs/integration-progress.md b/docs/integration-progress.md index 019d37b..17e048e 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -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). --- diff --git a/frontend/src/composables/useAuth.ts b/frontend/src/composables/useAuth.ts index dea8bdf..e02759d 100644 --- a/frontend/src/composables/useAuth.ts +++ b/frontend/src/composables/useAuth.ts @@ -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 { - return (await api.post('/auth/signup', payload)) as unknown as AuthUser + // 회원가입 — 인증 메일 발송 후 대기(자동 로그인 X). 인증 완료 전까지 로그인 불가. + async function signup(payload: SignupPayload): Promise { + return (await api.post('/auth/signup', payload)) as unknown as SignupResult + } + + // 인증 메일 재발송 + async function resendVerification(email: string): Promise { + 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 } } diff --git a/frontend/src/pages/relay/LoginPage.vue b/frontend/src/pages/relay/LoginPage.vue index 8439cee..bd9c43b 100644 --- a/frontend/src/pages/relay/LoginPage.vue +++ b/frontend/src/pages/relay/LoginPage.vue @@ -1,9 +1,10 @@