From 9c9b3735cbe1f23e67d9db8bda88824904092e2c Mon Sep 17 00:00:00 2001 From: kyuuseiryuu Date: Mon, 16 Mar 2026 12:45:12 +0900 Subject: [PATCH] feat: migrate user fav system from session aud to Logto sub - Update database schema to rename `UserFav` to `LogtoUserFav` with clearer field names (`logto_uid`, `kaiqiu_uid`). - Bump `jose` dependency to v6.2.1 for improved JWT verification. - Configure `@logto/react` to request the correct resource token for API access. - Implement token verification on the server side using `jose` and `jwtVerify`. - Update API routes (`/api/fav`) to extract the user ID from the verified JWT `sub` claim instead of the URL `aud` parameter. - Refactor frontend components (`FavButton`, `FavePlayersPage`) to use `useAuthHeaders` for fetching auth headers instead of manual token claims extraction. - Clean up unused migration and DAO functions related to the old `aud`-based logic. --- bun.lock | 5 +- package.json | 1 + .../20260307151049_init/migration.sql | 7 -- .../migration.sql | 7 ++ prisma/schema.prisma | 10 +- src/components/FavButton.tsx | 23 ++--- src/dao/FavPlayerDAO.ts | 28 +++--- src/frontend.tsx | 2 + src/hooks/useAuthHeaders.ts | 16 ++++ src/index.tsx | 18 ++-- src/page/FavPlayersPage.tsx | 6 +- src/utils/constants.ts | 2 +- src/utils/server.ts | 96 ++++++++----------- 13 files changed, 115 insertions(+), 106 deletions(-) delete mode 100644 prisma/migrations/20260307151049_init/migration.sql create mode 100644 prisma/migrations/20260316024219_add_new_fav_table/migration.sql create mode 100644 src/hooks/useAuthHeaders.ts diff --git a/bun.lock b/bun.lock index 1657ecc..0eabc4e 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "cheerio": "^1.2.0", "dayjs": "^1.11.19", "ics": "^3.8.1", + "jose": "^6.2.1", "lodash": "^4.17.23", "mariadb": "^3.5.2", "react": "^19", @@ -330,7 +331,7 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + "jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], @@ -496,6 +497,8 @@ "@chevrotain/gast/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "@logto/client/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + "@prisma/adapter-mariadb/mariadb": ["mariadb@3.4.5", "", { "dependencies": { "@types/geojson": "^7946.0.16", "@types/node": "^24.0.13", "denque": "^2.1.0", "iconv-lite": "^0.6.3", "lru-cache": "^10.4.3" } }, "sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ=="], "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="], diff --git a/package.json b/package.json index 0c7d7f4..4c3e831 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "cheerio": "^1.2.0", "dayjs": "^1.11.19", "ics": "^3.8.1", + "jose": "^6.2.1", "lodash": "^4.17.23", "mariadb": "^3.5.2", "react": "^19", diff --git a/prisma/migrations/20260307151049_init/migration.sql b/prisma/migrations/20260307151049_init/migration.sql deleted file mode 100644 index bb048fd..0000000 --- a/prisma/migrations/20260307151049_init/migration.sql +++ /dev/null @@ -1,7 +0,0 @@ --- CreateTable -CREATE TABLE `UserFav` ( - `aud` VARCHAR(191) NOT NULL, - `uid` VARCHAR(191) NOT NULL, - - PRIMARY KEY (`aud`, `uid`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/migrations/20260316024219_add_new_fav_table/migration.sql b/prisma/migrations/20260316024219_add_new_fav_table/migration.sql new file mode 100644 index 0000000..35371bf --- /dev/null +++ b/prisma/migrations/20260316024219_add_new_fav_table/migration.sql @@ -0,0 +1,7 @@ +-- CreateTable +CREATE TABLE `LogtoUserFav` ( + `logto_uid` VARCHAR(191) NOT NULL, + `kaiqiu_uid` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`logto_uid`, `kaiqiu_uid`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0e9cfa8..35c1588 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,8 +13,8 @@ datasource db { provider = "mysql" } -model UserFav { - aud String - uid String - @@id([aud, uid]) -} +model LogtoUserFav { + logto_uid String + kaiqiu_uid String + @@id([logto_uid, kaiqiu_uid]) +} \ No newline at end of file diff --git a/src/components/FavButton.tsx b/src/components/FavButton.tsx index c796fa2..6aeb843 100644 --- a/src/components/FavButton.tsx +++ b/src/components/FavButton.tsx @@ -4,6 +4,7 @@ import { useFavPlayerStore, type FavPlayer } from "../store/useFavPlayerStore"; import styled from "styled-components"; import { useRequest } from "ahooks"; import { useLogto } from "@logto/react"; +import { useAuthHeaders } from "../hooks/useAuthHeaders"; interface Props { user?: FavPlayer; @@ -21,30 +22,30 @@ const StyledContainer = styled.div` `; export function FavButton(props: Props) { - const { isAuthenticated, getIdTokenClaims } = useLogto(); + const { isAuthenticated } = useLogto(); const { fav, unFav, isFav } = useFavPlayerStore(store => store); + const headers = useAuthHeaders(); const favReq = useRequest(async () => { if (!isAuthenticated) return; - const claims = await getIdTokenClaims(); - await fetch(`/api/fav/${claims?.aud}/${props.user?.uid}`, { method: 'PUT' }); - }, { manual: true, refreshDeps: [props, isAuthenticated] }); + await fetch(`/api/fav/${props.user?.uid}`, { method: 'PUT', headers }); + }, { manual: true }); const unFavReq = useRequest(async () => { if (!isAuthenticated) return; - const claims = await getIdTokenClaims(); - await fetch(`/api/fav/${claims?.aud}/${props.user?.uid}`, { method: 'DELETE' }); - }, { manual: true, refreshDeps: [props, isAuthenticated] }); + await fetch(`/api/fav/${props.user?.uid}`, { method: 'DELETE', headers }); + }, { manual: true }); const [value, setValue] = useState(isFav(props.user?.uid) ? 1 : 0); useEffect(() => { if (!props.user) return; if (!isAuthenticated) return; const id = setTimeout(async () => { - const claims = await getIdTokenClaims(); - const aud = claims?.aud; - const res: { isFav: boolean } = await (await fetch(`/api/fav/${aud}/${props.user?.uid}`)).json(); + console.debug('Token', headers); + const res: { isFav: boolean } = await fetch(`/api/fav/${props.user?.uid}`, { + headers, + }).then(res => res.json()); setValue(res.isFav ? 1 : 0); }, 300); return () => clearTimeout(id); - }, []); + }, [isAuthenticated, headers]); const handleFavClick = useCallback((value: number) => { if (!props.user) return; setValue(value); diff --git a/src/dao/FavPlayerDAO.ts b/src/dao/FavPlayerDAO.ts index ac09dd9..8761e53 100644 --- a/src/dao/FavPlayerDAO.ts +++ b/src/dao/FavPlayerDAO.ts @@ -1,30 +1,30 @@ import { prisma } from "../prisma/db"; -export async function updateUserFav(aud: string, uid: string) { - const size = await prisma.userFav.count({ where: { aud, uid }}); +export async function updateUserFav(logto_uid: string, kaiqiu_uid: string) { + const size = await prisma.logtoUserFav.count({ where: { logto_uid, kaiqiu_uid }}); const isUnFav = size === 0; if (isUnFav) { - await prisma.userFav.create({ + await prisma.logtoUserFav.create({ data: { - aud, - uid, + logto_uid, + kaiqiu_uid, }, }); } } -export async function getUserFavList(aud: string) { - const result = await prisma.userFav.findMany({ - where: { aud }, - select: { uid: true }, +export async function getUserFavList(logto_uid: string) { + const result = await prisma.logtoUserFav.findMany({ + where: { logto_uid }, + select: { kaiqiu_uid: true }, }); - return result.map(e => e.uid); + return result.map(e => e.kaiqiu_uid); } -export async function deleteUserFav(aud: string, uid: string) { - await prisma.userFav.deleteMany({ where: { aud, uid }}); +export async function deleteUserFav(logto_uid: string, kaiqiu_uid: string) { + await prisma.logtoUserFav.deleteMany({ where: { logto_uid, kaiqiu_uid }}); } -export async function isUserFav(aud: string, uid: string) { - return (await prisma.userFav.count({ where: { aud, uid }})) > 0; +export async function isUserFav(logto_uid: string, kaiqiu_uid: string) { + return (await prisma.logtoUserFav.count({ where: { kaiqiu_uid, logto_uid }})) > 0; } diff --git a/src/frontend.tsx b/src/frontend.tsx index e876e0d..58df33a 100644 --- a/src/frontend.tsx +++ b/src/frontend.tsx @@ -12,12 +12,14 @@ import zhCN from 'antd/locale/zh_CN'; import { RouterProvider } from "react-router"; import { LogtoProvider, type LogtoConfig } from '@logto/react'; import { route } from "./routes"; +import { LOGTO_RESOURCE } from "./utils/constants"; const elem = document.getElementById("root")!; const config: LogtoConfig = { endpoint: 'https://logto.ksr.la/', appId: 'iq0oceaeqazpcned7hzil', + resources: [LOGTO_RESOURCE], }; const app = ( diff --git a/src/hooks/useAuthHeaders.ts b/src/hooks/useAuthHeaders.ts new file mode 100644 index 0000000..91982e7 --- /dev/null +++ b/src/hooks/useAuthHeaders.ts @@ -0,0 +1,16 @@ +import { useLogto } from "@logto/react" +import { useEffect, useState } from "react"; +import { LOGTO_RESOURCE } from "../utils/constants"; + +export const useAuthHeaders = (): HeadersInit => { + const { isAuthenticated, getAccessToken } = useLogto(); + const [headers, setHeaders] = useState({}); + useEffect(() => { + if (isAuthenticated) { + getAccessToken(LOGTO_RESOURCE).then(token => { + setHeaders({ Authorization: `Bearer ${token}` }) + }); + } + }, [isAuthenticated]); + return headers; +} \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index dd7ed9e..c36a5f7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,5 @@ import { serve } from "bun"; -import { getMatchInfo, xcxApi } from "./utils/server"; +import { getMatchInfo, verifyLogtoToken, xcxApi } from "./utils/server"; import ics from 'ics'; import index from "./index.html"; import { getUidScore } from "./services/uidScoreStore"; @@ -151,23 +151,27 @@ const server = serve({ return Response.json(profile); }, }, - "/api/fav/:aud": { + "/api/fav": { async GET(req) { - const list = await listFavPlayers(req.params.aud); + const payload = await verifyLogtoToken(req.headers); + const list = await listFavPlayers(`${payload.sub}`); return Response.json(list.filter(Boolean)); } }, - "/api/fav/:aud/:uid": { + "/api/fav/:uid": { async GET(req) { - const isFav = await checkIsUserFav(req.params.aud, req.params.uid); + const payload = await verifyLogtoToken(req.headers); + const isFav = await checkIsUserFav(`${payload.sub}`, req.params.uid); return Response.json({ isFav }); }, async PUT(req) { - await favPlayer(req.params.aud, req.params.uid); + const payload = await verifyLogtoToken(req.headers); + await favPlayer(`${payload.sub}`, req.params.uid); return Response.json({ ok: 'ok' }); }, async DELETE(req) { - await unFavPlayer(req.params.aud, req.params.uid); + const payload = await verifyLogtoToken(req.headers); + await unFavPlayer(`${payload.sub}`, req.params.uid); return Response.json({ ok: 'ok' }); } }, diff --git a/src/page/FavPlayersPage.tsx b/src/page/FavPlayersPage.tsx index cfa6ade..d5aa877 100644 --- a/src/page/FavPlayersPage.tsx +++ b/src/page/FavPlayersPage.tsx @@ -7,6 +7,7 @@ import { DeleteOutlined, LoginOutlined, SyncOutlined, UploadOutlined } from "@an import { useRequest } from "ahooks"; import type { XCXProfile } from "../types"; import useAutoLogin from "../hooks/useAutoLogin"; +import { useAuthHeaders } from "../hooks/useAuthHeaders"; enum SortType { DEFAULT = '注册时间', @@ -24,10 +25,11 @@ export function FavePlayersPage() { const [sortType, setSortType] = useState(SortType.DEFAULT); const [showType, setShowType] = useState(ShowType.LOCAL); const [claims, setClaims] = useState(); + const headers = useAuthHeaders(); const { login } = useAutoLogin(); const localList = Object.values(favMap); const favListRequest = useRequest(async (aud: string) => { - const res = await fetch(`/api/fav/${aud}`); + const res = await fetch(`/api/fav`, { headers }); const data = await res.json(); return data; }, { manual: true, cacheKey: 'favListRequest', cacheTime: 3 * 60 * 1000 }); @@ -61,7 +63,7 @@ export function FavePlayersPage() { const claims = await getIdTokenClaims()!; const aud = claims?.aud; const jobs = list.map(async u => { - await fetch(`/api/fav/${aud}/${u.uid}`, { method: 'PUT' }); + await fetch(`/api/fav/${u.uid}`, { method: 'PUT', headers }); }); message.open({ key: 'sync', content: '同步中...', type: 'loading' }); await Promise.all(jobs); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index fb23329..be4cfe8 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,3 +1,3 @@ - +export const LOGTO_RESOURCE = 'https://tt.ksr.la'; export const CLUB_SELECTOR_KEY = 'CLUB_SELECTOR'; export const STORE_PAGE_LIST_KEY = 'events-page-keys'; \ No newline at end of file diff --git a/src/utils/server.ts b/src/utils/server.ts index 3847507..bf85d50 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -1,9 +1,10 @@ -import type { IEventInfo, Player } from "../types"; +import type { Player } from "../types"; import * as cheerio from "cheerio"; import { XCXAPI } from "../services/xcxApi"; import { BASE_URL } from "./common"; import { RedisClient } from "bun"; -import dayjs from "dayjs"; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { LOGTO_RESOURCE } from "./constants"; const REQUIRED_ENVS = [ process.env.KAIQIUCC_TOKEN, @@ -40,62 +41,6 @@ export const htmlRequestHeaders = { "cookie": "SECKEY_ABVK=oTGgqH4ypGPFVdQ3J9K7PoAOPdZ+8R7CsUzi75gelcg%3D; uchome_sendmail=1" } -/** - * @param tagid 俱乐部 ID - */ -export async function listEvent(tagid: string): Promise { - const key = `my-kaiqiuwang:club-events:${tagid}`; - let html = await redis.get(key).catch(() => ''); - if (!html) { - html = await fetchEventListHTML(tagid); - redis.setex(key, 60 * 60 * REDIS_CACHE_HOUR, html); - } - return await parseEventList(html); -} - -/** - * @param tagid 俱乐部 ID - * @return HTML - */ -export async function fetchEventListHTML(tagid: string) { - const url = `${BASE_URL}/home/space.php?do=mtag&tagid=${tagid}&view=event`; - const resp = await fetch(url, { headers: htmlRequestHeaders }); - return resp.text() ?? ''; -} - -export async function parseEventList(html: string) { - const $ = cheerio.load(html); - const blockList = $('div.event_list > ol > li'); - const list: IEventInfo[] = []; - for (const block of blockList) { - const titleEl = $(block).find('.event_title'); - const title = titleEl.text(); - const eventPath = $(titleEl).find('a').attr('href') ?? ''; - const place = $(block).find('ul li:nth-of-type(2)').text().replace('比赛地点: \\t', '').trim(); - const eventURL = `${BASE_URL}/home/${eventPath}`; - const matchId = /\S+-(\d+).html$/.exec(eventPath)?.[1] ?? ''; - let eventPage = await redis.get(`my-kaiqiuwang:match:${matchId}`) ?? ''; - if (!eventPage) { - eventPage = await fetch(eventURL, { headers: htmlRequestHeaders }).then(res => res.text() ?? ''); - await redis.setex(`my-kaiqiuwang:match:${matchId}`, 60 * 60 * 10, eventPage) - } - const $eventPage = cheerio.load(eventPage); - const eventContent = $eventPage('.event_content').text().replace(/(\r|\n)/g, ',').split(',').filter(Boolean).join(' '); - const { y, M, D, H, m} = /比赛开始:.*?(?\d{4})年(?\d{2})月(?\d{2})日 \w+ (?\d{2}):(?\d{2})/ - .exec(eventContent)?.groups ?? {}; - const startDate = y ? `${y}-${M}-${D} ${H}:${m}` : ''; - const event: IEventInfo = { - title, - info: [`比赛时间:${startDate}`, place], - url: eventURL, - startDate, - matchId, - isFinished: dayjs(startDate).isBefore(), - } - list.push(event); - } - return list; -} /** * @@ -139,3 +84,38 @@ export async function getMatchInfo(matchId: string) { } return parseEventInfo(html); } + +export const extractBearerTokenFromHeaders = (authorization: string | null) => { + if (!authorization) { + return null; + } + + if (!authorization.startsWith('Bearer')) { + return null; + } + + return authorization.slice(7); // The length of 'Bearer ' is 7 +}; + +const jwks = createRemoteJWKSet(new URL('https://logto.ksr.la/oidc/jwks')); + +export const verifyLogtoToken = async (headers: Headers) => { + const auth = headers.get('Authorization'); + const token = extractBearerTokenFromHeaders(auth) + // console.debug('Authorization', { auth, token }); + if (!token) return {}; + const { payload } = await jwtVerify( + // The raw Bearer Token extracted from the request header + token, + jwks, + { + // Expected issuer of the token, issued by the Logto server + issuer: 'https://logto.ksr.la/oidc', + // Expected audience token, the resource indicator of the current API + audience: LOGTO_RESOURCE, + } + ); + // console.debug('Payload', payload); + // Sub is the user ID, used for user identification + return payload +} \ No newline at end of file