From f06003f1675d15e5c4b005323f574cb7bcd20bc0 Mon Sep 17 00:00:00 2001 From: Yuno Date: Mon, 25 Nov 2024 00:53:50 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EB=A7=88=EC=8A=A4=ED=86=A0=EB=8F=88?= =?UTF-8?q?=20emoji=20=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 닉네임에 같은 커모지가 여러번 쓰인 경우 발생하는 문제 해결 --- .../api/web/fetch-name-with-emoji/route.ts | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/app/api/web/fetch-name-with-emoji/route.ts b/src/app/api/web/fetch-name-with-emoji/route.ts index 87e5926..ca99daf 100644 --- a/src/app/api/web/fetch-name-with-emoji/route.ts +++ b/src/app/api/web/fetch-name-with-emoji/route.ts @@ -21,26 +21,24 @@ export async function POST(req: NextRequest) { return NextResponse.json({ nameWithEmoji: [] }); } const emojiInUsername = name.match(/:[\w]+:/g)?.map((el) => el.replaceAll(':', '')); - const nameArray = name.split(':').filter((el) => el !== ''); + let nameArray = name.split(':').filter((el) => el !== ''); const instanceType = await detectInstance(baseUrl); switch (instanceType) { case 'mastodon': try { - if (emojiInUsername && data.emojis) { - for (let i = 0; i < emojiInUsername.length; i++) { - usernameEmojiAddress.push(data.emojis[i].url); - } - - for (const el in nameArray) { - usernameIndex.push(nameArray.indexOf(emojiInUsername[el])); - } - const filteredIndex = usernameIndex.filter((value) => value >= 0); - - for (let i = 0; i < usernameEmojiAddress.length; i++) { - nameArray.splice(filteredIndex[i], 1, usernameEmojiAddress[i]); - } + if (emojiInUsername && data.emojis !== null) { + const emojis = data.emojis; + const newNameArray = nameArray.map((v) => { + const matched_emoji_url = emojis.find((emoji) => emoji.shortcode === v)?.url; + if (matched_emoji_url) { + return matched_emoji_url; + } else { + return v; + } + }); + nameArray = newNameArray; } return NextResponse.json({ nameWithEmoji: nameArray }); } catch (err) { From 485df49c903f2d4b86100450deed2f92e268bef0 Mon Sep 17 00:00:00 2001 From: Yuno Date: Sun, 24 Nov 2024 20:48:23 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=EB=AF=B8=EC=8A=A4=ED=82=A4=20=EC=95=B1=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=EC=97=90=20=EB=AA=87=EA=B0=80=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read:account 는 계정 정보를 새로고침 하기 위해서 필요 - read:blocks와 read:following 은 구현 예정인 차단 연동기능과 질문할 친구 추천기능을 위해서 필요함 --- src/app/api/web/misskey-login/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/web/misskey-login/route.ts b/src/app/api/web/misskey-login/route.ts index 3bfd72c..f9f6683 100644 --- a/src/app/api/web/misskey-login/route.ts +++ b/src/app/api/web/misskey-login/route.ts @@ -44,7 +44,7 @@ export async function POST(req: NextRequest) { const payload = { name: 'Neo-Quesdon', description: '새로운 퀘스돈, 네오-퀘스돈입니다.', - permission: ['write:notes'], + permission: ['read:account','read:blocks', 'read:following', 'write:notes'], callbackUrl: `${process.env.WEB_URL}/misskey-callback`, }; From da2cb5a636d95b5e5d17e1725422313257d504dd Mon Sep 17 00:00:00 2001 From: Yuno Date: Sun, 24 Nov 2024 20:49:59 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EB=AF=B8=EC=8A=A4=ED=82=A4=20=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B4=EB=82=BC=EB=95=8C=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=ED=97=A4=EB=8D=94=EC=99=80=20=EB=B0=94=EB=94=94=EC=9D=98=20i?= =?UTF-8?q?=20=EB=91=98=EB=8B=A4=20=EB=B3=B4=EB=82=B4=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_dto/fetch-name-with-emoji/fetch-name-with-emoji.dto.ts | 5 +++-- src/app/index.d.ts | 1 + src/app/main/questions/action.ts | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/_dto/fetch-name-with-emoji/fetch-name-with-emoji.dto.ts b/src/app/_dto/fetch-name-with-emoji/fetch-name-with-emoji.dto.ts index 6d88987..aad7086 100644 --- a/src/app/_dto/fetch-name-with-emoji/fetch-name-with-emoji.dto.ts +++ b/src/app/_dto/fetch-name-with-emoji/fetch-name-with-emoji.dto.ts @@ -22,8 +22,9 @@ export class fetchNameWithEmojiReqDto { @IsString() baseUrl: string; - // 마스토돈의 경우 닉네임에 들어간 커모지를 - // 배열로 따로 주기 때문에 그것에 대한 Validation이 필요함 + /** 마스토돈의 경우 닉네임에 들어간 커모지를 + 배열로 따로 주기 때문에 그것에 대한 Validation이 필요함 + */ @IsArray() @IsOptional() emojis: mastodonEmojiModel[] | null; diff --git a/src/app/index.d.ts b/src/app/index.d.ts index 8d10782..5625124 100644 --- a/src/app/index.d.ts +++ b/src/app/index.d.ts @@ -37,6 +37,7 @@ export interface typedAnswer { } export interface MkNoteAnswers { + i: string; cw: string; text: string; visibility: 'public' | 'home' | 'followers'; diff --git a/src/app/main/questions/action.ts b/src/app/main/questions/action.ts index eccce14..f5c783a 100644 --- a/src/app/main/questions/action.ts +++ b/src/app/main/questions/action.ts @@ -141,6 +141,7 @@ async function mkMisskeyNote( } const newAnswerNote: MkNoteAnswers = { + i: i, cw: title, text: text, visibility: visibility, From d74d17ed39c5c84805af84adfffd0c2a8b39d6e6 Mon Sep 17 00:00:00 2001 From: Yuno Date: Mon, 25 Nov 2024 00:53:06 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=ED=99=95=EC=9D=B8,=20refresh=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유저 테이블에 저장한 access 토큰을 사용해서 여전히 로그인이 유효한지 검증 - 토큰 refresh - 겸사겸사 프로필 업데이트 - 유저가 앱 권한을 취소한 경우 JWT도 revoke처리 --- .../20241124155645_jwt_index/migration.sql | 2 + prisma/schema.prisma | 1 + .../fetch-name-with-emoji.dto.ts | 2 +- .../_dto/refresh-token/refresh-token.dto.ts | 10 + src/app/api/_mastodon-entities/user.ts | 24 ++ src/app/api/_utils/jwt/generate-jwt.ts | 24 ++ src/app/api/_utils/jwt/jwtPayload.ts | 5 + src/app/api/_utils/jwt/verify-jwt.ts | 25 +- src/app/api/web/refresh-token/route.ts | 236 ++++++++++++++++++ src/app/main/_header.tsx | 26 ++ src/app/mastodon-callback/action.ts | 23 +- src/app/mastodon-callback/page.tsx | 2 + src/app/misskey-callback/actions.ts | 27 +- src/app/misskey-callback/page.tsx | 2 + 14 files changed, 362 insertions(+), 47 deletions(-) create mode 100644 prisma/migrations/20241124155645_jwt_index/migration.sql create mode 100644 src/app/_dto/refresh-token/refresh-token.dto.ts create mode 100644 src/app/api/_mastodon-entities/user.ts create mode 100644 src/app/api/_utils/jwt/generate-jwt.ts create mode 100644 src/app/api/_utils/jwt/jwtPayload.ts create mode 100644 src/app/api/web/refresh-token/route.ts diff --git a/prisma/migrations/20241124155645_jwt_index/migration.sql b/prisma/migrations/20241124155645_jwt_index/migration.sql new file mode 100644 index 0000000..883d0f3 --- /dev/null +++ b/prisma/migrations/20241124155645_jwt_index/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "jwtIndex" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3bb57de..31a0a4c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,7 @@ model user { token String userId String @unique profile profile? + jwtIndex Int @default(0) } model profile { diff --git a/src/app/_dto/fetch-name-with-emoji/fetch-name-with-emoji.dto.ts b/src/app/_dto/fetch-name-with-emoji/fetch-name-with-emoji.dto.ts index aad7086..84bc97b 100644 --- a/src/app/_dto/fetch-name-with-emoji/fetch-name-with-emoji.dto.ts +++ b/src/app/_dto/fetch-name-with-emoji/fetch-name-with-emoji.dto.ts @@ -1,6 +1,6 @@ import { IsArray, IsBoolean, IsOptional, IsString, ValidateIf } from 'class-validator'; -class mastodonEmojiModel { +export class mastodonEmojiModel { @IsString() shortcode: string; diff --git a/src/app/_dto/refresh-token/refresh-token.dto.ts b/src/app/_dto/refresh-token/refresh-token.dto.ts new file mode 100644 index 0000000..3630bc2 --- /dev/null +++ b/src/app/_dto/refresh-token/refresh-token.dto.ts @@ -0,0 +1,10 @@ +import { IsInt, IsString } from "class-validator"; + +export class RefreshTokenReqDto { + @IsString() + /** 사실 꼭 필요하진 않은데 디버깅용... */ + handle: string; + + @IsInt() + last_refreshed_time: number; +} \ No newline at end of file diff --git a/src/app/api/_mastodon-entities/user.ts b/src/app/api/_mastodon-entities/user.ts new file mode 100644 index 0000000..3148cd4 --- /dev/null +++ b/src/app/api/_mastodon-entities/user.ts @@ -0,0 +1,24 @@ +import { mastodonEmojiModel } from "@/app/_dto/fetch-name-with-emoji/fetch-name-with-emoji.dto"; + +/** + * Mastodon /api/v1/accounts/verify_credentials 에서 돌아오는 응답중에 필요한 것만 추린것 + * 아마도.. .문제 없겠지? + */ +export type MastodonUser = { + id: string; + username: string; + acct: string; + display_name?: string | null; + locked: boolean; + bot: boolean; + created_at: string; + url: string; + avatar: string | null; + avatar_static?: string | null; + header: string | null; + header_static?: string | null; + followers_count?: number; + following_count?: number; + statuses_count?: number; + emojis: mastodonEmojiModel[]; +}; diff --git a/src/app/api/_utils/jwt/generate-jwt.ts b/src/app/api/_utils/jwt/generate-jwt.ts new file mode 100644 index 0000000..c700e73 --- /dev/null +++ b/src/app/api/_utils/jwt/generate-jwt.ts @@ -0,0 +1,24 @@ +'use server'; + +import { SignJWT } from 'jose'; +import { jwtPayload } from './jwtPayload'; + +export async function generateJwt(hostname: string, handle: string, jwtIndex: number) { + const alg = 'HS256'; + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const jwtPayload: jwtPayload = { + server: hostname, + handle: handle, + jwtIndex: jwtIndex, + }; + + const webUrl = process.env.WEB_URL; + const jwtToken = await new SignJWT(jwtPayload) + .setProtectedHeader({ alg }) + .setIssuedAt() + .setIssuer(`${webUrl}`) + .setAudience('urn:example:audience') + .setExpirationTime('7d') + .sign(secret); + return jwtToken; +} diff --git a/src/app/api/_utils/jwt/jwtPayload.ts b/src/app/api/_utils/jwt/jwtPayload.ts new file mode 100644 index 0000000..fb185c1 --- /dev/null +++ b/src/app/api/_utils/jwt/jwtPayload.ts @@ -0,0 +1,5 @@ +export type jwtPayload = { + handle: string; + server: string; + jwtIndex: number; +}; diff --git a/src/app/api/_utils/jwt/verify-jwt.ts b/src/app/api/_utils/jwt/verify-jwt.ts index ba484b9..7bab684 100644 --- a/src/app/api/_utils/jwt/verify-jwt.ts +++ b/src/app/api/_utils/jwt/verify-jwt.ts @@ -1,12 +1,11 @@ 'use server'; import { jwtVerify } from 'jose'; +import { jwtPayload } from './jwtPayload'; +import { GetPrismaClient } from '../getPrismaClient/get-prisma-client'; +import { Logger } from '@/utils/logger/Logger'; -type jwtPayload = { - handle: string; - server: string; -}; - +const logger = new Logger('verifyToken'); /** * JWT 를 검증하고, 디코딩된 JWT의 페이로드를 반환 * @param token JWT @@ -15,6 +14,7 @@ type jwtPayload = { */ export async function verifyToken(token: string | null | undefined) { const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const prisma = GetPrismaClient.getClient(); try { if (typeof token !== 'string') { @@ -24,15 +24,28 @@ export async function verifyToken(token: string | null | undefined) { const data: jwtPayload = { handle: '', server: '', + jwtIndex: 0, }; - if (typeof payload.handle === 'string' && typeof payload.server === 'string') { + if ( + typeof payload.handle === 'string' && + typeof payload.server === 'string' && + typeof payload.jwtIndex === 'number' + ) { data.handle = payload.handle; data.server = payload.server; + data.jwtIndex = payload.jwtIndex; } else { + logger.debug('JWT payload error'); throw new Error('JWT payload error'); } + const user = await prisma.user.findUniqueOrThrow({ where: { handle: data.handle } }); + if (user.jwtIndex !== data.jwtIndex) { + logger.debug('This token is revoked'); + throw new Error('This token is revoked'); + } return data; } catch (err) { + logger.debug(`token not verified: ${err}`); throw new Error(`token not verified: ${err}`); } } diff --git a/src/app/api/web/refresh-token/route.ts b/src/app/api/web/refresh-token/route.ts new file mode 100644 index 0000000..976c54d --- /dev/null +++ b/src/app/api/web/refresh-token/route.ts @@ -0,0 +1,236 @@ +import { RefreshTokenReqDto } from '@/app/_dto/refresh-token/refresh-token.dto'; +import { Logger } from '@/utils/logger/Logger'; +import { validateStrict } from '@/utils/validator/strictValidator'; +import { NextRequest, NextResponse } from 'next/server'; +import { sendApiError } from '../../_utils/apiErrorResponse/sendApiError'; +import { verifyToken } from '../../_utils/jwt/verify-jwt'; +import { cookies } from 'next/headers'; +import { GetPrismaClient } from '../../_utils/getPrismaClient/get-prisma-client'; +import { profile, user } from '@prisma/client'; +import { createHash } from 'crypto'; +import { MiUser } from '../../_misskey-entities/user'; +import { fetchNameWithEmoji } from '../../_utils/fetchUsername'; +import { generateJwt } from '../../_utils/jwt/generate-jwt'; +import { MastodonUser } from '../../_mastodon-entities/user'; + +const logger = new Logger('refresh-token'); +export async function POST(req: NextRequest) { + let data; + try { + data = await validateStrict(RefreshTokenReqDto, await req.json()); + } catch (err) { + return sendApiError(400, `Bad Request! ${err}`); + } + const cookieStore = await cookies(); + let tokenPayload; + try { + tokenPayload = await verifyToken(cookieStore.get('jwtToken')?.value); + if (tokenPayload.handle !== data.handle) { + throw new Error('Handle not match with JWT'); + } + } catch (err) { + return sendApiError(401, `Auth Error! ${err}`); + } + const prisma = GetPrismaClient.getClient(); + const user = await prisma.user.findUniqueOrThrow({ where: { handle: tokenPayload.handle } }); + + try { + await refreshAndReValidateToken(user); + const jwtToken = await generateJwt(user.hostName, user.handle, user.jwtIndex); + cookieStore.set('jwtToken', jwtToken, { + expires: Date.now() + 1000 * 60 * 60 * 24 * 7, + httpOnly: true, + }); + } catch (err) { + logger.warn('User가 미스키/마스토돈에서 앱 권한을 Revoke한것 같아요. JWT index를 올릴게요. 자세한 정보:', err); + await prisma.user.update({where: {handle: user.handle}, data: {jwtIndex: (user.jwtIndex + 1)}}); + return sendApiError(401, `Refresh user failed!! ${err}`); + } + + return NextResponse.json({ message: '야호 JWT 갱신에 성공했어요!' }, { status: 200 }); +} + +/** + * 유저의 정보를 인스턴스에서 다시 가져와서 프로필을 업데이트함. + * 인스턴스에서 권한 문제로 실패한 경우 Throw. + * @param user + * @returns Promise + */ +async function refreshAndReValidateToken(user: user): Promise { + const prisma = GetPrismaClient.getClient(); + const userServer = await prisma.server.findUniqueOrThrow({ where: { instances: user.hostName } }); + + /** 테이블에 저장된 user의 Misskey / Mastodon access token */ + let userToken = user.token; + if (userServer.instanceType === 'cherrypick' || userServer.instanceType === 'misskey') { + /** Misskey/Cherrypick 인 경우는 저장된 access token을 i로 변환해야 함 */ + const i = createHash('sha256') + .update(userToken + userServer.appSecret, 'utf-8') + .digest('hex'); + userToken = i; + } + + switch (userServer.instanceType) { + case 'misskey': + case 'cherrypick': { + logger.debug('try to get user info from misskey...'); + let miUser: MiUser; + try { + const miResponse: MiUser | boolean = await fetchMisskeyUserInfo(userToken, user.hostName); + if ((miResponse as boolean) === false) { + //단순한 fetch 실패 + return; + } else { + miUser = miResponse as MiUser; + } + } catch (err) { + logger.log('미스키 AUTHENTICATION_FAILED... TODO: 유저 JWT무효화 처리'); + //인증 실패의 경우 여기서 throw + throw err; + } + + try { + const newNameWithEmoji = await fetchNameWithEmoji({ + name: miUser.name ?? miUser.username, + baseUrl: user.hostName, + emojis: null, + }); + const updateProfile: Partial = { + avatarUrl: miUser.avatarUrl ?? undefined, + name: newNameWithEmoji, + }; + const updateUser: Partial = { + name: newNameWithEmoji, + }; + await updateDb(user.handle, updateUser, updateProfile); + } catch { + return; + } + logger.log(`Misskey User Updated!`); + break; + } + case 'mastodon': { + logger.debug('try to get User info from mastodon...'); + let mastodonUser; + try { + const ret = await fetchMastodonUserInfo(userToken, user.hostName); + if ((ret as boolean) === false) { + return; + } else { + mastodonUser = ret as MastodonUser; + } + } catch (err) { + // 인증 실패의 경우 여기서 throw + logger.log('마스토돈 AUTHENTICATION_FAILED... TODO: 유저 JWT무효화 처리'); + throw err; + } + try { + const nameWithEmoji = await fetchNameWithEmoji({ + name: mastodonUser.display_name ?? mastodonUser.username, + baseUrl: user.hostName, + emojis: mastodonUser.emojis, + }); + const profileUpdate: Partial = { + name: nameWithEmoji, + avatarUrl: mastodonUser.avatar ?? undefined, + }; + const userUpdate: Partial = { + name: nameWithEmoji, + }; + await updateDb(user.handle, userUpdate, profileUpdate); + } catch { + return; + } + logger.log(`Mastodon User Updated!`); + break; + } + default: { + break; + } + } +} + +/** + * Misskey/Cherrypick 에서 유저 정보를 fetch + * 미스키의 응답이 200 인 경우는 json을 반환, 401, 403인 경우는 throw + * 401, 403 이 아닌 실패는 false 반환 + * @param i token + * @param host Misskey host + * @returns misskey API 'i' 에서 반환된 JSON, 또는 false + * @throws Misskey에서 토큰 인증에 실패한 경우 + */ +async function fetchMisskeyUserInfo(i: string, host: string): Promise { + let res; + try { + res = await fetch(`https://${host}/api/i`, { + method: 'POST', + headers: { + Authorization: `Bearer ${i}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ i: i }), + }); + } catch { + logger.debug('미스키 i API 호출 실패'); + return false; + } + // i 토큰 인증이 실패한 경우 + if (res.status === 403 || res.status === 401) { + logger.warn('Misskey returned AUTHENTICATION_FAILED'); + throw new Error(`Misskey AUTHENTICATION_FAILED ${await res.text()}`); + } + // 기타 오류 + else if (!res.ok) { + logger.debug('미스키 i API 호출 실패'); + return false; + } + // MiUser 를 받은 경우 + else { + return await res.json(); + } +} + +/** + * Mastodon 에서 유저 정보를 fetch 후 반환. + * Mastodon 응답이 200인 경우 json 반환, 인증 실패시 throw, 단순 오류시 false 반환 + * @param i token + * @param host Misskey host + * @returns Mastodon 응답이 200인 경우 json 반환, 권한이 아닌 이유로 실패시 false반환 + * @throws Mastodon 토큰 인증 실패시 throw + */ +async function fetchMastodonUserInfo(token: string, host: string): Promise { + let res; + try { + res = await fetch(`https://${host}/api/v1/accounts/verify_credentials`, { + headers: { Authorization: `Bearer ${token}`, 'Content-type': 'application/json' }, + }); + } catch { + // 네트워크 등의 이유로 fetch 자체가 실패한 경우 + logger.debug('마스토톤 API fetch 실패'); + return false; + } + // 마스토돈에서 토큰을 거부한 경우 + if (res.status === 401 || res.status === 403) { + throw new Error(`Error! ${await res.text()}`); + } + // 기타 오류 + else if (!res.ok) { + logger.debug('마스토돈 verify_credentials API 호출 실패'); + return false; + } else { + // 설마 200에 json이 아닌 응답을 주겠어? + return await res.json(); + } +} + +/** + * user, Profile 테이블 업데이트 + * @param params + */ +async function updateDb(targetUserHandle: string, updateUser: Partial, updateProfile: Partial) { + const prisma = GetPrismaClient.getClient(); + await prisma.$transaction(async (tx) => { + await tx.user.update({ where: { handle: targetUserHandle }, data: updateUser }); + await tx.profile.update({ where: { handle: targetUserHandle }, data: updateProfile }); + }); +} diff --git a/src/app/main/_header.tsx b/src/app/main/_header.tsx index 0378ba8..b864850 100644 --- a/src/app/main/_header.tsx +++ b/src/app/main/_header.tsx @@ -6,6 +6,7 @@ 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'); @@ -20,6 +21,8 @@ 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) { const res = await fetch('/api/db/fetch-my-profile', { @@ -31,6 +34,29 @@ export default function MainHeader() { } return; } + // FIXME: 3600초 대신 적당한 시간으로 고치기 + 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); + } + } const data = await res.json(); return data; } diff --git a/src/app/mastodon-callback/action.ts b/src/app/mastodon-callback/action.ts index a328669..45f09c0 100644 --- a/src/app/mastodon-callback/action.ts +++ b/src/app/mastodon-callback/action.ts @@ -5,7 +5,7 @@ import { mastodonCallbackTokenClaimPayload } from '../_dto/mastodon-callback/cal import { fetchNameWithEmoji } from '../api/_utils/fetchUsername'; import { DBpayload } from '../misskey-callback/page'; import { cookies } from 'next/headers'; -import { SignJWT } from 'jose'; +import { generateJwt } from '../api/_utils/jwt/generate-jwt'; import { GetPrismaClient } from '@/app/api/_utils/getPrismaClient/get-prisma-client'; import { Logger } from '@/utils/logger/Logger'; @@ -68,7 +68,9 @@ export async function login(loginReqestData: mastodonCallbackTokenClaimPayload) try { //프론트 쿠키스토어에 쿠키 저장 const cookieStore = await cookies(); - const jwtToken = await generateJwt(loginReq.mastodonHost, user_handle); + 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, @@ -130,23 +132,6 @@ async function requestMastodonAccessCodeAndUserInfo( } } -async function generateJwt(hostname: string, handle: string) { - const alg = 'HS256'; - const secret = new TextEncoder().encode(process.env.JWT_SECRET); - - const webUrl = process.env.WEB_URL; - const jwtToken = await new SignJWT({ - server: hostname, - handle: handle, - }) - .setProtectedHeader({ alg }) - .setIssuedAt() - .setIssuer(`${webUrl}`) - .setAudience('urn:example:audience') - .setExpirationTime('6h') - .sign(secret); - return jwtToken; -} async function pushDB(payload: DBpayload) { const prisma = GetPrismaClient.getClient(); diff --git a/src/app/mastodon-callback/page.tsx b/src/app/mastodon-callback/page.tsx index 754d2c5..a0c05f1 100644 --- a/src/app/mastodon-callback/page.tsx +++ b/src/app/mastodon-callback/page.tsx @@ -49,6 +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); router.replace('/main'); } diff --git a/src/app/misskey-callback/actions.ts b/src/app/misskey-callback/actions.ts index 8adc4fb..4f356cd 100644 --- a/src/app/misskey-callback/actions.ts +++ b/src/app/misskey-callback/actions.ts @@ -2,7 +2,6 @@ import { DBpayload } from './page'; import { cookies } from 'next/headers'; -import { SignJWT } from 'jose'; import { misskeyAccessKeyApiResponse } from '..'; import { MiUser } from '../api/_misskey-entities/user'; import { fetchNameWithEmoji } from '../api/_utils/fetchUsername'; @@ -11,6 +10,7 @@ import { misskeyCallbackTokenClaimPayload } from '../_dto/misskey-callback/callb import { misskeyUserInfoPayload } from '../_dto/misskey-callback/user-info.dto'; import { GetPrismaClient } from '@/app/api/_utils/getPrismaClient/get-prisma-client'; import { Logger } from '@/utils/logger/Logger'; +import { generateJwt } from '../api/_utils/jwt/generate-jwt'; const logger = new Logger('misskey-callback'); export async function login(loginReqestData: misskeyCallbackTokenClaimPayload): Promise { @@ -67,14 +67,16 @@ export async function login(loginReqestData: misskeyCallbackTokenClaimPayload): try { // 프론트 쿠키스토어에 쿠키 저장 const cookieStore = await cookies(); - const jwtToken = await generateJwt(loginReq.misskeyHost, user_handle); + 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 * 6, + expires: Date.now() + 1000 * 60 * 60 * 24 * 7, httpOnly: true, }); cookieStore.set('server', loginReq.misskeyHost, { - expires: Date.now() + 1000 * 60 * 60 * 6, + expires: Date.now() + 1000 * 60 * 60 * 24 * 7, httpOnly: true, }); } catch (err) { @@ -126,23 +128,6 @@ async function requestMiAccessTokenAndUserInfo(payload: misskeyCallbackTokenClai } } -async function generateJwt(hostname: string, handle: string) { - const alg = 'HS256'; - const secret = new TextEncoder().encode(process.env.JWT_SECRET); - - const webUrl = process.env.WEB_URL; - const jwtToken = await new SignJWT({ - server: hostname, - handle: handle, - }) - .setProtectedHeader({ alg }) - .setIssuedAt() - .setIssuer(`${webUrl}`) - .setAudience('urn:example:audience') - .setExpirationTime('6h') - .sign(secret); - return jwtToken; -} async function pushDB(payload: DBpayload) { const prisma = GetPrismaClient.getClient(); diff --git a/src/app/misskey-callback/page.tsx b/src/app/misskey-callback/page.tsx index 2e3e4c2..8a8dedc 100644 --- a/src/app/misskey-callback/page.tsx +++ b/src/app/misskey-callback/page.tsx @@ -58,6 +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); router.replace('/main'); } From fd738797354914f18c8ed5b58cd3087d3e51d8b8 Mon Sep 17 00:00:00 2001 From: Yuno Date: Mon, 25 Nov 2024 02:04:53 +0900 Subject: [PATCH 5/5] =?UTF-8?q?rate=20limit=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/web/refresh-token/route.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/app/api/web/refresh-token/route.ts b/src/app/api/web/refresh-token/route.ts index 976c54d..dd770f8 100644 --- a/src/app/api/web/refresh-token/route.ts +++ b/src/app/api/web/refresh-token/route.ts @@ -12,6 +12,9 @@ import { MiUser } from '../../_misskey-entities/user'; import { fetchNameWithEmoji } from '../../_utils/fetchUsername'; import { generateJwt } from '../../_utils/jwt/generate-jwt'; import { MastodonUser } from '../../_mastodon-entities/user'; +import { RateLimiterService } from '../../_utils/ratelimiter/rateLimiter'; +import { getIpFromRequest } from '../../_utils/getIp/get-ip-from-Request'; +import { getIpHash } from '../../_utils/getIp/get-ip-hash'; const logger = new Logger('refresh-token'); export async function POST(req: NextRequest) { @@ -21,6 +24,15 @@ export async function POST(req: NextRequest) { } catch (err) { return sendApiError(400, `Bad Request! ${err}`); } + const limiter = RateLimiterService.getLimiter(); + const ipHash = getIpHash(getIpFromRequest(req)); + const limited = await limiter.limit(`refresh-token-${ipHash}`, { + bucket_time: 600, + req_limit: 20, + }); + if (limited) { + return sendApiError(429, '요청 제한에 도달했습니다!'); + } const cookieStore = await cookies(); let tokenPayload; try {