mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-04 22:12:49 +08:00
fix: revoke API keys on password rotation and audit key owners
This commit is contained in:
1 parent
79fb8443be
commit
b9c79c8138
7 files changed
+110
-20
No files matched your search
+12
-3
@@ -12,7 +12,15 @@ Authorization: Bearer tsmb_xxxxxxxxxxxx
|
|||||||
X-API-Key: tsmb_xxxxxxxxxxxx
|
X-API-Key: tsmb_xxxxxxxxxxxx
|
||||||
```
|
```
|
||||||
|
|
||||||
Key 在 WebUI 设置页创建,权限与所属账户一致。浏览器 session(cookie)也可调用全部端点,两者行为相同。
|
Key 在 WebUI 设置页创建,权限与所属账户一致。除标注「仅浏览器 session」的端点外,API Key 与浏览器 session(cookie)使用相同的账户权限。
|
||||||
|
|
||||||
|
管理员 API Key 保留完整的 REST 管理权限,包括 `/api/users` 的创建用户、重置密码与权限变更;因此也可以创建新的可登录账户。`/api/keys` 的 session 限制只约束直接密钥管理,不能作为管理员 Key 的权限隔离措施。
|
||||||
|
|
||||||
|
### 密钥吊销与密码变更
|
||||||
|
|
||||||
|
API Key 没有自动到期时间,可在设置页随时吊销。删除账户会同时删除其全部 Key。成功修改自己的密码或由管理员重置密码,都会吊销该账户的全部 Key;依赖这些 Key 的外部集成需要重新生成并更新凭据。失败的密码变更不会吊销 Key。
|
||||||
|
|
||||||
|
修改自己的密码会保留当前浏览器 session,使其余 session 失效。管理员重置其他账户的密码会使目标账户的全部 session 失效;重置自己的密码时同样保留当前浏览器 session。
|
||||||
|
|
||||||
### 错误格式
|
### 错误格式
|
||||||
|
|
||||||
@@ -264,6 +272,7 @@ ProfileConfig:`{ avatarEnabled, descriptionEnabled, nicknameEnabled, awayStatusE
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST -H "X-API-Key: $KEY" -H "x-filename: theme.mp3" \
|
curl -X POST -H "X-API-Key: $KEY" -H "x-filename: theme.mp3" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary @theme.mp3 http://127.0.0.1:3000/api/music/local/upload
|
--data-binary @theme.mp3 http://127.0.0.1:3000/api/music/local/upload
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -330,7 +339,7 @@ curl -X POST -H "X-API-Key: $KEY" -H "x-filename: theme.mp3" \
|
|||||||
|
|
||||||
## API 密钥管理 /api/keys(仅浏览器 session)
|
## API 密钥管理 /api/keys(仅浏览器 session)
|
||||||
|
|
||||||
API Key **不能**调用这些端点(403)——泄露的 Key 无法自我复制;游客 session 也被拒绝。浏览器登录后调用。
|
API Key **不能直接调用这些密钥管理端点**(403);游客 session 也被拒绝。浏览器登录后调用。管理员 Key 仍保留上文所述的用户管理权限。
|
||||||
|
|
||||||
| 方法 | 路径 | 参数 | 返回 |
|
| 方法 | 路径 | 参数 | 返回 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
@@ -347,7 +356,7 @@ API Key **不能**调用这些端点(403)——泄露的 Key 无法自我复制;
|
|||||||
| GET | `/` | — | `{ users: [{ id, username, createdAt, role }] }` |
|
| GET | `/` | — | `{ users: [{ id, username, createdAt, role }] }` |
|
||||||
| POST | `/` | `{ username, password(≥8位), role: "admin"|"member" }` | `201 { id, username, role }`;重名 409 |
|
| POST | `/` | `{ username, password(≥8位), role: "admin"|"member" }` | `201 { id, username, role }`;重名 409 |
|
||||||
| DELETE | `/:id` | — | `204`(级联删除其 session 与 API Key) |
|
| DELETE | `/:id` | — | `204`(级联删除其 session 与 API Key) |
|
||||||
| POST | `/:id/reset-password` | `{ newPassword }` | `204`(该用户的 session 与 API Key 全部失效) |
|
| POST | `/:id/reset-password` | `{ newPassword }` | `204`(该用户的 API Key 全部失效,session 按上文密码变更规则处理) |
|
||||||
| PATCH | `/:id/role` | `{ role: "admin"|"member" }` | `204`(不能降级最后一个管理员) |
|
| PATCH | `/:id/role` | `{ role: "admin"|"member" }` | `204`(不能降级最后一个管理员) |
|
||||||
| GET | `/:id/permissions` | — | `{ capabilities: string[], bots: "all" | string[] }` |
|
| GET | `/:id/permissions` | — | `{ capabilities: string[], bots: "all" | string[] }` |
|
||||||
| PUT | `/:id/permissions` | `{ capabilities, bots: "all"|string[] }` | `{ success: true }` |
|
| PUT | `/:id/permissions` | `{ capabilities, bots: "all"|string[] }` | `{ success: true }` |
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface CreatedApiKey {
|
|||||||
export interface ApiKeyStore {
|
export interface ApiKeyStore {
|
||||||
/** Returns null when the per-user key cap is reached. */
|
/** Returns null when the per-user key cap is reached. */
|
||||||
create(userId: string, name: string): CreatedApiKey | null;
|
create(userId: string, name: string): CreatedApiKey | null;
|
||||||
|
findById(id: string): ApiKeyWithUser | null;
|
||||||
listForUser(userId: string): ApiKeyRow[];
|
listForUser(userId: string): ApiKeyRow[];
|
||||||
listAll(): ApiKeyWithUser[];
|
listAll(): ApiKeyWithUser[];
|
||||||
/** With userId, only deletes a key owned by that user. */
|
/** With userId, only deletes a key owned by that user. */
|
||||||
@@ -60,7 +61,9 @@ export function createApiKeyStore(db: Database.Database): ApiKeyStore {
|
|||||||
ORDER BY k.createdAt DESC`
|
ORDER BY k.createdAt DESC`
|
||||||
);
|
);
|
||||||
const selectByIdStmt = db.prepare(
|
const selectByIdStmt = db.prepare(
|
||||||
"SELECT id, userId, name, keyPrefix, createdAt, lastUsedAt FROM api_keys WHERE id = ?"
|
`SELECT k.id, k.userId, k.name, k.keyPrefix, k.createdAt, k.lastUsedAt, u.username
|
||||||
|
FROM api_keys k INNER JOIN users u ON u.id = k.userId
|
||||||
|
WHERE k.id = ?`
|
||||||
);
|
);
|
||||||
const deleteStmt = db.prepare("DELETE FROM api_keys WHERE id = ?");
|
const deleteStmt = db.prepare("DELETE FROM api_keys WHERE id = ?");
|
||||||
const deleteAllForUserStmt = db.prepare("DELETE FROM api_keys WHERE userId = ?");
|
const deleteAllForUserStmt = db.prepare("DELETE FROM api_keys WHERE userId = ?");
|
||||||
@@ -91,6 +94,10 @@ export function createApiKeyStore(db: Database.Database): ApiKeyStore {
|
|||||||
return { key: row, rawKey };
|
return { key: row, rawKey };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
findById(id) {
|
||||||
|
return (selectByIdStmt.get(id) as ApiKeyWithUser | undefined) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
listForUser(userId) {
|
listForUser(userId) {
|
||||||
return selectForUserStmt.all(userId) as ApiKeyRow[];
|
return selectForUserStmt.all(userId) as ApiKeyRow[];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import request from "supertest";
|
|||||||
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||||
import { createUserStore } from "../../data/users.js";
|
import { createUserStore } from "../../data/users.js";
|
||||||
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
||||||
import { createAuditStore } from "../../data/audit.js";
|
import { createAuditStore, type AuditStore } from "../../data/audit.js";
|
||||||
import { createApiKeyStore, MAX_API_KEYS_PER_USER, type ApiKeyStore } from "../../data/api-keys.js";
|
import { createApiKeyStore, MAX_API_KEYS_PER_USER, type ApiKeyStore } from "../../data/api-keys.js";
|
||||||
import { createPermissionStore } from "../../data/permissions.js";
|
import { createPermissionStore } from "../../data/permissions.js";
|
||||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||||
@@ -17,6 +17,7 @@ describe("api-keys router", () => {
|
|||||||
let app: express.Express;
|
let app: express.Express;
|
||||||
let sessions: SessionStore;
|
let sessions: SessionStore;
|
||||||
let apiKeys: ApiKeyStore;
|
let apiKeys: ApiKeyStore;
|
||||||
|
let audit: AuditStore;
|
||||||
let adminId: string;
|
let adminId: string;
|
||||||
let memberId: string;
|
let memberId: string;
|
||||||
let adminToken: string;
|
let adminToken: string;
|
||||||
@@ -26,7 +27,7 @@ describe("api-keys router", () => {
|
|||||||
botDb = createDatabase(":memory:");
|
botDb = createDatabase(":memory:");
|
||||||
const users = createUserStore(botDb.db);
|
const users = createUserStore(botDb.db);
|
||||||
sessions = createSessionStore(botDb.db);
|
sessions = createSessionStore(botDb.db);
|
||||||
const audit = createAuditStore(botDb.db);
|
audit = createAuditStore(botDb.db);
|
||||||
const permissions = createPermissionStore(botDb.db);
|
const permissions = createPermissionStore(botDb.db);
|
||||||
apiKeys = createApiKeyStore(botDb.db);
|
apiKeys = createApiKeyStore(botDb.db);
|
||||||
const admin = await users.createUser("alice", "pw-alice", "admin");
|
const admin = await users.createUser("alice", "pw-alice", "admin");
|
||||||
@@ -112,11 +113,20 @@ describe("api-keys router", () => {
|
|||||||
expect(apiKeys.listForUser(adminId)).toHaveLength(1);
|
expect(apiKeys.listForUser(adminId)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an admin can delete another user's key", async () => {
|
it("an admin revoking another user's key audits that key's owner", async () => {
|
||||||
const { key } = apiKeys.create(memberId, "member-key")!;
|
const { key } = apiKeys.create(memberId, "member-key")!;
|
||||||
const res = await asAdmin().delete(`/api/keys/${key.id}`);
|
const res = await asAdmin().delete(`/api/keys/${key.id}`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(apiKeys.listForUser(memberId)).toHaveLength(0);
|
expect(apiKeys.listForUser(memberId)).toHaveLength(0);
|
||||||
|
expect(audit.list(10, 0)).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
actorId: adminId,
|
||||||
|
actorUsername: "alice",
|
||||||
|
targetUserId: memberId,
|
||||||
|
targetUsername: "bob",
|
||||||
|
action: "api_key.deleted",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("admin can list all keys with ?all=1, members cannot", async () => {
|
it("admin can list all keys with ?all=1, members cannot", async () => {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import type { Logger } from "../../logger.js";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* API-key management (list / create / revoke), mounted at /api/keys.
|
* API-key management (list / create / revoke), mounted at /api/keys.
|
||||||
* Only interactive sessions may manage keys: a leaked key must never be able
|
* Only interactive sessions may call these endpoints. Administrator keys
|
||||||
* to mint its own replacements.
|
* retain user-management authority through /api/users.
|
||||||
*/
|
*/
|
||||||
export function createApiKeysRouter(apiKeys: ApiKeyStore, audit: AuditStore, logger: Logger): Router {
|
export function createApiKeysRouter(apiKeys: ApiKeyStore, audit: AuditStore, logger: Logger): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -63,11 +63,8 @@ export function createApiKeysRouter(apiKeys: ApiKeyStore, audit: AuditStore, log
|
|||||||
// DELETE /api/keys/:id — revoke; members only their own, admins any.
|
// DELETE /api/keys/:id — revoke; members only their own, admins any.
|
||||||
router.delete("/:id", (req, res) => {
|
router.delete("/:id", (req, res) => {
|
||||||
const user = req.user!;
|
const user = req.user!;
|
||||||
const ok =
|
const key = apiKeys.findById(req.params.id);
|
||||||
user.role === "admin"
|
if (!key || !apiKeys.delete(key.id, user.role === "admin" ? undefined : user.id)) {
|
||||||
? apiKeys.delete(req.params.id)
|
|
||||||
: apiKeys.delete(req.params.id, user.id);
|
|
||||||
if (!ok) {
|
|
||||||
res.status(404).json({ error: "API key not found" });
|
res.status(404).json({ error: "API key not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -75,8 +72,8 @@ export function createApiKeysRouter(apiKeys: ApiKeyStore, audit: AuditStore, log
|
|||||||
audit.record({
|
audit.record({
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
actorUsername: user.username,
|
actorUsername: user.username,
|
||||||
targetUserId: user.id,
|
targetUserId: key.userId,
|
||||||
targetUsername: user.username,
|
targetUsername: key.username,
|
||||||
action: "api_key.deleted",
|
action: "api_key.deleted",
|
||||||
});
|
});
|
||||||
} catch (auditErr) {
|
} catch (auditErr) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import pino from "pino";
|
|||||||
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||||
import { createUserStore, type UserStore } from "../../data/users.js";
|
import { createUserStore, type UserStore } from "../../data/users.js";
|
||||||
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
||||||
|
import { createApiKeyStore, type ApiKeyStore } from "../../data/api-keys.js";
|
||||||
import { createAuditStore } from "../../data/audit.js";
|
import { createAuditStore } from "../../data/audit.js";
|
||||||
import { createPermissionStore } from "../../data/permissions.js";
|
import { createPermissionStore } from "../../data/permissions.js";
|
||||||
import { getDefaultConfig, type GuestModeConfig } from "../../data/config.js";
|
import { getDefaultConfig, type GuestModeConfig } from "../../data/config.js";
|
||||||
@@ -13,7 +14,7 @@ import type { GuestPermissions, BotAccess } from "../../data/permissions.js";
|
|||||||
import { createSessionRouter } from "./session.js";
|
import { createSessionRouter } from "./session.js";
|
||||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||||
|
|
||||||
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
|
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore, apiKeys?: ApiKeyStore) {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
@@ -27,7 +28,8 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
|
|||||||
audit,
|
audit,
|
||||||
pino({ level: "silent" }),
|
pino({ level: "silent" }),
|
||||||
permissions,
|
permissions,
|
||||||
() => getDefaultConfig().guestMode
|
() => getDefaultConfig().guestMode,
|
||||||
|
apiKeys
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return app;
|
return app;
|
||||||
@@ -176,6 +178,68 @@ describe("session router", () => {
|
|||||||
}, 20000);
|
}, 20000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("session router — API key revocation", () => {
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
let users: UserStore;
|
||||||
|
let sessions: SessionStore;
|
||||||
|
let apiKeys: ApiKeyStore;
|
||||||
|
let app: express.Express;
|
||||||
|
let userId: string;
|
||||||
|
let currentCookie: string;
|
||||||
|
let rawKey: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
users = createUserStore(botDb.db);
|
||||||
|
sessions = createSessionStore(botDb.db);
|
||||||
|
apiKeys = createApiKeyStore(botDb.db);
|
||||||
|
const member = await users.createUser("alice", "old-password", "member");
|
||||||
|
userId = member.id;
|
||||||
|
currentCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(userId).token}`;
|
||||||
|
rawKey = apiKeys.create(userId, "integration")!.rawKey;
|
||||||
|
app = makeApp(botDb, users, sessions, apiKeys);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => botDb.close());
|
||||||
|
|
||||||
|
it("successful password change revokes all owned keys and preserves only the active browser session", async () => {
|
||||||
|
const secondKey = apiKeys.create(userId, "another-integration")!.rawKey;
|
||||||
|
const otherSession = `${SESSION_COOKIE_NAME}=${sessions.createSession(userId).token}`;
|
||||||
|
const otherUser = await users.createUser("bob", "other-password", "member");
|
||||||
|
const otherUserKey = apiKeys.create(otherUser.id, "other-user-integration")!.rawKey;
|
||||||
|
|
||||||
|
const changed = await request(app).post("/api/session/change-password")
|
||||||
|
.set("Cookie", currentCookie)
|
||||||
|
.send({ oldPassword: "old-password", newPassword: "new-password" });
|
||||||
|
expect(changed.status).toBe(204);
|
||||||
|
expect(apiKeys.validateAndTouch(rawKey)).toBeNull();
|
||||||
|
expect(apiKeys.validateAndTouch(secondKey)).toBeNull();
|
||||||
|
expect(apiKeys.listForUser(userId)).toEqual([]);
|
||||||
|
expect(apiKeys.validateAndTouch(otherUserKey)?.userId).toBe(otherUser.id);
|
||||||
|
expect((await request(app).get("/api/session/me").set("Cookie", currentCookie)).status).toBe(200);
|
||||||
|
expect((await request(app).get("/api/session/me").set("Cookie", otherSession)).status).toBe(401);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ oldPassword: "wrong-password", newPassword: "new-password", status: 401 },
|
||||||
|
{ oldPassword: "old-password", newPassword: "short", status: 400 },
|
||||||
|
])("failed password change ($status) leaves API keys valid", async ({ oldPassword, newPassword, status }) => {
|
||||||
|
const changed = await request(app).post("/api/session/change-password")
|
||||||
|
.set("Cookie", currentCookie)
|
||||||
|
.send({ oldPassword, newPassword });
|
||||||
|
expect(changed.status).toBe(status);
|
||||||
|
expect(apiKeys.validateAndTouch(rawKey)?.userId).toBe(userId);
|
||||||
|
expect((await request(app).get("/api/session/me").set("Cookie", currentCookie)).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unauthenticated password change leaves API keys valid", async () => {
|
||||||
|
const changed = await request(app).post("/api/session/change-password")
|
||||||
|
.send({ oldPassword: "old-password", newPassword: "new-password" });
|
||||||
|
expect(changed.status).toBe(401);
|
||||||
|
expect(apiKeys.validateAndTouch(rawKey)?.userId).toBe(userId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("session router — guest mode", () => {
|
describe("session router — guest mode", () => {
|
||||||
let botDb: BotDatabase;
|
let botDb: BotDatabase;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Request, Response, NextFunction } from "express";
|
|||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
import type { UserStore } from "../../data/users.js";
|
import type { UserStore } from "../../data/users.js";
|
||||||
import type { SessionStore } from "../../data/sessions.js";
|
import type { SessionStore } from "../../data/sessions.js";
|
||||||
|
import type { ApiKeyStore } from "../../data/api-keys.js";
|
||||||
import type { AuditStore } from "../../data/audit.js";
|
import type { AuditStore } from "../../data/audit.js";
|
||||||
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
|
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
|
||||||
import { SESSION_TTL_MS, GUEST_SESSION_TTL_MS } from "../../data/sessions.js";
|
import { SESSION_TTL_MS, GUEST_SESSION_TTL_MS } from "../../data/sessions.js";
|
||||||
@@ -54,7 +55,8 @@ export function createSessionRouter(
|
|||||||
audit: AuditStore,
|
audit: AuditStore,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
permissions: PermissionStore,
|
permissions: PermissionStore,
|
||||||
getGuestConfig: () => GuestModeConfig
|
getGuestConfig: () => GuestModeConfig,
|
||||||
|
apiKeys?: ApiKeyStore
|
||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -202,6 +204,7 @@ export function createSessionRouter(
|
|||||||
await users.changePassword(u.id, newPassword);
|
await users.changePassword(u.id, newPassword);
|
||||||
const currentToken = parseTokenFromCookie(req.headers.cookie);
|
const currentToken = parseTokenFromCookie(req.headers.cookie);
|
||||||
sessions.deleteAllForUser(u.id, currentToken ?? undefined);
|
sessions.deleteAllForUser(u.id, currentToken ?? undefined);
|
||||||
|
apiKeys?.deleteAllForUser(u.id);
|
||||||
try {
|
try {
|
||||||
audit.record({
|
audit.record({
|
||||||
actorId: u.id, actorUsername: u.username,
|
actorId: u.id, actorUsername: u.username,
|
||||||
|
|||||||
+1
-1
@@ -131,7 +131,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
app.use("/api/session/login", loginLimit);
|
app.use("/api/session/login", loginLimit);
|
||||||
app.use("/api/session/setup", setupLimit);
|
app.use("/api/session/setup", setupLimit);
|
||||||
|
|
||||||
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, () => options.config.guestMode));
|
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, () => options.config.guestMode, apiKeys));
|
||||||
|
|
||||||
// ─── Gates for everything else under /api ───────────────────────────────
|
// ─── Gates for everything else under /api ───────────────────────────────
|
||||||
const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode, apiKeys);
|
const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode, apiKeys);
|
||||||
|
|||||||
Reference in new issue
Block a user