mirror of
https://github.com/ZeroCatDev/ClassworksKV.git
synced 2025-10-22 10:23:12 +00:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b20d8dab96 | ||
![]() |
3c64226562 | ||
![]() |
da77018509 | ||
![]() |
f8f63d1ddd | ||
![]() |
5435814cb6 | ||
![]() |
d5fc9e2dae | ||
![]() |
6c05d71506 | ||
![]() |
be1d8d1328 | ||
![]() |
0576a02d6e | ||
![]() |
d83d748da0 | ||
![]() |
6ab78af370 |
29
README.md
29
README.md
@ -1,4 +1,5 @@
|
||||
# Classworks KV
|
||||
|
||||
[Classworks](https://cs.houlangs.com)用于班级大屏的作业板小工具
|
||||
|
||||
|
||||
@ -20,3 +21,31 @@ This project is licensed under the **GNU AGPL v3.0**.
|
||||
Copyright (C) 2025 **Sunwuyuan** (<https://wuyuan.dev>)
|
||||
See [LICENSE](./LICENSE) for details.
|
||||
|
||||
## 配置(OAuth / JWT)
|
||||
|
||||
在根目录创建或编辑 `.env`:
|
||||
|
||||
- 基础地址(用于回调):
|
||||
- `BASE_URL`: `http://localhost:3030`
|
||||
- `FRONTEND_URL`: `http://localhost:5173`
|
||||
|
||||
- STCN(Casdoor)OIDC:
|
||||
- `STCN_CLIENT_ID`: `53e65cfd81232e729730`
|
||||
- `STCN_CLIENT_SECRET`: `e1b1277f8906e5df162b1d2f2eb3692182dd2920`
|
||||
- 回调地址:`${BASE_URL}/accounts/oauth/stcn/callback`
|
||||
|
||||
- 其他可选提供者:GitHub、ZeroCat、厚浪云(Logto)
|
||||
|
||||
- JWT:
|
||||
- 默认 HS256(提供 `JWT_SECRET`)
|
||||
- 如需 RS256,请设置:
|
||||
- `JWT_ALG=RS256`
|
||||
- `JWT_PRIVATE_KEY`(PEM,\n 转义)
|
||||
- `JWT_PUBLIC_KEY`(PEM,\n 转义)
|
||||
- `JWT_EXPIRES_IN=7d`
|
||||
|
||||
完成后启动服务并访问:
|
||||
|
||||
- GET /accounts/oauth/providers 列出可用登录方式
|
||||
- 浏览器打开 /accounts/oauth/stcn 发起 STCN 登录
|
||||
|
||||
|
@ -24,7 +24,7 @@ const CONFIG = {
|
||||
// 应用ID
|
||||
appId: process.env.APP_ID || '1',
|
||||
// 授权页面地址(Classworks前端)
|
||||
authPageUrl: process.env.AUTH_PAGE_URL || 'http://localhost:5173/authorize',
|
||||
authPageUrl: process.env.FRONTEND_URL,
|
||||
// 本地回调服务器端口
|
||||
callbackPort: process.env.CALLBACK_PORT || '8080',
|
||||
// 回调路径
|
||||
|
@ -21,7 +21,7 @@ const CONFIG = {
|
||||
// 应用ID
|
||||
appId: process.env.APP_ID || '1',
|
||||
// 授权页面地址(Classworks前端)
|
||||
authPageUrl: process.env.AUTH_PAGE_URL || 'http://localhost:5173/authorize',
|
||||
authPageUrl: process.env.FRONTEND_URL,
|
||||
// 轮询间隔(秒)
|
||||
pollInterval: 3,
|
||||
// 最大轮询次数
|
||||
|
@ -7,10 +7,13 @@ export const oauthProviders = {
|
||||
tokenURL: "https://github.com/login/oauth/access_token",
|
||||
userInfoURL: "https://api.github.com/user",
|
||||
scope: "read:user user:email",
|
||||
// 展示相关
|
||||
name: "GitHub",
|
||||
displayName: "GitHub",
|
||||
icon: "github",
|
||||
color: "#24292e",
|
||||
description: "使用 GitHub 账号登录",
|
||||
website: "https://github.com",
|
||||
},
|
||||
zerocat: {
|
||||
clientId: process.env.ZEROCAT_CLIENT_ID,
|
||||
@ -19,10 +22,50 @@ export const oauthProviders = {
|
||||
tokenURL: "https://zerocat-api.houlangs.com/oauth/token",
|
||||
userInfoURL: "https://zerocat-api.houlangs.com/oauth/userinfo",
|
||||
scope: "user:basic user:email",
|
||||
// 展示相关
|
||||
name: "ZeroCat",
|
||||
displayName: "ZeroCat",
|
||||
icon: "zerocat",
|
||||
color: "#6366f1",
|
||||
color: "#415f91",
|
||||
description: "使用 ZeroCat 账号登录",
|
||||
website: "https://zerocat.dev",
|
||||
},
|
||||
stcn: {
|
||||
// STCN(Casdoor)- 标准 OIDC Provider
|
||||
clientId: process.env.STCN_CLIENT_ID,
|
||||
clientSecret: process.env.STCN_CLIENT_SECRET,
|
||||
// Casdoor 标准端点
|
||||
authorizationURL: "https://auth.smart-teach.cn/login/oauth/authorize",
|
||||
tokenURL: "https://auth.smart-teach.cn/api/login/oauth/access_token",
|
||||
userInfoURL: "https://auth.smart-teach.cn/api/userinfo",
|
||||
scope: "openid profile email offline_access",
|
||||
// 展示相关
|
||||
name: "stcn",
|
||||
displayName: "智教联盟账户",
|
||||
icon: "casdoor",
|
||||
color: "#1068af",
|
||||
description: "使用智教联盟账户登录",
|
||||
website: "https://auth.smart-teach.cn",
|
||||
tokenRequestFormat: "json", // Casdoor 推荐 JSON 提交
|
||||
},
|
||||
hly: {
|
||||
// 厚浪云(Logto) - OIDC Provider
|
||||
clientId: process.env.HLY_CLIENT_ID,
|
||||
clientSecret: process.env.HLY_CLIENT_SECRET,
|
||||
authorizationURL: "https://oauth.houlang.cloud/oidc/auth",
|
||||
tokenURL: "https://oauth.houlang.cloud/oidc/token",
|
||||
userInfoURL: "https://oauth.houlang.cloud/oidc/me",
|
||||
scope: "openid profile email offline_access",
|
||||
// 展示相关
|
||||
name: "厚浪云",
|
||||
displayName: "厚浪云",
|
||||
icon: "logto",
|
||||
color: "#2d53f8",
|
||||
textColor: "#ffffff",
|
||||
order: 40,
|
||||
description: "使用厚浪云账号登录",
|
||||
website: "https://houlang.cloud",
|
||||
pkce: true, // 启用PKCE支持
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ClassworksKV",
|
||||
"version": "1.0.8",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node ./bin/www",
|
||||
|
32
pnpm-lock.yaml
generated
32
pnpm-lock.yaml
generated
@ -1213,13 +1213,11 @@ packages:
|
||||
'@types/node@22.15.17':
|
||||
resolution: {integrity: sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==}
|
||||
|
||||
<<<<<<< HEAD
|
||||
'@types/node@24.6.1':
|
||||
resolution: {integrity: sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==}
|
||||
=======
|
||||
'@types/node@24.4.0':
|
||||
resolution: {integrity: sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==}
|
||||
>>>>>>> 12bded7e3d9aaf1a6f9f74126dec551d84efcd8f
|
||||
|
||||
'@types/node@24.6.1':
|
||||
resolution: {integrity: sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==}
|
||||
|
||||
'@types/oracledb@6.5.2':
|
||||
resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==}
|
||||
@ -2387,13 +2385,11 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
<<<<<<< HEAD
|
||||
undici-types@7.13.0:
|
||||
resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==}
|
||||
=======
|
||||
undici-types@7.11.0:
|
||||
resolution: {integrity: sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==}
|
||||
>>>>>>> 12bded7e3d9aaf1a6f9f74126dec551d84efcd8f
|
||||
|
||||
undici-types@7.13.0:
|
||||
resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==}
|
||||
|
||||
unpipe@1.0.0:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
@ -3710,16 +3706,14 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
<<<<<<< HEAD
|
||||
'@types/node@24.4.0':
|
||||
dependencies:
|
||||
undici-types: 7.11.0
|
||||
|
||||
'@types/node@24.6.1':
|
||||
dependencies:
|
||||
undici-types: 7.13.0
|
||||
optional: true
|
||||
=======
|
||||
'@types/node@24.4.0':
|
||||
dependencies:
|
||||
undici-types: 7.11.0
|
||||
>>>>>>> 12bded7e3d9aaf1a6f9f74126dec551d84efcd8f
|
||||
|
||||
'@types/oracledb@6.5.2':
|
||||
dependencies:
|
||||
@ -4982,12 +4976,10 @@ snapshots:
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
<<<<<<< HEAD
|
||||
undici-types@7.11.0: {}
|
||||
|
||||
undici-types@7.13.0:
|
||||
optional: true
|
||||
=======
|
||||
undici-types@7.11.0: {}
|
||||
>>>>>>> 12bded7e3d9aaf1a6f9f74126dec551d84efcd8f
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `Account` MODIFY `refreshToken` TEXT NULL;
|
5
prisma/migrations/20251007041015_update/migration.sql
Normal file
5
prisma/migrations/20251007041015_update/migration.sql
Normal file
@ -0,0 +1,5 @@
|
||||
-- DropIndex
|
||||
DROP INDEX `Account_accessToken_key` ON `Account`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `Account` MODIFY `accessToken` TEXT NOT NULL;
|
@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `refreshToken` on the `Account` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE `Account` DROP COLUMN `refreshToken`,
|
||||
MODIFY `accessToken` TEXT NULL;
|
@ -29,8 +29,7 @@ model Account {
|
||||
name String? // 用户名称
|
||||
avatarUrl String? // 用户头像URL
|
||||
providerData Json? // OAuth提供者返回的完整信息
|
||||
accessToken String @unique // 账户访问令牌
|
||||
refreshToken String? // OAuth refresh token (如果提供者支持)
|
||||
accessToken String? @db.Text // 账户访问令牌
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
@ -11,6 +11,24 @@ const prisma = new PrismaClient();
|
||||
// 存储OAuth state,防止CSRF攻击(生产环境应使用Redis等)
|
||||
const oauthStates = new Map();
|
||||
|
||||
// 生成PKCE code_verifier 和 code_challenge
|
||||
function generatePkcePair() {
|
||||
const codeVerifier = crypto
|
||||
.randomBytes(32)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
const challenge = crypto
|
||||
.createHash("sha256")
|
||||
.update(codeVerifier)
|
||||
.digest("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
return { codeVerifier, codeChallenge: challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成安全的访问令牌
|
||||
*/
|
||||
@ -23,22 +41,31 @@ function generateAccessToken() {
|
||||
* GET /accounts/oauth/providers
|
||||
*/
|
||||
router.get("/oauth/providers", (req, res) => {
|
||||
const providers = [];
|
||||
let providers = [];
|
||||
|
||||
for (const [key, config] of Object.entries(oauthProviders)) {
|
||||
// 只返回已配置的提供者
|
||||
if (config.clientId && config.clientSecret) {
|
||||
const pkceAllowed = !!config.pkce;
|
||||
if (config.clientId && (config.clientSecret || pkceAllowed)) {
|
||||
providers.push({
|
||||
id: key,
|
||||
name: config.name,
|
||||
displayName: config.displayName || config.name,
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
color: config.color, // 向后兼容
|
||||
brandColor: config.brandColor || config.color,
|
||||
textColor: config.textColor || "#ffffff",
|
||||
description: config.description,
|
||||
order: typeof config.order === 'number' ? config.order : 9999,
|
||||
authUrl: `/accounts/oauth/${key}`, // 前端用于发起认证的URL
|
||||
website: config.website,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 按 order 排序(从小到大)
|
||||
providers = providers.sort((a, b) => a.order - b.order);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: providers,
|
||||
@ -64,7 +91,8 @@ router.get("/oauth/:provider", (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||
const pkceAllowed = !!providerConfig.pkce;
|
||||
if (!providerConfig.clientId || (!providerConfig.clientSecret && !pkceAllowed)) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: `OAuth提供者 ${provider} 未配置`,
|
||||
@ -74,11 +102,20 @@ router.get("/oauth/:provider", (req, res) => {
|
||||
// 生成state参数
|
||||
const state = generateState();
|
||||
|
||||
// PKCE: 若启用,为此次会话生成code_verifier/challenge
|
||||
let codeChallenge, codeVerifier;
|
||||
if (pkceAllowed) {
|
||||
const pair = generatePkcePair();
|
||||
codeVerifier = pair.codeVerifier;
|
||||
codeChallenge = pair.codeChallenge;
|
||||
}
|
||||
|
||||
// 保存state和redirect_uri(5分钟过期)
|
||||
oauthStates.set(state, {
|
||||
provider,
|
||||
redirect_uri,
|
||||
timestamp: Date.now(),
|
||||
codeVerifier,
|
||||
});
|
||||
|
||||
// 清理过期的state(超过5分钟)
|
||||
@ -103,6 +140,11 @@ router.get("/oauth/:provider", (req, res) => {
|
||||
params.append("prompt", "consent");
|
||||
}
|
||||
|
||||
if (pkceAllowed && codeChallenge) {
|
||||
params.append("code_challenge", codeChallenge);
|
||||
params.append("code_challenge_method", "S256");
|
||||
}
|
||||
|
||||
const authUrl = `${providerConfig.authorizationURL}?${params.toString()}`;
|
||||
|
||||
// 重定向到OAuth提供者
|
||||
@ -145,20 +187,42 @@ router.get("/oauth/:provider/callback", async (req, res) => {
|
||||
|
||||
try {
|
||||
// 1. 使用授权码换取访问令牌
|
||||
const tokenResponse = await fetch(providerConfig.tokenURL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: providerConfig.clientId,
|
||||
client_secret: providerConfig.clientSecret,
|
||||
code: code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: getCallbackURL(provider),
|
||||
}),
|
||||
});
|
||||
let tokenResponse;
|
||||
if (providerConfig.tokenRequestFormat === 'json') {
|
||||
tokenResponse = await fetch(providerConfig.tokenURL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: providerConfig.clientId,
|
||||
...(providerConfig.clientSecret ? { client_secret: providerConfig.clientSecret } : {}),
|
||||
code: code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: getCallbackURL(provider),
|
||||
// PKCE: 携带code_verifier
|
||||
...(stateData?.codeVerifier ? { code_verifier: stateData.codeVerifier } : {}),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
tokenResponse = await fetch(providerConfig.tokenURL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: providerConfig.clientId,
|
||||
...(providerConfig.clientSecret ? { client_secret: providerConfig.clientSecret } : {}),
|
||||
code: code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: getCallbackURL(provider),
|
||||
// PKCE: 携带code_verifier
|
||||
...(stateData?.codeVerifier ? { code_verifier: stateData.codeVerifier } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
|
||||
@ -167,16 +231,24 @@ router.get("/oauth/:provider/callback", async (req, res) => {
|
||||
}
|
||||
|
||||
// 2. 使用访问令牌获取用户信息
|
||||
const userResponse = await fetch(providerConfig.userInfoURL, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${tokenData.access_token}`,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
});
|
||||
let userResponse;
|
||||
// Casdoor 支持两种方式:Authorization Bearer 或 accessToken 查询参数
|
||||
if (provider === 'stcn') {
|
||||
const url = new URL(providerConfig.userInfoURL);
|
||||
url.searchParams.set('accessToken', tokenData.access_token);
|
||||
userResponse = await fetch(url, { headers: { "Accept": "application/json" } });
|
||||
} else {
|
||||
userResponse = await fetch(providerConfig.userInfoURL, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${tokenData.access_token}`,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const userData = await userResponse.json();
|
||||
|
||||
// 3. 标准化用户数据(不同提供者返回的字段不同)
|
||||
// 3. 标准化用户数据(不同提供者返回的字段不同)
|
||||
let normalizedUser = {};
|
||||
|
||||
if (provider === "github") {
|
||||
@ -193,6 +265,30 @@ router.get("/oauth/:provider/callback", async (req, res) => {
|
||||
name: userData.nickname || userData.username,
|
||||
avatarUrl: userData.avatar,
|
||||
};
|
||||
} else if (provider === "hly") {
|
||||
// 厚浪云(Logto)标准OIDC用户信息
|
||||
normalizedUser = {
|
||||
providerId: userData.sub,
|
||||
email: userData.email_verified ? userData.email : null,
|
||||
name: userData.name || userData.preferred_username || userData.nickname,
|
||||
avatarUrl: userData.picture,
|
||||
};
|
||||
} else if (provider === "stcn") {
|
||||
// STCN(Casdoor)标准OIDC用户信息
|
||||
normalizedUser = {
|
||||
providerId: userData.sub,
|
||||
email: userData.email_verified ? userData.email : userData.email || null,
|
||||
name: userData.name || userData.preferred_username || userData.nickname,
|
||||
avatarUrl: userData.picture,
|
||||
};
|
||||
}
|
||||
|
||||
// 名称为空时,用邮箱@前部分回填(若邮箱可用)
|
||||
if ((!normalizedUser.name || normalizedUser.name.trim() === "") && normalizedUser.email) {
|
||||
const at = normalizedUser.email.indexOf("@");
|
||||
if (at > 0) {
|
||||
normalizedUser.name = normalizedUser.email.substring(0, at);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 查找或创建账户
|
||||
@ -214,7 +310,7 @@ router.get("/oauth/:provider/callback", async (req, res) => {
|
||||
name: normalizedUser.name || account.name,
|
||||
avatarUrl: normalizedUser.avatarUrl || account.avatarUrl,
|
||||
providerData: userData,
|
||||
refreshToken: tokenData.refresh_token || account.refreshToken,
|
||||
//refreshToken: tokenData.refresh_token || account.refreshToken,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
@ -230,7 +326,7 @@ router.get("/oauth/:provider/callback", async (req, res) => {
|
||||
avatarUrl: normalizedUser.avatarUrl,
|
||||
providerData: userData,
|
||||
accessToken,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
//refreshToken: tokenData.refresh_token,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -243,6 +339,12 @@ router.get("/oauth/:provider/callback", async (req, res) => {
|
||||
const callbackUrl = new URL(frontendBaseUrl);
|
||||
callbackUrl.searchParams.append("token", jwtToken);
|
||||
callbackUrl.searchParams.append("provider", provider);
|
||||
// 附带展示信息,便于前端显示品牌与名称
|
||||
const pconf = oauthProviders[provider] || {};
|
||||
callbackUrl.searchParams.append("providerName", pconf.displayName || pconf.name || provider);
|
||||
if (pconf.brandColor || pconf.color) {
|
||||
callbackUrl.searchParams.append("providerColor", pconf.brandColor || pconf.color);
|
||||
}
|
||||
callbackUrl.searchParams.append("success", "true");
|
||||
|
||||
res.redirect(callbackUrl.toString());
|
||||
@ -286,11 +388,27 @@ router.get("/profile", jwtAuth, async (req, res, next) => {
|
||||
},
|
||||
});
|
||||
|
||||
// 组装 provider 展示信息
|
||||
const pconf = (account?.provider && oauthProviders[account.provider]) || {};
|
||||
const providerInfo = {
|
||||
id: account?.provider || undefined,
|
||||
name: pconf.name,
|
||||
displayName: pconf.displayName || pconf.name || account?.provider,
|
||||
icon: pconf.icon,
|
||||
color: pconf.color, // 兼容字段
|
||||
brandColor: pconf.brandColor || pconf.color,
|
||||
textColor: pconf.textColor || "#ffffff",
|
||||
description: pconf.description,
|
||||
order: typeof pconf.order === 'number' ? pconf.order : undefined,
|
||||
website: pconf.website,
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: account.id,
|
||||
provider: account.provider,
|
||||
providerInfo,
|
||||
email: account.email,
|
||||
name: account.name,
|
||||
avatarUrl: account.avatarUrl,
|
||||
|
@ -140,7 +140,6 @@ router.post(
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid } = req.params;
|
||||
const newPassword = req.query.newPassword || req.body.newPassword;
|
||||
const passwordHint = req.query.passwordHint || req.body.passwordHint;
|
||||
|
||||
if (!newPassword) {
|
||||
return next(errors.createError(400, "新密码是必需的"));
|
||||
@ -166,7 +165,6 @@ router.post(
|
||||
where: { id: device.id },
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
passwordHint: passwordHint || null,
|
||||
},
|
||||
});
|
||||
|
||||
|
35
utils/jwt.js
35
utils/jwt.js
@ -1,33 +1,48 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
// JWT密钥 - 生产环境应该从环境变量读取
|
||||
// JWT 配置(支持 HS256 与 RS256)
|
||||
const JWT_ALG = (process.env.JWT_ALG || 'HS256').toUpperCase();
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
||||
|
||||
// HS256 密钥
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this-in-production';
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; // 默认7天过期
|
||||
|
||||
// RS256 密钥对(PEM 格式字符串)
|
||||
const JWT_PRIVATE_KEY = process.env.JWT_PRIVATE_KEY?.replace(/\\n/g, '\n');
|
||||
const JWT_PUBLIC_KEY = process.env.JWT_PUBLIC_KEY?.replace(/\\n/g, '\n');
|
||||
|
||||
function getSignVerifyKeys() {
|
||||
if (JWT_ALG === 'RS256') {
|
||||
if (!JWT_PRIVATE_KEY || !JWT_PUBLIC_KEY) {
|
||||
throw new Error('RS256 需要同时提供 JWT_PRIVATE_KEY 与 JWT_PUBLIC_KEY');
|
||||
}
|
||||
return { signKey: JWT_PRIVATE_KEY, verifyKey: JWT_PUBLIC_KEY };
|
||||
}
|
||||
// 默认 HS256
|
||||
return { signKey: JWT_SECRET, verifyKey: JWT_SECRET };
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发JWT token
|
||||
* @param {Object} payload - 要编码的数据
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
export function signToken(payload) {
|
||||
return jwt.sign(payload, JWT_SECRET, {
|
||||
const { signKey } = getSignVerifyKeys();
|
||||
return jwt.sign(payload, signKey, {
|
||||
expiresIn: JWT_EXPIRES_IN,
|
||||
algorithm: JWT_ALG,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证JWT token
|
||||
* @param {string} token - JWT token
|
||||
* @returns {Object} 解码后的payload
|
||||
*/
|
||||
export function verifyToken(token) {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
const { verifyKey } = getSignVerifyKeys();
|
||||
return jwt.verify(token, verifyKey, { algorithms: [JWT_ALG] });
|
||||
}
|
||||
|
||||
/**
|
||||
* 为账户生成JWT token
|
||||
* @param {Object} account - 账户对象
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
export function generateAccountToken(account) {
|
||||
return signToken({
|
||||
|
Loading…
x
Reference in New Issue
Block a user