From ac99f39f8a09fd720b837bef4dbb3b8eb1ef6571 Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Tue, 22 Aug 2023 22:40:53 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[Release]=20release=20?= =?UTF-8?q?sprint13=20(#618)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: hannkim Co-authored-by: Jisu Kim --- .github/workflows/docker-cd.yml | 54 ++++++++++++------- frontend/Dockerfile | 9 +++- .../MessagePage/BlockModal/BlockModal.tsx | 2 +- .../MessagePage/FriendsModal/FriendsModal.tsx | 2 +- 4 files changed, 45 insertions(+), 22 deletions(-) diff --git a/.github/workflows/docker-cd.yml b/.github/workflows/docker-cd.yml index 78a9fb7b..52d4926f 100644 --- a/.github/workflows/docker-cd.yml +++ b/.github/workflows/docker-cd.yml @@ -16,6 +16,9 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Login to DockerHub uses: docker/login-action@v1 with: @@ -23,30 +26,43 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push backend Docker image - run: | - cd backend - docker build -t ${{ secrets.DOCKER_USERNAME }}/ghostserver:latest . - docker push ${{ secrets.DOCKER_USERNAME }}/ghostserver:latest + uses: docker/build-push-action@v4 + with: + context: . + file: ./backend/Dockerfile + platforms: linux/amd64, linux/arm64/v8 + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/ghostserver:latest - name: Build and push frontend Docker image - run: | - cd frontend - docker build -t ${{ secrets.DOCKER_USERNAME }}/ghostclient:latest . - docker push ${{ secrets.DOCKER_USERNAME }}/ghostclient:latest + uses: docker/build-push-action@v4 + with: + context: . + file: ./frontend/Dockerfile + platforms: linux/amd64,linux/arm64/v8 + build-args: | + VITE_BASE_URL=${{ secrets.VITE_BASE_URL }} + VITE_API_URL=${{ secrets.VITE_BASE_URL }}/api/v1 + VITE_ASSET_URL=${{ secrets.VITE_BASE_URL }} + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/ghostclient:latest + deploy-to-ec2: runs-on: ubuntu-latest needs: build-and-push steps: - name: Deploy to Server - run: | - ssh-keyscan ${{ secrets.EC2_HOST }} >> ~/.ssh/known_hosts - echo "${{ secrets.SERVER_SSH_KEY }}" > ${{ secrets.EC2_KEY }} - chmod 600 ${{ secrets.EC2_KEY }} - scp docker-compose.yml ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:~/docker-compose.yml - ssh -i ${{ secrets.EC2_KEY }} ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} << 'ENDSSH' - docker pull ${{ secrets.DOCKER_USERNAME }}/ghostserver:latest - docker pull ${{ secrets.DOCKER_USERNAME }}/ghostclient:latest - docker compose -f ~/docker-compose.yml down - docker compose -f ~/docker-compose.yml up -d - ENDSSH + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + envs: GITHUB_SHA + script: | + sudo docker ps + sudo docker compose -f ~/docker-compose.yml down && \ + sudo docker pull ${{ secrets.DOCKER_USERNAME }}/ghostserver:latest && \ + sudo docker pull ${{ secrets.DOCKER_USERNAME }}/ghostclient:latest && \ + sudo docker compose -f ~/docker-compose.yml up -d + sudo docker image prune -f diff --git a/frontend/Dockerfile b/frontend/Dockerfile index caa853ee..06741f31 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -2,6 +2,14 @@ FROM node:18-alpine AS builder WORKDIR /app/frontend +ARG VITE_BASE_URL +ARG VITE_API_URL +ARG VITE_ASSET_URL + +ENV VITE_BASE_URL=$VITE_BASE_URL +ENV VITE_API_URL=$VITE_API_URL +ENV VITE_ASSET_URL=$VITE_ASSET_URL + COPY ./frontend /app/frontend COPY ./game /app/game @@ -17,4 +25,3 @@ COPY --from=builder /app/frontend/dist /app/dist COPY ./frontend/nginx.conf /etc/nginx/conf.d/default.conf ENTRYPOINT ["nginx", "-g", "daemon off;"] - diff --git a/frontend/src/pages/MessagePage/BlockModal/BlockModal.tsx b/frontend/src/pages/MessagePage/BlockModal/BlockModal.tsx index a436a016..b72706f2 100644 --- a/frontend/src/pages/MessagePage/BlockModal/BlockModal.tsx +++ b/frontend/src/pages/MessagePage/BlockModal/BlockModal.tsx @@ -43,7 +43,7 @@ export const BlockModal = ({ isOpen, onClose }: Omit) => ) Date: Thu, 24 Aug 2023 20:55:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[Release]=20Release=20?= =?UTF-8?q?Local=20login/signup=20(#625)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: hannkim Co-authored-by: Jisu Kim --- .dockerignore | 2 +- backend/.env.example | 6 + .../db/migrations/1692710671031-migration.ts | 15 ++ backend/db/migrations/orm-config.ts | 32 +++++ backend/{ => db}/seeding/data-source.ts | 18 +-- .../{ => db}/seeding/factory/auth.factory.ts | 2 +- .../seeding/factory/frieindship.factory.ts | 2 +- .../seeding/factory/game-history.factory.ts | 2 +- .../seeding/factory/message.factory.ts | 2 +- .../{ => db}/seeding/factory/user.factory.ts | 2 +- backend/{ => db}/seeding/reset-db.sh | 0 .../{ => db}/seeding/seeder/message.seeder.ts | 12 +- backend/package.json | 12 +- backend/src/auth/auth.controller.ts | 65 +++++---- backend/src/auth/auth.module.ts | 9 +- backend/src/auth/auth.service.ts | 117 ++++++++++++---- .../dto/request/local-login-request.dto.ts | 19 +++ .../dto/request/local-signup-request.dto.ts | 19 +++ backend/src/auth/guard/ft.guard.ts | 5 - backend/src/auth/guard/google.guard.ts | 5 - backend/src/auth/guard/social.guard.ts | 37 +++++ backend/src/auth/strategy/ft.strategy.ts | 8 +- backend/src/auth/strategy/github.strategy.ts | 23 +++ backend/src/auth/strategy/google.strategy.ts | 9 +- backend/src/auth/strategy/local.strategy.ts | 20 +++ backend/src/auth/type/login-info.ts | 14 +- ...e-options.ts => login-response-options.ts} | 2 +- .../auth/github/configuration.module.ts | 23 +++ .../auth/github/configuration.service.ts | 19 +++ .../src/config/auth/github/configuration.ts | 7 + .../config/database/configuration.service.ts | 4 +- backend/src/entity/auth.entity.ts | 10 +- backend/src/user/user.module.ts | 1 - backend/yarn.lock | 32 +++++ frontend/src/App.tsx | 4 + .../MessagePage/BlockModal/BlockModal.tsx | 2 +- .../MessagePage/FriendsModal/FriendsModal.tsx | 2 +- frontend/src/pages/PrePage/PrePage.tsx | 15 +- frontend/src/pages/SignInPage/index.tsx | 92 ++++++++++++ frontend/src/pages/SignUpPage/index.tsx | 132 ++++++++++++++++++ frontend/src/pages/index.ts | 2 + types/auth/request/index.ts | 2 + .../request/local-login-request.interface.ts | 4 + .../request/local-signup-request.interface.ts | 5 + 44 files changed, 700 insertions(+), 115 deletions(-) create mode 100644 backend/db/migrations/1692710671031-migration.ts create mode 100644 backend/db/migrations/orm-config.ts rename backend/{ => db}/seeding/data-source.ts (74%) rename backend/{ => db}/seeding/factory/auth.factory.ts (86%) rename backend/{ => db}/seeding/factory/frieindship.factory.ts (86%) rename backend/{ => db}/seeding/factory/game-history.factory.ts (83%) rename backend/{ => db}/seeding/factory/message.factory.ts (87%) rename backend/{ => db}/seeding/factory/user.factory.ts (85%) rename backend/{ => db}/seeding/reset-db.sh (100%) rename backend/{ => db}/seeding/seeder/message.seeder.ts (90%) create mode 100644 backend/src/auth/dto/request/local-login-request.dto.ts create mode 100644 backend/src/auth/dto/request/local-signup-request.dto.ts delete mode 100644 backend/src/auth/guard/ft.guard.ts delete mode 100644 backend/src/auth/guard/google.guard.ts create mode 100644 backend/src/auth/guard/social.guard.ts create mode 100644 backend/src/auth/strategy/github.strategy.ts create mode 100644 backend/src/auth/strategy/local.strategy.ts rename backend/src/auth/type/{social-response-options.ts => login-response-options.ts} (63%) create mode 100644 backend/src/config/auth/github/configuration.module.ts create mode 100644 backend/src/config/auth/github/configuration.service.ts create mode 100644 backend/src/config/auth/github/configuration.ts create mode 100644 frontend/src/pages/SignInPage/index.tsx create mode 100644 frontend/src/pages/SignUpPage/index.tsx create mode 100644 types/auth/request/local-login-request.interface.ts create mode 100644 types/auth/request/local-signup-request.interface.ts diff --git a/.dockerignore b/.dockerignore index 1d641430..2e32533d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,7 +4,7 @@ **/.env* **/.husky -./backend/seeding +./backend/db/seeding ./backend/yarn-error.log ./backend/test ./backend/.env.development diff --git a/backend/.env.example b/backend/.env.example index 1109fae5..cd09cf9e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,6 +32,12 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_CALLBACK_URL=http://localhost:3000/api/v1/auth/callback/google +# Github OAuth +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_CALLBACK_URL=http://localhost:3000/api/v1/auth/callback/github + + # JWT token secret 값 USER_JWT_SECRETKEY= # JWT token secret 값 diff --git a/backend/db/migrations/1692710671031-migration.ts b/backend/db/migrations/1692710671031-migration.ts new file mode 100644 index 00000000..5a8adbb3 --- /dev/null +++ b/backend/db/migrations/1692710671031-migration.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Migration1692710671031 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // create column at auth table + await queryRunner.query(`ALTER TABLE "auth" ADD COLUMN password varchar(64)`); + await queryRunner.query(`ALTER TABLE "auth" ADD COLUMN account_id varchar(32) UNIQUE`); + } + + public async down(queryRunner: QueryRunner): Promise { + // drop column from auth table + await queryRunner.query(`ALTER TABLE "auth" DROP COLUMN account_id`); + await queryRunner.query(`ALTER TABLE "auth" DROP COLUMN password`); + } +} diff --git a/backend/db/migrations/orm-config.ts b/backend/db/migrations/orm-config.ts new file mode 100644 index 00000000..a6ead72f --- /dev/null +++ b/backend/db/migrations/orm-config.ts @@ -0,0 +1,32 @@ +import { config } from 'dotenv'; +import { DataSource, DataSourceOptions } from 'typeorm'; +import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; + +import { Auth } from '../../src/entity/auth.entity'; + +import { Migration1692710671031 } from './1692710671031-migration'; + +config({ path: '.env.development' }); +if (process.env.NODE_ENV === 'production') { + config(); +} + +const auth1692710671031 = Migration1692710671031; + +export const options: DataSourceOptions = { + type: 'postgres', + host: process.env.POSTGRES_HOST, + port: Number(process.env.POSTGRES_PORT), + database: process.env.POSTGRES_DB, + username: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD, + synchronize: false, + logging: true, + entities: [Auth], + namingStrategy: new SnakeNamingStrategy(), + migrations: [auth1692710671031], +}; + +const dataSource = new DataSource(options); + +export default dataSource; diff --git a/backend/seeding/data-source.ts b/backend/db/seeding/data-source.ts similarity index 74% rename from backend/seeding/data-source.ts rename to backend/db/seeding/data-source.ts index 54d1d012..d9d39561 100644 --- a/backend/seeding/data-source.ts +++ b/backend/db/seeding/data-source.ts @@ -2,15 +2,15 @@ import { config } from 'dotenv'; import { DataSource, DataSourceOptions } from 'typeorm'; import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; -import { Achievement } from '../src/entity/achievement.entity'; -import { Auth } from '../src/entity/auth.entity'; -import { BlockedUser } from '../src/entity/blocked-user.entity'; -import { Friendship } from '../src/entity/friendship.entity'; -import { GameHistory } from '../src/entity/game-history.entity'; -import { MessageView } from '../src/entity/message-view.entity'; -import { Message } from '../src/entity/message.entity'; -import { UserRecord } from '../src/entity/user-record.entity'; -import { User } from '../src/entity/user.entity'; +import { Achievement } from '../../src/entity/achievement.entity'; +import { Auth } from '../../src/entity/auth.entity'; +import { BlockedUser } from '../../src/entity/blocked-user.entity'; +import { Friendship } from '../../src/entity/friendship.entity'; +import { GameHistory } from '../../src/entity/game-history.entity'; +import { MessageView } from '../../src/entity/message-view.entity'; +import { Message } from '../../src/entity/message.entity'; +import { UserRecord } from '../../src/entity/user-record.entity'; +import { User } from '../../src/entity/user.entity'; import seeder from './seeder/message.seeder'; diff --git a/backend/seeding/factory/auth.factory.ts b/backend/db/seeding/factory/auth.factory.ts similarity index 86% rename from backend/seeding/factory/auth.factory.ts rename to backend/db/seeding/factory/auth.factory.ts index 5b201e63..373186e8 100644 --- a/backend/seeding/factory/auth.factory.ts +++ b/backend/db/seeding/factory/auth.factory.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; -import { AuthStatus } from '../../src/entity/auth.entity'; +import { AuthStatus } from '../../../src/entity/auth.entity'; export default () => ({ //auth.id is auto generated diff --git a/backend/seeding/factory/frieindship.factory.ts b/backend/db/seeding/factory/frieindship.factory.ts similarity index 86% rename from backend/seeding/factory/frieindship.factory.ts rename to backend/db/seeding/factory/frieindship.factory.ts index e34e17ef..0e6b5d20 100644 --- a/backend/seeding/factory/frieindship.factory.ts +++ b/backend/db/seeding/factory/frieindship.factory.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; -import { User } from '../../src/entity/user.entity'; +import { User } from '../../../src/entity/user.entity'; export default (user1: User, user2: User, accept: boolean, lastMessageTime?: Date) => { return { diff --git a/backend/seeding/factory/game-history.factory.ts b/backend/db/seeding/factory/game-history.factory.ts similarity index 83% rename from backend/seeding/factory/game-history.factory.ts rename to backend/db/seeding/factory/game-history.factory.ts index e5ac04aa..de5f50e7 100644 --- a/backend/seeding/factory/game-history.factory.ts +++ b/backend/db/seeding/factory/game-history.factory.ts @@ -1,7 +1,7 @@ // file name should be .factory.ts import { faker } from '@faker-js/faker'; -import { User } from '../../src/entity/user.entity'; +import { User } from '../../../src/entity/user.entity'; export default (user1: User, user2: User) => { return { diff --git a/backend/seeding/factory/message.factory.ts b/backend/db/seeding/factory/message.factory.ts similarity index 87% rename from backend/seeding/factory/message.factory.ts rename to backend/db/seeding/factory/message.factory.ts index 96ac78be..2081337e 100644 --- a/backend/seeding/factory/message.factory.ts +++ b/backend/db/seeding/factory/message.factory.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; -import { Friendship } from '../../src/entity/friendship.entity'; +import { Friendship } from '../../../src/entity/friendship.entity'; export default (friendship: Friendship, prevDate?: Date) => { prevDate?.setDate(prevDate?.getDate() + faker.datatype.number({ min: 1, max: 10 })); diff --git a/backend/seeding/factory/user.factory.ts b/backend/db/seeding/factory/user.factory.ts similarity index 85% rename from backend/seeding/factory/user.factory.ts rename to backend/db/seeding/factory/user.factory.ts index 6e57bf2e..e6308937 100644 --- a/backend/seeding/factory/user.factory.ts +++ b/backend/db/seeding/factory/user.factory.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; -import { Auth } from '../../src/entity/auth.entity'; +import { Auth } from '../../../src/entity/auth.entity'; export default (auth: Auth) => { return { diff --git a/backend/seeding/reset-db.sh b/backend/db/seeding/reset-db.sh similarity index 100% rename from backend/seeding/reset-db.sh rename to backend/db/seeding/reset-db.sh diff --git a/backend/seeding/seeder/message.seeder.ts b/backend/db/seeding/seeder/message.seeder.ts similarity index 90% rename from backend/seeding/seeder/message.seeder.ts rename to backend/db/seeding/seeder/message.seeder.ts index a5ac7414..14d27947 100644 --- a/backend/seeding/seeder/message.seeder.ts +++ b/backend/db/seeding/seeder/message.seeder.ts @@ -1,12 +1,12 @@ import { faker } from '@faker-js/faker'; import { DataSource, Repository } from 'typeorm'; -import { Auth, AuthStatus } from '../../src/entity/auth.entity'; -import { Friendship } from '../../src/entity/friendship.entity'; -import { GameHistory } from '../../src/entity/game-history.entity'; -import { Message } from '../../src/entity/message.entity'; -import { UserRecord } from '../../src/entity/user-record.entity'; -import { User } from '../../src/entity/user.entity'; +import { Auth, AuthStatus } from '../../../src/entity/auth.entity'; +import { Friendship } from '../../../src/entity/friendship.entity'; +import { GameHistory } from '../../../src/entity/game-history.entity'; +import { Message } from '../../../src/entity/message.entity'; +import { UserRecord } from '../../../src/entity/user-record.entity'; +import { User } from '../../../src/entity/user.entity'; import authFactory from '../factory/auth.factory'; import frieindshipFactory from '../factory/frieindship.factory'; import gameHistoryFactory from '../factory/game-history.factory'; diff --git a/backend/package.json b/backend/package.json index 7f9fcb2f..0aa31d8d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,8 +22,12 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", "postinstall": "cd .. && husky install backend/.husky", - "seed": "npx ts-node ./seeding/data-source.ts", - "seed:reset": "./seeding/reset-db.sh" + "seed": "npx ts-node ./db/seeding/data-source.ts", + "seed:reset": "./db/seeding/reset-db.sh", + "migration:create": "npx typeorm-ts-node-commonjs migration:create ./db/migrations/migration", + "migration:generate": "npx typeorm-ts-node-commonjs migration:generate ./db/migrations/migration", + "migration:run": "npx typeorm-ts-node-commonjs migration:run -d ./db/migrations/orm-config.ts", + "migration:revert": "npx typeorm-ts-node-commonjs migration:revert -d ./db/migrations/orm-config.ts" }, "dependencies": { "@hapi/joi": "^17.1.1", @@ -50,8 +54,10 @@ "nodemailer": "^6.9.1", "passport": "^0.6.0", "passport-42": "^1.2.6", + "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "pg": "^8.10.0", "postgresql": "^0.0.1", "reflect-metadata": "^0.1.13", @@ -74,8 +80,10 @@ "@types/multer": "^1.4.7", "@types/node": "18.11.18", "@types/passport": "^1.0.12", + "@types/passport-github2": "^1.2.5", "@types/passport-google-oauth20": "^2.0.11", "@types/passport-jwt": "^3.0.9", + "@types/passport-local": "^1.0.35", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 457f619a..3fd5227f 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Res, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; import { ApiBadRequestResponse, ApiConflictResponse, @@ -19,13 +20,14 @@ import { AuthService } from './auth.service'; import { ExtractUser } from './decorator/extract-user.decorator'; import { SkipUserGuard } from './decorator/skip-user-guard.decorator'; import { CodeVerificationRequestDto } from './dto/request/code-verification-request.dto'; +import { LocalLoginRequestDto } from './dto/request/local-login-request.dto'; +import { LocalSignUpRequestDto } from './dto/request/local-signup-request.dto'; import { TwoFactorAuthRequestDto } from './dto/request/two-factor-auth-request.dto'; import { TwoFactorAuthResponseDto } from './dto/response/two-factor-auth-response.dto'; -import { FtGuard } from './guard/ft.guard'; -import { GoogleGuard } from './guard/google.guard'; +import { SocialGuard } from './guard/social.guard'; import { TwoFaGuard } from './guard/two-fa.guard'; import { LoginInfo } from './type/login-info'; -import { SocialResponseOptions } from './type/social-response-options'; +import { LoginResponseOptions } from './type/login-response-options'; @ApiTags('auth') @Controller('auth') @@ -34,47 +36,60 @@ export class AuthController { /** * @summary 로그인 - * @description GET /auth/login/ft + * @description GET /auth/login/ft, GET /auth/login/google, GET /auth/login/github */ - @ApiOperation({ summary: '42 로그인' }) + @ApiOperation({ summary: 'oauth 로그인' }) @SkipUserGuard() - @UseGuards(FtGuard) - @Get('login/ft') - login(): void { + @UseGuards(SocialGuard) + @Get('login/:provider') + socialLogin(): void { return; } /** * @summary 로그인 callback - * @description GET /auth/callback/ft + * @description GET /auth/callback/ft, GET /auth/callback/google, GET /auth/callback/github */ - @ApiOperation({ summary: '42 로그인 callback' }) + @ApiOperation({ summary: 'oauth 로그인 callback' }) @SkipUserGuard() - @UseGuards(FtGuard) // strategy.validate() -> return 값 기반으로 request 객체 담아줌 - @Get('callback/ft') - async callbackLogin(@ExtractUser() user: LoginInfo, @Res() res: Response): Promise { - const responseOptions: SocialResponseOptions = await this.authService.socialAuth(user); - + @UseGuards(SocialGuard) // strategy.validate() -> return 값 기반으로 request 객체 담아줌 + @Get('callback/:provider') + async callbackSocialLogin(@ExtractUser() user: LoginInfo, @Res() res: Response): Promise { + const responseOptions: LoginResponseOptions = await this.authService.socialAuth(user); if (responseOptions.cookieKey !== undefined) { res.cookie(responseOptions.cookieKey, responseOptions.token, COOKIE_OPTIONS); } res.redirect(responseOptions.redirectUrl); } - @ApiOperation({ summary: 'google 로그인' }) + /** + * @summary Local 로그인 + * @description POST /auth/login/local + */ + @ApiOperation({ summary: 'local 로그인' }) + @ApiNotFoundResponse({ type: ErrorResponseDto, description: '이메일 없음' }) + @ApiBadRequestResponse({ type: ErrorResponseDto, description: '잘못된 비밀번호' }) @SkipUserGuard() - @UseGuards(GoogleGuard) - @Get('login/google') - async googleLogin(): Promise { - return; + @UseGuards(AuthGuard('local')) + @HttpCode(HttpStatus.OK) + @Post('login/local') + async localLogin(@ExtractUser() user: LocalLoginRequestDto, @Res() res: Response): Promise { + const token = await this.authService.localLogin(user); + res.send({ token }); } - @ApiOperation({ summary: 'google 로그인 callback' }) + /** + * @summary Local 회원가입 + * @description POST /auth/signup/local + */ + @ApiOperation({ summary: 'local 회원가입' }) @SkipUserGuard() - @UseGuards(GoogleGuard) - @Get('callback/google') - async googleCallbackLogin(@ExtractUser() user: LoginInfo, @Res() res: Response): Promise { - return this.callbackLogin(user, res); + @Post('signup/local') + async localSignUp(@Body() signUpInfo: LocalSignUpRequestDto): Promise { + await this.authService.localSignUp(signUpInfo); + return { + message: '회원가입이 완료되었습니다.', + }; } /** diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 8e032d5b..f09793ed 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -7,25 +7,30 @@ import { MailerModule } from '@nestjs-modules/mailer'; import { TWO_FA_EXPIRES_IN, TWO_FA_MAX } from '../common/constant'; import { AppConfigModule } from '../config/app/configuration.module'; import { FtAuthConfigModule } from '../config/auth/ft/configuration.module'; +import { GithubAuthConfigModule } from '../config/auth/github/configuration.module'; import { GoogleAuthConfigModule } from '../config/auth/google/configuration.module'; import { JwtConfigModule } from '../config/auth/jwt/configuration.module'; import { MailerConfigModule } from '../config/auth/mailer/configuration.module'; import { MailerConfigService } from '../config/auth/mailer/configuration.service'; import { Auth } from '../entity/auth.entity'; +import { User } from '../entity/user.entity'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { UserGuard } from './guard/user.guard'; import { FtStrategy } from './strategy/ft.strategy'; +import { GithubStrategy } from './strategy/github.strategy'; import { GoogleStrategy } from './strategy/google.strategy'; +import { LocalStrategy } from './strategy/local.strategy'; import { UserStrategy } from './strategy/user.strategy'; @Module({ imports: [ - TypeOrmModule.forFeature([Auth]), + TypeOrmModule.forFeature([Auth, User]), JwtModule.register({}), FtAuthConfigModule, GoogleAuthConfigModule, + GithubAuthConfigModule, JwtConfigModule, MailerConfigModule, AppConfigModule, @@ -40,7 +45,7 @@ import { UserStrategy } from './strategy/user.strategy'; inject: [MailerConfigService], }), ], - providers: [AuthService, FtStrategy, UserStrategy, GoogleStrategy, UserGuard], + providers: [AuthService, FtStrategy, UserStrategy, GoogleStrategy, GithubStrategy, LocalStrategy, UserGuard], controllers: [AuthController], exports: [AuthService], }) diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 254f0120..ee4d1c2d 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,20 +1,33 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; -import { BadRequestException, ConflictException, ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { MailerService } from '@nestjs-modules/mailer'; +import { compare, hash } from 'bcrypt'; import { Cache } from 'cache-manager'; -import { Repository } from 'typeorm'; +import { nanoid } from 'nanoid'; +import { EntityManager, Repository } from 'typeorm'; import { AUTH_JWT_EXPIRES_IN, TWO_FA_EXPIRES_IN, TWO_FA_JWT_EXPIRES_IN, USER_JWT_EXPIRES_IN } from '../common/constant'; import { SuccessResponseDto } from '../common/dto/success-response.dto'; import { AppConfigService } from '../config/app/configuration.service'; import { JwtConfigService } from '../config/auth/jwt/configuration.service'; -import { Auth } from '../entity/auth.entity'; +import { Auth, AuthStatus } from '../entity/auth.entity'; +import { UserRecord } from '../entity/user-record.entity'; +import { User } from '../entity/user.entity'; +import { LocalLoginRequestDto } from './dto/request/local-login-request.dto'; +import { LocalSignUpRequestDto } from './dto/request/local-signup-request.dto'; import { TwoFactorAuthResponseDto } from './dto/response/two-factor-auth-response.dto'; import { LoginInfo } from './type/login-info'; -import { SocialResponseOptions } from './type/social-response-options'; +import { LoginResponseOptions } from './type/login-response-options'; import { TwoFactorAuth } from './type/two-factor-auth'; @Injectable() @@ -22,6 +35,8 @@ export class AuthService { constructor( @InjectRepository(Auth) private readonly authRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, private readonly jwtService: JwtService, private readonly jwtConfigService: JwtConfigService, private readonly appConfigService: AppConfigService, @@ -29,15 +44,14 @@ export class AuthService { private readonly mailerService: MailerService, ) {} - async signUp(user: LoginInfo): Promise { - const auth = await this.authRepository.findOneBy({ email: user.email }); - + async signUp(auth: Auth | null, loginInfo: LoginInfo): Promise { + let authId: number; if (auth === null) { - user.id = (await this.authRepository.insert({ email: user.email })).identifiers[0].id; + authId = await this.createAuth(loginInfo.email, null, loginInfo.id); } else { - user.id = auth.id; + authId = auth.id; } - const payload = { userId: user.id }; + const payload = { userId: authId }; const signOptions = { secret: this.jwtConfigService.authSecretKey, expiresIn: AUTH_JWT_EXPIRES_IN, @@ -54,26 +68,51 @@ export class AuthService { return this.jwtService.sign(payload, signOptions); } - async socialAuth(user: LoginInfo): Promise { - const auth = await this.authRepository.findOneBy({ email: user.email }); + async socialAuth(loginInfo: LoginInfo): Promise { let token = ''; const clientUrl = this.appConfigService.clientUrl; - if (auth === null) { - // unregistered user - token = await this.signUp(user); + const auth = await this.authRepository.findOneBy({ accountId: loginInfo.id }); + if (auth === null || auth.status === AuthStatus.UNREGISTERD) { + // unregistered users + token = await this.signUp(auth, loginInfo); return { cookieKey: 'jwt-for-unregistered', token, redirectUrl: `${clientUrl}/auth/register` }; - } else { - const userId = auth.id; - const { twoFa } = await this.getTwoFactorAuth(userId); - if (twoFa === null) { - token = this.signIn(userId); - return { token, redirectUrl: `${clientUrl}/auth?token=${token}` }; - } else { - token = await this.sendAuthCode(userId, twoFa); - } - return { cookieKey: 'jwt-for-2fa', token, redirectUrl: `${clientUrl}/auth/2fa` }; } + return await this.checkTwoFactorAuth(auth.id); + } + + async localLogin(loginInfo: LocalLoginRequestDto): Promise { + const auth = await this.authRepository.findOneBy({ email: loginInfo.email }); + if (auth === null || auth.password === null) { + throw new NotFoundException('이메일 또는 비밀번호를 확인해주세요.'); + } + if ((await compare(loginInfo.password, auth.password)) === false) { + throw new BadRequestException('비밀번호를 확인해주세요 .'); + } + return (await this.checkTwoFactorAuth(auth.id)).token; + } + + async localSignUp(signUpInfo: LocalSignUpRequestDto): Promise { + const email = signUpInfo.email; + const nickname = signUpInfo.nickname; + const password = await hash(signUpInfo.password, 5); + const accountId = 'local-' + nanoid(); + + if (await this.authRepository.findOneBy({ email })) { + throw new ConflictException('이미 존재하는 이메일입니다.'); + } + if (await this.userRepository.findOneBy({ nickname })) { + throw new ConflictException('중복된 닉네임입니다.'); + } + // create auth + const authId = await this.createAuth(email, password, accountId); + + // create user + await this.userRepository.manager.transaction(async (manager: EntityManager) => { + await manager.insert(User, { id: authId, nickname: nickname }); + await manager.insert(UserRecord, { id: authId }); + await manager.update(Auth, { id: authId }, { status: AuthStatus.REGISTERD }); + }); } async twoFactorAuthSignIn(myId: number, code: string): Promise { @@ -132,7 +171,7 @@ export class AuthService { if (value === undefined) { throw new ForbiddenException('유효하지 않은 인증 코드입니다.'); } - if ((await this.authRepository.findOneBy({ email: value.email })) !== null) { + if ((await this.authRepository.findOneBy({ twoFa: value.email })) !== null) { throw new ConflictException('이미 사용중인 이메일입니다.'); } await this.verifyTwoFactorAuth(myId, code, value.code); @@ -149,6 +188,17 @@ export class AuthService { } // SECTION private + private async createAuth(email: string | null, password: string | null, accountId: string): Promise { + const authId = ( + await this.authRepository.insert({ + email, + password, + accountId, + }) + ).identifiers[0].id; + return authId; + } + private async verifyTwoFactorAuth(myId: number, code: string, successCode: string) { if (code !== successCode) { throw new BadRequestException('잘못된 인증 코드입니다.'); @@ -156,6 +206,21 @@ export class AuthService { await this.cacheManager.del(`${myId}`); } + // 2fa 인증 유저인지 확인 후 로그인 + private async checkTwoFactorAuth(userId: number): Promise { + const clientUrl = this.appConfigService.clientUrl; + let token = ''; + + const { twoFa } = await this.getTwoFactorAuth(userId); + if (twoFa === null) { + token = this.signIn(userId); + return { token, redirectUrl: `${clientUrl}/auth?token=${token}` }; + } else { + token = await this.sendAuthCode(userId, twoFa); + } + return { cookieKey: 'jwt-for-2fa', token, redirectUrl: `${clientUrl}/auth/2fa` }; + } + private getEmailTemplate(code: string): string { return ` diff --git a/backend/src/auth/dto/request/local-login-request.dto.ts b/backend/src/auth/dto/request/local-login-request.dto.ts new file mode 100644 index 00000000..0a0e58f2 --- /dev/null +++ b/backend/src/auth/dto/request/local-login-request.dto.ts @@ -0,0 +1,19 @@ +import { IsEmail, IsString } from 'class-validator'; + +import { LocalLoginRequest } from '@/types/auth/request'; + +export class LocalLoginRequestDto implements LocalLoginRequest { + /** + * 이메일 + * @example 'sample@sample.com' + */ + @IsEmail() + email: string; + + /** + * 비밀번호 + * @example 'sample1234' + */ + @IsString() + password: string; +} diff --git a/backend/src/auth/dto/request/local-signup-request.dto.ts b/backend/src/auth/dto/request/local-signup-request.dto.ts new file mode 100644 index 00000000..864f6100 --- /dev/null +++ b/backend/src/auth/dto/request/local-signup-request.dto.ts @@ -0,0 +1,19 @@ +import { IsEmail, IsString, Matches } from 'class-validator'; + +import { LocalSignUpRequest } from '@/types/auth/request'; + +export class LocalSignUpRequestDto implements LocalSignUpRequest { + @IsEmail() + email: string; + + // TODO @IsStrongPassword() + @IsString() + password: string; + + /** + * nickname + * @example 'san1' + */ + @Matches(/^[가-힣a-zA-Z0-9]{1,8}$/, { message: '유효하지 않은 닉네임 입니다.' }) + nickname: string; +} diff --git a/backend/src/auth/guard/ft.guard.ts b/backend/src/auth/guard/ft.guard.ts deleted file mode 100644 index ccaa20a2..00000000 --- a/backend/src/auth/guard/ft.guard.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; - -@Injectable() -export class FtGuard extends AuthGuard('ft') {} diff --git a/backend/src/auth/guard/google.guard.ts b/backend/src/auth/guard/google.guard.ts deleted file mode 100644 index df609677..00000000 --- a/backend/src/auth/guard/google.guard.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; - -@Injectable() -export class GoogleGuard extends AuthGuard('google') {} diff --git a/backend/src/auth/guard/social.guard.ts b/backend/src/auth/guard/social.guard.ts new file mode 100644 index 00000000..27272edb --- /dev/null +++ b/backend/src/auth/guard/social.guard.ts @@ -0,0 +1,37 @@ +import { CanActivate, ExecutionContext, Injectable, NotFoundException, OnModuleInit } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Request } from 'express'; +import { Observable } from 'rxjs'; + +class FtGuard extends AuthGuard('ft') {} + +class GoogleGuard extends AuthGuard('google') {} + +class GithubGuard extends AuthGuard('github') {} + +@Injectable() +export class SocialGuard implements CanActivate, OnModuleInit { + private ftGuard: FtGuard; + private googleGuard: GoogleGuard; + private githubGuard: GithubGuard; + + onModuleInit() { + this.ftGuard = new FtGuard(); + this.googleGuard = new GoogleGuard(); + this.githubGuard = new GithubGuard(); + } + + canActivate(context: ExecutionContext): boolean | Promise | Observable { + const request: Request = context.switchToHttp().getRequest(); + const provider: string = request.params.provider; + if (provider === 'ft') { + return this.ftGuard.canActivate(context); + } else if (provider === 'google') { + return this.googleGuard.canActivate(context); + } else if (provider === 'github') { + return this.githubGuard.canActivate(context); + } else { + throw new NotFoundException('존재하지 않는 provider입니다.'); + } + } +} diff --git a/backend/src/auth/strategy/ft.strategy.ts b/backend/src/auth/strategy/ft.strategy.ts index 5a54d009..9fe2f979 100644 --- a/backend/src/auth/strategy/ft.strategy.ts +++ b/backend/src/auth/strategy/ft.strategy.ts @@ -1,4 +1,4 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-42'; @@ -14,6 +14,7 @@ export class FtStrategy extends PassportStrategy(Strategy, 'ft') { callbackURL: ftAuthConfigService.url, profileFields: { // validate()에서 사용할 정보 (profile에 들어있음) + id: 'id', email: 'email', }, }); @@ -28,9 +29,6 @@ export class FtStrategy extends PassportStrategy(Strategy, 'ft') { * @returns validate()에서 return한 값 */ async validate(accessToken: string, refreshToken: string, profile: LoginInfo): Promise { - if (profile.email === undefined || profile.email === null) { - throw new UnauthorizedException('42 email is empty'); - } - return { provider: '42', email: profile.email, id: null }; + return { provider: 'ft', email: profile.email ?? null, id: `ft-${profile.id}` }; } } diff --git a/backend/src/auth/strategy/github.strategy.ts b/backend/src/auth/strategy/github.strategy.ts new file mode 100644 index 00000000..3e4fc09b --- /dev/null +++ b/backend/src/auth/strategy/github.strategy.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, Profile } from 'passport-github2'; + +import { GithubAuthConfigService } from '../../config/auth/github/configuration.service'; +import { LoginInfo } from '../type/login-info'; + +@Injectable() +export class GithubStrategy extends PassportStrategy(Strategy, 'github') { + constructor(private readonly githubAuthConfigService: GithubAuthConfigService) { + super({ + clientID: githubAuthConfigService.clientId, + clientSecret: githubAuthConfigService.clientSecret, + callbackURL: githubAuthConfigService.callbackUrl, + scope: ['user:email'], + }); + } + + async validate(accessToken: string, refreshToken: string, profile: Profile): Promise { + const email = profile.emails?.[0].value ?? null; + return { provider: 'github', email, id: `github-${profile.id}` }; + } +} diff --git a/backend/src/auth/strategy/google.strategy.ts b/backend/src/auth/strategy/google.strategy.ts index 3b23b370..67c67a61 100644 --- a/backend/src/auth/strategy/google.strategy.ts +++ b/backend/src/auth/strategy/google.strategy.ts @@ -1,4 +1,4 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Profile, Strategy } from 'passport-google-oauth20'; @@ -17,10 +17,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { } async validate(accessToken: string, refreshToken: string, profile: Profile): Promise { - const email = profile.emails?.[0].value; - if (email === undefined) { - throw new UnauthorizedException('Google email is empty'); - } - return { provider: 'google', email, id: null }; + const email = profile.emails?.[0].value ?? null; + return { provider: 'google', email, id: `google-${profile.id}` }; } } diff --git a/backend/src/auth/strategy/local.strategy.ts b/backend/src/auth/strategy/local.strategy.ts new file mode 100644 index 00000000..ee4630b0 --- /dev/null +++ b/backend/src/auth/strategy/local.strategy.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-local'; + +import { LocalLoginRequestDto } from '../dto/request/local-login-request.dto'; + +@Injectable() +export class LocalStrategy extends PassportStrategy(Strategy, 'local') { + constructor() { + super({ + usernameField: 'email', + passwordField: 'password', + passReqToCallback: false, + }); + } + + async validate(email: string, password: string): Promise { + return { email, password }; + } +} diff --git a/backend/src/auth/type/login-info.ts b/backend/src/auth/type/login-info.ts index 086bd6b6..592ddd08 100644 --- a/backend/src/auth/type/login-info.ts +++ b/backend/src/auth/type/login-info.ts @@ -1,9 +1,9 @@ +/** + * type for oauth login profile information + */ + export type LoginInfo = { - provider: '42' | 'google'; - email: string; - id: number | null; - /** - * id === null -> unregistered - * id !== null -> registered - */ + provider: 'ft' | 'google' | 'github'; + email: string | null; + id: string; }; diff --git a/backend/src/auth/type/social-response-options.ts b/backend/src/auth/type/login-response-options.ts similarity index 63% rename from backend/src/auth/type/social-response-options.ts rename to backend/src/auth/type/login-response-options.ts index 2fdfb500..2a346803 100644 --- a/backend/src/auth/type/social-response-options.ts +++ b/backend/src/auth/type/login-response-options.ts @@ -1,4 +1,4 @@ -export type SocialResponseOptions = { +export type LoginResponseOptions = { cookieKey?: string; token: string; redirectUrl: string; diff --git a/backend/src/config/auth/github/configuration.module.ts b/backend/src/config/auth/github/configuration.module.ts new file mode 100644 index 00000000..8e35f2cc --- /dev/null +++ b/backend/src/config/auth/github/configuration.module.ts @@ -0,0 +1,23 @@ +import * as Joi from '@hapi/joi'; +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; + +import configuration from './configuration'; +import { GithubAuthConfigService } from './configuration.service'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + envFilePath: ['.env.development', '.env'], + load: [configuration], + validationSchema: Joi.object({ + GITHUB_CLIENT_ID: Joi.string(), + GITHUB_CLIENT_SECRET: Joi.string(), + GITHUB_CALLBACK_URL: Joi.string().default('http://localhost:3000/api/v1/auth/callback/github'), + }), + }), + ], + providers: [ConfigService, GithubAuthConfigService], + exports: [ConfigService, GithubAuthConfigService], +}) +export class GithubAuthConfigModule {} diff --git a/backend/src/config/auth/github/configuration.service.ts b/backend/src/config/auth/github/configuration.service.ts new file mode 100644 index 00000000..10e96eba --- /dev/null +++ b/backend/src/config/auth/github/configuration.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class GithubAuthConfigService { + constructor(private configService: ConfigService) {} + + get clientId() { + return this.configService.get('githubAuth.clientId'); + } + + get clientSecret() { + return this.configService.get('githubAuth.clientSecret'); + } + + get callbackUrl() { + return this.configService.get('githubAuth.callbackUrl'); + } +} diff --git a/backend/src/config/auth/github/configuration.ts b/backend/src/config/auth/github/configuration.ts new file mode 100644 index 00000000..c2db50c1 --- /dev/null +++ b/backend/src/config/auth/github/configuration.ts @@ -0,0 +1,7 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('githubAuth', () => ({ + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + callbackUrl: process.env.GITHUB_CALLBACK_URL, +})); diff --git a/backend/src/config/database/configuration.service.ts b/backend/src/config/database/configuration.service.ts index ef303bba..2d669df7 100644 --- a/backend/src/config/database/configuration.service.ts +++ b/backend/src/config/database/configuration.service.ts @@ -16,9 +16,9 @@ export class DatabaseConfigService implements TypeOrmOptionsFactory { password: this.configService.get('database.password'), database: this.configService.get('database.name'), autoLoadEntities: true, - synchronize: true, - logging: true, + logging: ['error', 'warn'], namingStrategy: new SnakeNamingStrategy(), + synchronize: true, }; } } diff --git a/backend/src/entity/auth.entity.ts b/backend/src/entity/auth.entity.ts index 5bc8f533..0bb049f0 100644 --- a/backend/src/entity/auth.entity.ts +++ b/backend/src/entity/auth.entity.ts @@ -10,8 +10,14 @@ export class Auth { @PrimaryGeneratedColumn() id: number; - @Column({ length: 320, unique: true }) - email: string; + @Column({ type: 'varchar', length: 320, nullable: true }) + email: string | null; + + @Column({ type: 'varchar', length: 64, nullable: true }) + password: string | null; + + @Column({ type: 'varchar', length: 32, unique: true }) + accountId: string | null; @Column({ type: 'varchar', length: 320, nullable: true }) twoFa: string | null; diff --git a/backend/src/user/user.module.ts b/backend/src/user/user.module.ts index 87e10297..737e24c4 100644 --- a/backend/src/user/user.module.ts +++ b/backend/src/user/user.module.ts @@ -23,6 +23,5 @@ import { UserService } from './user.service'; ], controllers: [UserController], providers: [UserService], - exports: [UserService], }) export class UserModule {} diff --git a/backend/yarn.lock b/backend/yarn.lock index 5bd3c5b8..b0e260f5 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1267,6 +1267,15 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/passport-github2@^1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@types/passport-github2/-/passport-github2-1.2.5.tgz#79064e213ff33ca0a9d21af3da09a3230fac8099" + integrity sha512-+WLyrd8JPsCxroK34EjegR0j3FMxp6wqB9cw/sRCFkWT9qic1dymAn021gr336EpyjzdhjUd2KKrqyxvdFSvOA== + dependencies: + "@types/express" "*" + "@types/passport" "*" + "@types/passport-oauth2" "*" + "@types/passport-google-oauth20@^2.0.11": version "2.0.11" resolved "https://registry.yarnpkg.com/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.11.tgz#271ec71de3030a3e1c004b24e633e4b298ccba97" @@ -1285,6 +1294,15 @@ "@types/jsonwebtoken" "*" "@types/passport-strategy" "*" +"@types/passport-local@^1.0.35": + version "1.0.35" + resolved "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.35.tgz#233d370431b3f93bb43cf59154fb7519314156d9" + integrity sha512-K4eLTJ8R0yYW8TvCqkjB0pTKoqfUSdl5PfZdidTjV2ETV3604fQxtY6BHKjQWAx50WUS0lqzBvKv3LoI1ZBPeA== + dependencies: + "@types/express" "*" + "@types/passport" "*" + "@types/passport-strategy" "*" + "@types/passport-oauth2@*": version "1.4.12" resolved "https://registry.yarnpkg.com/@types/passport-oauth2/-/passport-oauth2-1.4.12.tgz#c2ae0ee3b16646188d8b0b6cdbc6880a0247dc5f" @@ -6211,6 +6229,13 @@ passport-42@^1.2.6: dependencies: passport-oauth2 "^1.4.0" +passport-github2@^0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/passport-github2/-/passport-github2-0.1.12.tgz#a72ebff4fa52a35bc2c71122dcf470d1116f772c" + integrity sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw== + dependencies: + passport-oauth2 "1.x.x" + passport-google-oauth20@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz#0d241b2d21ebd3dc7f2b60669ec4d587e3a674ef" @@ -6226,6 +6251,13 @@ passport-jwt@^4.0.1: jsonwebtoken "^9.0.0" passport-strategy "^1.0.0" +passport-local@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz#1fe63268c92e75606626437e3b906662c15ba6ee" + integrity sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow== + dependencies: + passport-strategy "1.x.x" + passport-oauth2@1.x.x, passport-oauth2@^1.4.0: version "1.7.0" resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.7.0.tgz#5c4766c8531ac45ffe9ec2c09de9809e2c841fc4" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5b85a2bc..d3432cef 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,8 @@ import { TwoFactorLoginPage } from './pages/TwoFactorLoginPage'; import { ProtectedRoute } from './ProtectedRoute'; import { DebugObserver } from './DebugObserver'; import { RouteErrorPage } from './pages/RouteErrorPage'; +import SignInPage from './pages/SignInPage'; +import SignUpPage from './pages/SignUpPage'; function App() { return ( @@ -52,6 +54,8 @@ function App() { } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/MessagePage/BlockModal/BlockModal.tsx b/frontend/src/pages/MessagePage/BlockModal/BlockModal.tsx index b72706f2..e40777ff 100644 --- a/frontend/src/pages/MessagePage/BlockModal/BlockModal.tsx +++ b/frontend/src/pages/MessagePage/BlockModal/BlockModal.tsx @@ -46,7 +46,7 @@ export const BlockModal = ({ isOpen, onClose }: Omit) => sizes="lg" color="secondary" placeholder="플레이어 닉네임" - style={{ flexGrow: 1 }} + style={{ flexGrow: 1, width: '24rem' }} /> updateBlocked(nickname)}> 차단하기 diff --git a/frontend/src/pages/MessagePage/FriendsModal/FriendsModal.tsx b/frontend/src/pages/MessagePage/FriendsModal/FriendsModal.tsx index f5e15366..7fe74d81 100644 --- a/frontend/src/pages/MessagePage/FriendsModal/FriendsModal.tsx +++ b/frontend/src/pages/MessagePage/FriendsModal/FriendsModal.tsx @@ -55,7 +55,7 @@ export const FriendsModal = ({ isOpen, onClose }: Omit) sizes="lg" color="secondary" placeholder="플레이어 닉네임" - style={{ flexGrow: 1 }} + style={{ flexGrow: 1, width: '24rem' }} /> 친구추가 diff --git a/frontend/src/pages/PrePage/PrePage.tsx b/frontend/src/pages/PrePage/PrePage.tsx index 95dea764..077c8e93 100644 --- a/frontend/src/pages/PrePage/PrePage.tsx +++ b/frontend/src/pages/PrePage/PrePage.tsx @@ -3,8 +3,11 @@ import { ReactComponent as Logo } from '@/svgs/logo-lg.svg'; import { ReactComponent as GoogleIcon } from '@/svgs/google.svg'; import { ReactComponent as GithubIcon } from '@/svgs/github.svg'; import { ReactComponent as FourtyTwoIcon } from '@/svgs/42.svg'; +import { useNavigate } from 'react-router-dom'; export const PrePage = () => { + const navigation = useNavigate(); + const handle42Login = () => { location.href = `${import.meta.env.VITE_API_URL}/auth/login/ft`; }; @@ -13,6 +16,10 @@ export const PrePage = () => { location.href = `${import.meta.env.VITE_API_URL}/auth/login/google`; }; + const handleGithubLogin = () => { + location.href = `${import.meta.env.VITE_API_URL}/auth/login/github`; + }; + return ( { rowGap={1.5} size={{ paddingTB: 1 }} > - + navigation('/auth/signin')}> LOGIN - SIGN UP + navigation('/auth/signup')}> + SIGN UP + { - + diff --git a/frontend/src/pages/SignInPage/index.tsx b/frontend/src/pages/SignInPage/index.tsx new file mode 100644 index 00000000..8b62593c --- /dev/null +++ b/frontend/src/pages/SignInPage/index.tsx @@ -0,0 +1,92 @@ +import { GameButton, GameInput, Grid, Text } from '@/common'; +import { ApiError, ApiResponse, post } from '@/libs/api'; +import { setAccessToken } from '@/libs/api/auth'; +import { useMutation } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; + +type SignInInfo = { + email: string; + password: string; +}; + +type TokenResponse = { + token: string; +}; + +const postSignIn = async (info: SignInInfo) => { + return await post('auth/login/local', info); +}; + +export default function SignInPage() { + const [info, setInfo] = useState({ + email: '', + password: '', + }); + const { mutate: signIn, isSuccess } = useMutation(postSignIn, { + onSuccess: (data: TokenResponse) => { + console.log('login'); + const { token } = data; + if (token) { + setAccessToken(token); + } + }, + onError: (error: ApiError) => { + alert(error.message); + }, + }); + + useEffect(() => { + if (isSuccess) window.location.replace('/'); + }, [isSuccess]); + + const handleInput = (e: React.ChangeEvent) => { + setInfo({ + ...info, + [e.target.name]: e.target.value, + }); + }; + + const handleSignIn = () => { + console.log('signin click'); + signIn(info); + }; + + return ( + + + GhostPong + + + Login to continue + + + + + Log in + + + ); +} diff --git a/frontend/src/pages/SignUpPage/index.tsx b/frontend/src/pages/SignUpPage/index.tsx new file mode 100644 index 00000000..54fb9742 --- /dev/null +++ b/frontend/src/pages/SignUpPage/index.tsx @@ -0,0 +1,132 @@ +import { GameButton, GameInput, Grid, Text } from '@/common'; +import { ApiError, ApiResponse, post } from '@/libs/api'; +import { useMutation } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +type SignUpInfo = { + email: string; + nickname: string; + password: string; +}; + +const postSignUp = async ({ email, nickname, password }: SignUpInfo) => { + return await post('auth/signup/local', { + email, + nickname, + password, + }); +}; + +export default function SignUpPage() { + const navigation = useNavigate(); + const [info, setInfo] = useState({ + email: '', + nickname: '', + password: '', + passwordcheck: '', + }); + const { mutate: signUp, isSuccess } = useMutation(postSignUp, { + onSuccess: (message: ApiResponse) => { + console.log('signup', message); + }, + onError: (error: ApiError) => { + alert(error.message); + }, + }); + + useEffect(() => { + alert('회원가입이 완료되었습니다.'); + if (isSuccess) { + navigation('/pre'); + } + }, [isSuccess]); + + const handleInput = (e: React.ChangeEvent) => { + setInfo({ + ...info, + [e.target.name]: e.target.value, + }); + console.log(info); + }; + + const handleSignUp = () => { + console.log('signup click'); + if (info.email === '' || info.nickname === '' || info.password === '' || info.passwordcheck === '') { + alert('정보를 입력해주세요.'); + console.log(info); + return; + } + if (info.password !== info.passwordcheck) { + alert('비밀번호가 일치하지 않습니다.'); + setInfo({ + ...info, + password: '', + passwordcheck: '', + }); + return; + } + signUp(info); + }; + + return ( + + + GhostPong + + + Welcome to GhostPong ! + + + + + + + Sign up + + + ); +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 912d153c..c59b70c5 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -8,3 +8,5 @@ export * from './MessagePage'; export * from './PrePage'; export * from './ProfilePage'; export * from './RegisterPage'; +export * from './SignInPage'; +export * from './SignUpPage'; diff --git a/types/auth/request/index.ts b/types/auth/request/index.ts index d44c69da..bbbb86de 100644 --- a/types/auth/request/index.ts +++ b/types/auth/request/index.ts @@ -1,2 +1,4 @@ export * from './two-factor-auth-request.interface'; export * from './code-verification-request.interface'; +export * from './local-login-request.interface'; +export * from './local-signup-request.interface'; diff --git a/types/auth/request/local-login-request.interface.ts b/types/auth/request/local-login-request.interface.ts new file mode 100644 index 00000000..b19b7b65 --- /dev/null +++ b/types/auth/request/local-login-request.interface.ts @@ -0,0 +1,4 @@ +export interface LocalLoginRequest { + email: string; + password: string; +} diff --git a/types/auth/request/local-signup-request.interface.ts b/types/auth/request/local-signup-request.interface.ts new file mode 100644 index 00000000..895c1569 --- /dev/null +++ b/types/auth/request/local-signup-request.interface.ts @@ -0,0 +1,5 @@ +export interface LocalSignUpRequest { + email: string; + password: string; + nickname: string; +}