Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
abad1b7
refactor: 스키마 필드명 정규화 및 클라이언트 설정 수정
qjatjr29 Dec 29, 2025
f2830a0
chore: OAuth 및 JWT 인증 라이브러리 추가
qjatjr29 Dec 29, 2025
650ff7b
chore: Jest 설정 수정 - 테스트 디렉토리 분리
qjatjr29 Dec 29, 2025
3232243
style: 미사용 변수 감지 규칙 추가
qjatjr29 Dec 29, 2025
85ed287
refactor: ESM 임포트 확장자 제거 및 async 호출 개선
qjatjr29 Dec 29, 2025
4032d47
chore: 불필요한 파일 제거
qjatjr29 Dec 29, 2025
cedf7df
refactor: ESM 확장자 제거 및 Prisma Client 임포트 경로 통일
qjatjr29 Dec 29, 2025
9685e18
feat: JWT 토큰 생성 및 검증 서비스 구현
qjatjr29 Dec 29, 2025
14238d2
style: eslint 테스트 파일 규칙 추가
qjatjr29 Dec 29, 2025
259796b
test: JwtProvider 테스트 코드 추가
qjatjr29 Dec 29, 2025
bf77a4f
feat: Google OAuth 2.0 구현
qjatjr29 Dec 29, 2025
193247b
test: GoogleStrategy 단위 테스트 작성
qjatjr29 Dec 29, 2025
9a060c7
feat: Google OAuth 2.0 로그인 엔드포인트 및 토큰 관리 기능 구현
qjatjr29 Dec 29, 2025
80d5781
test: auth service 테스트 코드 작성
qjatjr29 Dec 29, 2025
a12d8e5
feat: JWT 인증 가드 및 CurrentUser 데코레이터 구현
qjatjr29 Dec 29, 2025
b411049
feat: 사용자 조회 엔드포인트 및 서비스 구현
qjatjr29 Dec 29, 2025
0d8ec8b
test: UsersService 단위 테스트 작성
qjatjr29 Dec 29, 2025
e305c87
refactor: jwt 관련 기능 jwt 모듈로 이동
qjatjr29 Dec 29, 2025
8e4d6db
feat: 애플리케이션 모듈에 AuthModule, UsersModule 추가
qjatjr29 Dec 29, 2025
a76368e
chore: passport-oauth2 관련 패키지 설정
qjatjr29 Dec 29, 2025
dffdcfc
feat: Naver OAuth 2.0 전략 및 가드 구현
qjatjr29 Dec 29, 2025
25ab10b
refactor: jest - @/ 모듈 경로 매핑 수정
qjatjr29 Dec 29, 2025
60a0351
test: NaverStrategy 단위 테스트 작성
qjatjr29 Dec 29, 2025
7b85561
refactor: GoogleStrategy 코드 수정
qjatjr29 Dec 29, 2025
1ca485e
feat: Naver OAuth 엔드포인트 추가 및 콜백 로직 분리
qjatjr29 Dec 29, 2025
a45952b
chore: passport-kakao 패키지 추가
qjatjr29 Dec 29, 2025
fff4be1
feat: Kakao OAuth 2.0 전략 및 가드 구현
qjatjr29 Dec 29, 2025
2a4e1e7
feat: Kakao OAuth 엔드포인트 추가
qjatjr29 Dec 29, 2025
79df639
test: KakaoStrategy 단위 테스트 작성
qjatjr29 Dec 29, 2025
620c167
test: NaverStrategy 단위 테스트 수정
qjatjr29 Dec 29, 2025
018f16f
chore: 백엔드 환경변수 예시 추가
qjatjr29 Dec 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
DATABASE_URL="postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"

# JWT
JWT_ACCESS_SECRET="secret-access-key"
JWT_REFRESH_SECRET="secret-refresh-key"
JWT_ACCESS_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="7d"

# Frontend URL (CORS & OAuth Redirect)
FRONTEND_URL="http://localhost:5173"

# Google OAuth2
GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=xxxx
GOOGLE_CALLBACK_URL=http://localhost:3000/auth/oauth2/callback/google

