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:
@@ -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 가 자동 동기화된다.
|
||||
|
||||
Generated
+125
-11
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
// 메일 모듈 — 현재는 로그 기반 플레이스홀더(MailService) 제공.
|
||||
@Module({
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 캐싱은 완료.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,3 +21,9 @@ export interface SignupPayload {
|
||||
name: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
// 회원가입 응답 — 인증 메일 발송 후 대기 상태(자동 로그인하지 않음)
|
||||
export interface SignupResult {
|
||||
email: string
|
||||
verificationPending: true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user