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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user