From ffafa7a2383883bde0cb9f07debe57feff0c19d6 Mon Sep 17 00:00:00 2001 From: jeonjeunghoon Date: Fri, 11 Aug 2023 16:39:33 +0900 Subject: [PATCH 01/23] =?UTF-8?q?test:=20trash=20api=20handler=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/constants/apis/url.ts | 1 + frontend/src/mocks/handlers.ts | 8 +++++++- frontend/src/mocks/handlers/trash.ts | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 frontend/src/mocks/handlers/trash.ts diff --git a/frontend/src/constants/apis/url.ts b/frontend/src/constants/apis/url.ts index c17bbd53b..a364e3e98 100644 --- a/frontend/src/constants/apis/url.ts +++ b/frontend/src/constants/apis/url.ts @@ -1,3 +1,4 @@ export const baseURL = process.env.BASE_URL; export const writingURL = `${baseURL}/writings`; export const categoryURL = `${baseURL}/categories`; +export const trashURL = `${baseURL}/trash`; diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 19ee25cac..cccf72ec5 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -1,5 +1,11 @@ import { categoryHandlers } from './handlers/category'; import { errorHandlers } from './handlers/error'; +import { trashHandlers } from './handlers/trash'; import { writingHandlers } from './handlers/writing'; -export const handlers = [...writingHandlers, ...categoryHandlers, ...errorHandlers]; +export const handlers = [ + ...writingHandlers, + ...categoryHandlers, + ...errorHandlers, + ...trashHandlers, +]; diff --git a/frontend/src/mocks/handlers/trash.ts b/frontend/src/mocks/handlers/trash.ts new file mode 100644 index 000000000..c3e5b8101 --- /dev/null +++ b/frontend/src/mocks/handlers/trash.ts @@ -0,0 +1,19 @@ +import { trashURL } from 'constants/apis/url'; +import { rest } from 'msw'; + +export const trashHandlers = [ + // 글 휴지통으로 이동 / 글 영구 삭제 + rest.post(trashURL, (_, res, ctx) => { + return res(ctx.status(200)); + }), + + // 휴지통에서 글 목록 조회 + rest.get(trashURL, (_, res, ctx) => { + return res(ctx.status(200)); + }), + + // 휴지통에서 글 복구 + rest.post(`${trashURL}/restore`, (_, res, ctx) => { + return res(ctx.status(200)); + }), +]; From 6948d354d9eb9163aba144e9ac63f213d7f1efa1 Mon Sep 17 00:00:00 2001 From: jeonjeunghoon Date: Fri, 11 Aug 2023 16:45:11 +0900 Subject: [PATCH 02/23] =?UTF-8?q?feat:=20`trash`=20api=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/apis/trash.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 frontend/src/apis/trash.ts diff --git a/frontend/src/apis/trash.ts b/frontend/src/apis/trash.ts new file mode 100644 index 000000000..2b028e8a6 --- /dev/null +++ b/frontend/src/apis/trash.ts @@ -0,0 +1,27 @@ +import { trashURL } from 'constants/apis/url'; +import { http } from './fetch'; + +// 글 휴지통으로 이동: POST +export const moveToTrash = (writingIds: number[]) => + http.post(trashURL, { + json: { + writingIds, + isPermanentDelete: false, + }, + }); + +// 글 영구 삭제: POST +export const deletePermanentWritings = (writingIds: number[]) => + http.post(trashURL, { + json: { + writingIds, + isPermanentDelete: true, + }, + }); + +// 휴지통에 있는 글 목록 조회: GET +export const getDeletedWritings = () => http.get(trashURL); + +// 휴지통에서 글 복구: POST +export const restoreDeletedWritings = (writingIds: number[]) => + http.post(`${trashURL}/restore`, { json: { writingIds } }); From f6031604d54f3ef1c46b775915d8b154b5906db4 Mon Sep 17 00:00:00 2001 From: jeonjeunghoon Date: Fri, 11 Aug 2023 16:52:57 +0900 Subject: [PATCH 03/23] =?UTF-8?q?feat:=20=ED=9C=B4=EC=A7=80=ED=86=B5=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/TrashCan/TrashCan.tsx | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 frontend/src/components/TrashCan/TrashCan.tsx diff --git a/frontend/src/components/TrashCan/TrashCan.tsx b/frontend/src/components/TrashCan/TrashCan.tsx new file mode 100644 index 000000000..3fcd7578d --- /dev/null +++ b/frontend/src/components/TrashCan/TrashCan.tsx @@ -0,0 +1,50 @@ +import Accordion from 'components/@common/Accordion/Accordion'; +import { styled } from 'styled-components'; + +const TrashCan = () => { + return ( + + + + goWritingTablePage(categoryId)} + aria-label='휴지통으로 이동하기' + > + 휴지통 + + + + + + ); +}; + +export default TrashCan; + +const S = { + Button: styled.button` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + height: 3.2rem; + padding: 0.8rem; + border-radius: 8px; + font-size: 1.4rem; + + &:hover { + div { + display: inline-flex; + gap: 0.8rem; + } + } + `, + + Text: styled.p` + color: ${({ theme }) => theme.color.gray10}; + font-weight: 600; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + `, +}; From 942b09fa278951b4ef1b7bb80eaa902470251cbd Mon Sep 17 00:00:00 2001 From: jeonjeunghoon Date: Mon, 14 Aug 2023 13:15:57 +0900 Subject: [PATCH 04/23] =?UTF-8?q?feat:=20`TrashCanPage`=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/TrashCanPage/TrashCanPage.tsx | 34 +++++++++++++++++++ frontend/src/routes/Router.tsx | 5 +++ 2 files changed, 39 insertions(+) create mode 100644 frontend/src/pages/TrashCanPage/TrashCanPage.tsx diff --git a/frontend/src/pages/TrashCanPage/TrashCanPage.tsx b/frontend/src/pages/TrashCanPage/TrashCanPage.tsx new file mode 100644 index 000000000..61221caa4 --- /dev/null +++ b/frontend/src/pages/TrashCanPage/TrashCanPage.tsx @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import { getDeletedWritings } from 'apis/trash'; +import WritingTable from 'components/WritingTable/WritingTable'; +import { styled } from 'styled-components'; + +const TrashCanPage = () => { + const { data } = useQuery(['deletedWritings'], getDeletedWritings, { + onError: () => alert('휴지통의 글을 불러올 수 없습니다'), + }); + + return ( + + 휴지통 + {/* */} + + + ); +}; + +export default TrashCanPage; + +const S = { + Article: styled.article` + width: 90%; + padding: 8rem 4rem; + + background-color: ${({ theme }) => theme.color.gray1}; + `, + + CategoryNameTitle: styled.h1` + font-size: 4rem; + margin-bottom: 5rem; + `, +}; diff --git a/frontend/src/routes/Router.tsx b/frontend/src/routes/Router.tsx index 82c4ed3ab..1ae1396c2 100644 --- a/frontend/src/routes/Router.tsx +++ b/frontend/src/routes/Router.tsx @@ -2,6 +2,7 @@ import { RouterProvider, createBrowserRouter } from 'react-router-dom'; import WritingPage from 'pages/WritingPage/WritingPage'; import WritingTablePage from 'pages/WritingTablePage/WritingTablePage'; import App from '../App'; +import TrashCanPage from 'pages/TrashCanPage/TrashCanPage'; export const Router = () => { const browserRouter = createBrowserRouter([ @@ -17,6 +18,10 @@ export const Router = () => { path: '/writings/:categoryId', element: , }, + { + path: '/trash-can', + element: , + }, ], }, ]); From e9a753b04336a2f54ed45074aaafdabd43489796 Mon Sep 17 00:00:00 2001 From: jeonjeunghoon Date: Mon, 14 Aug 2023 13:16:23 +0900 Subject: [PATCH 05/23] =?UTF-8?q?feat:=20`TrashCan`=20=EA=B3=BC=20`TrashCa?= =?UTF-8?q?nPage`=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/TrashCan/TrashCan.tsx | 8 ++++---- frontend/src/hooks/usePageNavigate.ts | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/TrashCan/TrashCan.tsx b/frontend/src/components/TrashCan/TrashCan.tsx index 3fcd7578d..f6b440375 100644 --- a/frontend/src/components/TrashCan/TrashCan.tsx +++ b/frontend/src/components/TrashCan/TrashCan.tsx @@ -1,15 +1,15 @@ import Accordion from 'components/@common/Accordion/Accordion'; +import { usePageNavigate } from 'hooks/usePageNavigate'; import { styled } from 'styled-components'; const TrashCan = () => { + const { goTrashCanPage } = usePageNavigate(); + return ( - goWritingTablePage(categoryId)} - aria-label='휴지통으로 이동하기' - > + 휴지통 diff --git a/frontend/src/hooks/usePageNavigate.ts b/frontend/src/hooks/usePageNavigate.ts index dfd471a9b..f2704ca27 100644 --- a/frontend/src/hooks/usePageNavigate.ts +++ b/frontend/src/hooks/usePageNavigate.ts @@ -7,5 +7,7 @@ export const usePageNavigate = () => { const goWritingTablePage = (categoryId: number) => navigate(`/writings/${categoryId}`); - return { goWritingPage, goWritingTablePage }; + const goTrashCanPage = () => navigate('/trash-can'); + + return { goWritingPage, goWritingTablePage, goTrashCanPage }; }; From 86ca2dd21b33a861fb68cc0beda7dadf0ee7a4e0 Mon Sep 17 00:00:00 2001 From: jeonjeunghoon Date: Mon, 14 Aug 2023 13:16:37 +0900 Subject: [PATCH 06/23] =?UTF-8?q?feat:=20`Layout`=20=EC=97=90=20=ED=9C=B4?= =?UTF-8?q?=EC=A7=80=ED=86=B5=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Layout/Layout.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/pages/Layout/Layout.tsx b/frontend/src/pages/Layout/Layout.tsx index bd23ec6d0..6ed9a1c5c 100644 --- a/frontend/src/pages/Layout/Layout.tsx +++ b/frontend/src/pages/Layout/Layout.tsx @@ -9,6 +9,8 @@ import WritingSideBar from 'components/WritingSideBar/WritingSideBar'; import CategorySection from 'components/Category/Section/Section'; import { useModal } from 'hooks/@common/useModal'; import FileUploadModal from 'components/FileUploadModal/FileUploadModal'; +import Divider from 'components/@common/Divider/Divider'; +import TrashCan from 'components/TrashCan/TrashCan'; export type PageContext = { isLeftSidebarOpen?: boolean; @@ -51,7 +53,10 @@ const Layout = () => { Add Post + + + Date: Mon, 14 Aug 2023 16:43:11 +0900 Subject: [PATCH 07/23] Merge branch 'develop' of https://github.com/woowacourse-teams/2023-dong-gle into feature/trash-can-248 --- .github/draft-release.yml | 17 + .github/workflows/deploy-dev.yml | 15 +- .github/workflows/deploy-prod.yml | 17 +- .github/workflows/fe-test-e2e.yml | 35 + .github/workflows/semantic-versioning.yml | 64 ++ backend/build.gradle | 4 + .../repository/BlockRepository.java | 10 +- .../repository/ContentRepository.java | 7 - .../repository/TokenRepository.java | 10 + .../application/service/AuthService.java | 31 +- .../application/service/PublishService.java | 2 +- .../application/service/WritingService.java | 26 +- .../oauth/kakao/KakaoOAuthService.java | 7 +- .../oauth/notion/NotionOAuthService.java | 2 +- .../oauth/tistory/TistoryOAuthService.java | 2 +- .../request/MarkdownUploadRequest.java | 7 +- .../request/OAuthAccessTokenRequest.java | 2 +- .../org/donggle/backend/auth/JwtToken.java | 65 ++ .../backend/auth/JwtTokenProvider.java | 72 ++ .../donggle/backend/auth/JwtTokenService.java | 22 + .../EmptyAuthorizationHeaderException.java | 11 + .../InvalidAccessTokenException.java | 11 + .../auth/exception/NoSuchTokenException.java | 11 + .../auth/presentation/AuthInterceptor.java | 32 + .../RefreshTokenAuthInterceptor.java | 42 + .../presentation/TokenArgumentResolver.java | 33 + .../auth/support/AuthenticationPrincipal.java | 11 + .../auth/support/AuthorizationExtractor.java | 31 + .../org/donggle/backend/config/InitData.java | 22 +- .../donggle/backend/domain/member/Member.java | 16 +- .../parser/markdown/MarkDownParser.java | 20 +- .../domain/parser/notion/NotionParser.java | 35 +- .../domain/renderer/html/HtmlRenderer.java | 49 +- .../donggle/backend/domain/writing/Block.java | 60 -- .../content/{Content.java => Block.java} | 20 +- .../{CodeBlockContent.java => CodeBlock.java} | 7 +- .../{ImageContent.java => ImageBlock.java} | 7 +- .../{NormalContent.java => NormalBlock.java} | 9 +- .../backend/ui/KakaoOAuthController.java | 61 +- .../donggle/backend/ui/WritingController.java | 2 +- .../backend/ui/config/WebMvcConfig.java | 32 + .../ui/response/AccessTokenResponse.java | 6 + .../backend/ui/response/TokenResponse.java | 4 + .../src/main/resources/application-prod.yaml | 6 + backend/src/main/resources/application.yaml | 6 + .../backend/auth/JwtTokenProviderTest.java | 39 + .../domain/parser/MarkDownParserTest.java | 63 +- .../parser/notion/NotionBlockJsonBuilder.java | 34 +- .../parser/notion/NotionParserTest.java | 50 +- .../domain/parser/notion/RichTextTest.java | 6 +- .../renderer/html/HtmlRendererTest.java | 88 +- .../backend/domain/writing/BlockTest.java | 16 +- docker-compose.yml | 3 + frontend/.eslintrc | 1 + frontend/.gitignore | 4 +- frontend/cypress.config.ts | 10 + frontend/cypress/e2e/main-page.cy.ts | 54 ++ frontend/cypress/fixtures/example.json | 5 + frontend/cypress/fixtures/markdown-test.md | 8 + frontend/cypress/support/commands.ts | 41 + frontend/cypress/support/e2e.ts | 20 + frontend/package.json | 12 +- frontend/src/apis/login.ts | 10 + frontend/src/apis/writings.ts | 5 + frontend/src/assets/icons/index.ts | 3 +- frontend/src/assets/icons/kakao-login.svg | 9 + .../icons/{delete.svg => trash-can.svg} | 0 .../@common/FileUploader/FileUploader.tsx | 7 +- .../src/components/@common/Input/Input.tsx | 6 +- .../components/Category/Category/Category.tsx | 12 +- .../src/components/Category/Header/Header.tsx | 1 + .../Category/WritingList/WritingList.tsx | 33 +- .../Category/WritingList/useDeleteWritings.ts | 19 + .../components/DeleteButton/DeleteButton.tsx | 28 + .../FileUploadModal/useFileUploadModal.ts | 2 +- .../src/components/Login/KakaoLoginButton.tsx | 17 + .../WritingPropertySection.tsx | 4 +- .../WritingTable/WritingTable.stories.tsx | 5 +- .../components/WritingTable/WritingTable.tsx | 18 +- .../WritingTitle/WritingTitle.tsx | 111 +++ .../WritingViewer/WritingViewer.stories.tsx | 7 +- .../WritingViewer/WritingViewer.tsx | 28 +- frontend/src/constants/apis/oauth.ts | 8 + frontend/src/constants/apis/url.ts | 5 + frontend/src/hooks/useFileUpload.ts | 5 + frontend/src/hooks/usePageNavigate.ts | 7 +- frontend/src/mocks/handlers.ts | 2 + frontend/src/mocks/handlers/login.ts | 15 + frontend/src/mocks/handlers/writing.ts | 5 + .../src/pages/IntroducePage/IntroducePage.tsx | 11 + frontend/src/pages/OauthPage/OauthPage.tsx | 47 ++ .../src/pages/WritingPage/WritingPage.tsx | 5 +- .../WritingTablePage/WritingTablePage.tsx | 2 +- frontend/src/routes/Router.tsx | 12 +- frontend/src/types/apis/login.ts | 11 + frontend/src/types/apis/writings.ts | 5 + frontend/tsconfig.json | 5 +- frontend/yarn.lock | 772 +++++++++++++++++- 98 files changed, 2233 insertions(+), 424 deletions(-) create mode 100644 .github/draft-release.yml create mode 100644 .github/workflows/fe-test-e2e.yml create mode 100644 .github/workflows/semantic-versioning.yml delete mode 100644 backend/src/main/java/org/donggle/backend/application/repository/ContentRepository.java create mode 100644 backend/src/main/java/org/donggle/backend/application/repository/TokenRepository.java create mode 100644 backend/src/main/java/org/donggle/backend/auth/JwtToken.java create mode 100644 backend/src/main/java/org/donggle/backend/auth/JwtTokenProvider.java create mode 100644 backend/src/main/java/org/donggle/backend/auth/JwtTokenService.java create mode 100644 backend/src/main/java/org/donggle/backend/auth/exception/EmptyAuthorizationHeaderException.java create mode 100644 backend/src/main/java/org/donggle/backend/auth/exception/InvalidAccessTokenException.java create mode 100644 backend/src/main/java/org/donggle/backend/auth/exception/NoSuchTokenException.java create mode 100644 backend/src/main/java/org/donggle/backend/auth/presentation/AuthInterceptor.java create mode 100644 backend/src/main/java/org/donggle/backend/auth/presentation/RefreshTokenAuthInterceptor.java create mode 100644 backend/src/main/java/org/donggle/backend/auth/presentation/TokenArgumentResolver.java create mode 100644 backend/src/main/java/org/donggle/backend/auth/support/AuthenticationPrincipal.java create mode 100644 backend/src/main/java/org/donggle/backend/auth/support/AuthorizationExtractor.java delete mode 100644 backend/src/main/java/org/donggle/backend/domain/writing/Block.java rename backend/src/main/java/org/donggle/backend/domain/writing/content/{Content.java => Block.java} (72%) rename backend/src/main/java/org/donggle/backend/domain/writing/content/{CodeBlockContent.java => CodeBlock.java} (79%) rename backend/src/main/java/org/donggle/backend/domain/writing/content/{ImageContent.java => ImageBlock.java} (77%) rename backend/src/main/java/org/donggle/backend/domain/writing/content/{NormalContent.java => NormalBlock.java} (74%) create mode 100644 backend/src/main/java/org/donggle/backend/ui/response/AccessTokenResponse.java create mode 100644 backend/src/main/java/org/donggle/backend/ui/response/TokenResponse.java create mode 100644 backend/src/test/java/org/donggle/backend/auth/JwtTokenProviderTest.java create mode 100644 frontend/cypress.config.ts create mode 100644 frontend/cypress/e2e/main-page.cy.ts create mode 100644 frontend/cypress/fixtures/example.json create mode 100644 frontend/cypress/fixtures/markdown-test.md create mode 100644 frontend/cypress/support/commands.ts create mode 100644 frontend/cypress/support/e2e.ts create mode 100644 frontend/src/apis/login.ts create mode 100644 frontend/src/assets/icons/kakao-login.svg rename frontend/src/assets/icons/{delete.svg => trash-can.svg} (100%) create mode 100644 frontend/src/components/Category/WritingList/useDeleteWritings.ts create mode 100644 frontend/src/components/DeleteButton/DeleteButton.tsx create mode 100644 frontend/src/components/Login/KakaoLoginButton.tsx create mode 100644 frontend/src/components/WritingViewer/WritingTitle/WritingTitle.tsx create mode 100644 frontend/src/constants/apis/oauth.ts create mode 100644 frontend/src/mocks/handlers/login.ts create mode 100644 frontend/src/pages/IntroducePage/IntroducePage.tsx create mode 100644 frontend/src/pages/OauthPage/OauthPage.tsx create mode 100644 frontend/src/types/apis/login.ts diff --git a/.github/draft-release.yml b/.github/draft-release.yml new file mode 100644 index 000000000..907dda01a --- /dev/null +++ b/.github/draft-release.yml @@ -0,0 +1,17 @@ +name-template: 'v$RESOLVED_VERSION' +tag-template: 'v$RESOLVED_VERSION' +version-resolver: + major: + labels: + - 'major' + minor: + labels: + - 'minor' + patch: + labels: + - 'patch' + default: patch +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +template: | + ## 변경사항 + $CHANGES diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 8239cc2f5..fc3a88813 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -21,12 +21,15 @@ jobs: echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> .env echo "SPRING_PROFILES_ACTIVE=${{ secrets.SPRING_PROFILES_ACTIVE }}" >> .env echo "PROFILE=${{ secrets.DEV_PROFILE }}" >> .env - echo "KAKAO_CLIENT_ID"=${{ secrets.KAKAO_CLIENT_ID }} >> .env - echo "KAKAO_CLIENT_SECRET"=${{ secrets.KAKAO_CLIENT_SECRET }} >> .env - echo "TISTORY_CLIENT_ID"=${{ secrets.TISTORY_CLIENT_ID }} >> .env - echo "TISTORY_CLIENT_SECRET"=${{ secrets.TISTORY_CLIENT_SECRET }} >> .env - echo "NOTION_CLIENT_ID"=${{ secrets.NOTION_CLIENT_ID }} >> .env - echo "NOTION_CLIENT_SECRET"=${{ secrets.NOTION_CLIENT_SECRET }} >> .env + echo "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" >> .env + echo "KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" >> .env + echo "TISTORY_CLIENT_ID=${{ secrets.TISTORY_CLIENT_ID }}" >> .env + echo "TISTORY_CLIENT_SECRET=${{ secrets.TISTORY_CLIENT_SECRET }}" >> .env + echo "NOTION_CLIENT_ID=${{ secrets.NOTION_CLIENT_ID }}" >> .env + echo "NOTION_CLIENT_SECRET=${{ secrets.NOTION_CLIENT_SECRET }}" >> .env + echo "JWT_SECRET_KET=${{ secrets.JWT_SECRET_KEY }}" >> .env + echo "ACCESS_TOKEN_EXPIRE=${{ secrets.ACCESS_TOKEN_EXPIRE_LENGTH }}" >> .env + echo "REFRESH_TOKEN_EXPIRE_LENGTH=${{ secrets.REFRESH_TOKEN_EXPIRE_LENGTH }}" >> .env ## deploy to production - name: Deploy to prod run: | diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index f512e8afd..c54db5f6f 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -20,13 +20,16 @@ jobs: echo "DB_USERNAME=${{ secrets.DB_USERNAME_PROD }}" >> .env echo "DB_PASSWORD=${{ secrets.DB_PASSWORD_PROD }}" >> .env echo "SPRING_PROFILES_ACTIVE=${{ secrets.SPRING_PROFILES_ACTIVE }}" >> .env - echo "PROFILE"=${{ secrets.PROD_PROFILE }} >> .env - echo "KAKAO_CLIENT_ID"=${{ secrets.KAKAO_CLIENT_ID }} >> .env - echo "KAKAO_CLIENT_SECRET"=${{ secrets.KAKAO_CLIENT_SECRET }} >> .env - echo "TISTORY_CLIENT_ID"=${{ secrets.TISTORY_CLIENT_ID }} >> .env - echo "TISTORY_CLIENT_SECRET"=${{ secrets.TISTORY_CLIENT_SECRET }} >> .env - echo "NOTION_CLIENT_ID"=${{ secrets.NOTION_CLIENT_ID }} >> .env - echo "NOTION_CLIENT_SECRET"=${{ secrets.NOTION_CLIENT_SECRET }} >> .env + echo "PROFILE=${{ secrets.PROD_PROFILE }}" >> .env + echo "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" >> .env + echo "KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" >> .env + echo "TISTORY_CLIENT_ID=${{ secrets.TISTORY_CLIENT_ID }}" >> .env + echo "TISTORY_CLIENT_SECRET=${{ secrets.TISTORY_CLIENT_SECRET }}" >> .env + echo "NOTION_CLIENT_ID=${{ secrets.NOTION_CLIENT_ID }}" >> .env + echo "NOTION_CLIENT_SECRET=${{ secrets.NOTION_CLIENT_SECRET }}" >> .env + echo "JWT_SECRET_KET=${{ secrets.JWT_SECRET_KEY }}" >> .env + echo "ACCESS_TOKEN_EXPIRE=${{ secrets.ACCESS_TOKEN_EXPIRE_LENGTH }}" >> .env + echo "REFRESH_TOKEN_EXPIRE_LENGTH=${{ secrets.REFRESH_TOKEN_EXPIRE_LENGTH }}" >> .env ## deploy to production - name: Deploy to prod run: | diff --git a/.github/workflows/fe-test-e2e.yml b/.github/workflows/fe-test-e2e.yml new file mode 100644 index 000000000..65067400d --- /dev/null +++ b/.github/workflows/fe-test-e2e.yml @@ -0,0 +1,35 @@ +name: Cypress Tests + +on: + pull_request: + branches: + - develop + paths: + - frontend/** + - .github/** + +defaults: + run: + working-directory: ./frontend + +jobs: + cypress-run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Create .env.development file + run: | + touch .env.development + echo "BASE_URL=${{secrets.BASE_URL_DEVELOPMENT}}" >> .env.development + + - name: Cypress run + uses: cypress-io/github-action@v5 + with: + start: yarn start + wait-on: 'http://localhost:3000' + browser: chrome + working-directory: ./frontend + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/semantic-versioning.yml b/.github/workflows/semantic-versioning.yml new file mode 100644 index 000000000..f5e2c799e --- /dev/null +++ b/.github/workflows/semantic-versioning.yml @@ -0,0 +1,64 @@ +name: semantic-versioning +on: + pull_request: + types: [closed] +jobs: + semantic-versioning: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true && github.base_ref == 'main' + steps: + - name: semantic-versioning + uses: release-drafter/release-drafter@v5 + with: + config-name: draft-release.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + package-version-update: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true && github.base_ref == 'main' + steps: + - uses: actions/checkout@v3 + + - name: Github 회원 정보 설정 + env: + MY_EMAIL: 2023donggle@gmail.com + MY_NAME: Dong-gle + run: | + git config --global user.email $MY_EMAIL + git config --global user.name $MY_NAME + + - name: Node.js 설치 + uses: actions/setup-node@v3 + with: + node-version: 18.x + + - name: PR Label 확인 + id: pr-labels + uses: joerick/pr-labels-action@v1.0.9 + + - name: 메이저 버전 업데이트 + if: | + contains(steps.pr-labels.outputs.labels, ' major ') + run: | + yarn version --major + working-directory: ./frontend + + - name: 마이너 버전 업데이트 + if: | + contains(steps.pr-labels.outputs.labels, ' minor ') + run: | + yarn version --minor + working-directory: ./frontend + + - name: 패치 버전 업데이트 + if: | + contains(steps.pr-labels.outputs.labels, ' patch ') + run: | + yarn version --patch + working-directory: ./frontend + + - name: main 브랜치 Push + uses: ad-m/github-push-action@master + with: + branch: 'main' diff --git a/backend/build.gradle b/backend/build.gradle index 622e19f01..35bd158ca 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -28,6 +28,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'mysql:mysql-connector-java:8.0.28' diff --git a/backend/src/main/java/org/donggle/backend/application/repository/BlockRepository.java b/backend/src/main/java/org/donggle/backend/application/repository/BlockRepository.java index 5a1a45a23..fae6b106e 100644 --- a/backend/src/main/java/org/donggle/backend/application/repository/BlockRepository.java +++ b/backend/src/main/java/org/donggle/backend/application/repository/BlockRepository.java @@ -1,17 +1,13 @@ package org.donggle.backend.application.repository; -import org.donggle.backend.domain.writing.Block; -import org.springframework.data.jpa.repository.EntityGraph; +import org.donggle.backend.domain.writing.content.Block; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; import java.util.Optional; public interface BlockRepository extends JpaRepository { - @EntityGraph(attributePaths = {"content"}) - List findAllByWritingId(final Long writingId); + List findAllByWritingId(Long writingId); - @Override - @EntityGraph(attributePaths = {"content"}) - Optional findById(final Long id); + Optional findById(Long id); } diff --git a/backend/src/main/java/org/donggle/backend/application/repository/ContentRepository.java b/backend/src/main/java/org/donggle/backend/application/repository/ContentRepository.java deleted file mode 100644 index 1192ca7eb..000000000 --- a/backend/src/main/java/org/donggle/backend/application/repository/ContentRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.donggle.backend.application.repository; - -import org.donggle.backend.domain.writing.content.Content; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ContentRepository extends JpaRepository { -} diff --git a/backend/src/main/java/org/donggle/backend/application/repository/TokenRepository.java b/backend/src/main/java/org/donggle/backend/application/repository/TokenRepository.java new file mode 100644 index 000000000..68413202f --- /dev/null +++ b/backend/src/main/java/org/donggle/backend/application/repository/TokenRepository.java @@ -0,0 +1,10 @@ +package org.donggle.backend.application.repository; + +import org.donggle.backend.auth.JwtToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TokenRepository extends JpaRepository { + Optional findByMemberId(final Long memberId); +} diff --git a/backend/src/main/java/org/donggle/backend/application/service/AuthService.java b/backend/src/main/java/org/donggle/backend/application/service/AuthService.java index b5149c41b..49254ced4 100644 --- a/backend/src/main/java/org/donggle/backend/application/service/AuthService.java +++ b/backend/src/main/java/org/donggle/backend/application/service/AuthService.java @@ -4,7 +4,12 @@ import lombok.RequiredArgsConstructor; import org.donggle.backend.application.repository.MemberRepository; import org.donggle.backend.application.service.oauth.kakao.dto.KakaoProfileResponse; +import org.donggle.backend.auth.JwtTokenProvider; +import org.donggle.backend.auth.JwtTokenService; +import org.donggle.backend.auth.exception.NoSuchTokenException; import org.donggle.backend.domain.member.Member; +import org.donggle.backend.domain.member.MemberName; +import org.donggle.backend.ui.response.TokenResponse; import org.springframework.stereotype.Service; @Service @@ -12,9 +17,31 @@ @RequiredArgsConstructor public class AuthService { private final MemberRepository memberRepository; + private final JwtTokenProvider jwtTokenProvider; + private final JwtTokenService jwtTokenService; - public void loginByKakao(final KakaoProfileResponse kakaoProfileResponse) { + public TokenResponse loginByKakao(final KakaoProfileResponse kakaoProfileResponse) { final Member loginMember = memberRepository.findByKakaoId(kakaoProfileResponse.id()) - .orElseGet(() -> memberRepository.save(Member.createByKakao(kakaoProfileResponse))); + .orElseGet(() -> memberRepository.save(Member.createByKakao( + new MemberName(kakaoProfileResponse.getNickname()), + kakaoProfileResponse.id()) + )); + return createTokens(loginMember); + } + + public TokenResponse reissueAccessTokenAndRefreshToken(final Long memberId) { + final Member member = memberRepository.findById(memberId). + orElseThrow(NoSuchTokenException::new); + + return createTokens(member); + } + + private TokenResponse createTokens(final Member loginMember) { + final String accessToken = jwtTokenProvider.createAccessToken(loginMember.getId()); + final String refreshToken = jwtTokenProvider.createRefreshToken(loginMember.getId()); + + jwtTokenService.synchronizeRefreshToken(loginMember, refreshToken); + + return new TokenResponse(accessToken, refreshToken); } } diff --git a/backend/src/main/java/org/donggle/backend/application/service/PublishService.java b/backend/src/main/java/org/donggle/backend/application/service/PublishService.java index 86ed748c6..6d960ea1b 100644 --- a/backend/src/main/java/org/donggle/backend/application/service/PublishService.java +++ b/backend/src/main/java/org/donggle/backend/application/service/PublishService.java @@ -23,8 +23,8 @@ import org.donggle.backend.domain.member.MemberCredentials; import org.donggle.backend.domain.renderer.html.HtmlRenderer; import org.donggle.backend.domain.renderer.html.HtmlStyleRenderer; -import org.donggle.backend.domain.writing.Block; import org.donggle.backend.domain.writing.Writing; +import org.donggle.backend.domain.writing.content.Block; import org.donggle.backend.exception.notfound.BlogNotFoundException; import org.donggle.backend.exception.notfound.WritingNotFoundException; import org.springframework.stereotype.Service; diff --git a/backend/src/main/java/org/donggle/backend/application/service/WritingService.java b/backend/src/main/java/org/donggle/backend/application/service/WritingService.java index d5f2a8710..c05521ee4 100644 --- a/backend/src/main/java/org/donggle/backend/application/service/WritingService.java +++ b/backend/src/main/java/org/donggle/backend/application/service/WritingService.java @@ -21,10 +21,9 @@ import org.donggle.backend.domain.parser.notion.NotionParser; import org.donggle.backend.domain.renderer.html.HtmlRenderer; import org.donggle.backend.domain.renderer.html.HtmlStyleRenderer; -import org.donggle.backend.domain.writing.Block; import org.donggle.backend.domain.writing.Title; import org.donggle.backend.domain.writing.Writing; -import org.donggle.backend.domain.writing.content.Content; +import org.donggle.backend.domain.writing.content.Block; import org.donggle.backend.exception.business.InvalidFileFormatException; import org.donggle.backend.exception.notfound.CategoryNotFoundException; import org.donggle.backend.exception.notfound.MemberNotFoundException; @@ -68,18 +67,14 @@ public Long uploadMarkDownFile(final Long memberId, final MarkdownUploadRequest } final String originalFileText = new String(request.file().getBytes(), StandardCharsets.UTF_8); - final MarkDownParser markDownParser = new MarkDownParser(new MarkDownStyleParser()); final Member findMember = findMember(memberId); final Category findCategory = findCategory(request.categoryId()); final Writing writing = Writing.lastOf(findMember, new Title(findFileName(originalFilename)), findCategory); final Writing savedWriting = saveAndGetWriting(findCategory, writing); + final MarkDownParser markDownParser = new MarkDownParser(new MarkDownStyleParser(), savedWriting); - final List contents = markDownParser.parse(originalFileText); - final List blocks = contents.stream() - .map(content -> new Block(savedWriting, content)) - .toList(); + final List blocks = markDownParser.parse(originalFileText); blockRepository.saveAll(blocks); - return savedWriting.getId(); } @@ -98,23 +93,22 @@ public Long uploadNotionPage(final Long memberId, final NotionUploadRequest requ final NotionApiService notionApiService = new NotionApiService(); final String blockId = request.blockId(); - final NotionParser notionParser = new NotionParser(); - final NotionBlockNode parentBlockNode = notionApiService.retrieveParentBlockNode(blockId, notionToken); - final String title = notionParser.parseTitle(parentBlockNode); + final String title = findTitle(parentBlockNode); final Writing writing = Writing.lastOf(findMember, new Title(title), findCategory); final Writing savedWriting = saveAndGetWriting(findCategory, writing); + final NotionParser notionParser = new NotionParser(savedWriting); final List bodyBlockNodes = notionApiService.retrieveBodyBlockNodes(parentBlockNode, notionToken); - final List contents = notionParser.parseBody(bodyBlockNodes); - final List blocks = contents.stream() - .map(content -> new Block(savedWriting, content)) - .toList(); + final List blocks = notionParser.parseBody(bodyBlockNodes); blockRepository.saveAll(blocks); - return writing.getId(); } + private String findTitle(final NotionBlockNode parentBlockNode) { + return parentBlockNode.getBlockProperties().get("title").asText(); + } + private Writing saveAndGetWriting(final Category findCategory, final Writing writing) { if (isNotEmptyCategory(findCategory)) { final Writing lastWriting = findLastWritingInCategory(findCategory.getId()); diff --git a/backend/src/main/java/org/donggle/backend/application/service/oauth/kakao/KakaoOAuthService.java b/backend/src/main/java/org/donggle/backend/application/service/oauth/kakao/KakaoOAuthService.java index e74329d09..f745242f1 100644 --- a/backend/src/main/java/org/donggle/backend/application/service/oauth/kakao/KakaoOAuthService.java +++ b/backend/src/main/java/org/donggle/backend/application/service/oauth/kakao/KakaoOAuthService.java @@ -4,6 +4,7 @@ import org.donggle.backend.application.service.oauth.kakao.dto.KakaoProfileResponse; import org.donggle.backend.application.service.oauth.kakao.dto.KakaoTokenResponse; import org.donggle.backend.application.service.request.OAuthAccessTokenRequest; +import org.donggle.backend.ui.response.TokenResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; @@ -45,16 +46,16 @@ public String createAuthorizeRedirectUri(final String redirectUri) { .toUriString(); } - public void login(final OAuthAccessTokenRequest oAuthAccessTokenRequest) { + public TokenResponse login(final OAuthAccessTokenRequest oAuthAccessTokenRequest) { final String accessToken = requestAccessToken(oAuthAccessTokenRequest); final KakaoProfileResponse kakaoProfileResponse = requestKakaoProfile(accessToken); - authService.loginByKakao(kakaoProfileResponse); + return authService.loginByKakao(kakaoProfileResponse); } private String requestAccessToken(final OAuthAccessTokenRequest oAuthAccessTokenRequest) { final BodyInserters.FormInserter bodyForm = BodyInserters.fromFormData("grant_type", GRANT_TYPE) .with("client_id", clientId) - .with("redirect_uri", oAuthAccessTokenRequest.redirectUri()) + .with("redirect_uri", oAuthAccessTokenRequest.redirect_uri()) .with("code", oAuthAccessTokenRequest.code()) .with("client_secret", clientSecret); diff --git a/backend/src/main/java/org/donggle/backend/application/service/oauth/notion/NotionOAuthService.java b/backend/src/main/java/org/donggle/backend/application/service/oauth/notion/NotionOAuthService.java index 49eca8293..af29b33da 100644 --- a/backend/src/main/java/org/donggle/backend/application/service/oauth/notion/NotionOAuthService.java +++ b/backend/src/main/java/org/donggle/backend/application/service/oauth/notion/NotionOAuthService.java @@ -40,7 +40,7 @@ public String createRedirectUri(final String redirectUri) { } public void getAccessToken(final OAuthAccessTokenRequest oAuthAccessTokenRequest) { - final String redirectUri = oAuthAccessTokenRequest.redirectUri(); + final String redirectUri = oAuthAccessTokenRequest.redirect_uri(); final String code = oAuthAccessTokenRequest.code(); NotionTokenResponse response = webClient.post() diff --git a/backend/src/main/java/org/donggle/backend/application/service/oauth/tistory/TistoryOAuthService.java b/backend/src/main/java/org/donggle/backend/application/service/oauth/tistory/TistoryOAuthService.java index edebb77e0..e924a579f 100644 --- a/backend/src/main/java/org/donggle/backend/application/service/oauth/tistory/TistoryOAuthService.java +++ b/backend/src/main/java/org/donggle/backend/application/service/oauth/tistory/TistoryOAuthService.java @@ -34,7 +34,7 @@ public String createAuthorizeRedirectUri(final String redirectUri) { } public String getAccessToken(final OAuthAccessTokenRequest oAuthAccessTokenRequest) { - final String redirectUri = oAuthAccessTokenRequest.redirectUri(); + final String redirectUri = oAuthAccessTokenRequest.redirect_uri(); final String code = oAuthAccessTokenRequest.code(); final String tokenUri = createTokenUri(redirectUri, code); diff --git a/backend/src/main/java/org/donggle/backend/application/service/request/MarkdownUploadRequest.java b/backend/src/main/java/org/donggle/backend/application/service/request/MarkdownUploadRequest.java index 382387c0d..858560d2f 100644 --- a/backend/src/main/java/org/donggle/backend/application/service/request/MarkdownUploadRequest.java +++ b/backend/src/main/java/org/donggle/backend/application/service/request/MarkdownUploadRequest.java @@ -1,6 +1,11 @@ package org.donggle.backend.application.service.request; +import jakarta.validation.constraints.NotNull; import org.springframework.web.multipart.MultipartFile; -public record MarkdownUploadRequest(MultipartFile file, Long categoryId) { +public record MarkdownUploadRequest( + MultipartFile file, + @NotNull(message = "카테고리 아이디는 필수입니다.") + Long categoryId +) { } diff --git a/backend/src/main/java/org/donggle/backend/application/service/request/OAuthAccessTokenRequest.java b/backend/src/main/java/org/donggle/backend/application/service/request/OAuthAccessTokenRequest.java index 017207f62..50e94b8bf 100644 --- a/backend/src/main/java/org/donggle/backend/application/service/request/OAuthAccessTokenRequest.java +++ b/backend/src/main/java/org/donggle/backend/application/service/request/OAuthAccessTokenRequest.java @@ -1,4 +1,4 @@ package org.donggle.backend.application.service.request; -public record OAuthAccessTokenRequest(String redirectUri, String code) { +public record OAuthAccessTokenRequest(String redirect_uri, String code) { } diff --git a/backend/src/main/java/org/donggle/backend/auth/JwtToken.java b/backend/src/main/java/org/donggle/backend/auth/JwtToken.java new file mode 100644 index 000000000..d698be4f3 --- /dev/null +++ b/backend/src/main/java/org/donggle/backend/auth/JwtToken.java @@ -0,0 +1,65 @@ +package org.donggle.backend.auth; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.donggle.backend.domain.member.Member; + +import java.util.Objects; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class JwtToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String refreshToken; + @OneToOne(fetch = FetchType.LAZY) + private Member member; + + public JwtToken(final String token, final Member member) { + this.refreshToken = token; + this.member = member; + } + + public boolean isDifferentRefreshToken(final String refreshToken) { + return !this.refreshToken.equals(refreshToken); + } + + public void updateRefreshToken(final String refreshToken) { + this.refreshToken = refreshToken; + } + + @Override + public String toString() { + return "JwtToken{" + + "id=" + id + + ", token='" + refreshToken + '\'' + + ", member=" + member + + '}'; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final JwtToken jwtToken = (JwtToken) o; + return Objects.equals(getId(), jwtToken.getId()); + } + + @Override + public int hashCode() { + return Objects.hash(getId()); + } +} diff --git a/backend/src/main/java/org/donggle/backend/auth/JwtTokenProvider.java b/backend/src/main/java/org/donggle/backend/auth/JwtTokenProvider.java new file mode 100644 index 000000000..70b5277b9 --- /dev/null +++ b/backend/src/main/java/org/donggle/backend/auth/JwtTokenProvider.java @@ -0,0 +1,72 @@ +package org.donggle.backend.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.donggle.backend.auth.exception.NoSuchTokenException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class JwtTokenProvider { + private static final String MEMBER_ID_KEY = "memberId"; + + private final SecretKey key; + private final long accessTokenValidityInMilliseconds; + private final long refreshTokenValidityInMilliseconds; + + public JwtTokenProvider(@Value("${security.jwt.token.secret-key}") final String secretKey, + @Value("${security.jwt.token.access-token-expire-length}") final int accessTokenValidityInMilliseconds, + @Value("${security.jwt.token.refresh-token-expire-length}") final int refreshTokenValidityInMilliseconds) { + this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds; + this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds; + } + + public String createAccessToken(final Long payload) { + return createToken(payload, accessTokenValidityInMilliseconds); + } + + public String createRefreshToken(final Long payload) { + return createToken(payload, refreshTokenValidityInMilliseconds); + } + + private String createToken(final Long payload, final long validityInMilliseconds) { + final Date now = new Date(); + final Date validity = new Date(now.getTime() + validityInMilliseconds); + + return Jwts.builder() + .claim(MEMBER_ID_KEY, payload) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public Long getPayload(final String token) { + return getClaims(token).getBody().get(MEMBER_ID_KEY, Long.class); + } + + public boolean inValidTokenUsage(final String token) { + try { + final Jws claims = getClaims(token); + return claims.getBody().getExpiration().before(new Date()); + } catch (final ExpiredJwtException e) { + throw new NoSuchTokenException(); + } catch (final JwtException | IllegalArgumentException e) { + return true; + } + } + + private Jws getClaims(final String token) { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + } +} diff --git a/backend/src/main/java/org/donggle/backend/auth/JwtTokenService.java b/backend/src/main/java/org/donggle/backend/auth/JwtTokenService.java new file mode 100644 index 000000000..a560b6b23 --- /dev/null +++ b/backend/src/main/java/org/donggle/backend/auth/JwtTokenService.java @@ -0,0 +1,22 @@ +package org.donggle.backend.auth; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.donggle.backend.application.repository.TokenRepository; +import org.donggle.backend.domain.member.Member; +import org.springframework.stereotype.Service; + +@Service +@Transactional +@RequiredArgsConstructor +public class JwtTokenService { + private final TokenRepository tokenRepository; + + public void synchronizeRefreshToken(final Member member, final String refreshToken) { + tokenRepository.findByMemberId(member.getId()) + .ifPresentOrElse( + token -> token.updateRefreshToken(refreshToken), + () -> tokenRepository.save(new JwtToken(refreshToken, member)) + ); + } +} diff --git a/backend/src/main/java/org/donggle/backend/auth/exception/EmptyAuthorizationHeaderException.java b/backend/src/main/java/org/donggle/backend/auth/exception/EmptyAuthorizationHeaderException.java new file mode 100644 index 000000000..16827e98e --- /dev/null +++ b/backend/src/main/java/org/donggle/backend/auth/exception/EmptyAuthorizationHeaderException.java @@ -0,0 +1,11 @@ +package org.donggle.backend.auth.exception; + +import org.donggle.backend.exception.business.BusinessException; + +public class EmptyAuthorizationHeaderException extends BusinessException { + private static final String MESSAGE = "header에 Authorization이 존재하지 않습니다."; + + public EmptyAuthorizationHeaderException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/org/donggle/backend/auth/exception/InvalidAccessTokenException.java b/backend/src/main/java/org/donggle/backend/auth/exception/InvalidAccessTokenException.java new file mode 100644 index 000000000..f4260eb3d --- /dev/null +++ b/backend/src/main/java/org/donggle/backend/auth/exception/InvalidAccessTokenException.java @@ -0,0 +1,11 @@ +package org.donggle.backend.auth.exception; + +import org.donggle.backend.exception.business.BusinessException; + +public class InvalidAccessTokenException extends BusinessException { + private static final String MESSAGE = "유효하지 않은 토큰입니다."; + + public InvalidAccessTokenException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/org/donggle/backend/auth/exception/NoSuchTokenException.java b/backend/src/main/java/org/donggle/backend/auth/exception/NoSuchTokenException.java new file mode 100644 index 000000000..d6ec50c5c --- /dev/null +++ b/backend/src/main/java/org/donggle/backend/auth/exception/NoSuchTokenException.java @@ -0,0 +1,11 @@ +package org.donggle.backend.auth.exception; + +import org.donggle.backend.exception.business.BusinessException; + +public class NoSuchTokenException extends BusinessException { + private static final String MESSAGE = "존재하지 않는 토큰입니다."; + + public NoSuchTokenException() { + super(MESSAGE); + } +} diff --git a/backend/src/main/java/org/donggle/backend/auth/presentation/AuthInterceptor.java b/backend/src/main/java/org/donggle/backend/auth/presentation/AuthInterceptor.java new file mode 100644 index 000000000..9b4975651 --- /dev/null +++ b/backend/src/main/java/org/donggle/backend/auth/presentation/AuthInterceptor.java @@ -0,0 +1,32 @@ +package org.donggle.backend.auth.presentation; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.donggle.backend.auth.JwtTokenProvider; +import org.donggle.backend.auth.exception.InvalidAccessTokenException; +import org.donggle.backend.auth.support.AuthorizationExtractor; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.servlet.HandlerInterceptor; + +@RequiredArgsConstructor +public class AuthInterceptor implements HandlerInterceptor { + private final JwtTokenProvider jwtTokenProvider; + + @Override + public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) { + if (CorsUtils.isPreFlightRequest(request)) { + return true; + } + + validateToken(request); + return true; + } + + private void validateToken(final HttpServletRequest request) { + final String token = AuthorizationExtractor.extract(request); + if (jwtTokenProvider.inValidTokenUsage(token)) { + throw new InvalidAccessTokenException(); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/donggle/backend/auth/presentation/RefreshTokenAuthInterceptor.java b/backend/src/main/java/org/donggle/backend/auth/presentation/RefreshTokenAuthInterceptor.java new file mode 100644 index 000000000..73e5c89a7 --- /dev/null +++ b/backend/src/main/java/org/donggle/backend/auth/presentation/RefreshTokenAuthInterceptor.java @@ -0,0 +1,42 @@ +package org.donggle.backend.auth.presentation; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.donggle.backend.application.repository.TokenRepository; +import org.donggle.backend.auth.JwtToken; +import org.donggle.backend.auth.JwtTokenProvider; +import org.donggle.backend.auth.exception.NoSuchTokenException; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.Arrays; + +@RequiredArgsConstructor +public class RefreshTokenAuthInterceptor implements HandlerInterceptor { + private final JwtTokenProvider jwtTokenProvider; + private final TokenRepository tokenRepository; + + @Override + public boolean preHandle(final HttpServletRequest request, + final HttpServletResponse response, + final Object handler) { + final String refreshToken = extract(request); + final Long memberId = jwtTokenProvider.getPayload(refreshToken); + final JwtToken jwtToken = tokenRepository.findByMemberId(memberId) + .orElseThrow(NoSuchTokenException::new); + + if (jwtToken.isDifferentRefreshToken(refreshToken) || jwtTokenProvider.inValidTokenUsage(refreshToken)) { + throw new NoSuchTokenException(); + } + + return true; + } + + private String extract(final HttpServletRequest request) { + return Arrays.stream(request.getCookies()) + .filter(cookie -> "refreshToken".equals(cookie.getName())) + .findFirst() + .orElseThrow(NoSuchTokenException::new) + .getValue(); + } +} diff --git a/backend/src/main/java/org/donggle/backend/auth/presentation/TokenArgumentResolver.java b/backend/src/main/java/org/donggle/backend/auth/presentation/TokenArgumentResolver.java new file mode 100644 index 000000000..af636db41 --- /dev/null +++ b/backend/src/main/java/org/donggle/backend/auth/presentation/TokenArgumentResolver.java @@ -0,0 +1,33 @@ +package org.donggle.backend.auth.presentation; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.donggle.backend.auth.JwtTokenProvider; +import org.donggle.backend.auth.support.AuthenticationPrincipal; +import org.donggle.backend.auth.support.AuthorizationExtractor; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.Objects; + +@RequiredArgsConstructor +public class TokenArgumentResolver implements HandlerMethodArgumentResolver { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + public boolean supportsParameter(final MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthenticationPrincipal.class); + } + + @Override + public Long resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer, + final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) { + final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + final String token = AuthorizationExtractor.extract(Objects.requireNonNull(request)); + return jwtTokenProvider.getPayload(token); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/donggle/backend/auth/support/AuthenticationPrincipal.java b/backend/src/main/java/org/donggle/backend/auth/support/AuthenticationPrincipal.java new file mode 100644 index 000000000..19ca31589 --- /dev/null +++ b/backend/src/main/java/org/donggle/backend/auth/support/AuthenticationPrincipal.java @@ -0,0 +1,11 @@ +package org.donggle.backend.auth.support; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthenticationPrincipal { +} \ No newline at end of file diff --git a/backend/src/main/java/org/donggle/backend/auth/support/AuthorizationExtractor.java b/backend/src/main/java/org/donggle/backend/auth/support/AuthorizationExtractor.java new file mode 100644 index 000000000..8949a4fcb --- /dev/null +++ b/backend/src/main/java/org/donggle/backend/auth/support/AuthorizationExtractor.java @@ -0,0 +1,31 @@ +package org.donggle.backend.auth.support; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.donggle.backend.auth.exception.EmptyAuthorizationHeaderException; +import org.donggle.backend.auth.exception.NoSuchTokenException; +import org.springframework.http.HttpHeaders; + +import java.util.Objects; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AuthorizationExtractor { + private static final String BEARER_TYPE = "Bearer "; + + public static String extract(final HttpServletRequest request) { + final String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (Objects.isNull(authorizationHeader)) { + throw new EmptyAuthorizationHeaderException(); + } + + validateAuthorizationFormat(authorizationHeader); + return authorizationHeader.substring(BEARER_TYPE.length()).trim(); + } + + private static void validateAuthorizationFormat(final String authorizationHeader) { + if (!authorizationHeader.toLowerCase().startsWith(BEARER_TYPE.toLowerCase())) { + throw new NoSuchTokenException(); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/donggle/backend/config/InitData.java b/backend/src/main/java/org/donggle/backend/config/InitData.java index b6d304a27..daad5f9b0 100644 --- a/backend/src/main/java/org/donggle/backend/config/InitData.java +++ b/backend/src/main/java/org/donggle/backend/config/InitData.java @@ -9,19 +9,17 @@ import org.donggle.backend.domain.blog.Blog; import org.donggle.backend.domain.blog.BlogType; import org.donggle.backend.domain.category.Category; -import org.donggle.backend.domain.member.Email; import org.donggle.backend.domain.member.Member; import org.donggle.backend.domain.member.MemberName; -import org.donggle.backend.domain.member.Password; -import org.donggle.backend.domain.writing.Block; import org.donggle.backend.domain.writing.BlockType; import org.donggle.backend.domain.writing.Style; import org.donggle.backend.domain.writing.StyleRange; import org.donggle.backend.domain.writing.StyleType; import org.donggle.backend.domain.writing.Title; import org.donggle.backend.domain.writing.Writing; +import org.donggle.backend.domain.writing.content.Block; import org.donggle.backend.domain.writing.content.Depth; -import org.donggle.backend.domain.writing.content.NormalContent; +import org.donggle.backend.domain.writing.content.NormalBlock; import org.donggle.backend.domain.writing.content.RawText; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Profile; @@ -37,7 +35,7 @@ public class InitData implements CommandLineRunner { private final InitService initService; @Override - public void run(String... args) { + public void run(final String... args) { initService.init(); } @@ -52,11 +50,7 @@ public static class InitService { @Transactional public void init() { - final Member savedMember = memberRepository.save(new Member( - new MemberName("동그리"), - new Email("a@a.com"), - new Password("1234") - )); + final Member savedMember = memberRepository.save(Member.createByKakao(new MemberName("동그리"), 1L)); final Category savedCategory = categoryRepository.save(Category.basic(savedMember)); @@ -69,16 +63,16 @@ public void init() { savedCategory )); - blockRepository.save(new Block( - savedWriting, - new NormalContent( + final Block savedBlock = blockRepository.save( + new NormalBlock( + savedWriting, Depth.from(1), BlockType.PARAGRAPH, RawText.from("테스트 글입니다."), List.of(new Style(new StyleRange(0, 2), StyleType.BOLD) ) ) - )); + ); } } } diff --git a/backend/src/main/java/org/donggle/backend/domain/member/Member.java b/backend/src/main/java/org/donggle/backend/domain/member/Member.java index e1890cd19..59a38268a 100644 --- a/backend/src/main/java/org/donggle/backend/domain/member/Member.java +++ b/backend/src/main/java/org/donggle/backend/domain/member/Member.java @@ -9,7 +9,6 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import org.donggle.backend.application.service.oauth.kakao.dto.KakaoProfileResponse; import org.donggle.backend.domain.common.BaseEntity; import java.util.Objects; @@ -24,22 +23,15 @@ public class Member extends BaseEntity { @NotNull @Embedded private MemberName memberName; - @NotNull - @Embedded - private Email email; - @NotNull - @Embedded - private Password password; private Long kakaoId; - public Member(final MemberName memberName, final Email email, final Password password) { + private Member(final MemberName memberName, final Long kakaoId) { this.memberName = memberName; - this.email = email; - this.password = password; + this.kakaoId = kakaoId; } - public static Member createByKakao(final KakaoProfileResponse kakaoProfileResponse) { - return new Member(new MemberName(kakaoProfileResponse.getNickname()), null, null); + public static Member createByKakao(final MemberName memberName, final Long kakaoId) { + return new Member(memberName, kakaoId); } @Override diff --git a/backend/src/main/java/org/donggle/backend/domain/parser/markdown/MarkDownParser.java b/backend/src/main/java/org/donggle/backend/domain/parser/markdown/MarkDownParser.java index 8b92d1f23..632c97431 100644 --- a/backend/src/main/java/org/donggle/backend/domain/parser/markdown/MarkDownParser.java +++ b/backend/src/main/java/org/donggle/backend/domain/parser/markdown/MarkDownParser.java @@ -3,14 +3,15 @@ import lombok.RequiredArgsConstructor; import org.donggle.backend.domain.writing.BlockType; import org.donggle.backend.domain.writing.Style; -import org.donggle.backend.domain.writing.content.CodeBlockContent; -import org.donggle.backend.domain.writing.content.Content; +import org.donggle.backend.domain.writing.Writing; +import org.donggle.backend.domain.writing.content.Block; +import org.donggle.backend.domain.writing.content.CodeBlock; import org.donggle.backend.domain.writing.content.Depth; +import org.donggle.backend.domain.writing.content.ImageBlock; import org.donggle.backend.domain.writing.content.ImageCaption; -import org.donggle.backend.domain.writing.content.ImageContent; import org.donggle.backend.domain.writing.content.ImageUrl; import org.donggle.backend.domain.writing.content.Language; -import org.donggle.backend.domain.writing.content.NormalContent; +import org.donggle.backend.domain.writing.content.NormalBlock; import org.donggle.backend.domain.writing.content.RawText; import java.util.ArrayList; @@ -30,8 +31,9 @@ public class MarkDownParser { private static final int SPACE_GROUP_INDEX = 2; private final MarkDownStyleParser markDownStyleParser; + private final Writing writing; - public List parse(final String text) { + public List parse(final String text) { return splitBlocks(text).stream() .map(this::createContentFromTextBlock) .toList(); @@ -64,7 +66,7 @@ private boolean isExist(final String matchText) { return matchText != null && !matchText.isEmpty(); } - private Content createContentFromTextBlock(final String textBlock) { + private Block createContentFromTextBlock(final String textBlock) { final Depth depth = parseDepth(textBlock); final String removedDepthText = removeDepth(depth, textBlock); final Matcher matcher = findBlockMatcher(removedDepthText); @@ -72,17 +74,17 @@ private Content createContentFromTextBlock(final String textBlock) { switch (blockType) { case CODE_BLOCK -> { - return new CodeBlockContent(blockType, RawText.from(matcher.group(2)), Language.from(matcher.group(1))); + return new CodeBlock(writing, blockType, RawText.from(matcher.group(2)), Language.from(matcher.group(1))); } case IMAGE -> { // TODO: image regex 이전 plainText가 들어오는 경우 처리 로직 추가하기 - return new ImageContent(blockType, new ImageUrl(matcher.group(2)), new ImageCaption(matcher.group(1))); + return new ImageBlock(writing, blockType, new ImageUrl(matcher.group(2)), new ImageCaption(matcher.group(1))); } default -> { final String removedBlockTypeText = matcher.replaceAll(""); final String removedStyleTypeText = markDownStyleParser.removeStyles(removedBlockTypeText); final List