# Naver OAuth2
NAVER_CLIENT_ID=xxxx
NAVER_CLIENT_SECRET=xxxx
NAVER_CALLBACK_URL=http://localhost:3000/auth/oauth2/callback/naver

# Kakao OAuth2
KAKAO_CLIENT_ID=xxxx
KAKAO_CLIENT_SECRET=xxxx
KAKAO_CALLBACK_URL=http://localhost:3000/auth/oauth2/callback/kakao
58 changes: 40 additions & 18 deletions apps/api/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,46 @@ import base from '../../eslint.config.mjs';
import { defineConfig } from 'eslint/config';
import globals from 'globals';

export default defineConfig(...base, {
files: ['**/*.{ts,js}'],
languageOptions: {
globals: { ...globals.node, ...globals.jest },
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
export default defineConfig(
...base,
{
files: ['**/*.{ts,js}'],
languageOptions: {
globals: { ...globals.node, ...globals.jest },
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-extraneous-class': [
'error',
{
allowWithDecorator: true, // NestJS 데코레이터가 적용된 class
},
],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-extraneous-class': [
'error',
{
allowWithDecorator: true, // NestJS 데코레이터가 적용된 class
},
],
{
files: ['**/*.spec.ts', '**/*.test.ts', 'test/**/*.ts'],
rules: {
'@typescript-eslint/unbound-method': 'off', // Jest expect 관련 에러 해결
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-argument': 'off', // Mock 데이터 전달 시 any 허용
},
},
});
);
40 changes: 20 additions & 20 deletions apps/api/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
const path = require('path');

module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
// .js 확장자를 제거하여 TypeScript 파일로 매핑
'^@/(.*)\\.js$': '<rootDir>/$1',
'^@/(.*)$': '<rootDir>/$1',
// 상대 경로 .js 확장자 제거 (모든 경로에서)
'^(\\.{1,2}/.*)\\.js$': '$1',
'^@locus/shared$': path.resolve(__dirname, '../../packages/shared/src'),
'^@locus/shared/(.*)$':
path.resolve(__dirname, '../../packages/shared/src') + '/$1',
},
moduleFileExtensions: ['js', 'json', 'ts'],
roots: ['<rootDir>/src', '<rootDir>/test'],
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
// .js 확장자를 제거하여 TypeScript 파일로 매핑
'^@/(.*)\\.js$': '<rootDir>/src/$1',
'^@/(.*)$': '<rootDir>/src/$1',
// 상대 경로 .js 확장자 제거 (모든 경로에서)
'^(\\.{1,2}/.*)\\.js$': '$1',
'^@locus/shared$': path.resolve(__dirname, '../../packages/shared/src'),
'^@locus/shared/(.*)$':
path.resolve(__dirname, '../../packages/shared/src') + '/$1',
},
};
14 changes: 13 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"name": "@locus/api",
"version": "0.0.1",
"type": "module",
"description": "Locus API 서버 - 공간 기반 기록 서비스 백엔드",
"author": "Team Haping",
"private": true,
Expand Down Expand Up @@ -29,9 +28,18 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-kakao": "^1.0.1",
"passport-oauth2": "^1.8.0",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
Expand All @@ -44,6 +52,10 @@
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.6.0",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/passport-kakao": "^1.0.3",
"@types/passport-oauth2": "^1.8.0",
"@types/supertest": "^6.0.2",
"jest": "^30.1.3",
"prisma": "^7.2.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ CREATE TABLE "users" (
"nickname" TEXT,
"profile_image_url" TEXT,
"provider" "Provider" NOT NULL DEFAULT 'LOCAL',
"providerId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"provider_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,

CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
Expand All @@ -20,7 +20,4 @@ CREATE TABLE "users" (
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");

-- CreateIndex
CREATE INDEX "users_email_idx" ON "users"("email");

-- CreateIndex
CREATE INDEX "users_provider_providerId_idx" ON "users"("provider", "providerId");
CREATE INDEX "users_provider_provider_id_idx" ON "users"("provider", "provider_id");
14 changes: 6 additions & 8 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
generator client {
provider = "prisma-client"
output = "../generated/prisma"
provider = "prisma-client-js"
}

datasource db {
Expand All @@ -12,16 +11,15 @@ model User {
email String @unique
password String? // OAuth 사용자는 null
nickname String?
profile_image_url String?
profileImageUrl String? @map("profile_image_url")

// OAuth 관련 필드
provider Provider @default(LOCAL)
providerId String? // OAuth 제공자의 고유 ID
providerId String? @map("provider_id") // OAuth 제공자의 고유 ID

createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([email])
@@index([provider, providerId])
@@map("users")
}
Expand Down
22 changes: 0 additions & 22 deletions apps/api/src/app.controller.spec.ts

This file was deleted.

12 changes: 6 additions & 6 deletions apps/api/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service.js';
import { AppService } from './app.service';

@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
constructor(private readonly appService: AppService) {}

@Get()
getHello(): string {
return this.appService.getHello();
}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
16 changes: 12 additions & 4 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
import { PrismaModule } from './prisma/prisma.module.js';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';

@Module({
imports: [PrismaModule],
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuthModule,
UsersModule,
],
controllers: [AppController],
providers: [AppService],
})
Expand Down
73 changes: 73 additions & 0 deletions apps/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { GoogleAuthGuard } from './guards/google-auth.guard';
import { User } from '@prisma/client';
import { ConfigService } from '@nestjs/config';
import { NaverAuthGuard } from './guards/naver-auth.guard';
import { KakaoAuthGuard } from './guards/kakao-auth.guard';

@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly configService: ConfigService,
) {}

@Get('oauth2/google')
@UseGuards(GoogleAuthGuard)
async googleLogin() {
// Guard가 Google 로그인 페이지로 리다이렉트
}

@Get('oauth2/naver')
@UseGuards(NaverAuthGuard)
async naverLogin() {
// Guard가 Naver 로그인 페이지로 리다이렉트
}

@Get('oauth2/kakao')
@UseGuards(KakaoAuthGuard)
async kakaoLogin() {
// Guard가 Kakao 로그인 페이지로 리다이렉트
}

@Get('oauth2/callback/google')
@UseGuards(GoogleAuthGuard)
async googleCallback(@Req() req: Request, @Res() res: Response) {
await this.handleOAuthCallback(req, res);
}

@Get('oauth2/callback/naver')
@UseGuards(NaverAuthGuard)
async naverCallback(@Req() req: Request, @Res() res: Response) {
await this.handleOAuthCallback(req, res);
}

@Get('oauth2/callback/kakao')
@UseGuards(KakaoAuthGuard)
async kakaoCallback(@Req() req: Request, @Res() res: Response) {
await this.handleOAuthCallback(req, res);
}

// TODO: POST /auth/reissue

private async handleOAuthCallback(req: Request, res: Response) {
const user = req.user as User;

const tokens = await this.authService.generateTokens(user);

const redirectUrl = this.buildRedirectUrl(
tokens.accessToken,
tokens.refreshToken,
);

res.redirect(redirectUrl);
}

private buildRedirectUrl(accessToken: string, refreshToken: string): string {
const frontendUrl = this.configService.getOrThrow<string>('FRONTEND_URL');

return `${frontendUrl}/auth/callback?accessToken=${accessToken}&refreshToken=${refreshToken}`;
}
}
22 changes: 22 additions & 0 deletions apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { GoogleStrategy } from './strategies/google.strategy';
import { JwtModule } from '@nestjs/jwt';
import { JwtProvider } from '@/jwt/jwt.provider';
import { NaverStrategy } from './strategies/naver.strategy';
import { KakaoStrategy } from './strategies/kakao.strategy';

@Module({
imports: [JwtModule, UsersModule],
providers: [
AuthService,
GoogleStrategy,
NaverStrategy,
KakaoStrategy,
JwtProvider,
],
controllers: [AuthController],
})
export class AuthModule {}
Loading