1
1
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2025-10-23 10:53:14 +00:00

Compare commits

..

No commits in common. "b20d8dab96d8b42f60617a56e0f854d8b8d3824f" and "be1d8d1328a14e422bc85251013bb99694de159e" have entirely different histories.

12 changed files with 64 additions and 209 deletions

View File

@ -1,5 +1,4 @@
# Classworks KV # Classworks KV
[Classworks](https://cs.houlangs.com)用于班级大屏的作业板小工具 [Classworks](https://cs.houlangs.com)用于班级大屏的作业板小工具
@ -21,31 +20,3 @@ This project is licensed under the **GNU AGPL v3.0**.
Copyright (C) 2025 **Sunwuyuan** (<https://wuyuan.dev>) Copyright (C) 2025 **Sunwuyuan** (<https://wuyuan.dev>)
See [LICENSE](./LICENSE) for details. See [LICENSE](./LICENSE) for details.
## 配置OAuth / JWT
在根目录创建或编辑 `.env`
- 基础地址(用于回调):
- `BASE_URL`: `http://localhost:3030`
- `FRONTEND_URL`: `http://localhost:5173`
- STCNCasdoorOIDC
- `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 登录

View File

@ -7,13 +7,10 @@ export const oauthProviders = {
tokenURL: "https://github.com/login/oauth/access_token", tokenURL: "https://github.com/login/oauth/access_token",
userInfoURL: "https://api.github.com/user", userInfoURL: "https://api.github.com/user",
scope: "read:user user:email", scope: "read:user user:email",
// 展示相关
name: "GitHub", name: "GitHub",
displayName: "GitHub",
icon: "github", icon: "github",
color: "#24292e", color: "#24292e",
description: "使用 GitHub 账号登录", description: "使用 GitHub 账号登录",
website: "https://github.com",
}, },
zerocat: { zerocat: {
clientId: process.env.ZEROCAT_CLIENT_ID, clientId: process.env.ZEROCAT_CLIENT_ID,
@ -22,49 +19,23 @@ export const oauthProviders = {
tokenURL: "https://zerocat-api.houlangs.com/oauth/token", tokenURL: "https://zerocat-api.houlangs.com/oauth/token",
userInfoURL: "https://zerocat-api.houlangs.com/oauth/userinfo", userInfoURL: "https://zerocat-api.houlangs.com/oauth/userinfo",
scope: "user:basic user:email", scope: "user:basic user:email",
// 展示相关
name: "ZeroCat", name: "ZeroCat",
displayName: "ZeroCat",
icon: "zerocat", icon: "zerocat",
color: "#415f91", color: "#6366f1",
description: "使用 ZeroCat 账号登录", description: "使用 ZeroCat 账号登录",
website: "https://zerocat.dev",
},
stcn: {
// STCNCasdoor- 标准 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: { hly: {
// 厚浪云Logto - OIDC Provider // 厚浪云Logto - OIDC Provider
clientId: process.env.HLY_CLIENT_ID, clientId: process.env.HLY_CLIENT_ID,
clientSecret: process.env.HLY_CLIENT_SECRET, clientSecret: process.env.HLY_CLIENT_SECRET, // 可选若使用PKCE且应用为Public可不配置
authorizationURL: "https://oauth.houlang.cloud/oidc/auth", authorizationURL: "https://oauth.houlang.cloud/oidc/auth",
tokenURL: "https://oauth.houlang.cloud/oidc/token", tokenURL: "https://oauth.houlang.cloud/oidc/token",
userInfoURL: "https://oauth.houlang.cloud/oidc/me", userInfoURL: "https://oauth.houlang.cloud/oidc/me",
scope: "openid profile email offline_access", scope: "openid profile email offline_access",
// 展示相关
name: "厚浪云", name: "厚浪云",
displayName: "厚浪云",
icon: "logto", icon: "logto",
color: "#2d53f8", color: "#0ea5e9",
textColor: "#ffffff",
order: 40,
description: "使用厚浪云账号登录", description: "使用厚浪云账号登录",
website: "https://houlang.cloud",
pkce: true, // 启用PKCE支持 pkce: true, // 启用PKCE支持
}, },
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "ClassworksKV", "name": "ClassworksKV",
"version": "1.1.1", "version": "1.0.9",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "node ./bin/www", "start": "node ./bin/www",

32
pnpm-lock.yaml generated
View File

@ -1213,11 +1213,13 @@ packages:
'@types/node@22.15.17': '@types/node@22.15.17':
resolution: {integrity: sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==} resolution: {integrity: sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==}
'@types/node@24.4.0': <<<<<<< HEAD
resolution: {integrity: sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==}
'@types/node@24.6.1': '@types/node@24.6.1':
resolution: {integrity: sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==} resolution: {integrity: sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==}
=======
'@types/node@24.4.0':
resolution: {integrity: sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==}
>>>>>>> 12bded7e3d9aaf1a6f9f74126dec551d84efcd8f
'@types/oracledb@6.5.2': '@types/oracledb@6.5.2':
resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==}
@ -2385,11 +2387,13 @@ packages:
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.11.0: <<<<<<< HEAD
resolution: {integrity: sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==}
undici-types@7.13.0: undici-types@7.13.0:
resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==} resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==}
=======
undici-types@7.11.0:
resolution: {integrity: sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==}
>>>>>>> 12bded7e3d9aaf1a6f9f74126dec551d84efcd8f
unpipe@1.0.0: unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
@ -3706,14 +3710,16 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/node@24.4.0': <<<<<<< HEAD
dependencies:
undici-types: 7.11.0
'@types/node@24.6.1': '@types/node@24.6.1':
dependencies: dependencies:
undici-types: 7.13.0 undici-types: 7.13.0
optional: true optional: true
=======
'@types/node@24.4.0':
dependencies:
undici-types: 7.11.0
>>>>>>> 12bded7e3d9aaf1a6f9f74126dec551d84efcd8f
'@types/oracledb@6.5.2': '@types/oracledb@6.5.2':
dependencies: dependencies:
@ -4976,10 +4982,12 @@ snapshots:
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici-types@7.11.0: {} <<<<<<< HEAD
undici-types@7.13.0: undici-types@7.13.0:
optional: true optional: true
=======
undici-types@7.11.0: {}
>>>>>>> 12bded7e3d9aaf1a6f9f74126dec551d84efcd8f
unpipe@1.0.0: {} unpipe@1.0.0: {}

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `Account` MODIFY `refreshToken` TEXT NULL;

View File

@ -1,5 +0,0 @@
-- DropIndex
DROP INDEX `Account_accessToken_key` ON `Account`;
-- AlterTable
ALTER TABLE `Account` MODIFY `accessToken` TEXT NOT NULL;

View File

@ -1 +0,0 @@
-- This is an empty migration.

View File

@ -1,9 +0,0 @@
/*
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;

View File

@ -29,7 +29,8 @@ model Account {
name String? // 用户名称 name String? // 用户名称
avatarUrl String? // 用户头像URL avatarUrl String? // 用户头像URL
providerData Json? // OAuth提供者返回的完整信息 providerData Json? // OAuth提供者返回的完整信息
accessToken String? @db.Text // 账户访问令牌 accessToken String @unique // 账户访问令牌
refreshToken String? // OAuth refresh token (如果提供者支持)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -41,7 +41,7 @@ function generateAccessToken() {
* GET /accounts/oauth/providers * GET /accounts/oauth/providers
*/ */
router.get("/oauth/providers", (req, res) => { router.get("/oauth/providers", (req, res) => {
let providers = []; const providers = [];
for (const [key, config] of Object.entries(oauthProviders)) { for (const [key, config] of Object.entries(oauthProviders)) {
// 只返回已配置的提供者 // 只返回已配置的提供者
@ -50,22 +50,14 @@ router.get("/oauth/providers", (req, res) => {
providers.push({ providers.push({
id: key, id: key,
name: config.name, name: config.name,
displayName: config.displayName || config.name,
icon: config.icon, icon: config.icon,
color: config.color, // 向后兼容 color: config.color,
brandColor: config.brandColor || config.color,
textColor: config.textColor || "#ffffff",
description: config.description, description: config.description,
order: typeof config.order === 'number' ? config.order : 9999,
authUrl: `/accounts/oauth/${key}`, // 前端用于发起认证的URL authUrl: `/accounts/oauth/${key}`, // 前端用于发起认证的URL
website: config.website,
}); });
} }
} }
// 按 order 排序(从小到大)
providers = providers.sort((a, b) => a.order - b.order);
res.json({ res.json({
success: true, success: true,
data: providers, data: providers,
@ -187,42 +179,22 @@ router.get("/oauth/:provider/callback", async (req, res) => {
try { try {
// 1. 使用授权码换取访问令牌 // 1. 使用授权码换取访问令牌
let tokenResponse; const tokenResponse = await fetch(providerConfig.tokenURL, {
if (providerConfig.tokenRequestFormat === 'json') { method: "POST",
tokenResponse = await fetch(providerConfig.tokenURL, { headers: {
method: "POST", "Accept": "application/json",
headers: { "Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json", },
"Content-Type": "application/json", body: new URLSearchParams({
}, client_id: providerConfig.clientId,
body: JSON.stringify({ ...(providerConfig.clientSecret ? { client_secret: providerConfig.clientSecret } : {}),
client_id: providerConfig.clientId, code: code,
...(providerConfig.clientSecret ? { client_secret: providerConfig.clientSecret } : {}), grant_type: "authorization_code",
code: code, redirect_uri: getCallbackURL(provider),
grant_type: "authorization_code", // PKCE: 携带code_verifier
redirect_uri: getCallbackURL(provider), ...(stateData?.codeVerifier ? { code_verifier: stateData.codeVerifier } : {}),
// 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(); const tokenData = await tokenResponse.json();
@ -231,20 +203,12 @@ router.get("/oauth/:provider/callback", async (req, res) => {
} }
// 2. 使用访问令牌获取用户信息 // 2. 使用访问令牌获取用户信息
let userResponse; const userResponse = await fetch(providerConfig.userInfoURL, {
// Casdoor 支持两种方式Authorization Bearer 或 accessToken 查询参数 headers: {
if (provider === 'stcn') { "Authorization": `Bearer ${tokenData.access_token}`,
const url = new URL(providerConfig.userInfoURL); "Accept": "application/json",
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(); const userData = await userResponse.json();
@ -273,14 +237,6 @@ router.get("/oauth/:provider/callback", async (req, res) => {
name: userData.name || userData.preferred_username || userData.nickname, name: userData.name || userData.preferred_username || userData.nickname,
avatarUrl: userData.picture, avatarUrl: userData.picture,
}; };
} else if (provider === "stcn") {
// STCNCasdoor标准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,
};
} }
// 名称为空时,用邮箱@前部分回填(若邮箱可用) // 名称为空时,用邮箱@前部分回填(若邮箱可用)
@ -310,7 +266,7 @@ router.get("/oauth/:provider/callback", async (req, res) => {
name: normalizedUser.name || account.name, name: normalizedUser.name || account.name,
avatarUrl: normalizedUser.avatarUrl || account.avatarUrl, avatarUrl: normalizedUser.avatarUrl || account.avatarUrl,
providerData: userData, providerData: userData,
//refreshToken: tokenData.refresh_token || account.refreshToken, refreshToken: tokenData.refresh_token || account.refreshToken,
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
@ -326,7 +282,7 @@ router.get("/oauth/:provider/callback", async (req, res) => {
avatarUrl: normalizedUser.avatarUrl, avatarUrl: normalizedUser.avatarUrl,
providerData: userData, providerData: userData,
accessToken, accessToken,
//refreshToken: tokenData.refresh_token, refreshToken: tokenData.refresh_token,
}, },
}); });
} }
@ -339,12 +295,6 @@ router.get("/oauth/:provider/callback", async (req, res) => {
const callbackUrl = new URL(frontendBaseUrl); const callbackUrl = new URL(frontendBaseUrl);
callbackUrl.searchParams.append("token", jwtToken); callbackUrl.searchParams.append("token", jwtToken);
callbackUrl.searchParams.append("provider", provider); 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"); callbackUrl.searchParams.append("success", "true");
res.redirect(callbackUrl.toString()); res.redirect(callbackUrl.toString());
@ -388,27 +338,11 @@ 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({ res.json({
success: true, success: true,
data: { data: {
id: account.id, id: account.id,
provider: account.provider, provider: account.provider,
providerInfo,
email: account.email, email: account.email,
name: account.name, name: account.name,
avatarUrl: account.avatarUrl, avatarUrl: account.avatarUrl,

View File

@ -140,6 +140,7 @@ router.post(
errors.catchAsync(async (req, res, next) => { errors.catchAsync(async (req, res, next) => {
const { uuid } = req.params; const { uuid } = req.params;
const newPassword = req.query.newPassword || req.body.newPassword; const newPassword = req.query.newPassword || req.body.newPassword;
const passwordHint = req.query.passwordHint || req.body.passwordHint;
if (!newPassword) { if (!newPassword) {
return next(errors.createError(400, "新密码是必需的")); return next(errors.createError(400, "新密码是必需的"));
@ -165,6 +166,7 @@ router.post(
where: { id: device.id }, where: { id: device.id },
data: { data: {
password: hashedPassword, password: hashedPassword,
passwordHint: passwordHint || null,
}, },
}); });

View File

@ -1,48 +1,33 @@
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
// JWT 配置(支持 HS256 与 RS256 // JWT密钥 - 生产环境应该从环境变量读取
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_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 * 签发JWT token
* @param {Object} payload - 要编码的数据
* @returns {string} JWT token
*/ */
export function signToken(payload) { export function signToken(payload) {
const { signKey } = getSignVerifyKeys(); return jwt.sign(payload, JWT_SECRET, {
return jwt.sign(payload, signKey, {
expiresIn: JWT_EXPIRES_IN, expiresIn: JWT_EXPIRES_IN,
algorithm: JWT_ALG,
}); });
} }
/** /**
* 验证JWT token * 验证JWT token
* @param {string} token - JWT token
* @returns {Object} 解码后的payload
*/ */
export function verifyToken(token) { export function verifyToken(token) {
const { verifyKey } = getSignVerifyKeys(); return jwt.verify(token, JWT_SECRET);
return jwt.verify(token, verifyKey, { algorithms: [JWT_ALG] });
} }
/** /**
* 为账户生成JWT token * 为账户生成JWT token
* @param {Object} account - 账户对象
* @returns {string} JWT token
*/ */
export function generateAccountToken(account) { export function generateAccountToken(account) {
return signToken({ return signToken({