diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 95f603d..d2ba29e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,14 +2,14 @@ name: CD on: push: - branches: [be-develop, main] + branches: [main, be-develop] jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout... - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v3 @@ -46,4 +46,4 @@ jobs: docker pull traveline/traveline-docker docker stop traveline-container || true docker rm traveline-container || true - docker run -e DB_HOST=${{ secrets.DB_HOST }} -e DB_PORT=${{ secrets.DB_PORT }} -e DB_USER=${{ secrets.DB_USER }} -e DB_PASSWORD=${{ secrets.DB_PASSWORD }} -e DB_DATABASE=${{ secrets.DB_DATABASE }} -e NCP_ACCESS_KEY_ID=${{ secrets.NCP_ACCESS_KEY_ID }} -e NCP_SECRET_ACCESS_KEY=${{ secrets.NCP_SECRET_ACCESS_KEY }} -e NCP_REGION=${{ secrets.NCP_REGION }} -e JWT_SECRET_ACCESS=${{ secrets.JWT_SECRET_ACCESS }} -e JWT_SECRET_REFRESH=${{ secrets.JWT_SECRET_REFRESH }} -e CLIENT_ID=${{ secrets.CLIENT_ID }} -e TEAM_ID=${{ secrets.TEAM_ID }} -e KEY_ID=${{ secrets.KEY_ID }} -e AUTH_KEY_LINE1=${{ secrets.AUTH_KEY_LINE1 }} -e AUTH_KEY_LINE2=${{ secrets.AUTH_KEY_LINE2 }} -e AUTH_KEY_LINE3=${{ secrets.AUTH_KEY_LINE3 }} -e AUTH_KEY_LINE4=${{ secrets.AUTH_KEY_LINE4 }} -e KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }} -e X_NCP_APIGW_API_KEY_ID=${{ secrets.X_NCP_APIGW_API_KEY_ID }} -e X_NCP_APIGW_API_KEY=${{ secrets.X_NCP_APIGW_API_KEY }} -e AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }} -e AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }} -e AWS_REGION=${{ secrets.AWS_REGION }} -e GREENEYE_SECRET_KEY=${{ secrets.GREENEYE_SECRET_KEY }} -e GREENEYE_DOMAIN_ID=${{ secrets.GREENEYE_DOMAIN_ID }} -e GREENEYE_SIGNATURE=${{ secrets.GREENEYE_SIGNATURE }} -d -p ${{secrets.EXTERNAL_PORT}}:${{secrets.INTERNAL_PORT}} --name traveline-container traveline/traveline-docker + docker run -e DB_HOST=${{ secrets.DB_HOST }} -e DB_PORT=${{ secrets.DB_PORT }} -e DB_USER=${{ secrets.DB_USER }} -e DB_PASSWORD=${{ secrets.DB_PASSWORD }} -e DB_DATABASE=${{ secrets.DB_DATABASE }} -e NCP_ACCESS_KEY_ID=${{ secrets.NCP_ACCESS_KEY_ID }} -e NCP_SECRET_ACCESS_KEY=${{ secrets.NCP_SECRET_ACCESS_KEY }} -e NCP_REGION=${{ secrets.NCP_REGION }} -e JWT_SECRET_ACCESS=${{ secrets.JWT_SECRET_ACCESS }} -e JWT_SECRET_REFRESH=${{ secrets.JWT_SECRET_REFRESH }} -e CLIENT_ID=${{ secrets.CLIENT_ID }} -e TEAM_ID=${{ secrets.TEAM_ID }} -e KEY_ID=${{ secrets.KEY_ID }} -e AUTH_KEY_LINE1=${{ secrets.AUTH_KEY_LINE1 }} -e AUTH_KEY_LINE2=${{ secrets.AUTH_KEY_LINE2 }} -e AUTH_KEY_LINE3=${{ secrets.AUTH_KEY_LINE3 }} -e AUTH_KEY_LINE4=${{ secrets.AUTH_KEY_LINE4 }} -e KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }} -e AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }} -e AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }} -e AWS_REGION=${{ secrets.AWS_REGION }} -e GREENEYE_SECRET_KEY=${{ secrets.GREENEYE_SECRET_KEY }} -e GREENEYE_DOMAIN_ID=${{ secrets.GREENEYE_DOMAIN_ID }} -e GREENEYE_SIGNATURE=${{ secrets.GREENEYE_SIGNATURE }} -d -p ${{secrets.EXTERNAL_PORT}}:${{secrets.INTERNAL_PORT}} --name traveline-container traveline/traveline-docker diff --git a/BE/src/app.controller.ts b/BE/src/app.controller.ts index 9b71d18..cd9608e 100644 --- a/BE/src/app.controller.ts +++ b/BE/src/app.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Render, Res } from '@nestjs/common'; +import { Controller, Get, Param, Res } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() @@ -16,9 +16,10 @@ export class AppController { response.redirect(url); } - @Get('ip-process-result') - @Render('ip-process-result.ejs') - ipProcessResult() { - return {}; - } + // 추후 수정 예정 + // @Get('ip-process-result') + // @Render('ip-process-result.ejs') + // ipProcessResult() { + // return {}; + // } } diff --git a/BE/src/auth/auth.controller.ts b/BE/src/auth/auth.controller.ts index dd7c0f7..655de2c 100644 --- a/BE/src/auth/auth.controller.ts +++ b/BE/src/auth/auth.controller.ts @@ -6,17 +6,20 @@ import { Get, Req, UseGuards, - Query, - ParseBoolPipe, - Res, + Param, } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { CreateAuthRequestDto } from './dto/create-auth-request.dto'; +import { + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; import { CreateAuthRequestForDevDto } from './dto/create-auth-request-for-dev.dto'; -import { DeleteAuthDto } from './dto/delete-auth.dto'; import { AuthGuard } from './auth.guard'; import { login, refresh, withdrawal } from './auth.swagger'; +import { SocialLoginRequestDto } from 'src/socialLogin/dto/social-login-request.dto'; +import { SocialWithdrawRequestDto } from 'src/socialLogin/dto/social-withdraw-request.dto'; @Controller('auth') @ApiTags('Auth API') @@ -30,18 +33,8 @@ export class AuthController { }) @ApiOkResponse({ description: 'OK', schema: { example: refresh } }) refresh(@Req() request) { - return this.authService.refresh(request); - } - - @Post('login') - @ApiOperation({ - summary: '로그인 또는 회원가입 API', - description: - '전달받은 idToken 내의 회원 정보를 확인하고 존재하는 회원이면 로그인을, 존재하지 않는 회원이면 회원가입을 진행합니다.', - }) - @ApiOkResponse({ description: 'OK', schema: { example: login } }) - login(@Req() request, @Body() createAuthDto: CreateAuthRequestDto) { - return this.authService.login(request, createAuthDto); + const headerMap: Map = this.makeHeaderMap(request); + return this.authService.refresh(headerMap); } @Post('login/dev') @@ -57,8 +50,34 @@ export class AuthController { return this.authService.loginForDev(createAuthForDevDto); } + @Post('login/:social') + @ApiParam({ + name: 'social', + enum: ['apple', 'kakao'], + description: '소셜 로그인 종류', + }) + @ApiOperation({ + summary: '로그인 또는 회원가입 API', + description: + '전달받은 idToken 내의 회원 정보를 확인하고 존재하는 회원이면 로그인을, 존재하지 않는 회원이면 회원가입을 진행합니다.', + }) + @ApiOkResponse({ description: 'OK', schema: { example: login } }) + socialLogin( + @Req() request, + @Param('social') social: string, + @Body() socialLoginRequestDto: SocialLoginRequestDto + ) { + const headerMap: Map = this.makeHeaderMap(request); + return this.authService.login(social, headerMap, socialLoginRequestDto); + } + @UseGuards(AuthGuard) - @Delete('withdrawal') + @Delete('withdraw/:social') + @ApiParam({ + name: 'social', + enum: ['apple', 'kakao'], + description: '소셜 로그인 종류', + }) @ApiOperation({ summary: '탈퇴 API', description: @@ -68,19 +87,55 @@ export class AuthController { description: 'OK', schema: { example: withdrawal }, }) - withdrawal(@Req() request, @Body() deleteAuthDto: DeleteAuthDto) { - return this.authService.withdrawal(request, deleteAuthDto); + socialWithdraw( + @Req() request, + @Param('social') social: string, + @Body() socialWithdrawRequestDto: SocialWithdrawRequestDto + ) { + const userId = request['user'].id; + return this.authService.withdraw(social, userId, socialWithdrawRequestDto); } - @Get('ip') - async manageIp( - @Res() response, - @Query('id') id: string, - @Query('ip') ip: string, - @Query('allow', ParseBoolPipe) allow: boolean - ) { - if (await this.authService.manageIp(id, ip, allow)) { - response.redirect('/ip-process-result'); - } + private makeHeaderMap(request): Map { + return Object.keys(request.headers).reduce((m, key) => { + m.set(key, request.headers[key]); + return m; + }, new Map()); } + + // @UseGuards(AuthGuard) + // @Delete('withdrawal') + // @ApiOperation({ + // summary: '탈퇴 API', + // description: + // '전달받은 idToken과 authorizationCode를 이용해 탈퇴를 진행합니다.', + // }) + // @ApiOkResponse({ + // description: 'OK', + // schema: { example: withdrawal }, + // }) + // withdrawal( + // @Req() request, + // @Body() socialWithdrawRequestDto: SocialWithdrawRequestDto + // ) { + // const userId = request['user'].id; + // return this.authService.withdrawalApple( + // 'apple', + // userId, + // socialWithdrawRequestDto + // ); + // } + + // 추후 수정 예정 + // @Get('ip') + // async manageIp( + // @Res() response, + // @Query('id') id: string, + // @Query('ip') ip: string, + // @Query('allow', ParseBoolPipe) allow: boolean + // ) { + // if (await this.authService.manageIp(id, ip, allow)) { + // response.redirect('/ip-process-result'); + // } + // } } diff --git a/BE/src/auth/auth.module.ts b/BE/src/auth/auth.module.ts index 3211f40..fe6a376 100644 --- a/BE/src/auth/auth.module.ts +++ b/BE/src/auth/auth.module.ts @@ -7,10 +7,19 @@ import { StorageService } from 'src/storage/storage.service'; import { HttpModule } from '@nestjs/axios'; import { AuthGuard } from './auth.guard'; import { EmailModule } from 'src/email/email.module'; +import { KakaoLoginStrategy } from 'src/socialLogin/kakao-login-strategy'; +import { AppleLoginStrategy } from 'src/socialLogin/apple-login-strategy'; @Module({ imports: [UsersModule, HttpModule, EmailModule], controllers: [AuthController], - providers: [AuthService, UsersService, StorageService, AuthGuard], + providers: [ + AuthService, + UsersService, + StorageService, + AuthGuard, + KakaoLoginStrategy, + AppleLoginStrategy, + ], }) export class AuthModule {} diff --git a/BE/src/auth/auth.service.ts b/BE/src/auth/auth.service.ts index ca1702a..45e4321 100644 --- a/BE/src/auth/auth.service.ts +++ b/BE/src/auth/auth.service.ts @@ -1,56 +1,76 @@ import { BadRequestException, Injectable, - InternalServerErrorException, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import { HttpService } from '@nestjs/axios'; -import * as jwt from 'jsonwebtoken'; import { UsersService } from 'src/users/users.service'; -import { CreateAuthRequestDto } from './dto/create-auth-request.dto'; import { CreateAuthRequestForDevDto } from './dto/create-auth-request-for-dev.dto'; -import { firstValueFrom } from 'rxjs'; -import { JwksClient } from 'jwks-rsa'; import { EmailService } from 'src/email/email.service'; +import { SocialLoginStrategy } from 'src/socialLogin/social-login-strategy.interface'; +import { KakaoLoginStrategy } from 'src/socialLogin/kakao-login-strategy'; +import { User } from 'src/users/entities/user.entity'; +import { SocialLoginRequestDto } from 'src/socialLogin/dto/social-login-request.dto'; +import { AppleLoginStrategy } from 'src/socialLogin/apple-login-strategy'; +import { SocialWithdrawRequestDto } from 'src/socialLogin/dto/social-withdraw-request.dto'; @Injectable() export class AuthService { + private socialLoginStrategyMap: Map = new Map< + string, + SocialLoginStrategy + >(); + constructor( private readonly jwtService: JwtService, - private readonly httpService: HttpService, private readonly usersService: UsersService, - private readonly emilService: EmailService - ) {} + private readonly emilService: EmailService, + private readonly kakaoLoginStrategy: KakaoLoginStrategy, + private readonly appleLoginStrategy: AppleLoginStrategy + ) { + this.socialLoginStrategyMap.set('kakao', kakaoLoginStrategy); + this.socialLoginStrategyMap.set('apple', appleLoginStrategy); + } + + private getLoginStrategy(social: string) { + const socialLoginStrategy: SocialLoginStrategy = + this.socialLoginStrategyMap.get(social); + + if (!socialLoginStrategy) { + throw new BadRequestException('지원하지 않는 소셜 로그인 플랫폼입니다'); + } + + return socialLoginStrategy; + } + + async refresh(headerMap: Map) { + const [type, token] = headerMap.get('authorization')?.split(' ') ?? []; - async refresh(request) { - const ipAddress = request.headers['x-real-ip']; - const [type, token] = request.headers.authorization?.split(' ') ?? []; if (type !== 'Bearer') { throw new BadRequestException('JWT가 아닙니다.'); } else if (!token) { throw new BadRequestException('토큰이 존재하지 않습니다.'); } + try { - const payload = await this.jwtService.verifyAsync(token, { + const { id } = await this.jwtService.verifyAsync(token, { secret: process.env.JWT_SECRET_REFRESH, }); - const id = payload.id; const user = await this.usersService.findUserById(id); + if (!user) { throw new UnauthorizedException('회원 정보가 존재하지 않습니다.'); } - let bannedIpArray; - if (user.bannedIp === null) { - bannedIpArray = []; - } else { - bannedIpArray = user.bannedIp; - } - if (ipAddress in bannedIpArray) { + + const ipAddress = headerMap.get('x-real-ip'); + const bannedIps = user.bannedIp === null ? [] : user.bannedIp; + + if (ipAddress in bannedIps) { throw new UnauthorizedException( '비정상적인 접근 시도로 차단된 IP입니다.' ); } + const accessToken = await this.jwtService.signAsync({ id }); return { accessToken }; } catch (error) { @@ -62,89 +82,64 @@ export class AuthService { } } - async decodeIdToken(idToken) { - const kid = jwt.decode(idToken, { - complete: true, - }).header.kid; - - const client = new JwksClient({ - jwksUri: 'https://appleid.apple.com/auth/keys', - }); - - const key = await client.getSigningKey(kid); - const verifyingKey = key.getPublicKey(); - - const decodedResult = jwt.verify(idToken, verifyingKey, { - algorithms: ['RS256'], - }); - - const decodedIdToken = - typeof decodedResult === 'string' - ? JSON.parse(decodedResult) - : decodedResult; - - if ( - decodedIdToken.iss !== 'https://appleid.apple.com' || - decodedIdToken.aud !== process.env.CLIENT_ID - ) { - throw new UnauthorizedException( - 'identity 토큰 내의 정보가 올바르지 않습니다.' - ); - } - return decodedIdToken; - } + async login( + social: string, + headerMap: Map, + socialLoginRequestDto: SocialLoginRequestDto + ) { + const [type, token] = headerMap.get('authorization')?.split(' ') ?? []; - async login(request, createAuthDto: CreateAuthRequestDto) { - const ipAddress = request.headers['x-real-ip']; - const [type, token] = request.headers.authorization?.split(' ') ?? []; if (type === 'Bearer' && token) { throw new BadRequestException('JWT가 이미 존재합니다.'); } - const idToken = createAuthDto.idToken; - - const appleId = (await this.decodeIdToken(idToken)).sub; - let user = await this.usersService.getUserInfoByResourceId(appleId); + const socialLoginStrategy: SocialLoginStrategy = + this.getLoginStrategy(social); + console.log(social); + const { resourceId, email } = await socialLoginStrategy.login( + socialLoginRequestDto + ); + const findUser = + await this.usersService.getUserInfoByResourceId(resourceId); - if (!user) { - const email = createAuthDto.email; - if (!email) { - throw new BadRequestException('이메일 정보가 누락되어있습니다.'); - } - user = await this.usersService.createUser(appleId, email, ipAddress); - if (!user) { - throw new InternalServerErrorException(); - } + let user: User; + if (findUser) { + user = findUser; } else { - const allowedIpArray = user.allowedIp; - let bannedIpArray; - if (user.bannedIp === null) { - bannedIpArray = []; - } else { - bannedIpArray = user.bannedIp; - } - if (ipAddress in bannedIpArray) { - throw new UnauthorizedException( - '접속하신 IP에서의 계정 접근이 차단되어있습니다.' - ); - } - if (!(ipAddress in allowedIpArray)) { - const html = await this.emilService.template('email.ejs', { - username: user.name, - newIp: ipAddress, - id: user.id, - }); - - await this.emilService.sendEmail( - user.email, - '[traveline] 새로운 환경 로그인 안내', - html - ); - } + const ipAddress = headerMap.get('x-real-ip'); + user = await this.usersService.createUser(resourceId, email, ipAddress); } - const payload = { id: user.id }; + // 추후 수정 예정 + // else { + // const allowedIpArray = user.allowedIp; + // let bannedIpArray; + // if (user.bannedIp === null) { + // bannedIpArray = []; + // } else { + // bannedIpArray = user.bannedIp; + // } + // if (ipAddress in bannedIpArray) { + // throw new UnauthorizedException( + // '접속하신 IP에서의 계정 접근이 차단되어있습니다.' + // ); + // } + // if (!(ipAddress in allowedIpArray)) { + // const html = await this.emilService.template('email.ejs', { + // username: user.name, + // newIp: ipAddress, + // id: user.id, + // }); + + // await this.emilService.sendEmail( + // user.email, + // '[traveline] 새로운 환경 로그인 안내', + // html + // ); + // } + // } + const payload = { id: user.id }; return { accessToken: await this.jwtService.signAsync(payload), refreshToken: await this.jwtService.signAsync(payload, { @@ -160,29 +155,39 @@ export class AuthService { if (user) { const payload = { id }; - try { - const html = await this.emilService.template('email.ejs', { - username: user.name, - newIp: '아 이 피', - id: user.id, - }); - - await this.emilService.sendEmail( - user.email, - '[traveline] 새로운 환경 로그인 안내', - html - ); - return { - accessToken: await this.jwtService.signAsync(payload), - refreshToken: await this.jwtService.signAsync(payload, { - expiresIn: '30d', - secret: process.env.JWT_SECRET_REFRESH, - }), - }; - } catch (e) { - console.log(e); - } + // 추후 수정 예정 + // try { + // const html = await this.emilService.template('email.ejs', { + // username: user.name, + // newIp: '아 이 피', + // id: user.id, + // }); + + // await this.emilService.sendEmail( + // user.email, + // '[traveline] 새로운 환경 로그인 안내', + // html + // ); + + // return { + // accessToken: await this.jwtService.signAsync(payload), + // refreshToken: await this.jwtService.signAsync(payload, { + // expiresIn: '30d', + // secret: process.env.JWT_SECRET_REFRESH, + // }), + // }; + // } catch (e) { + // console.log(e); + // } + + return { + accessToken: await this.jwtService.signAsync(payload), + refreshToken: await this.jwtService.signAsync(payload, { + expiresIn: '30d', + secret: process.env.JWT_SECRET_REFRESH, + }), + }; } throw new BadRequestException( @@ -192,112 +197,50 @@ export class AuthService { ); } - clientSecretGenerator(clientId) { - const header = { alg: 'ES256', kid: process.env.KEY_ID }; - const iat = Math.floor(Date.now() / 1000); - const exp = iat + 60 * 60; - const payload = { - iss: process.env.TEAM_ID, - iat, - exp, - aud: 'https://appleid.apple.com', - sub: clientId, - }; - - const key = - '-----BEGIN PRIVATE KEY-----\n' + - process.env.AUTH_KEY_LINE1 + - '\n' + - process.env.AUTH_KEY_LINE2 + - '\n' + - process.env.AUTH_KEY_LINE3 + - '\n' + - process.env.AUTH_KEY_LINE4 + - '\n' + - '-----END PRIVATE KEY-----'; - - return jwt.sign(payload, key, { - algorithm: 'ES256', - header, - }); - } - - async withdrawal(request, deleteAuthDto) { - const revokeUser = await this.usersService.findUserById(request['user'].id); - - const idToken = deleteAuthDto.idToken; - const authorizationCode = deleteAuthDto.authorizationCode; - - const decodedIdToken = await this.decodeIdToken(idToken); - - if (decodedIdToken.sub !== revokeUser.resourceId) { - throw new BadRequestException( - 'identify 토큰과 access 토큰 내의 회원정보가 충돌합니다.' - ); - } - - const clientId = decodedIdToken.aud; - const clientSecret = this.clientSecretGenerator(clientId); - const payload = { - code: authorizationCode, - client_id: clientId, - grant_type: 'authorization_code', - client_secret: clientSecret, - }; - - const tokenRequestResult = await firstValueFrom( - this.httpService.post('https://appleid.apple.com/auth/token', payload, { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }) - ); - - const info = tokenRequestResult.data; - const token = info.refresh_token; - const revoke = { - client_id: clientId, - client_secret: clientSecret, - token, - }; + async withdraw( + social: string, + userId: string, + socialWithdrawRequestDto: SocialWithdrawRequestDto + ) { + const socialLoginStrategy: SocialLoginStrategy = + this.getLoginStrategy(social); + const { id, resourceId } = await this.usersService.findUserById(userId); - const revokeRequetResult = await firstValueFrom( - this.httpService.post('https://appleid.apple.com/auth/revoke', revoke, { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }) - ); - - if (revokeRequetResult.status === 200) { - await this.usersService.deleteUser(revokeUser.id); + try { + await socialLoginStrategy.withdraw(resourceId, socialWithdrawRequestDto); + await this.usersService.deleteUser(id); return { revoke: true }; + } catch { + return { revoke: false }; } - return { revoke: false }; } - async manageIp(id, ip, allow) { - const user = await this.usersService.findUserById(id); - - const allowedIp = user.allowedIp; - - let bannedIp; - if (user.bannedIp === null) { - bannedIp = []; - } else { - bannedIp = user.bannedIp; - } - - if (allow) { - allowedIp.push(ip); - } else { - bannedIp.push(ip); - } - const result = await this.usersService.updateUserIp(id, { - allowedIp, - bannedIp, - }); - - if (result.affected !== 1) { - throw new InternalServerErrorException(); - } - - return true; - } + // async manageIp(id, ip, allow) { + // const user = await this.usersService.findUserById(id); + + // const allowedIp = user.allowedIp; + + // let bannedIp; + // if (user.bannedIp === null) { + // bannedIp = []; + // } else { + // bannedIp = user.bannedIp; + // } + + // if (allow) { + // allowedIp.push(ip); + // } else { + // bannedIp.push(ip); + // } + // const result = await this.usersService.updateUserIp(id, { + // allowedIp, + // bannedIp, + // }); + + // if (result.affected !== 1) { + // throw new InternalServerErrorException(); + // } + + // return true; + // } } diff --git a/BE/src/auth/dto/create-auth-response.ts b/BE/src/auth/dto/create-auth-response.ts deleted file mode 100644 index 23cd3e8..0000000 --- a/BE/src/auth/dto/create-auth-response.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; - -export class CreateAuthResponseDto { - @ApiProperty() - @IsString() - accessToken: string; - - @ApiProperty() - @IsString() - refreshToken: string; -} diff --git a/BE/src/auth/dto/delete-auth.dto.ts b/BE/src/auth/dto/delete-auth.dto.ts deleted file mode 100644 index baf440f..0000000 --- a/BE/src/auth/dto/delete-auth.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; - -export class DeleteAuthDto { - @ApiProperty() - @IsString() - idToken: string; - - @ApiProperty() - @IsString() - authorizationCode: string; -} diff --git a/BE/src/exception/exception.filter.ts b/BE/src/exception/exception.filter.ts index 10b6336..9336635 100644 --- a/BE/src/exception/exception.filter.ts +++ b/BE/src/exception/exception.filter.ts @@ -15,6 +15,9 @@ export class HttpExceptionFilter implements ExceptionFilter { const request = context.getRequest(); if (!(exception instanceof HttpException)) { + winstonLogger.log( + `**Only Server: Response from ${request.method} ${request.url}\nresponse: ${exception.name} - ${exception.message}` + ); exception = new InternalServerErrorException(); } diff --git a/BE/src/socialLogin/apple-login-strategy.ts b/BE/src/socialLogin/apple-login-strategy.ts new file mode 100644 index 0000000..e1c6395 --- /dev/null +++ b/BE/src/socialLogin/apple-login-strategy.ts @@ -0,0 +1,138 @@ +import { SocialWithdrawRequestDto } from './dto/social-withdraw-request.dto'; +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { SocialLoginStrategy } from './social-login-strategy.interface'; +import { SocialLoginRequestDto } from './dto/social-login-request.dto'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import { JwksClient } from 'jwks-rsa'; +import * as jwt from 'jsonwebtoken'; + +@Injectable() +export class AppleLoginStrategy implements SocialLoginStrategy { + constructor(private readonly httpService: HttpService) {} + + async login( + socialLoginRequestDto: SocialLoginRequestDto + ): Promise<{ resourceId: string; email: string }> { + try { + const { idToken, email } = socialLoginRequestDto; + const resourceId = (await this.decodeIdToken(idToken)).sub; + return { resourceId, email }; + } catch (error) { + throw new UnauthorizedException('유효하지 않은 형식의 토큰입니다.'); + } + } + + async withdraw( + resourceId: string, + socialWithdrawRequestDto: SocialWithdrawRequestDto + ): Promise { + const { idToken, authorizationCode } = socialWithdrawRequestDto; + const decodedIdToken = await this.decodeIdToken(idToken); + + if (decodedIdToken.sub !== resourceId) { + throw new BadRequestException( + 'identify 토큰과 access 토큰 내의 회원정보가 충돌합니다.' + ); + } + + const clientId = decodedIdToken.aud; + const clientSecret = this.clientSecretGenerator(clientId); + const payload = { + code: authorizationCode, + client_id: clientId, + grant_type: 'authorization_code', + client_secret: clientSecret, + }; + + const { data } = await firstValueFrom( + this.httpService.post('https://appleid.apple.com/auth/token', payload, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + ); + + const info = data; + const token = info.refresh_token; + const revoke = { + client_id: clientId, + client_secret: clientSecret, + token, + }; + + const { status } = await firstValueFrom( + this.httpService.post('https://appleid.apple.com/auth/revoke', revoke, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + ); + + if (status !== 200) { + throw new Error('revoke 요청 실패'); + } + } + + private async decodeIdToken(idToken) { + const kid = jwt.decode(idToken, { + complete: true, + }).header.kid; + + const client = new JwksClient({ + jwksUri: 'https://appleid.apple.com/auth/keys', + }); + + const key = await client.getSigningKey(kid); + const verifyingKey = key.getPublicKey(); + + const decodedResult = jwt.verify(idToken, verifyingKey, { + algorithms: ['RS256'], + }); + + const decodedIdToken = + typeof decodedResult === 'string' + ? JSON.parse(decodedResult) + : decodedResult; + + if ( + decodedIdToken.iss !== 'https://appleid.apple.com' || + decodedIdToken.aud !== process.env.CLIENT_ID + ) { + throw new UnauthorizedException( + 'identity 토큰 내의 정보가 올바르지 않습니다.' + ); + } + return decodedIdToken; + } + + private clientSecretGenerator(clientId) { + const header = { alg: 'ES256', kid: process.env.KEY_ID }; + const iat = Math.floor(Date.now() / 1000); + const exp = iat + 60 * 60; + const payload = { + iss: process.env.TEAM_ID, + iat, + exp, + aud: 'https://appleid.apple.com', + sub: clientId, + }; + + const key = + '-----BEGIN PRIVATE KEY-----\n' + + process.env.AUTH_KEY_LINE1 + + '\n' + + process.env.AUTH_KEY_LINE2 + + '\n' + + process.env.AUTH_KEY_LINE3 + + '\n' + + process.env.AUTH_KEY_LINE4 + + '\n' + + '-----END PRIVATE KEY-----'; + + return jwt.sign(payload, key, { + algorithm: 'ES256', + header, + }); + } +} diff --git a/BE/src/auth/dto/create-auth-request.dto.ts b/BE/src/socialLogin/dto/social-login-request.dto.ts similarity index 65% rename from BE/src/auth/dto/create-auth-request.dto.ts rename to BE/src/socialLogin/dto/social-login-request.dto.ts index 963205e..3fe507e 100644 --- a/BE/src/auth/dto/create-auth-request.dto.ts +++ b/BE/src/socialLogin/dto/social-login-request.dto.ts @@ -7,14 +7,12 @@ import { MinLength, } from 'class-validator'; -export class CreateAuthRequestDto { - @ApiProperty() +export class SocialLoginRequestDto { + @ApiProperty({ required: false }) @IsString() - idToken: string; + idToken?: string; - @ApiProperty({ - required: false, - }) + @ApiProperty({ required: false }) @IsEmail() @MinLength(4) @MaxLength(35) diff --git a/BE/src/socialLogin/dto/social-withdraw-request.dto.ts b/BE/src/socialLogin/dto/social-withdraw-request.dto.ts new file mode 100644 index 0000000..9940623 --- /dev/null +++ b/BE/src/socialLogin/dto/social-withdraw-request.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class SocialWithdrawRequestDto { + @ApiProperty({ + required: false, + description: 'Kakao 계정은 idToken 없음\nApple 계정은 idToken 필수', + }) + @IsString() + idToken?: string; + + @ApiProperty({ + required: false, + description: + 'Kakao 계정은 authorizationCode 없음\nApple 계정은 authorizationCode 필수', + }) + @IsString() + authorizationCode?: string; +} diff --git a/BE/src/socialLogin/kakao-login-strategy.ts b/BE/src/socialLogin/kakao-login-strategy.ts new file mode 100644 index 0000000..e22792d --- /dev/null +++ b/BE/src/socialLogin/kakao-login-strategy.ts @@ -0,0 +1,55 @@ +import { SocialWithdrawRequestDto } from './dto/social-withdraw-request.dto'; +import { SocialLoginStrategy } from './social-login-strategy.interface'; +import { SocialLoginRequestDto } from './dto/social-login-request.dto'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import * as jwt from 'jsonwebtoken'; +import { firstValueFrom } from 'rxjs'; + +@Injectable() +export class KakaoLoginStrategy implements SocialLoginStrategy { + constructor(private readonly httpService: HttpService) {} + + async login( + socialLoginRequestDto: SocialLoginRequestDto + ): Promise<{ resourceId: string; email: string }> { + try { + const { idToken } = socialLoginRequestDto; + const { sub, email } = jwt.decode(idToken) as { + sub: string; + email: string; + }; + return { resourceId: sub, email }; + } catch (error) { + throw new UnauthorizedException('유효하지 않은 형식의 토큰입니다.'); + } + } + + async withdraw( + resourceId: string, + socialWithdrawRequestDto: SocialWithdrawRequestDto + ): Promise { + const payload = { + target_id_type: 'user_id', + target_id: resourceId, + }; + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `KakaoAK ${process.env.KAKAO_SERVICE_APP_ADMIN_KEY}`, + }; + + try { + await firstValueFrom( + this.httpService.post( + 'https://kapi.kakao.com/v1/user/unlink', + payload, + { + headers: headers, + } + ) + ); + } catch (error) { + throw new Error(error.msg); + } + } +} diff --git a/BE/src/socialLogin/social-login-strategy.interface.ts b/BE/src/socialLogin/social-login-strategy.interface.ts new file mode 100644 index 0000000..7977256 --- /dev/null +++ b/BE/src/socialLogin/social-login-strategy.interface.ts @@ -0,0 +1,12 @@ +import { SocialWithdrawRequestDto } from './dto/social-withdraw-request.dto'; +import { SocialLoginRequestDto } from './dto/social-login-request.dto'; + +export interface SocialLoginStrategy { + login( + socialLoginRequestDto: SocialLoginRequestDto + ): Promise<{ resourceId: string; email: string }>; + withdraw( + resourceId: string, + socialWithdrawRequestDto: SocialWithdrawRequestDto + ): Promise; +} diff --git a/BE/src/timelines/timelines.controller.ts b/BE/src/timelines/timelines.controller.ts index c918d92..a93430e 100644 --- a/BE/src/timelines/timelines.controller.ts +++ b/BE/src/timelines/timelines.controller.ts @@ -218,12 +218,13 @@ export class TimelinesController { @Get(':id/translate') @ApiOperation({ - summary: 'Papago API 번역', + summary: 'Papago API 번역 (outdated)', description: 'Papago API를 사용하여 타임라인 세부 내용을 영어로 번역하고, 그 결과를 반환합니다.', }) @ApiOkResponse({ schema: { example: translate_OK } }) async translate(@Param('id', ParseUUIDPipe) id: string) { - return this.timelinesService.translate(id); + return 'Translation Service has ended'; + // return this.timelinesService.translate(id); } } diff --git a/BE/src/timelines/timelines.service.ts b/BE/src/timelines/timelines.service.ts index 953264e..3b43388 100644 --- a/BE/src/timelines/timelines.service.ts +++ b/BE/src/timelines/timelines.service.ts @@ -31,30 +31,41 @@ export class TimelinesService { file: Express.Multer.File, createTimelineDto: CreateTimelineDto ) { - const posting = await this.postingsService.findOne( - createTimelineDto.posting - ); + let imagePath: string; - if (posting.writer.id !== userId) { - throw new ForbiddenException( - '본인이 작성한 게시글에 대해서만 타임라인을 생성할 수 있습니다.' + try { + const posting = await this.postingsService.findOne( + createTimelineDto.posting ); - } - const timeline = await this.initialize(createTimelineDto); - timeline.posting = posting; + if (posting.writer.id !== userId) { + throw new ForbiddenException( + '본인이 작성한 게시글에 대해서만 타임라인을 생성할 수 있습니다.' + ); + } + + const timeline = await this.initialize(createTimelineDto); + timeline.posting = posting; - if (file) { - const filePath = `${userId}/${posting.id}/`; - const { path } = await this.storageService.upload(filePath, file); - timeline.image = path; + if (file) { + const filePath = `${userId}/${posting.id}/`; + const { path } = await this.storageService.upload(filePath, file); + imagePath = path; + timeline.image = imagePath; - if (!posting.thumbnail) { - await this.postingsRepository.updateThumbnail(posting.id, path); + if (!posting.thumbnail) { + await this.postingsRepository.updateThumbnail(posting.id, imagePath); + } + } + + return this.timelinesRepository.save(timeline); + } catch (error) { + if (imagePath) { + await this.storageService.delete(imagePath); } - } - return this.timelinesRepository.save(timeline); + throw error; + } } async findAll(postingId: string, day: number) { @@ -92,31 +103,47 @@ export class TimelinesService { image: Express.Multer.File, updateTimelineDto: UpdateTimelineDto ) { - const timeline = await this.findOne(id); - const isThumbnail = timeline.image === timeline.posting.thumbnail; - if (timeline.image) { - await this.storageService.delete(timeline.image); - } + let imagePath: string; + + try { + const timeline = await this.findOne(id); + const isThumbnail = timeline.image === timeline.posting.thumbnail; + const updatedTimeline = await this.initialize(updateTimelineDto); + updatedTimeline.id = id; + + if (image) { + const imagePlainPath = `${userId}/${timeline.posting.id}/`; + const { path } = await this.storageService.upload( + imagePlainPath, + image + ); + imagePath = path; + updatedTimeline.image = imagePath; + } else { + updatedTimeline.image = null; + } + + const updatedResult = await this.timelinesRepository.update( + id, + updatedTimeline + ); - const updatedTimeline = await this.initialize(updateTimelineDto); - updatedTimeline.id = id; + if (isThumbnail) { + await this.findOneAndUpdateThumbnail(timeline.posting.id); + } - if (image) { - const imagePath = `${userId}/${timeline.posting.id}/`; - const { path } = await this.storageService.upload(imagePath, image); - updatedTimeline.image = path; - } + if (timeline.image) { + await this.storageService.delete(timeline.image); + } - const updatedResult = await this.timelinesRepository.update( - id, - updatedTimeline - ); + return updatedResult; + } catch (error) { + if (imagePath) { + await this.storageService.delete(imagePath); + } - if (isThumbnail) { - await this.findOneAndUpdateThumbnail(timeline.posting.id); + throw error; } - - return updatedResult; } @Transactional() @@ -166,29 +193,29 @@ export class TimelinesService { return documents; } - async translate(id: string) { - const { description } = await this.findOne(id); - const body = { - source: 'ko', - target: 'en', - text: description, - }; - const { - data: { - message: { result }, - }, - } = await firstValueFrom( - this.httpService.post(PAPAGO_URL, body, { - headers: { - 'Content-Type': 'application/json', - 'X-NCP-APIGW-API-KEY-ID': process.env.X_NCP_APIGW_API_KEY_ID, - 'X-NCP-APIGW-API-KEY': process.env.X_NCP_APIGW_API_KEY, - }, - }) - ); - - return { description: result.translatedText }; - } + // async translate(id: string) { + // const { description } = await this.findOne(id); + // const body = { + // source: 'ko', + // target: 'en', + // text: description, + // }; + // const { + // data: { + // message: { result }, + // }, + // } = await firstValueFrom( + // this.httpService.post(PAPAGO_URL, body, { + // headers: { + // 'Content-Type': 'application/json', + // 'X-NCP-APIGW-API-KEY-ID': process.env.X_NCP_APIGW_API_KEY_ID, + // 'X-NCP-APIGW-API-KEY': process.env.X_NCP_APIGW_API_KEY, + // }, + // }) + // ); + + // return { description: result.translatedText }; + // } private async findOneAndUpdateThumbnail(postingId: string) { const result = diff --git a/BE/src/users/dto/user-info.dto.ts b/BE/src/users/dto/user-info.dto.ts index 563d47f..242aa03 100644 --- a/BE/src/users/dto/user-info.dto.ts +++ b/BE/src/users/dto/user-info.dto.ts @@ -11,4 +11,8 @@ export class UserInfoDto { @IsString() @ApiProperty({ nullable: true }) avatar: string; + + @IsString() + @ApiProperty({ nullable: true }) + avatarPath: string; } diff --git a/BE/src/users/users.service.ts b/BE/src/users/users.service.ts index 179e06d..c6fc479 100644 --- a/BE/src/users/users.service.ts +++ b/BE/src/users/users.service.ts @@ -56,13 +56,13 @@ export class UsersService { return this.userRepository.findById(id); } - async getUserInfoById(id: string): Promise { + async getUserInfoById(id: string) { const user = await this.userRepository.findById(id); const avatarPath = user.avatar; if (user.avatar !== null) { user.avatar = await this.storageService.getImageUrl(avatarPath); } - return { name: user.name, avatar: user.avatar }; + return { name: user.name, avatar: user.avatar, avatarPath }; } async getUserInfoByResourceId(resourceId: string) { diff --git a/BE/src/users/users.swagger.ts b/BE/src/users/users.swagger.ts index 7f61317..9a14523 100644 --- a/BE/src/users/users.swagger.ts +++ b/BE/src/users/users.swagger.ts @@ -2,6 +2,7 @@ export const Users = { name: '레몬', avatar: 'https://traveline.kr.object.ncloudstorage.com/123456789012345678901234567890123456/bd67709e-0a62-4cb2-acb5-a26d539f90e5.jpg?AWSAccessKeyId=qjvpzL57YZV54DgXNdvd&Expires=1701542436&Signature=PLHDtpwQVMT0SeM5hsGWl%2F7JV2w%3D', + avatarPath: '123456789012345678901234567890123456/lemon.jpg', }; export const getUsersDuplicate = {