diff --git a/README.md b/README.md index 92c7fff..a9fb174 100644 --- a/README.md +++ b/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** () 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 登录 + diff --git a/config/oauth.js b/config/oauth.js index c98a559..624cd36 100644 --- a/config/oauth.js +++ b/config/oauth.js @@ -7,9 +7,11 @@ 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", + color: "#24292e", // 兼容旧字段 description: "使用 GitHub 账号登录", }, zerocat: { @@ -19,11 +21,30 @@ 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", description: "使用 ZeroCat 账号登录", }, + 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: "#1f6feb", + description: "使用智教联盟账户登录", + tokenRequestFormat: "json", // Casdoor 推荐 JSON 提交 + }, hly: { // 厚浪云(Logto) - OIDC Provider clientId: process.env.HLY_CLIENT_ID, @@ -32,9 +53,14 @@ export const oauthProviders = { 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: "#0ea5e9", + brandColor: "#0ea5e9", + textColor: "#ffffff", + order: 40, description: "使用厚浪云账号登录", pkce: true, // 启用PKCE支持 }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ceef85f..cd87d07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/prisma/migrations/20251007040712_increase_account_refresh_token_length/migration.sql b/prisma/migrations/20251007040712_increase_account_refresh_token_length/migration.sql new file mode 100644 index 0000000..51eb10a --- /dev/null +++ b/prisma/migrations/20251007040712_increase_account_refresh_token_length/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `Account` MODIFY `refreshToken` TEXT NULL; diff --git a/prisma/migrations/20251007041015_update/migration.sql b/prisma/migrations/20251007041015_update/migration.sql new file mode 100644 index 0000000..4635b70 --- /dev/null +++ b/prisma/migrations/20251007041015_update/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX `Account_accessToken_key` ON `Account`; + +-- AlterTable +ALTER TABLE `Account` MODIFY `accessToken` TEXT NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 543b38a..267ebd3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,8 +29,8 @@ model Account { name String? // 用户名称 avatarUrl String? // 用户头像URL providerData Json? // OAuth提供者返回的完整信息 - accessToken String @unique // 账户访问令牌 - refreshToken String? // OAuth refresh token (如果提供者支持) + accessToken String @db.Text // 账户访问令牌 + refreshToken String? @db.Text // OAuth refresh token (如果提供者支持) - 可能很长,使用 TEXT createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/routes/accounts.js b/routes/accounts.js index 84049f3..8fc7a74 100644 --- a/routes/accounts.js +++ b/routes/accounts.js @@ -41,7 +41,7 @@ function generateAccessToken() { * GET /accounts/oauth/providers */ router.get("/oauth/providers", (req, res) => { - const providers = []; + let providers = []; for (const [key, config] of Object.entries(oauthProviders)) { // 只返回已配置的提供者 @@ -50,14 +50,21 @@ router.get("/oauth/providers", (req, res) => { 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 }); } } + // 按 order 排序(从小到大) + providers = providers.sort((a, b) => a.order - b.order); + res.json({ success: true, data: providers, @@ -179,22 +186,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, - ...(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 } : {}), - }), - }); + 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(); @@ -203,12 +230,20 @@ 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(); @@ -237,6 +272,14 @@ router.get("/oauth/:provider/callback", async (req, res) => { 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, + }; } // 名称为空时,用邮箱@前部分回填(若邮箱可用) @@ -295,6 +338,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()); @@ -338,11 +387,26 @@ 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, + }; + res.json({ success: true, data: { id: account.id, provider: account.provider, + providerInfo, email: account.email, name: account.name, avatarUrl: account.avatarUrl, diff --git a/routes/device.js b/routes/device.js index f4f4c1d..67951b2 100644 --- a/routes/device.js +++ b/routes/device.js @@ -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, }, }); diff --git a/utils/jwt.js b/utils/jwt.js index 70eb47b..af3f1a6 100644 --- a/utils/jwt.js +++ b/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({