Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] alpha 서버 배포 CI/CD 코드 테스트 #72

Merged
merged 24 commits into from
Nov 11, 2024
Merged
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5d5318b
➕ add : express 모듈의 Request 타입 선언 재정의(#7)
jinddings Nov 7, 2024
0a276f9
⚙️ chore : typeOrmModule이 모든 entity 포함하도록 변경(#7)
jinddings Nov 7, 2024
4357a54
✨ feat : refresh Token 구현(#7)
jinddings Nov 7, 2024
a9c95d7
✨ feat : Token Refresh 요청 API 구현(#7)
jinddings Nov 7, 2024
91607bd
➕ add : kakao strategy 추가(#4)
jinddings Nov 7, 2024
0f58a72
Merge branch 'back/main' into feature/api/login-#7
jinddings Nov 7, 2024
1fe2082
🔧 fix : merge confict 수정
jinddings Nov 11, 2024
b872066
♻️ refactor: lint 에 위배되는 코드 수정
jinddings Nov 11, 2024
403e99b
Merge branch 'dev' of https://github.com/boostcampwm-2024/web16-JuGa …
jinddings Nov 11, 2024
bbcb40e
⚙️ chore : FE lint 오류 수정
jinddings Nov 11, 2024
50b9170
Merge branch 'back/main' of https://github.com/boostcampwm-2024/web16…
jinddings Nov 11, 2024
7f20998
🔧 fix : refreshToken, accessToken response 방식 수정
jinddings Nov 11, 2024
e1dda3f
🔧 fix : jwt secret .env에서 읽도록 수정, refresh API 응답 방식 수정
jinddings Nov 11, 2024
3e0df74
Merge pull request #43 from boostcampwm-2024/feature/api/login-#7
jinddings Nov 11, 2024
3d548b9
Merge branch 'alpha' into back/main
jinddings Nov 11, 2024
ddbbd79
🔧 fix : lint 오류 수정
jinddings Nov 11, 2024
14aa4eb
⚙️ chore : deploy alpha 디버그 코드 추가
jinddings Nov 11, 2024
3f09927
⚙️ chore : npm install 코드 수정
jinddings Nov 11, 2024
5061b7f
⚙️ chore : action 버전 수정
jinddings Nov 11, 2024
2cf4769
⚙️ chore : audit 무시
jinddings Nov 11, 2024
053c7a9
⚙️ chore : continue on error 추가
jinddings Nov 11, 2024
b933cb7
⚙️ chore : npm run test BE 디렉토리에서만 실행
jinddings Nov 11, 2024
c3ccb20
⚙️ chore : key secret 변경
jinddings Nov 11, 2024
0cf7bca
⚙️ chore : 동시에 두 가지 job 이 실행되지 않도록 변경
jinddings Nov 11, 2024
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
7 changes: 5 additions & 2 deletions .github/workflows/deply-alpha.yml
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@ jobs:
build-and-deploy:
runs-on: ubuntu-latest
strategy:
max-parallel: 1
matrix:
app:
[
@@ -22,7 +23,7 @@ jobs:
]

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
@@ -38,9 +39,11 @@ jobs:
- name: Install dependencies
working-directory: ./${{matrix.app.dir}}
continue-on-error: true
run: npm ci

- name: Run tests
if: ${{ matrix.app.name == 'be' }}
working-directory: ./${{matrix.app.dir}}
run: npm test
env:
@@ -97,7 +100,7 @@ jobs:
with:
host: ${{ secrets.NCP_ALPHA_SERVER_HOST }}
username: ${{ secrets.NCP_ALPHA_SERVER_USERNAME }}
key: ${{ secrets.NCP_ALPHA_SERVER_SSH_KEY }}
key: ${{ secrets.NCP_SERVER_SSH_KEY }}
port: 22
script: |
docker system prune -af
81 changes: 81 additions & 0 deletions BE/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion BE/package.json
Original file line number Diff line number Diff line change
@@ -30,19 +30,23 @@
"@nestjs/schedule": "^4.1.1",
"@nestjs/swagger": "^8.0.1",
"@nestjs/typeorm": "^10.0.2",
"@types/passport-jwt": "^4.0.1",
"@nestjs/websockets": "^10.4.7",
"@types/cookie-parser": "^1.4.7",
"@types/passport-jwt": "^4.0.1",
"axios": "^1.7.7",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.7",
"cross-env": "^7.0.3",
"docker": "^1.0.0",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"fastify-swagger": "^5.1.1",
"mysql2": "^3.11.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-kakao": "^1.0.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"socket.io": "^4.8.1",
13 changes: 2 additions & 11 deletions BE/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -5,26 +5,17 @@ import { ScheduleModule } from '@nestjs/schedule';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { User } from './auth/user.entity';
import { StockIndexModule } from './stock/index/stock-index.module';
import { StockTopfiveModule } from './stock/topfive/stock-topfive.module';
import { KoreaInvestmentModule } from './koreaInvestment/korea-investment.module';
import { SocketModule } from './websocket/socket.module';
import { typeOrmConfig } from './configs/typeorm.config';

@Module({
imports: [
ScheduleModule.forRoot(),
ConfigModule.forRoot(),
TypeOrmModule.forRoot({
type: 'mysql', // 데이터베이스 타입
host: process.env.DB_HOST,
port: 3306,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWD,
database: process.env.DB_DATABASE,
entities: [User],
synchronize: true,
}),
TypeOrmModule.forRoot(typeOrmConfig),
KoreaInvestmentModule,
AuthModule,
StockIndexModule,
57 changes: 52 additions & 5 deletions BE/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -3,18 +3,25 @@ import {
Post,
Get,
Body,
Req,
ValidationPipe,
UseGuards,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiOperation } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthCredentialsDto } from './dto/auth-credentials.dto';

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

@ApiOperation({ summary: '회원 가입 API' })
@Post('/signup')
@@ -24,16 +31,56 @@ export class AuthController {

@ApiOperation({ summary: '로그인 API' })
@Post('/login')
loginWithCredentials(
async loginWithCredentials(
@Body(ValidationPipe) authCredentialsDto: AuthCredentialsDto,
@Res() res: Response,
) {
return this.authService.loginUser(authCredentialsDto);
const { accessToken, refreshToken } =
await this.authService.loginUser(authCredentialsDto);

res.cookie('refreshToken', refreshToken, { httpOnly: true });
res.cookie('isRefreshToken', true, { httpOnly: true });
return res.status(200).json({ accessToken });
}

@ApiOperation({ summary: 'Token 인증 테스트 API' })
@Get('/test')
@UseGuards(AuthGuard())
@UseGuards(AuthGuard('jwt'))
test(@Req() req: Request) {
return req;
}

@ApiOperation({ summary: 'Kakao 로그인 API' })
@Get('/kakao')
@UseGuards(AuthGuard('kakao'))
async kakaoLogin(
@Body() authCredentialsDto: AuthCredentialsDto,
@Res() res: Response,
) {
const { accessToken, refreshToken } =
await this.authService.kakaoLoginUser(authCredentialsDto);

res.cookie('refreshToken', refreshToken, { httpOnly: true });
res.cookie('isRefreshToken', true, { httpOnly: true });
return res.status(200).json({ accessToken });
}

@ApiOperation({ summary: 'Refresh Token 요청 API' })
@Get('/refresh')
async refresh(@Req() req: Request, @Res() res: Response) {
if (
typeof req.cookies.refreshToken !== 'string' ||
typeof req.cookies.accessToken !== 'string'
) {
throw new UnauthorizedException('Invalid refresh token');
}

const { refreshToken } = req.cookies;

const newAccessToken = await this.authService.refreshToken(refreshToken);

res.cookie('refreshToken', refreshToken, { httpOnly: true });
res.cookie('isRefreshToken', true, { httpOnly: true });
return res.status(200).json({ accessToken: newAccessToken });
}
}
21 changes: 14 additions & 7 deletions BE/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -2,25 +2,32 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { User } from './user.entity';
import { UserRepository } from './user.repository';
import { JwtStrategy } from './jwt.strategy';
import { JwtStrategy } from './strategy/jwt.strategy';
import { KakaoStrategy } from './strategy/kakao.strategy';

@Module({
imports: [
TypeOrmModule.forFeature([User]),
ConfigModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: 'Juga16',
signOptions: {
expiresIn: 3600,
},
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRATION_TIME'),
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, UserRepository, JwtStrategy],
providers: [AuthService, UserRepository, JwtStrategy, KakaoStrategy],
exports: [JwtStrategy, PassportModule],
})
export class AuthModule {}
106 changes: 102 additions & 4 deletions BE/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { ConfigService } from '@nestjs/config';
import { UserRepository } from './user.repository';
import { AuthCredentialsDto } from './dto/auth-credentials.dto';

@@ -11,6 +12,7 @@ export class AuthService {
@InjectRepository(UserRepository)
private userRepository: UserRepository,
private jwtService: JwtService,
private readonly configService: ConfigService,
) {}

async signUp(authCredentialsDto: AuthCredentialsDto): Promise<void> {
@@ -19,15 +21,111 @@ export class AuthService {

async loginUser(
authCredentialsDto: AuthCredentialsDto,
): Promise<{ accessToken: string }> {
): Promise<{ accessToken: string; refreshToken: string }> {
const { email, password } = authCredentialsDto;
const user = await this.userRepository.findOne({ where: { email } });

if (user && (await bcrypt.compare(password, user.password))) {
const payload = { email };
const accessToken = this.jwtService.sign(payload);
return { accessToken };
const { accessToken, refreshToken } =
await this.getJWTToken(authCredentialsDto);

await this.setCurrentRefreshToken(refreshToken, user.id);

return { accessToken, refreshToken };
}
throw new UnauthorizedException('Please check your login credentials');
}

async kakaoLoginUser(
authCredentialsDto: AuthCredentialsDto,
): Promise<{ accessToken: string; refreshToken: string }> {
return this.getJWTToken(authCredentialsDto);
}

async getJWTToken(authCredentialsDto: AuthCredentialsDto) {
const accessToken = await this.generateAccessToken(authCredentialsDto);
const refreshToken = await this.generateRefreshToken(authCredentialsDto);
return { accessToken, refreshToken };
}

async generateAccessToken(
authCredentialsDto: AuthCredentialsDto,
): Promise<string> {
return authCredentialsDto.email
? this.jwtService.signAsync({ email: authCredentialsDto.email })
: this.jwtService.signAsync({ kakaoId: authCredentialsDto.kakaoId });
}

async generateRefreshToken(
authCredentialsDto: AuthCredentialsDto,
): Promise<string> {
if (authCredentialsDto.email) {
return this.jwtService.signAsync(
{ email: authCredentialsDto.email },
{
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: this.configService.get<string>(
'JWT_REFRESH_EXPIRATION_TIME',
),
},
);
}
return this.jwtService.signAsync(
{ kakaoId: authCredentialsDto.kakaoId },
{
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: this.configService.get<string>(
'JWT_REFRESH_EXPIRATION_TIME',
),
},
);
}

async setCurrentRefreshToken(refreshToken: string, userId: number) {
const currentDate = new Date();
const salt = await bcrypt.genSalt();
const currentRefreshToken = await bcrypt.hash(refreshToken, salt);
const currentRefreshTokenExpiresAt = new Date(
currentDate.getTime() +
parseInt(
this.configService.get<string>('JWT_REFRESH_EXPIRATION_TIME'),
10,
),
);

await this.userRepository.update(userId, {
currentRefreshToken,
currentRefreshTokenExpiresAt,
});
}

async refreshToken(refreshToken: string): Promise<string> {
try {
const decodedRefreshToken = this.jwtService.verify(refreshToken, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
});

const user = decodedRefreshToken.email
? await this.userRepository.findOne({
where: { email: decodedRefreshToken.email },
})
: await this.userRepository.findOne({
where: { kakaoId: decodedRefreshToken.kakaoId },
});

const isRefreshTokenMatching = await bcrypt.compare(
refreshToken,
user.currentRefreshToken,
);

if (!isRefreshTokenMatching) {
throw new UnauthorizedException('Invalid Token');
}

const accessToken = this.generateAccessToken(user.toAuthCredentialsDto());
return await accessToken;
} catch (error) {
throw new UnauthorizedException('Invalid Token');
}
}
}
38 changes: 26 additions & 12 deletions BE/src/auth/dto/auth-credentials.dto.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,41 @@
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
import {
IsString,
Matches,
MaxLength,
MinLength,
IsOptional,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class AuthCredentialsDto {
@ApiProperty({
description: '유저 이메일',
minLength: 4,
maxLength: 20,
type: 'string',
})
@IsString()
@MinLength(4)
@MaxLength(20)
email: string;
email?: string;

@ApiProperty({
description: '유저 비밀번호',
minLength: 4,
maxLength: 20,
type: 'string',
})
@IsString()
@MinLength(4)
@MaxLength(20)
@Matches(/^[a-zA-Z0-9]*$/)
password: string;
@Matches(/^[a-zA-Z0-9]*$/, {
message: '비밀번호는 영문과 숫자만 사용가능합니다',
})
password?: string;

@ApiProperty({
description: '카카오 ID',
})
@IsString()
@IsOptional()
kakaoId?: string;

@ApiProperty({
description: '카카오 액세스 토큰',
})
@IsString()
@IsOptional()
kakaoAccessToken?: string;
}
Original file line number Diff line number Diff line change
@@ -2,16 +2,18 @@ import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { User } from './user.entity';
import { ConfigService } from '@nestjs/config';
import { UserRepository } from '../user.repository';
import { User } from '../user.entity';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
@InjectRepository(UserRepository) private userRepository: UserRepository,
private readonly configService: ConfigService,
) {
super({
secretOrKey: 'Juga16',
secretOrKey: configService.get<string>('JWT_SECRET'),
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
});
}
55 changes: 55 additions & 0 deletions BE/src/auth/strategy/kakao.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy } from 'passport-kakao';

interface KakaoStrategyOptions {
clientID: string;
clientSecret: string;
callbackURL: string;
}

interface KakaoProfile extends Profile {
id: number;
_json: {
id: number;
};
}

interface KakaoUser {
kakaoId: number;
}

@Injectable()
export class KakaoStrategy extends PassportStrategy<Strategy>(
Strategy,
'kakao',
) {
constructor(private readonly configService: ConfigService) {
const options: KakaoStrategyOptions = {
clientID: configService.get<string>('KAKAO_CLIENT_ID') || '',
clientSecret: '',
callbackURL: `${configService.get<string>('BACKEND_URL') || ''}/auth/kakao`,
};

super(options);
}

validate(
accessToken: string,
refreshToken: string,
profile: KakaoProfile,
done: (error: Error, user?: KakaoUser) => void,
) {
try {
// eslint-disable-next-line no-underscore-dangle
const kakaoId = profile._json.id;
const user = {
kakaoId,
};
done(null, user);
} catch (error) {
done(error instanceof Error ? error : new Error(String(error)));
}
}
}
18 changes: 18 additions & 0 deletions BE/src/auth/user.entity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { AuthCredentialsDto } from './dto/auth-credentials.dto';

@Entity()
export class User extends BaseEntity {
@@ -16,4 +17,21 @@ export class User extends BaseEntity {

@Column({ default: -1 })
kakaoId: number;

@Column({ default: '' })
currentRefreshToken: string;

@Column({ type: 'datetime', nullable: true })
currentRefreshTokenExpiresAt: Date;

toAuthCredentialsDto(): AuthCredentialsDto {
if (this.kakaoId === -1) {
return {
email: this.email,
password: this.password,
};
}

throw new Error('Cannot convert Kakao user to auth credentials');
}
}
13 changes: 13 additions & 0 deletions BE/src/auth/user.repository.ts
Original file line number Diff line number Diff line change
@@ -18,4 +18,17 @@ export class UserRepository extends Repository<User> {
const user = this.create({ email, password: hashedPassword });
await this.save(user);
}

async updateUserWithRefreshToken(
id: number,
{
refreshToken,
refreshTokenExpiresAt,
}: { refreshToken: string; refreshTokenExpiresAt: Date },
) {
const user = await this.findOne({ where: { id } });
user.currentRefreshToken = refreshToken;
user.currentRefreshTokenExpiresAt = refreshTokenExpiresAt;
await this.save(user);
}
}
15 changes: 15 additions & 0 deletions BE/src/configs/typeorm.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import * as dotenv from 'dotenv';

dotenv.config();

export const typeOrmConfig: TypeOrmModuleOptions = {
type: 'mysql',
host: process.env.DB_HOST,
port: 3306,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWD,
database: process.env.DB_DATABASE,
entities: [`${__dirname}/../**/*.entity{.js,.ts}`],
synchronize: true,
};
2 changes: 2 additions & 0 deletions BE/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
import { setupSwagger } from './util/swagger';

@@ -14,6 +15,7 @@ async function bootstrap() {
optionsSuccessStatus: 204,
});

app.use(cookieParser());
await app.listen(process.env.PORT ?? 3000);
}

11 changes: 11 additions & 0 deletions BE/src/types/express.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Request as Req } from 'express';
import { UUID } from 'crypto';

declare module 'express' {
interface Request extends Req {
user: {
kakaoId?: number;
userId?: UUID;
};
}
}
3 changes: 2 additions & 1 deletion BE/tsconfig.json
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
"noFallthroughCasesInSwitch": false,
"typeRoots": ["node_modules/@types", "./src/types"]
}
}
2 changes: 1 addition & 1 deletion FE/src/components/TopFive/Nav.tsx
Original file line number Diff line number Diff line change
@@ -28,7 +28,7 @@
indicator.style.left = `${currentButton.offsetLeft}px`;
indicator.style.width = `${currentButton.offsetWidth}px`;
}
}, [currentMarket]);

Check warning on line 31 in FE/src/components/TopFive/Nav.tsx

GitHub Actions / FE-test-and-build

React Hook useEffect has a missing dependency: 'markets'. Either include it or remove the dependency array

Check warning on line 31 in FE/src/components/TopFive/Nav.tsx

GitHub Actions / build-and-deploy (fe, FE, 5173, juga-docker-fe)

React Hook useEffect has a missing dependency: 'markets'. Either include it or remove the dependency array

return (
<div className='relative flex gap-1 text-xl font-bold'>
@@ -43,7 +43,7 @@
key={market}
ref={(el) => (buttonRefs.current[index] = el)}
onClick={() => handleMarketChange(market)}
className={`relative px-2 py-2`}
className={'relative px-2 py-2'}
>
{market}
</button>