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.
This commit is contained in:
parent
ba8dfdf973
commit
9c9b3735cb
5
bun.lock
5
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=="],
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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])
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 = (
|
||||
<StrictMode>
|
||||
|
||||
16
src/hooks/useAuthHeaders.ts
Normal file
16
src/hooks/useAuthHeaders.ts
Normal file
@ -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<HeadersInit>({});
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
getAccessToken(LOGTO_RESOURCE).then(token => {
|
||||
setHeaders({ Authorization: `Bearer ${token}` })
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
return headers;
|
||||
}
|
||||
@ -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' });
|
||||
}
|
||||
},
|
||||
|
||||
@ -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>(SortType.DEFAULT);
|
||||
const [showType, setShowType] = useState(ShowType.LOCAL);
|
||||
const [claims, setClaims] = useState<IdTokenClaims>();
|
||||
const headers = useAuthHeaders();
|
||||
const { login } = useAutoLogin();
|
||||
const localList = Object.values(favMap);
|
||||
const favListRequest = useRequest<XCXProfile[], [string]>(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);
|
||||
|
||||
@ -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';
|
||||
@ -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<IEventInfo[]> {
|
||||
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} = /比赛开始:.*?(?<y>\d{4})年(?<M>\d{2})月(?<D>\d{2})日 \w+ (?<H>\d{2}):(?<m>\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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user