From ae002c0997a3c2d0d1d405e67388d49e9032f4bd Mon Sep 17 00:00:00 2001 From: Yunochi Date: Mon, 25 Nov 2024 10:07:18 +0900 Subject: [PATCH 01/10] =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EC=B0=BD=EC=97=90=20=ED=95=B8=EB=93=A4=EC=9D=84=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=ED=95=9C=20=EA=B2=BD=EC=9A=B0=EB=8F=84=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/page.tsx | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 0772e57..a14093e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -51,19 +51,26 @@ const mastodonAuth = async ({ host }: loginReqDto) => { }; /** - * https://example.com/ 같은 URL 형식으로 온 경우 Host 형식으로 변환 - * 소문자 처리 - * @param urlOrHost + * https://example.com/ 같은 URL 형식이나 handle 형식으로 입력한 경우 host로 변환. + * host를 소문자 처리후 반환 + * @param urlOrHostOrHandle * @returns */ -function convertHost(urlOrHost: string) { - const re = /\/\/[^/@\s]+(:[0-9]{1,5})?\/?/; - const matched_str = urlOrHost.match(re)?.[0]; - if (matched_str) { - console.log(`URL ${urlOrHost} replaced with ${matched_str.replaceAll('/', '')}`); - return matched_str.replaceAll('/', '').toLowerCase(); +function convertHost(urlOrHostOrHandle: string) { + const url_regex = /\/\/[^/@\s]+(:[0-9]{1,5})?\/?/; + const matched_host_from_url = urlOrHostOrHandle.match(url_regex)?.[0]; + const handle_regex = /(:?@)[^@\s\n\r\t]+$/g; + const matched_host_from_handle = urlOrHostOrHandle.match(handle_regex)?.[0]; + if (matched_host_from_url) { + const replaceed = matched_host_from_url.replaceAll('/', '').toLowerCase(); + console.log(`URL ${urlOrHostOrHandle} replaced with ${replaceed}`); + return replaceed; + } else if(matched_host_from_handle) { + const replaced = matched_host_from_handle.replaceAll('@', '').toLowerCase(); + console.log(`Handle ${urlOrHostOrHandle} replaced with ${replaced}`); + return replaced; } - return urlOrHost.toLowerCase(); + return urlOrHostOrHandle.toLowerCase(); } export default function Home() { From a5fdd3a7f10e4cd1e03707aca39e63c591ae4933 Mon Sep 17 00:00:00 2001 From: Yunochi Date: Mon, 25 Nov 2024 12:56:51 +0900 Subject: [PATCH 02/10] =?UTF-8?q?Feat:=20=EB=B9=A0=EB=A5=B8=20=EC=9E=AC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/web/refresh-token/route.ts | 1 + src/app/page.tsx | 93 +++++++++++++++----------- src/utils/checkLogin/fastLoginCheck.ts | 18 +++++ 3 files changed, 73 insertions(+), 39 deletions(-) create mode 100644 src/utils/checkLogin/fastLoginCheck.ts diff --git a/src/app/api/web/refresh-token/route.ts b/src/app/api/web/refresh-token/route.ts index 0a85c40..b5e659a 100644 --- a/src/app/api/web/refresh-token/route.ts +++ b/src/app/api/web/refresh-token/route.ts @@ -47,6 +47,7 @@ export async function POST(req: NextRequest) { const user = await prisma.user.findUniqueOrThrow({ where: { handle: tokenPayload.handle } }); try { + logger.log('Try refresh JWT...'); await refreshAndReValidateToken(user); const jwtToken = await generateJwt(user.hostName, user.handle, user.jwtIndex); cookieStore.set('jwtToken', jwtToken, { diff --git a/src/app/page.tsx b/src/app/page.tsx index a14093e..0cd6dfe 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,7 @@ import detectInstance from '../utils/detectInstance/detectInstance'; import { loginReqDto } from './_dto/web/login/login.dto'; import GithubRepoLink from './_components/github'; import DialogModalOneButton from './_components/modalOneButton'; +import { loginCheck } from '@/utils/checkLogin/fastLoginCheck'; interface FormValue { address: string; @@ -65,7 +66,7 @@ function convertHost(urlOrHostOrHandle: string) { const replaceed = matched_host_from_url.replaceAll('/', '').toLowerCase(); console.log(`URL ${urlOrHostOrHandle} replaced with ${replaceed}`); return replaceed; - } else if(matched_host_from_handle) { + } else if (matched_host_from_handle) { const replaced = matched_host_from_handle.replaceAll('@', '').toLowerCase(); console.log(`Handle ${urlOrHostOrHandle} replaced with ${replaced}`); return replaced; @@ -88,46 +89,60 @@ export default function Home() { const onSubmit: SubmitHandler = async (e) => { setIsLoading(true); const host = convertHost(e.address); - localStorage.setItem('server', host); - detectInstance(host).then((type) => { - const payload: loginReqDto = { - host: host, - }; - switch (type) { - case 'misskey': - case 'cherrypick': - misskeyAuth(payload) - .then((r) => { - setIsLoading(false); - router.replace(r.url); - }) - .catch((err) => { - setIsLoading(false); - setErrorMessage(err); - errModalRef.current?.showModal(); - }); - break; - case 'mastodon': - mastodonAuth(payload) - .then((r) => { - router.replace(r); - }) - .catch((err) => { - setIsLoading(false); - setErrorMessage(err); - errModalRef.current?.showModal(); - }); - break; - default: - setErrorMessage(`알 수 없는 인스턴스 타입 '${type}' 이에요!`); - errModalRef.current?.showModal(); - console.log('아무것도 없는뎁쇼?'); + /// 이미 로그인 되어있는 경우 빠른 재 로그인 시도 + const lastUsedHost = localStorage.getItem('server'); + const lastUsedHandle = localStorage.getItem('user_handle'); + if (lastUsedHost === host && lastUsedHandle != null) { + console.log('Try Fast Relogin...'); + const relogin_success = await loginCheck(); + if (relogin_success) { + console.log('Fast ReLogin OK!!'); + router.replace('/main'); + return; } - }).catch((err) => { - setErrorMessage(err); - errModalRef.current?.showModal(); - }); + } + localStorage.removeItem('handle'); + localStorage.setItem('server', host); + await detectInstance(host) + .then((type) => { + const payload: loginReqDto = { + host: host, + }; + switch (type) { + case 'misskey': + case 'cherrypick': + misskeyAuth(payload) + .then((r) => { + router.replace(r.url); + }) + .catch((err) => { + setErrorMessage(err); + errModalRef.current?.showModal(); + }); + break; + case 'mastodon': + mastodonAuth(payload) + .then((r) => { + router.replace(r); + }) + .catch((err) => { + setErrorMessage(err); + errModalRef.current?.showModal(); + }); + break; + default: + setErrorMessage(`알 수 없는 인스턴스 타입 '${type}' 이에요!`); + errModalRef.current?.showModal(); + } + }) + .catch(() => { + setErrorMessage('인스턴스 타입 감지에 실패했어요!'); + errModalRef.current?.showModal(); + }) + .finally(() => { + setIsLoading(false); + }); }; useEffect(() => { diff --git a/src/utils/checkLogin/fastLoginCheck.ts b/src/utils/checkLogin/fastLoginCheck.ts new file mode 100644 index 0000000..abb6ccf --- /dev/null +++ b/src/utils/checkLogin/fastLoginCheck.ts @@ -0,0 +1,18 @@ +'use client'; + +export async function loginCheck(): Promise { + try { + const res = await fetch('/api/db/fetch-my-profile', { + method: 'GET', + }); + if (!res.ok) { + return false; + } else { + return true; + } + + } catch (err) { + console.log('로그인 체크 실패', err); + return false; + } +} \ No newline at end of file From 13c87f1db1e7b8c8ba980b1dcfcb288507a1754c Mon Sep 17 00:00:00 2001 From: Yunochi Date: Mon, 25 Nov 2024 14:06:01 +0900 Subject: [PATCH 03/10] =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81,?= =?UTF-8?q?=20=EC=95=A1=EC=84=B8=EC=8A=A4=ED=86=A0=ED=81=B0=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EC=8B=9C=20JWT=20=ED=8F=90=EA=B8=B0=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/web/refresh-token/route.ts | 2 +- src/app/main/questions/action.ts | 219 +++++++++++++------------ 2 files changed, 115 insertions(+), 106 deletions(-) diff --git a/src/app/api/web/refresh-token/route.ts b/src/app/api/web/refresh-token/route.ts index b5e659a..169907c 100644 --- a/src/app/api/web/refresh-token/route.ts +++ b/src/app/api/web/refresh-token/route.ts @@ -55,7 +55,7 @@ export async function POST(req: NextRequest) { httpOnly: true, }); } catch (err) { - logger.warn('User가 미스키/마스토돈에서 앱 권한을 Revoke한것 같아요. JWT index를 올릴게요. 자세한 정보:', err); + logger.warn('User Revoked Access token. JWT를 Revoke합니다... Detail:', err); await prisma.user.update({where: {handle: user.handle}, data: {jwtIndex: (user.jwtIndex + 1)}}); return sendApiError(401, `Refresh user failed!! ${err}`); } diff --git a/src/app/main/questions/action.ts b/src/app/main/questions/action.ts index f5c783a..c89eb03 100644 --- a/src/app/main/questions/action.ts +++ b/src/app/main/questions/action.ts @@ -5,7 +5,7 @@ import { verifyToken } from '@/app/api/_utils/jwt/verify-jwt'; import { sendApiError } from '@/app/api/_utils/apiErrorResponse/sendApiError'; import { GetPrismaClient } from '@/app/api/_utils/getPrismaClient/get-prisma-client'; import { Logger } from '@/utils/logger/Logger'; -import { question } from '@prisma/client'; +import { question, server, user } from '@prisma/client'; import { createHash } from 'crypto'; import { cookies } from 'next/headers'; @@ -21,7 +21,7 @@ export async function getQuestion(id: number) { return findWithId; } -export async function postAnswer(questionId: question['id'] | null, answer: typedAnswer) { +export async function postAnswer(questionId: question['id'] | null, typedAnswer: typedAnswer) { const postLogger = new Logger('postAnswer'); const prisma = GetPrismaClient.getClient(); const cookieStore = await cookies(); @@ -33,113 +33,106 @@ export async function postAnswer(questionId: question['id'] | null, answer: type } catch (err) { return sendApiError(401, 'Unauthorized'); } + if (!questionId) { + return sendApiError(400, 'Bad Request'); + } + const q = await prisma.question.findUniqueOrThrow({ where: { id: questionId } }); + if (q.questioneeHandle !== tokenPayload.handle) { + throw new Error(`This question is not for you`); + } + const answeredUser = await prisma.user.findUniqueOrThrow({ + where: { + handle: tokenPayload.handle, + }, + }); + const server = await prisma.server.findUniqueOrThrow({ + where: { + instances: answeredUser.hostName, + }, + }); - if (questionId !== null) { - // 트랜잭션으로 All or Nothing 처리 - const [question, postWithAnswer] = await prisma.$transaction(async (tx) => { - const q = await tx.question.findUniqueOrThrow({ where: { id: questionId } }); - if (q.questioneeHandle !== tokenPayload.handle) { - throw new Error(`This question is not for you`); - } - const a = await tx.answer.create({ - data: { - question: q.question, - questioner: q.questioner, - answer: answer.answer, - answeredPersonHandle: tokenPayload.handle, - nsfwedAnswer: answer.nsfwedAnswer, - }, - }); - await tx.question.delete({ - where: { - id: q.id, - }, - }); - - return [q, a]; - }); - const answeredUser = await prisma.user.findUniqueOrThrow({ - where: { - handle: tokenPayload.handle, - }, - }); - - const server = await prisma.server.findUniqueOrThrow({ - where: { - instances: answeredUser.hostName, - }, - }); - - const instanceType = server.instanceType; + const userSettings = await prisma.profile.findUniqueOrThrow({ + where: { + handle: tokenPayload.handle, + }, + }); + const createdAnswer = await prisma.answer.create({ + data: { + question: q.question, + questioner: q.questioner, + answer: typedAnswer.answer, + answeredPersonHandle: tokenPayload.handle, + nsfwedAnswer: typedAnswer.nsfwedAnswer, + }, + }); + const answerUrl = `${process.env.WEB_URL}/main/user/${answeredUser.handle}/${createdAnswer.id}`; - const baseUrl = process.env.WEB_URL; - const answerUrl = `${baseUrl}/main/user/${answeredUser.handle}/${postWithAnswer.id}`; - postLogger.log('Created new answer:', answerUrl); - //답변 올리는 부분 - const userSettings = await prisma.profile.findUniqueOrThrow({ - where: { - handle: tokenPayload.handle, - }, - }); - if (!userSettings.stopPostAnswer) { - if (answeredUser && server) { - const i = createHash('sha256') - .update(answeredUser.token + server.appSecret, 'utf-8') - .digest('hex'); - const host = answeredUser.hostName; - if (answer.nsfwedAnswer === true && question.questioner === null) { - const title = `⚠️ 이 질문은 NSFW한 질문이에요! #neo_quesdon`; - const text = `Q: ${question.question}\nA: ${answer.answer}\n#neo_quesdon ${answerUrl}`; - if (instanceType === 'misskey' || instanceType === 'cherrypick') { - await mkMisskeyNote(i, title, text, host, answer.visibility); - } else { - await mastodonToot(answeredUser.token, title, text, host, answer.visibility); - } - } else if (answer.nsfwedAnswer === false && question.questioner !== null) { - const title = `Q: ${question.question} #neo_quesdon`; - const text = `질문자:${question.questioner}\nA: ${answer.answer}\n#neo_quesdon ${answerUrl}`; - if (instanceType === 'misskey' || instanceType === 'cherrypick') { - await mkMisskeyNote(i, title, text, host, answer.visibility); - } else { - await mastodonToot(answeredUser.token, title, text, host, answer.visibility); - } - } else if (answer.nsfwedAnswer === true && question.questioner !== null) { - const title = `⚠️ 이 질문은 NSFW한 질문이에요! #neo_quesdon`; - const text = `질문자:${question.questioner}\nQ:${question.question}\nA: ${answer.answer}\n#neo_quesdon ${answerUrl}`; - if (instanceType === 'misskey' || instanceType === 'cherrypick') { - await mkMisskeyNote(i, title, text, host, answer.visibility); - } else { - await mastodonToot(answeredUser.token, title, text, host, answer.visibility); - } - } else { - const title = `Q: ${question.question} #neo_quesdon`; - const text = `A: ${answer.answer}\n#neo_quesdon ${answerUrl}`; - if (instanceType === 'misskey' || instanceType === 'cherrypick') { - await mkMisskeyNote(i, title, text, host, answer.visibility); - } else { - await mastodonToot(answeredUser.token, title, text, host, answer.visibility); - } - } + if (!userSettings.stopPostAnswer) { + let title; + let text; + if (typedAnswer.nsfwedAnswer === true) { + title = `⚠️ 이 질문은 NSFW한 질문이에요! #neo_quesdon`; + if (q.questioner) { + text = `질문자:${q.questioner}\nQ:${q.question}\nA: ${typedAnswer.answer}\n#neo_quesdon ${answerUrl}`; } else { - postLogger.error('user not found'); + text = `Q: ${q.question}\nA: ${typedAnswer.answer}\n#neo_quesdon ${answerUrl}`; + } + } else { + title = `Q: ${q.question} #neo_quesdon`; + if (q.questioner) { + text = `질문자:${q.questioner}\nA: ${typedAnswer.answer}\n#neo_quesdon ${answerUrl}`; + } else { + text = `A: ${typedAnswer.answer}\n#neo_quesdon ${answerUrl}`; } } + try { + switch (server.instanceType) { + case 'misskey': + case 'cherrypick': + await mkMisskeyNote({ user: answeredUser, server: server }, { title: title, text: text, visibility: typedAnswer.visibility }); + break; + case 'mastodon': + await mastodonToot({ user: answeredUser }, { title: title, text: text, visibility: typedAnswer.visibility }); + break; + default: + break; + } + } catch { + postLogger.warn('답변 작성 실패!'); + /// 미스키/마스토돈에 글 올리는데 실패했으면 다시 answer 삭제 + await prisma.answer.delete({ where: { id: createdAnswer.id } }); + throw new Error('답변 작성 실패!'); + } } + + await prisma.question.delete({ + where: { + id: q.id, + }, + }); + + postLogger.log('Created new answer:', answerUrl); } async function mkMisskeyNote( - i: string, - title: string, - text: string, - hostname: string, - visibility: 'public' | 'home' | 'followers', + { user, server }: { + user: user, + server: server, + }, + { title, text, visibility }: { + title: string, + text: string, + visibility: MkNoteAnswers['visibility'], + } ) { const NoteLogger = new Logger('mkMisskeyNote'); // 미스키 CW길이제한 처리 if (title.length > 100) { title = title.substring(0, 90) + '.....'; } - + const i = createHash('sha256') + .update(user.token + server.appSecret, 'utf-8') + .digest('hex'); const newAnswerNote: MkNoteAnswers = { i: i, cw: title, @@ -147,7 +140,7 @@ async function mkMisskeyNote( visibility: visibility, }; try { - const res = await fetch(`https://${hostname}/api/notes/create`, { + const res = await fetch(`https://${user.hostName}/api/notes/create`, { method: 'POST', headers: { Authorization: `Bearer ${i}`, @@ -155,22 +148,32 @@ async function mkMisskeyNote( }, body: JSON.stringify(newAnswerNote), }); - if (!res.ok) { + if (res.status === 401 || res.status === 403) { + NoteLogger.warn('User Revoked Access token. JWT를 Revoke합니다... Detail:', await res.text()); + const prisma = GetPrismaClient.getClient(); + await prisma.user.update({ where: { handle: user.handle }, data: { jwtIndex: (user.jwtIndex + 1) } }); + throw new Error('Note Create Fail! (Token Revoked)'); + } + else if (!res.ok) { throw new Error(`Note Create Fail! ${await res.text()}`); } else { NoteLogger.log(`Note Created! ${res.statusText}`); } } catch (err) { NoteLogger.warn(err); + throw err; } } async function mastodonToot( - i: string, - title: string, - text: string, - hostname: string, - visibility: 'public' | 'home' | 'followers', + { user }: { + user: user, + }, + { title, text, visibility }: { + title: string, + text: string, + visibility: MkNoteAnswers['visibility'], + } ) { const tootLogger = new Logger('mastodonToot'); let newVisibility: 'public' | 'unlisted' | 'private'; @@ -194,22 +197,28 @@ async function mastodonToot( visibility: newVisibility, }; try { - const res = await fetch(`https://${hostname}/api/v1/statuses`, { + const res = await fetch(`https://${user.hostName}/api/v1/statuses`, { method: 'POST', headers: { - Authorization: `Bearer ${i}`, + Authorization: `Bearer ${user.token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(newAnswerToot), }); - - if (!res.ok) { + if (res.status === 401 || res.status === 403) { + tootLogger.warn('User Revoked Access token. JWT를 Revoke합니다.. Detail:', await res.text()); + const prisma = GetPrismaClient.getClient(); + await prisma.user.update({ where: { handle: user.handle }, data: { jwtIndex: (user.jwtIndex + 1) } }); + throw new Error('Toot Create Fail! (Token Revoked)'); + } + else if (!res.ok) { throw new Error(`HTTP Error! status:${await res.text()}`); } else { tootLogger.log(`Toot Created! ${res.statusText}`); } } catch (err) { tootLogger.warn(`Toot Create Fail!`, err); + throw err; } } From 1bf5f67d6c12871a6d0a2a4dcca0ff7ca8778a4d Mon Sep 17 00:00:00 2001 From: Yunochi Date: Mon, 25 Nov 2024 14:06:27 +0900 Subject: [PATCH 04/10] =?UTF-8?q?429=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/db/create-question/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/db/create-question/route.ts b/src/app/api/db/create-question/route.ts index 2b11133..f8829b0 100644 --- a/src/app/api/db/create-question/route.ts +++ b/src/app/api/db/create-question/route.ts @@ -24,7 +24,7 @@ export async function POST(req: NextRequest) { req_limit: 10, }); if (limited) { - return sendApiError(429, '요청 제한에 도달했습니다!'); + return sendApiError(429, '요청 제한에 도달했습니다! 잠시후 다시 시도해 주세요!'); } } else { const limiter = RateLimiterService.getLimiter(); @@ -34,7 +34,7 @@ export async function POST(req: NextRequest) { req_limit: 10, }); if (limited) { - return sendApiError(429, '요청 제한에 도달했습니다!'); + return sendApiError(429, '요청 제한에 도달했습니다! 잠시후 다시 시도해 주세요!'); } } From 272888f93a8418d0b76da89b340109933b738cbb Mon Sep 17 00:00:00 2001 From: Yunochi Date: Mon, 25 Nov 2024 15:03:54 +0900 Subject: [PATCH 05/10] =?UTF-8?q?Refactor:=20JWT=20refresh,=20logout=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/main/_header.tsx | 34 ++++----------------- src/utils/logout/logout.ts | 9 ++++++ src/utils/refreshJwt/refresh-jwt-token.ts | 36 +++++++++++++++++++++++ 3 files changed, 50 insertions(+), 29 deletions(-) create mode 100644 src/utils/logout/logout.ts create mode 100644 src/utils/refreshJwt/refresh-jwt-token.ts diff --git a/src/app/main/_header.tsx b/src/app/main/_header.tsx index b864850..b8e8377 100644 --- a/src/app/main/_header.tsx +++ b/src/app/main/_header.tsx @@ -6,13 +6,8 @@ import { FaUser } from 'react-icons/fa'; import { userProfileMeDto } from '../_dto/fetch-profile/Profile.dto'; import DialogModalTwoButton from '../_components/modalTwoButton'; import DialogModalOneButton from '../_components/modalOneButton'; -import { RefreshTokenReqDto } from '../_dto/refresh-token/refresh-token.dto'; - -const logout = async () => { - await fetch('/api/web/logout'); - localStorage.removeItem('user_handle'); - window.location.reload(); -}; +import { refreshJwt } from '@/utils/refreshJwt/refresh-jwt-token'; +import { logout } from '@/utils/logout/logout'; export default function MainHeader() { const [user, setUser] = useState(); @@ -21,7 +16,6 @@ export default function MainHeader() { const fetchMyProfile = async () => { const user_handle = localStorage.getItem('user_handle'); - const last_token_refresh = Number.parseInt(localStorage.getItem('last_token_refresh') ?? '0'); const now = Math.ceil(Date.now() / 1000); if (user_handle) { @@ -34,28 +28,10 @@ export default function MainHeader() { } return; } - // FIXME: 3600초 대신 적당한 시간으로 고치기 + // JWT 리프레시로부터 1시간이 지난 경우 refresh 시도 + const last_token_refresh = Number.parseInt(localStorage.getItem('last_token_refresh') ?? '0'); if (now - last_token_refresh > 3600) { - localStorage.setItem('last_token_refresh', `${now}`); - try { - const req: RefreshTokenReqDto = { - handle: user_handle, - last_refreshed_time: last_token_refresh, - } - const res = await fetch('/api/web/refresh-token', { - method: 'POST', - headers: { - 'content-type': 'application/json' - }, - body: JSON.stringify(req), - }); - if (res.status === 401 || res.status === 403) { - alert(`마스토돈/미스키 에서 앱 인증이 해제되었어요! ${await res.text()}`); - await logout(); - } - } catch (err) { - console.error(err); - } + await refreshJwt(); } const data = await res.json(); return data; diff --git a/src/utils/logout/logout.ts b/src/utils/logout/logout.ts new file mode 100644 index 0000000..fecb8d4 --- /dev/null +++ b/src/utils/logout/logout.ts @@ -0,0 +1,9 @@ +'use client'; + +export async function logout() { + try { + await fetch('/api/web/logout'); + } catch {} + localStorage.removeItem('user_handle'); + window.location.reload(); +} diff --git a/src/utils/refreshJwt/refresh-jwt-token.ts b/src/utils/refreshJwt/refresh-jwt-token.ts new file mode 100644 index 0000000..69cbf0e --- /dev/null +++ b/src/utils/refreshJwt/refresh-jwt-token.ts @@ -0,0 +1,36 @@ +'use client'; + +import { RefreshTokenReqDto } from '@/app/_dto/refresh-token/refresh-token.dto'; +import { logout } from '../logout/logout'; + +/** + * /api/web/refresh-token 를 호출해서 JWT를 Refresh 하려고 시도합니다. + * 만약 JWT가 인증 해제된 경우 로그아웃을 수행합니다. + * 성공적으로 JWT를 refresh 한 경우 last_token_refresh 를 현재 시간으로 업데이트합니다. + */ +export async function refreshJwt() { + const now = Math.ceil(Date.now() / 1000); + const user_handle = localStorage.getItem('user_handle'); + const last_token_refresh = Number.parseInt(localStorage.getItem('last_token_refresh') ?? '0'); + if (!user_handle) return; + try { + const req: RefreshTokenReqDto = { + handle: user_handle, + last_refreshed_time: last_token_refresh, + }; + const res = await fetch('/api/web/refresh-token', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(req), + }); + if (res.status === 401 || res.status === 403) { + alert(`마스토돈/미스키 에서 앱 인증이 해제되었어요!`); + await logout(); + } + localStorage.setItem('last_token_refresh', `${now}`); + } catch (err) { + console.error(err); + } +} From aecfc3cb64928d8900383f4f973afe471d64bbc3 Mon Sep 17 00:00:00 2001 From: Yunochi Date: Mon, 25 Nov 2024 15:13:22 +0900 Subject: [PATCH 06/10] =?UTF-8?q?Refactor:=20JWT=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/_utils/jwt/generate-jwt.ts | 3 +++ src/app/mastodon-callback/action.ts | 5 ++--- src/app/misskey-callback/actions.ts | 1 - 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/api/_utils/jwt/generate-jwt.ts b/src/app/api/_utils/jwt/generate-jwt.ts index c700e73..c9894bd 100644 --- a/src/app/api/_utils/jwt/generate-jwt.ts +++ b/src/app/api/_utils/jwt/generate-jwt.ts @@ -2,7 +2,9 @@ import { SignJWT } from 'jose'; import { jwtPayload } from './jwtPayload'; +import { Logger } from '@/utils/logger/Logger'; +const logger = new Logger('generateJwt'); export async function generateJwt(hostname: string, handle: string, jwtIndex: number) { const alg = 'HS256'; const secret = new TextEncoder().encode(process.env.JWT_SECRET); @@ -20,5 +22,6 @@ export async function generateJwt(hostname: string, handle: string, jwtIndex: nu .setAudience('urn:example:audience') .setExpirationTime('7d') .sign(secret); + logger.log(`Make new JWT: ${JSON.stringify(jwtPayload)}`); return jwtToken; } diff --git a/src/app/mastodon-callback/action.ts b/src/app/mastodon-callback/action.ts index 45f09c0..1543d0d 100644 --- a/src/app/mastodon-callback/action.ts +++ b/src/app/mastodon-callback/action.ts @@ -71,13 +71,12 @@ export async function login(loginReqestData: mastodonCallbackTokenClaimPayload) const prisma = GetPrismaClient.getClient(); const user = await prisma.user.findUniqueOrThrow({where: {handle: user_handle}}); const jwtToken = await generateJwt(loginReq.mastodonHost, user_handle, user.jwtIndex); - logger.log(`Send JWT to Frontend... ${jwtToken}`); cookieStore.set('jwtToken', jwtToken, { - expires: Date.now() + 1000 * 60 * 60 * 6, + expires: Date.now() + 1000 * 60 * 60 * 24 * 7, httpOnly: true, }); cookieStore.set('server', loginReq.mastodonHost, { - expires: Date.now() + 1000 * 60 * 60 * 6, + expires: Date.now() + 1000 * 60 * 60 * 24 * 7, httpOnly: true, }); } catch (err) { diff --git a/src/app/misskey-callback/actions.ts b/src/app/misskey-callback/actions.ts index 4f356cd..f72c1ab 100644 --- a/src/app/misskey-callback/actions.ts +++ b/src/app/misskey-callback/actions.ts @@ -70,7 +70,6 @@ export async function login(loginReqestData: misskeyCallbackTokenClaimPayload): const prisma = GetPrismaClient.getClient(); const user = await prisma.user.findUniqueOrThrow({where: {handle: user_handle}}); const jwtToken = await generateJwt(loginReq.misskeyHost, user_handle, user.jwtIndex); - logger.log(`Send JWT to Frontend... ${jwtToken}`); cookieStore.set('jwtToken', jwtToken, { expires: Date.now() + 1000 * 60 * 60 * 24 * 7, httpOnly: true, From 2e6fbc31d4c67903e69d024746eb71ed5d5ae7fe Mon Sep 17 00:00:00 2001 From: Yunochi Date: Mon, 25 Nov 2024 15:15:31 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20setIteml=20=ED=91=9C=EA=B8=B0?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/mastodon-callback/page.tsx | 4 ++-- src/app/misskey-callback/page.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/mastodon-callback/page.tsx b/src/app/mastodon-callback/page.tsx index a0c05f1..0574e37 100644 --- a/src/app/mastodon-callback/page.tsx +++ b/src/app/mastodon-callback/page.tsx @@ -49,8 +49,8 @@ export default function CallbackPage() { const handle = `@${user.profile.username}@${server}`; localStorage.setItem('user_handle', handle); - const now = `${Math.ceil(Date.now() / 1000)}`; - localStorage.setItem('last_token_refresh', now); + const now = Math.ceil(Date.now() / 1000); + localStorage.setItem('last_token_refresh', `${now}`); router.replace('/main'); } diff --git a/src/app/misskey-callback/page.tsx b/src/app/misskey-callback/page.tsx index 8a8dedc..7c3785e 100644 --- a/src/app/misskey-callback/page.tsx +++ b/src/app/misskey-callback/page.tsx @@ -58,8 +58,8 @@ export default function CallbackPage() { const handle = `@${user.username}@${server}`; localStorage.setItem('user_handle', handle); - const now = `${Math.ceil(Date.now() / 1000)}`; - localStorage.setItem('last_token_refresh', now); + const now = Math.ceil(Date.now() / 1000); + localStorage.setItem('last_token_refresh', `${now}`); router.replace('/main'); } From db1cd722a3b0f5f9593facb44ef0dd0c181db571 Mon Sep 17 00:00:00 2001 From: Yunochi Date: Mon, 25 Nov 2024 15:37:33 +0900 Subject: [PATCH 08/10] =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=86=A0=EB=8F=88,?= =?UTF-8?q?=EB=AF=B8=EC=8A=A4=ED=82=A4=20=EC=BD=9C=EB=B0=B1=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 콜백 페이지에서 로그인 실패시 모달창으로 에러 보여주도록 수정 --- src/app/index.d.ts | 11 +++++++ src/app/mastodon-callback/action.ts | 2 +- src/app/mastodon-callback/page.tsx | 36 ++++++++++++++--------- src/app/misskey-callback/actions.ts | 5 ++-- src/app/misskey-callback/page.tsx | 45 ++++++++++++++--------------- 5 files changed, 59 insertions(+), 40 deletions(-) diff --git a/src/app/index.d.ts b/src/app/index.d.ts index 5625124..f0ba897 100644 --- a/src/app/index.d.ts +++ b/src/app/index.d.ts @@ -56,3 +56,14 @@ export interface postQuestion { answeredPerson: user; answeredPersonHandle: string; } + +export interface DBpayload { + account: user['account']; + accountLower: user['accountLower']; + hostName: user['hostName']; + handle: user['handle']; + name: profile['name']; + avatarUrl: profile['avatarUrl']; + accessToken: user['token']; + userId: user['userId']; +}; \ No newline at end of file diff --git a/src/app/mastodon-callback/action.ts b/src/app/mastodon-callback/action.ts index 1543d0d..13e2d8d 100644 --- a/src/app/mastodon-callback/action.ts +++ b/src/app/mastodon-callback/action.ts @@ -3,11 +3,11 @@ import { validateStrict } from '@/utils/validator/strictValidator'; import { mastodonCallbackTokenClaimPayload } from '../_dto/mastodon-callback/callback-token-claim.dto'; import { fetchNameWithEmoji } from '../api/_utils/fetchUsername'; -import { DBpayload } from '../misskey-callback/page'; import { cookies } from 'next/headers'; import { generateJwt } from '../api/_utils/jwt/generate-jwt'; import { GetPrismaClient } from '@/app/api/_utils/getPrismaClient/get-prisma-client'; import { Logger } from '@/utils/logger/Logger'; +import { DBpayload } from '..'; const logger = new Logger('Mastodon-callback'); export async function login(loginReqestData: mastodonCallbackTokenClaimPayload) { diff --git a/src/app/mastodon-callback/page.tsx b/src/app/mastodon-callback/page.tsx index 0574e37..ef4d7cc 100644 --- a/src/app/mastodon-callback/page.tsx +++ b/src/app/mastodon-callback/page.tsx @@ -1,13 +1,20 @@ 'use client'; import Image from 'next/image'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { mastodonCallbackTokenClaimPayload } from '../_dto/mastodon-callback/callback-token-claim.dto'; import { login } from './action'; import { useRouter } from 'next/navigation'; +import DialogModalOneButton from '../_components/modalOneButton'; + +const onErrorModalClick = () => { + window.location.replace('/'); +}; export default function CallbackPage() { const [id, setId] = useState(0); + const errModalRef = useRef(null); + const [errMessage, setErrorMessage] = useState(); const router = useRouter(); @@ -41,12 +48,9 @@ export default function CallbackPage() { try { res = await login(payload); } catch (err) { - console.error(`login failed!`, err); throw err; } - const user = res.user; - const handle = `@${user.profile.username}@${server}`; localStorage.setItem('user_handle', handle); const now = Math.ceil(Date.now() / 1000); @@ -56,20 +60,26 @@ export default function CallbackPage() { } } catch (err) { console.error(err); - return ( -
- 로그인 중에 문제가 발생했어요... 다시 시도해 보세요 -
- ); + setErrorMessage(`로그인 중에 문제가 발생했어요... 다시 시도해 보세요`); + errModalRef.current?.showModal(); } }; fn(); }, []); return ( -
- Login Loading - 로그인하고 있어요... -
+ <> +
+ Login Loading + 로그인하고 있어요... +
+ + ); } diff --git a/src/app/misskey-callback/actions.ts b/src/app/misskey-callback/actions.ts index f72c1ab..2cfd50f 100644 --- a/src/app/misskey-callback/actions.ts +++ b/src/app/misskey-callback/actions.ts @@ -1,8 +1,7 @@ 'use server'; - -import { DBpayload } from './page'; + import { cookies } from 'next/headers'; -import { misskeyAccessKeyApiResponse } from '..'; +import { DBpayload, misskeyAccessKeyApiResponse } from '..'; import { MiUser } from '../api/_misskey-entities/user'; import { fetchNameWithEmoji } from '../api/_utils/fetchUsername'; import { validateStrict } from '@/utils/validator/strictValidator'; diff --git a/src/app/misskey-callback/page.tsx b/src/app/misskey-callback/page.tsx index 7c3785e..1b2c4a5 100644 --- a/src/app/misskey-callback/page.tsx +++ b/src/app/misskey-callback/page.tsx @@ -1,27 +1,21 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { login } from './actions'; import { MiUser as MiUser } from '../api/_misskey-entities/user'; import { misskeyCallbackTokenClaimPayload } from '../_dto/misskey-callback/callback-token-claim.dto'; import { misskeyUserInfoPayload } from '../_dto/misskey-callback/user-info.dto'; -import type { profile, user } from '@prisma/client'; - -export type DBpayload = { - account: user['account']; - accountLower: user['accountLower']; - hostName: user['hostName']; - handle: user['handle']; - name: profile['name']; - avatarUrl: profile['avatarUrl']; - accessToken: user['token']; - userId: user['userId']; -}; +import DialogModalOneButton from '../_components/modalOneButton'; +const onErrorModalClick = () => { + window.location.replace('/'); +} export default function CallbackPage() { const [id, setId] = useState(0); + const errModalRef = useRef(null); + const [errMessage, setErrorMessage] = useState(); const router = useRouter(); @@ -50,7 +44,6 @@ export default function CallbackPage() { try { res = await login(payload); } catch (err) { - console.error(`login failed!`, err); throw err; } @@ -64,12 +57,9 @@ export default function CallbackPage() { router.replace('/main'); } } catch (err) { + setErrorMessage(`로그인 중에 문제가 발생했어요... 다시 시도해 보세요`); + errModalRef.current?.showModal(); console.error(err); - return ( -
- 로그인 중에 문제가 발생했어요... 다시 시도해 보세요 -
- ); } }; @@ -77,9 +67,18 @@ export default function CallbackPage() { }, [router]); return ( -
- Login Loading - 로그인하고 있어요... -
+ <> +
+ Login Loading + 로그인하고 있어요... +
+ + ); } From 5e42de19cc4c7e30dbdc6fb6095ea2e91f96d0e6 Mon Sep 17 00:00:00 2001 From: Yunochi Date: Mon, 25 Nov 2024 16:01:52 +0900 Subject: [PATCH 09/10] Refactor --- src/app/api/_utils/fetchUsername.ts | 2 +- src/app/main/user/[handle]/_answers.tsx | 2 +- src/app/mastodon-callback/action.ts | 53 +++++++++++++++---------- src/app/misskey-callback/actions.ts | 2 +- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/app/api/_utils/fetchUsername.ts b/src/app/api/_utils/fetchUsername.ts index 75c0984..5d2d3e6 100644 --- a/src/app/api/_utils/fetchUsername.ts +++ b/src/app/api/_utils/fetchUsername.ts @@ -11,7 +11,7 @@ export async function fetchNameWithEmoji(fetchUserNameReq: fetchNameWithEmojiReq body: JSON.stringify(fetchUserNameReq), }); if (!res.ok) { - logger.error(`fail to get username with emojis `, res.status, res.statusText); + logger.error(`fail to get username with emojis `, res.status, await res.text()); throw new Error('fail to get username with emojis'); } const body: fetchNameWithEmojiResDto = await res.json(); diff --git a/src/app/main/user/[handle]/_answers.tsx b/src/app/main/user/[handle]/_answers.tsx index 7f9a67b..7610a17 100644 --- a/src/app/main/user/[handle]/_answers.tsx +++ b/src/app/main/user/[handle]/_answers.tsx @@ -49,7 +49,7 @@ export default function UserPage() { if (res.ok) { return res.json(); } else { - throw new Error(`fetch-user-answers fail! ${res.status}, ${res.statusText}`); + throw new Error(`fetch-user-answers fail! ${res.status}, ${await res.text()}`); } } catch (err) { alert(err); diff --git a/src/app/mastodon-callback/action.ts b/src/app/mastodon-callback/action.ts index 13e2d8d..526eeec 100644 --- a/src/app/mastodon-callback/action.ts +++ b/src/app/mastodon-callback/action.ts @@ -69,7 +69,7 @@ export async function login(loginReqestData: mastodonCallbackTokenClaimPayload) //프론트 쿠키스토어에 쿠키 저장 const cookieStore = await cookies(); const prisma = GetPrismaClient.getClient(); - const user = await prisma.user.findUniqueOrThrow({where: {handle: user_handle}}); + const user = await prisma.user.findUniqueOrThrow({ where: { handle: user_handle } }); const jwtToken = await generateJwt(loginReq.mastodonHost, user_handle, user.jwtIndex); cookieStore.set('jwtToken', jwtToken, { expires: Date.now() + 1000 * 60 * 60 * 24 * 7, @@ -108,30 +108,43 @@ async function requestMastodonAccessCodeAndUserInfo( }); if (checkInstances) { - const res = await fetch(`https://${payload.mastodonHost}/oauth/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'authorization_code', - redirect_uri: `${process.env.WEB_URL}/mastodon-callback`, - client_id: client_id, - client_secret: client_secret, - code: payload.callback_code, - state: payload.state, - }), - }).then((r) => r.json()); - - const myProfile = await fetch(`https://${payload.mastodonHost}/api/v1/accounts/verify_credentials`, { - headers: { Authorization: `Bearer ${res.access_token}` }, - }).then((r) => r.json()); - - return { profile: myProfile, token: res.access_token }; + try { + const res_token = await fetch(`https://${payload.mastodonHost}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + redirect_uri: `${process.env.WEB_URL}/mastodon-callback`, + client_id: client_id, + client_secret: client_secret, + code: payload.callback_code, + state: payload.state, + }), + }); + if (!res_token.ok) { + logger.warn('Mastodon Login Fail!. Mastodon Response: ', await res_token.text()); + throw new Error('Mastodon Login Fail!'); + } + const tokenResponse = await res_token.json(); + + const res_verify = await fetch(`https://${payload.mastodonHost}/api/v1/accounts/verify_credentials`, { + headers: { Authorization: `Bearer ${tokenResponse.access_token}` }, + }); + if (!res_token.ok) { + logger.warn('Mastodon Login Fail(token Verify). Mastodon Response: ', await res_verify.text()); + throw new Error('Mastodon Login Fail!'); + } + const myProfile = await res_verify.json(); + + return { profile: myProfile, token: tokenResponse.access_token }; + } catch (err) { + throw err; + } } else { throw new Error('there is no instances'); } } - async function pushDB(payload: DBpayload) { const prisma = GetPrismaClient.getClient(); diff --git a/src/app/misskey-callback/actions.ts b/src/app/misskey-callback/actions.ts index 2cfd50f..d4f23cd 100644 --- a/src/app/misskey-callback/actions.ts +++ b/src/app/misskey-callback/actions.ts @@ -118,7 +118,7 @@ async function requestMiAccessTokenAndUserInfo(payload: misskeyCallbackTokenClai const resBody = await res.json(); return resBody; } else { - logger.error(`Fail to get Misskey Access token`, res.status, res.statusText); + logger.warn(`Fail to get Misskey Access token. Misskey Response:`, res.status, await res.text()); return null; } } else { From 568bd1733898dcbb88b616cfc87cd7fbe309fdc0 Mon Sep 17 00:00:00 2001 From: Yunochi Date: Mon, 25 Nov 2024 16:17:38 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=97=86=EC=9D=B4=20=EC=A6=90=EA=B8=B0=EA=B8=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=88=8C=EB=A0=80=EC=9D=84=EB=95=8C=20=ED=99=95=EC=8B=A4?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/page.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 0cd6dfe..080bad3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -8,6 +8,7 @@ import { loginReqDto } from './_dto/web/login/login.dto'; import GithubRepoLink from './_components/github'; import DialogModalOneButton from './_components/modalOneButton'; import { loginCheck } from '@/utils/checkLogin/fastLoginCheck'; +import { logout } from '@/utils/logout/logout'; interface FormValue { address: string; @@ -51,6 +52,12 @@ const mastodonAuth = async ({ host }: loginReqDto) => { return await res.json(); }; +const goWithoutLogin = async () => { + try { + await logout(); + } catch {} + window.location.replace('/main'); +}; /** * https://example.com/ 같은 URL 형식이나 handle 형식으로 입력한 경우 host로 변환. * host를 소문자 처리후 반환 @@ -205,11 +212,7 @@ export default function Home() { )} -