feat(auth): add configurable password login with SSO default
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# 登录方式与 Portainer 配置
|
||||
|
||||
不设置 `BI_AUTH_MODE` 时默认 `sso`,沿用业务系统 jumpToken 登录。
|
||||
设置 `BI_AUTH_MODE=password` 时只开放固定密码登录,关闭 SSO 换票入口。
|
||||
浏览器从 `/api/auth/config` 获取模式,密码不会打包到前端,不使用 VITE_ 密码变量。
|
||||
|
||||
## Portainer Stack
|
||||
|
||||
在现有 BI 服务的 `environment` 中合并以下配置,保留原镜像、端口、数据库等配置:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
BI_AUTH_MODE: ${BI_AUTH_MODE:-sso}
|
||||
BI_AUTH_PASSWORD: ${BI_AUTH_PASSWORD:-}
|
||||
JWT_SECRET: ${JWT_SECRET:?必须配置独立的随机签名密钥}
|
||||
DEV_BYPASS_AUTH: "0"
|
||||
NODE_ENV: production
|
||||
```
|
||||
|
||||
在 Stack 的 Environment variables 区域录入以下名称和值:
|
||||
|
||||
| 名称 | 固定密码模式 | SSO 模式 |
|
||||
| --- | --- | --- |
|
||||
| BI_AUTH_MODE | password | sso |
|
||||
| BI_AUTH_PASSWORD | 独立随机密码,至少 16 字符 | 留空 |
|
||||
| JWT_SECRET | 独立随机密钥,至少 32 字符 | 保留部署专用密钥 |
|
||||
|
||||
不要把真实密码提交进 Git。Portainer 中填写的 Stack 变量必须通过上面的 environment 映射才能进入容器。
|
||||
更新 Stack 并重新创建容器;单纯 Restart 不会更新容器环境变量。
|
||||
容器方式部署:Duplicate/Edit → Advanced container settings → Env,填写同样变量,再重新部署。
|
||||
需要先构建包含本功能的新镜像;旧镜像不认识这些变量。
|
||||
|
||||
## 权限、安全和验证
|
||||
|
||||
- 固定密码身份是共享只读身份,具有全量看板数据读取范围及能源访问权限,无调度/反馈管理角色;服务端拒绝写入方法。
|
||||
- 会话有效期 8 小时。更换密码、JWT_SECRET 或切换模式后,原密码会话失效。
|
||||
- 每个服务实例 15 分钟最多 20 次密码验证请求;多副本需在网关配置共享限流。共享限流可能被恶意请求耗尽,建议只在内网或受控网络开放。
|
||||
- 外网必须使用 HTTPS,避免明文传输密码和令牌。密码/环境变量对拥有容器管理权限的人可见。
|
||||
- 密码少于 16 字符或签名密钥少于 32 字符时拒绝登录;无默认访问密码。不要沿用镜像中的旧默认 JWT_SECRET。
|
||||
- DB_READ_ONLY=1 时密码登录仍可用,业务写入仍被禁止。
|
||||
- 测试未登录访问、错误密码、正确密码、刷新恢复会话;切回 sso 后检查跳转登录。
|
||||
- 开发免登录仅 SSO 开发模式生效,生产必须保持 DEV_BYPASS_AUTH=0。
|
||||
|
||||
Portainer 官方说明:https://docs.portainer.io/user/docker/containers/advanced
|
||||
以及 https://docs.portainer.io/sts/user/docker/stacks/add
|
||||
+3
-1
@@ -3,6 +3,7 @@ import { Shell } from "./components/Shell";
|
||||
import AuthProvider from "./auth/AuthProvider";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||
import PasswordLogin from "./auth/PasswordLogin";
|
||||
import { canAccessEnergy } from "./shared/auth/roles";
|
||||
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
|
||||
import { buildModules } from "./app/modules";
|
||||
@@ -22,7 +23,7 @@ const HydrogenPrototypeBoard = lazy(
|
||||
normalizeBrowserPath();
|
||||
|
||||
function AuthGate() {
|
||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||
const { isLoading, isAuthenticated, error, user, mode } = useAuth();
|
||||
const [route, setRoute] = useState(readBrowserRoute);
|
||||
const { routeKey, pathSet } = route;
|
||||
|
||||
@@ -71,6 +72,7 @@ function AuthGate() {
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (mode === 'password') return <PasswordLogin />;
|
||||
return <UnauthorizedPage message={error || undefined} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { setTokenGetter } from './api-client';
|
||||
const AUTH_API = '/api/auth';
|
||||
|
||||
export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [mode, setMode] = useState<'sso' | 'password'>('sso');
|
||||
const [state, setState] = useState<AuthState>({
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
@@ -19,8 +20,6 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setTokenGetter(() => tokenRef.current);
|
||||
|
||||
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
|
||||
if (authStarted.current) return;
|
||||
authStarted.current = true;
|
||||
|
||||
// 监听 401 事件
|
||||
const onUnauthorized = () => {
|
||||
@@ -30,14 +29,25 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
window.addEventListener('auth:unauthorized', onUnauthorized);
|
||||
|
||||
authenticate();
|
||||
if (!authStarted.current) { authStarted.current = true; authenticate(); }
|
||||
|
||||
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
|
||||
}, []);
|
||||
|
||||
async function authenticate() {
|
||||
let currentMode: 'sso' | 'password';
|
||||
try {
|
||||
const response = await fetch(`${AUTH_API}/config`, { cache: 'no-store' });
|
||||
const config = await response.json();
|
||||
if (!response.ok || !['sso', 'password'].includes(config.mode)) throw new Error();
|
||||
currentMode = config.mode;
|
||||
setMode(currentMode);
|
||||
} catch {
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '无法获取验证配置,请刷新重试' });
|
||||
return;
|
||||
}
|
||||
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||
if (currentMode === 'sso' && import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
@@ -59,15 +69,15 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
tokenRef.current = savedToken;
|
||||
// 验证 token 是否仍然有效(尝试请求 health)
|
||||
try {
|
||||
const res = await fetch('/api/health', {
|
||||
const res = await fetch(`${AUTH_API}/me`, {
|
||||
headers: { Authorization: `Bearer ${savedToken}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const savedUser = sessionStorage.getItem('bi_user');
|
||||
const savedUser = await res.json();
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: savedUser ? JSON.parse(savedUser) : null,
|
||||
user: savedUser,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
@@ -75,6 +85,12 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
} catch { /* token 无效,继续流程 */ }
|
||||
sessionStorage.removeItem('bi_jwt');
|
||||
sessionStorage.removeItem('bi_user');
|
||||
tokenRef.current = null;
|
||||
}
|
||||
|
||||
if (currentMode === 'password') {
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 从 URL 提取 jumpToken
|
||||
@@ -119,8 +135,18 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithPassword(password: string) {
|
||||
const response = await fetch(`${AUTH_API}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.token) throw new Error(data.message || '登录失败');
|
||||
tokenRef.current = data.token;
|
||||
sessionStorage.setItem('bi_jwt', data.token);
|
||||
sessionStorage.setItem('bi_user', JSON.stringify(data.user));
|
||||
setState({ isLoading: false, isAuthenticated: true, user: data.user, error: null });
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={state}>
|
||||
<AuthContext.Provider value={{ ...state, mode, loginWithPassword }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from './useAuth';
|
||||
|
||||
export default function PasswordLogin() {
|
||||
const { loginWithPassword, error: sessionError } = useAuth();
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
return <main className="min-h-screen flex items-center justify-center bg-slate-950 p-6">
|
||||
<form className="w-full max-w-sm rounded-2xl bg-white p-7 shadow-xl" onSubmit={async event => {
|
||||
event.preventDefault(); if (busy || !loginWithPassword) return;
|
||||
setBusy(true); setError('');
|
||||
try { await loginWithPassword(password); } catch (e) { setError(e instanceof Error ? e.message : '登录失败,请重试'); }
|
||||
finally { setBusy(false); setPassword(''); }
|
||||
}}>
|
||||
<p className="text-xs font-semibold text-blue-600">羚牛 · 能源 BI</p>
|
||||
<h1 className="mt-2 text-2xl font-bold text-slate-900">访问看板</h1>
|
||||
<p className="mt-2 mb-6 text-sm text-slate-500">请输入管理员提供的访问密码。此入口仅提供只读访问。</p>
|
||||
<label htmlFor="bi-password" className="text-sm font-medium text-slate-700">访问密码</label>
|
||||
<input id="bi-password" type="password" autoComplete="current-password" required maxLength={1024} value={password} onChange={e => setPassword(e.target.value)} className="mt-2 w-full rounded-lg border border-slate-300 px-3 py-3 text-base focus:outline-blue-600" />
|
||||
{error || sessionError ? <p role="alert" className="mt-3 text-sm text-red-600">{error || sessionError}</p> : null}
|
||||
<button disabled={busy || !password} className="mt-5 min-h-11 w-full rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white disabled:opacity-50">{busy ? '正在验证…' : '进入看板'}</button>
|
||||
</form>
|
||||
</main>;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface AuthState {
|
||||
mode?: 'sso' | 'password';
|
||||
loginWithPassword?: (password: string) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
user: {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { JwtPayload } from './types.js';
|
||||
|
||||
export function authMode() {
|
||||
const mode = process.env.BI_AUTH_MODE || 'sso';
|
||||
if (mode !== 'sso' && mode !== 'password') throw new Error('Invalid BI_AUTH_MODE');
|
||||
return mode;
|
||||
}
|
||||
|
||||
export function passwordConfig() {
|
||||
const password = process.env.BI_AUTH_PASSWORD || '';
|
||||
const secret = process.env.JWT_SECRET || '';
|
||||
if (password.length < 16 || secret.length < 32) throw new Error('Password authentication is not configured securely');
|
||||
return { password, key: createHmac('sha256', secret).update(`bi-password:${password}`).digest('hex') };
|
||||
}
|
||||
|
||||
export function verifyAuthToken(token: string): JwtPayload {
|
||||
const passwordMode = authMode() === 'password';
|
||||
const key = passwordMode ? passwordConfig().key : process.env.JWT_SECRET || 'ln-bi-default-secret';
|
||||
const payload = jwt.verify(token, key, { algorithms: ['HS256'] }) as JwtPayload;
|
||||
if (passwordMode ? payload.authMethod !== 'password' : payload.authMethod === 'password') throw new Error('Authentication mode changed');
|
||||
return payload;
|
||||
}
|
||||
@@ -3,14 +3,18 @@ import jwt from 'jsonwebtoken';
|
||||
import pool from '../db.js';
|
||||
import type { AuthUser, JwtPayload, PermissionLevel } from './types.js';
|
||||
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
|
||||
import { authMode, verifyAuthToken } from './config.js';
|
||||
import { passwordRouter } from './password.js';
|
||||
|
||||
const app = new Hono();
|
||||
app.route('/', passwordRouter());
|
||||
|
||||
const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com';
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
||||
|
||||
/** GET /api/auth/exchange?jumpToken=xxx — 一步完成:换取用户信息 + 签发 JWT */
|
||||
app.get('/exchange', async (c) => {
|
||||
if (authMode() !== 'sso') return c.json({ message: '当前使用固定密码登录' }, 403);
|
||||
const jumpToken = c.req.query('jumpToken');
|
||||
if (!jumpToken) return c.json({ error: 'Missing jumpToken' }, 400);
|
||||
|
||||
@@ -90,7 +94,7 @@ app.get('/me', async (c) => {
|
||||
return c.json({ error: 'No token' }, 401);
|
||||
}
|
||||
try {
|
||||
const payload = jwt.verify(authHeader.slice(7), JWT_SECRET) as JwtPayload;
|
||||
const payload = verifyAuthToken(authHeader.slice(7));
|
||||
return c.json(payload);
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid token' }, 401);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Context, Next } from 'hono';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { JwtPayload, AuthUser } from './types.js';
|
||||
import type { AuthUser } from './types.js';
|
||||
import { authMode, verifyAuthToken } from './config.js';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
||||
|
||||
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
|
||||
const BYPASS_AUTH = false;
|
||||
@@ -15,7 +14,7 @@ export async function authMiddleware(c: Context, next: Next) {
|
||||
}
|
||||
|
||||
// 本地开发免登录开关:.env 里设 DEV_BYPASS_AUTH=1 启用
|
||||
if (process.env.DEV_BYPASS_AUTH === '1') {
|
||||
if (process.env.NODE_ENV !== 'production' && process.env.DEV_BYPASS_AUTH === '1' && authMode() === 'sso') {
|
||||
const devUser: AuthUser = {
|
||||
userId: 'dev-local',
|
||||
userName: '本地开发',
|
||||
@@ -42,7 +41,10 @@ export async function authMiddleware(c: Context, next: Next) {
|
||||
const token = authHeader.slice(7);
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as JwtPayload;
|
||||
const payload = verifyAuthToken(token);
|
||||
if (payload.authMethod === 'password' && !['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
|
||||
return c.json({ error: 'Password account is read-only' }, 403);
|
||||
}
|
||||
const user: AuthUser = {
|
||||
userId: payload.userId,
|
||||
userName: payload.userName,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { passwordRouter } from './password.js';
|
||||
import { authMiddleware } from './middleware.js';
|
||||
import { readOnlyMiddleware } from '../read-only-middleware.js';
|
||||
import { verifyAuthToken } from './config.js';
|
||||
|
||||
test('固定密码模式默认关闭、失败限流、只读权限及换密失效', async () => {
|
||||
const previous = { ...process.env };
|
||||
try {
|
||||
delete process.env.BI_AUTH_MODE;
|
||||
process.env.JWT_SECRET = 'test-signing-secret-with-at-least-32-characters';
|
||||
process.env.BI_AUTH_PASSWORD = 'test-password-only-123456';
|
||||
process.env.DEV_BYPASS_AUTH = '0';
|
||||
const app = new Hono();
|
||||
app.use('/api/*', readOnlyMiddleware);
|
||||
app.route('/api/auth', passwordRouter());
|
||||
app.use('/api/*', authMiddleware);
|
||||
app.get('/api/data', c => c.json({ ok: true }));
|
||||
app.post('/api/data', c => c.json({ ok: true }));
|
||||
const login = (password: unknown) => app.request('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
|
||||
assert.deepEqual(await (await app.request('/api/auth/config')).json(), { mode: 'sso' });
|
||||
assert.equal((await login(process.env.BI_AUTH_PASSWORD)).status, 403);
|
||||
const ssoToken = jwt.sign({ userId: 'sso-user', roles: [] }, process.env.JWT_SECRET);
|
||||
assert.equal(verifyAuthToken(ssoToken).userId, 'sso-user');
|
||||
process.env.BI_AUTH_MODE = 'password';
|
||||
assert.throws(() => verifyAuthToken(ssoToken));
|
||||
assert.equal((await login('wrong')).status, 401);
|
||||
process.env.DB_READ_ONLY = '1';
|
||||
const response = await login(process.env.BI_AUTH_PASSWORD);
|
||||
assert.equal(response.status, 200);
|
||||
const { token, user } = await response.json();
|
||||
assert.deepEqual(user.roles, ['BI-LEADER-ENERGY']);
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
assert.equal((await app.request('/api/data', { headers })).status, 200);
|
||||
const payload = verifyAuthToken(token);
|
||||
assert.equal(Number(payload.exp) - Number(payload.iat), 8 * 60 * 60);
|
||||
process.env.DB_READ_ONLY = '0';
|
||||
assert.equal((await app.request('/api/data', { method: 'POST', headers })).status, 403);
|
||||
assert.equal((await app.request('/api/data')).status, 401);
|
||||
assert.throws(() => verifyAuthToken(jwt.sign({ authMethod: 'password' }, 'wrong-key')));
|
||||
process.env.BI_AUTH_PASSWORD = 'a-different-password-123456';
|
||||
assert.equal((await app.request('/api/data', { headers })).status, 401);
|
||||
process.env.BI_AUTH_MODE = 'sso';
|
||||
assert.equal((await app.request('/api/data', { headers })).status, 401);
|
||||
process.env.BI_AUTH_MODE = 'password';
|
||||
delete process.env.BI_AUTH_PASSWORD;
|
||||
assert.equal((await login('')).status, 503);
|
||||
process.env.BI_AUTH_PASSWORD = 'a-different-password-123456';
|
||||
for (let i = 0; i < 20; i++) await login('wrong');
|
||||
assert.equal((await login(process.env.BI_AUTH_PASSWORD)).status, 429);
|
||||
} finally {
|
||||
for (const key of ['BI_AUTH_MODE', 'BI_AUTH_PASSWORD', 'JWT_SECRET', 'DEV_BYPASS_AUTH', 'DB_READ_ONLY']) {
|
||||
if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Hono } from 'hono';
|
||||
import { bodyLimit } from 'hono/body-limit';
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { authMode, passwordConfig } from './config.js';
|
||||
|
||||
export function passwordRouter() {
|
||||
const app = new Hono();
|
||||
app.use('/password', bodyLimit({ maxSize: 4096 }));
|
||||
// One shared account: a bounded instance-wide budget cannot be bypassed by spoofing proxy headers.
|
||||
let attempts = 0;
|
||||
let resetAt = 0;
|
||||
app.get('/config', c => {
|
||||
c.header('Cache-Control', 'no-store');
|
||||
try { return c.json({ mode: authMode() }); }
|
||||
catch { return c.json({ message: '验证方式配置错误,请联系管理员' }, 503); }
|
||||
});
|
||||
app.post('/password', async c => {
|
||||
c.header('Cache-Control', 'no-store');
|
||||
try {
|
||||
if (authMode() !== 'password') return c.json({ message: '未启用固定密码登录' }, 403);
|
||||
const config = passwordConfig();
|
||||
if (Date.now() >= resetAt) { attempts = 0; resetAt = Date.now() + 15 * 60_000; }
|
||||
if (attempts >= 20) {
|
||||
c.header('Retry-After', String(Math.ceil((resetAt - Date.now()) / 1000)));
|
||||
return c.json({ message: '尝试过于频繁,请稍后再试' }, 429);
|
||||
}
|
||||
attempts++;
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (typeof body?.password !== 'string' || body.password.length > 1024) return c.json({ message: '密码错误' }, 401);
|
||||
const digest = (s: string) => createHash('sha256').update(s).digest();
|
||||
if (!timingSafeEqual(digest(body.password), digest(config.password))) return c.json({ message: '密码错误' }, 401);
|
||||
const user = { userId: 'bi-password-viewer', userName: '看板访客', loginName: 'bi-password-viewer', depCode: '', depName: '', permissionLevel: 'full' as const, roles: ['BI-LEADER-ENERGY'], authMethod: 'password' as const };
|
||||
return c.json({ token: jwt.sign(user, config.key, { expiresIn: '8h', algorithm: 'HS256' }), user });
|
||||
} catch { return c.json({ message: '密码登录配置不可用,请联系管理员' }, 503); }
|
||||
});
|
||||
return app;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface AuthUser {
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
authMethod?: 'sso' | 'password';
|
||||
userId: string;
|
||||
userName: string;
|
||||
loginName: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MiddlewareHandler } from 'hono';
|
||||
/** Guard preview write endpoints before auth/route handlers can perform work. */
|
||||
export const readOnlyMiddleware: MiddlewareHandler = async (context, next) => {
|
||||
if (process.env.DB_READ_ONLY === '1'
|
||||
&& context.req.path !== '/api/auth/password'
|
||||
&& !['GET', 'HEAD', 'OPTIONS'].includes(context.req.method)) {
|
||||
return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user