Skip to content

Commit 4e98435

Browse files
Merge pull request #199 from boostcampwm-2024/dev
[Deploy] 4주차 스프린트 배포
2 parents 2574086 + 763cb87 commit 4e98435

File tree

139 files changed

+6898
-674
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

139 files changed

+6898
-674
lines changed

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,9 @@ node_modules
77

88
# husky 관련
99
.husky/_
10-
.husky/.gitignore
10+
.husky/.gitignore
11+
12+
# docker-compose 관련 파일
13+
14+
mysql-data
15+
redis-data

backend/package.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,37 @@
2222
"dependencies": {
2323
"@nestjs/common": "^10.0.0",
2424
"@nestjs/core": "^10.0.0",
25+
"@nestjs/jwt": "^10.2.0",
26+
"@nestjs/passport": "^10.0.3",
2527
"@nestjs/platform-express": "^10.0.0",
2628
"@nestjs/platform-socket.io": "^10.4.6",
29+
"@nestjs/typeorm": "^10.0.2",
2730
"@nestjs/websockets": "^10.4.6",
31+
"@types/passport-jwt": "^4.0.1",
32+
"axios": "^1.7.7",
33+
"cookie-parser": "^1.4.7",
2834
"dotenv": "^16.4.5",
2935
"ioredis": "^5.4.1",
36+
"mysql2": "^3.11.4",
37+
"passport": "^0.7.0",
38+
"passport-custom": "^1.1.1",
39+
"passport-jwt": "^4.0.1",
3040
"reflect-metadata": "^0.2.0",
3141
"rxjs": "^7.8.1",
32-
"socket.io": "^4.8.1"
42+
"socket.io": "^4.8.1",
43+
"typeorm": "^0.3.20",
44+
"typeorm-naming-strategies": "^4.1.0",
45+
"typeorm-transactional": "^0.5.0"
3346
},
3447
"devDependencies": {
3548
"@nestjs/cli": "^10.0.0",
3649
"@nestjs/schematics": "^10.0.0",
3750
"@nestjs/testing": "^10.0.0",
51+
"@types/cookie-parser": "^1.4.7",
3852
"@types/express": "^5.0.0",
3953
"@types/jest": "^29.5.2",
4054
"@types/node": "^20.3.1",
55+
"@types/passport-github": "^1.1.12",
4156
"@types/supertest": "^6.0.0",
4257
"@typescript-eslint/eslint-plugin": "^8.0.0",
4358
"@typescript-eslint/parser": "^8.0.0",

backend/src/app.module.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,33 @@
11
import { Module } from "@nestjs/common";
2+
23
import { AppController } from "./app.controller";
34
import { AppService } from "./app.service";
5+
46
import { SocketModule } from "./signaling-server/socket.module";
57
import { RoomModule } from "./room/room.module";
68
import { RedisModule } from "./redis/redis.module";
9+
import { AuthModule } from "./auth/auth.module";
10+
import { UserModule } from "./user/user.module";
11+
import { TypeOrmModule } from "@nestjs/typeorm";
712

813
import "dotenv/config";
914

15+
import { createDataSource, typeOrmConfig } from "./config/typeorm.config";
16+
import { QuestionListModule } from "./question-list/question-list.module";
17+
1018
@Module({
11-
imports: [SocketModule, RoomModule, RedisModule],
19+
imports: [
20+
TypeOrmModule.forRootAsync({
21+
useFactory: async () => typeOrmConfig, // 설정 객체를 직접 반환
22+
dataSourceFactory: async () => await createDataSource(), // 분리된 데이터소스 생성 함수 사용
23+
}),
24+
SocketModule,
25+
RoomModule,
26+
RedisModule,
27+
AuthModule,
28+
UserModule,
29+
QuestionListModule,
30+
],
1231
controllers: [AppController],
1332
providers: [AppService],
1433
})
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Test, TestingModule } from "@nestjs/testing";
2+
import { AuthController } from "./auth.controller";
3+
4+
describe("AuthController", () => {
5+
let controller: AuthController;
6+
7+
beforeEach(async () => {
8+
const module: TestingModule = await Test.createTestingModule({
9+
controllers: [AuthController],
10+
}).compile();
11+
12+
controller = module.get<AuthController>(AuthController);
13+
});
14+
15+
it("should be defined", () => {
16+
expect(controller).toBeDefined();
17+
});
18+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Controller, Get, Post, Res, Req, UseGuards } from "@nestjs/common";
2+
import { AuthGuard } from "@nestjs/passport";
3+
import { Request, Response } from "express";
4+
import { AuthService } from "./auth.service";
5+
import { GithubProfile } from "./github/gitub-profile.decorator";
6+
import { Profile } from "passport-github";
7+
import { setCookieConfig } from "../config/cookie.config";
8+
import { JwtPayload, JwtTokenPair } from "./jwt/jwt.decorator";
9+
import { IJwtPayload, IJwtTokenPair } from "./jwt/jwt.model";
10+
11+
@Controller("auth")
12+
export class AuthController {
13+
private static ACCESS_TOKEN = "accessToken";
14+
private static REFRESH_TOKEN = "refreshToken";
15+
16+
constructor(private readonly authService: AuthService) {}
17+
18+
@Post("github")
19+
@UseGuards(AuthGuard("github"))
20+
async githubCallback(
21+
@Req() req: Request,
22+
@Res({ passthrough: true }) res: Response,
23+
@GithubProfile() profile: Profile
24+
) {
25+
const id = parseInt(profile.id);
26+
27+
const result = await this.authService.getTokenByGithubId(id);
28+
29+
res.cookie("accessToken", result.accessToken.token, {
30+
maxAge: result.accessToken.expireTime,
31+
...setCookieConfig,
32+
});
33+
34+
res.cookie("refreshToken", result.refreshToken.token, {
35+
maxAge: result.refreshToken.expireTime,
36+
...setCookieConfig,
37+
});
38+
39+
return {
40+
success: true,
41+
};
42+
}
43+
44+
@Get("whoami")
45+
@UseGuards(AuthGuard("jwt"))
46+
async handleWhoami(@Req() req: Request, @JwtPayload() token: IJwtPayload) {
47+
return token;
48+
}
49+
50+
@Get("refresh")
51+
@UseGuards(AuthGuard("jwt-refresh"))
52+
async handleRefresh(
53+
@Res({ passthrough: true }) res: Response,
54+
@JwtTokenPair() token: IJwtTokenPair
55+
) {
56+
res.cookie("accessToken", token.accessToken.token, {
57+
maxAge: token.accessToken.expireTime,
58+
...setCookieConfig,
59+
});
60+
61+
return {
62+
success: true,
63+
};
64+
}
65+
}

backend/src/auth/auth.module.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module } from "@nestjs/common";
2+
import { AuthController } from "./auth.controller";
3+
import { AuthService } from "./auth.service";
4+
import { GithubStrategy } from "./github/github.strategy";
5+
import { UserRepository } from "../user/user.repository";
6+
import { JwtModule } from "./jwt/jwt.module";
7+
8+
@Module({
9+
imports: [JwtModule],
10+
controllers: [AuthController],
11+
providers: [AuthService, GithubStrategy, UserRepository],
12+
})
13+
export class AuthModule {}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { Test, TestingModule } from "@nestjs/testing";
2+
import { AuthService } from "./auth.service";
3+
import { UserRepository } from "../user/user.repository";
4+
import { Profile } from "passport-github";
5+
6+
// typeorm-transactional 모킹
7+
jest.mock("typeorm-transactional", () => ({
8+
Transactional: () => () => ({}),
9+
runOnTransactionCommit: () => () => ({}),
10+
runOnTransactionRollback: () => () => ({}),
11+
runOnTransactionComplete: () => () => ({}),
12+
initializeTransactionalContext: () => ({}),
13+
}));
14+
15+
describe("AuthService", () => {
16+
let authService: AuthService;
17+
let userRepository: UserRepository;
18+
19+
// Mock GitHub 프로필 데이터
20+
const mockGithubProfile: Profile = {
21+
id: "12345",
22+
displayName: "Test User",
23+
username: "testuser",
24+
profileUrl: "https://abcd/",
25+
photos: [],
26+
provider: "github",
27+
_raw: "",
28+
_json: {},
29+
};
30+
31+
// Mock 유저 데이터
32+
const mockUser = {
33+
id: 1,
34+
loginId: null,
35+
passwordHash: null,
36+
githubId: 12345,
37+
username: "camper_12345",
38+
};
39+
40+
beforeEach(async () => {
41+
// Mock Repository 생성
42+
const mockUserRepository = {
43+
getUserByGithubId: jest.fn(),
44+
createUser: jest.fn(),
45+
};
46+
47+
const module: TestingModule = await Test.createTestingModule({
48+
providers: [
49+
AuthService,
50+
{
51+
provide: UserRepository,
52+
useValue: mockUserRepository,
53+
},
54+
],
55+
}).compile();
56+
57+
authService = module.get<AuthService>(AuthService);
58+
userRepository = module.get<UserRepository>(UserRepository);
59+
});
60+
61+
describe("githubLogin", () => {
62+
it("기존 사용자가 있을 경우 해당 사용자를 반환해야 한다", async () => {
63+
// Given
64+
jest.spyOn(userRepository, "getUserByGithubId").mockResolvedValue(
65+
mockUser
66+
);
67+
68+
// When
69+
const result = await authService.githubLogin(mockGithubProfile);
70+
71+
// Then
72+
expect(userRepository.getUserByGithubId).toHaveBeenCalledWith(
73+
parseInt(mockGithubProfile.id)
74+
);
75+
expect(result).toEqual(mockUser);
76+
expect(userRepository.createUser).not.toHaveBeenCalled();
77+
});
78+
79+
it("새로운 사용자의 경우 새 계정을 생성해야 한다", async () => {
80+
// Given
81+
jest.spyOn(userRepository, "getUserByGithubId").mockResolvedValue(
82+
null
83+
);
84+
jest.spyOn(userRepository, "createUser").mockResolvedValue(
85+
mockUser
86+
);
87+
88+
// When
89+
const result = await authService.githubLogin(mockGithubProfile);
90+
91+
// Then
92+
expect(userRepository.getUserByGithubId).toHaveBeenCalledWith(
93+
parseInt(mockGithubProfile.id)
94+
);
95+
expect(userRepository.createUser).toHaveBeenCalledWith({
96+
githubId: parseInt(mockGithubProfile.id),
97+
username: `camper_${mockGithubProfile.id}`,
98+
});
99+
expect(result).toEqual(mockUser);
100+
});
101+
102+
it("getUserByGithubId 에러 발생 시 예외를 던져야 한다", async () => {
103+
// Given
104+
const error = new Error("Database error");
105+
jest.spyOn(userRepository, "getUserByGithubId").mockRejectedValue(
106+
error
107+
);
108+
109+
// When & Then
110+
await expect(
111+
authService.githubLogin(mockGithubProfile)
112+
).rejects.toThrow(error);
113+
});
114+
115+
it("createUser 에러 발생 시 예외를 던져야 한다", async () => {
116+
// Given
117+
const error = new Error("Database error");
118+
jest.spyOn(userRepository, "getUserByGithubId").mockResolvedValue(
119+
null
120+
);
121+
jest.spyOn(userRepository, "createUser").mockRejectedValue(error);
122+
123+
// When & Then
124+
await expect(
125+
authService.githubLogin(mockGithubProfile)
126+
).rejects.toThrow(error);
127+
});
128+
});
129+
});

backend/src/auth/auth.service.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Injectable, UnauthorizedException } from "@nestjs/common";
2+
import { UserRepository } from "../user/user.repository";
3+
import { Transactional } from "typeorm-transactional";
4+
import "dotenv/config";
5+
import { DAY, HOUR } from "../utils/time";
6+
import { JwtService } from "./jwt/jwt.service";
7+
8+
@Injectable()
9+
export class AuthService {
10+
private static ACCESS_TOKEN_EXPIRATION_TIME = 3 * HOUR;
11+
private static ACCESS_TOKEN_EXPIRATION = 30 * DAY;
12+
13+
constructor(
14+
private readonly userRepository: UserRepository,
15+
private readonly jwtService: JwtService
16+
) {}
17+
18+
@Transactional()
19+
public async getTokenByGithubId(id: number) {
20+
let user = await this.userRepository.getUserByGithubId(id);
21+
22+
if (!user) {
23+
user = await this.userRepository.createUser({
24+
githubId: id,
25+
username: `camper_${id}`,
26+
});
27+
}
28+
29+
return await this.jwtService.createJwtToken(user.id);
30+
}
31+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// github-auth.strategy.ts
2+
import { Injectable, UnauthorizedException } from "@nestjs/common";
3+
import { PassportStrategy } from "@nestjs/passport";
4+
import { Strategy } from "passport-custom";
5+
import { Request } from "express";
6+
import axios from "axios";
7+
import "dotenv/config";
8+
9+
@Injectable()
10+
export class GithubStrategy extends PassportStrategy(Strategy, "github") {
11+
private static REQUEST_ACCESS_TOKEN_URL =
12+
"https://github.com/login/oauth/access_token";
13+
private static REQUEST_USER_URL = "https://api.github.com/user";
14+
15+
constructor() {
16+
super();
17+
}
18+
19+
async validate(req: Request, done: any) {
20+
const { code } = req.body;
21+
22+
if (!code) {
23+
throw new UnauthorizedException("Authorization code not found");
24+
}
25+
26+
const tokenResponse = await axios.post(
27+
GithubStrategy.REQUEST_ACCESS_TOKEN_URL,
28+
{
29+
client_id: process.env.OAUTH_GITHUB_ID,
30+
client_secret: process.env.OAUTH_GITHUB_SECRET,
31+
code: code,
32+
},
33+
{
34+
headers: { Accept: "application/json" },
35+
}
36+
);
37+
38+
const { access_token } = tokenResponse.data;
39+
40+
// GitHub 사용자 정보 조회
41+
const userResponse = await axios.get(GithubStrategy.REQUEST_USER_URL, {
42+
headers: {
43+
Authorization: `Bearer ${access_token}`,
44+
},
45+
});
46+
47+
return done(null, {
48+
profile: userResponse.data,
49+
});
50+
}
51+
}

0 commit comments

Comments
 (0)