diff --git a/apps/csms/src/db/ocpp-schema.ts b/apps/csms/src/db/ocpp-schema.ts index 5a141e8..93a0263 100644 --- a/apps/csms/src/db/ocpp-schema.ts +++ b/apps/csms/src/db/ocpp-schema.ts @@ -85,6 +85,12 @@ export const chargePoint = pgTable('charge_point', { pricingMode: varchar('pricing_mode', { enum: ['fixed', 'tou'] }) .notNull() .default('fixed'), + /** + * OCPP Security Profile 1/2: HTTP Basic Auth 密码哈希(scrypt) + * 格式::,均为 hex 编码 + * 首次创建充电桩时自动生成,明文密码仅在创建/重置时返回一次 + */ + passwordHash: varchar('password_hash', { length: 255 }), createdAt: timestamp('created_at', { withTimezone: true }) .notNull() .defaultNow(), diff --git a/apps/csms/src/index.ts b/apps/csms/src/index.ts index 85b558e..41e6af0 100644 --- a/apps/csms/src/index.ts +++ b/apps/csms/src/index.ts @@ -8,6 +8,10 @@ import { logger } from 'hono/logger' import { showRoutes } from 'hono/dev' import { auth } from './lib/auth.ts' import { createOcppHandler } from './ocpp/handler.ts' +import { verifyOcppPassword } from './lib/ocpp-auth.ts' +import { useDrizzle } from './lib/db.ts' +import { chargePoint } from './db/schema.ts' +import { eq } from 'drizzle-orm' import statsRoutes from './routes/stats.ts' import statsChartRoutes from './routes/stats-chart.ts' import chargePointRoutes from './routes/charge-points.ts' @@ -83,11 +87,45 @@ app.get('/api', (c) => { app.get( '/ocpp/:chargePointId', + async (c, next) => { + const chargePointId = c.req.param('chargePointId') + const authHeader = c.req.header('Authorization') + + if (!authHeader?.startsWith('Basic ')) { + c.header('WWW-Authenticate', 'Basic realm="OCPP"') + return c.json({ error: 'Unauthorized' }, 401) + } + + let id: string, password: string + try { + const decoded = atob(authHeader.slice(6)) + const colonIdx = decoded.indexOf(':') + if (colonIdx === -1) throw new Error('Invalid format') + id = decoded.slice(0, colonIdx) + password = decoded.slice(colonIdx + 1) + } catch { + return c.json({ error: 'Invalid Authorization header' }, 400) + } + + if (id !== chargePointId) { + return c.json({ error: 'Unauthorized' }, 401) + } + + const db = useDrizzle() + const [cp] = await db + .select({ passwordHash: chargePoint.passwordHash }) + .from(chargePoint) + .where(eq(chargePoint.chargePointIdentifier, chargePointId)) + .limit(1) + + if (!cp?.passwordHash || !(await verifyOcppPassword(password, cp.passwordHash))) { + return c.json({ error: 'Unauthorized' }, 401) + } + + await next() + }, upgradeWebSocket((c) => { const chargePointId = c.req.param('chargePointId') - if (!chargePointId) { - throw new Error('Missing chargePointId parameter') - } const connInfo = getConnInfo(c) return createOcppHandler(chargePointId, connInfo.remote.address) }), diff --git a/apps/csms/src/lib/ocpp-auth.ts b/apps/csms/src/lib/ocpp-auth.ts new file mode 100644 index 0000000..1c08947 --- /dev/null +++ b/apps/csms/src/lib/ocpp-auth.ts @@ -0,0 +1,32 @@ +import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto' +import { promisify } from 'node:util' + +const scryptAsync = promisify(scrypt) + +const SALT_LEN = 16 +const KEY_LEN = 64 + +/** 生成随机明文密码(24 位 hex 字符串) */ +export function generateOcppPassword(): string { + return randomBytes(12).toString('hex') +} + +/** 将明文密码哈希为存储格式 `:` */ +export async function hashOcppPassword(password: string): Promise { + const salt = randomBytes(SALT_LEN) + const hash = (await scryptAsync(password, salt, KEY_LEN)) as Buffer + return `${salt.toString('hex')}:${hash.toString('hex')}` +} + +/** 验证明文密码是否与存储的哈希匹配 */ +export async function verifyOcppPassword( + password: string, + stored: string, +): Promise { + const [saltHex, hashHex] = stored.split(':') + if (!saltHex || !hashHex) return false + const salt = Buffer.from(saltHex, 'hex') + const expectedHash = Buffer.from(hashHex, 'hex') + const actualHash = (await scryptAsync(password, salt, KEY_LEN)) as Buffer + return timingSafeEqual(expectedHash, actualHash) +} diff --git a/apps/csms/src/ocpp/actions/start-transaction.ts b/apps/csms/src/ocpp/actions/start-transaction.ts index 606608e..c19e50b 100644 --- a/apps/csms/src/ocpp/actions/start-transaction.ts +++ b/apps/csms/src/ocpp/actions/start-transaction.ts @@ -73,7 +73,7 @@ export async function handleStartTransaction( if (rejected) { await db .update(connector) - .set({ status: "Available", updatedAt: now }) + .set({ status: "Available", updatedAt: now.toDate() }) .where(eq(connector.id, conn.id)); } diff --git a/apps/csms/src/routes/charge-points.ts b/apps/csms/src/routes/charge-points.ts index dff6060..eef92c6 100644 --- a/apps/csms/src/routes/charge-points.ts +++ b/apps/csms/src/routes/charge-points.ts @@ -1,9 +1,10 @@ import { Hono } from "hono"; -import { desc, eq, sql } from "drizzle-orm"; +import { desc, eq, sql, inArray } from "drizzle-orm"; import dayjs from "dayjs"; import { useDrizzle } from "@/lib/db.js"; import { chargePoint, connector } from "@/db/schema.js"; import { ocppConnections } from "@/ocpp/handler.js"; +import { generateOcppPassword, hashOcppPassword } from "@/lib/ocpp-auth.js"; import type { HonoEnv } from "@/types/hono.ts"; const app = new Hono(); @@ -84,6 +85,9 @@ app.post("/", async (c) => { return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400); } + const plainPassword = generateOcppPassword() + const passwordHash = await hashOcppPassword(plainPassword) + const [created] = await db .insert(chargePoint) .values({ @@ -95,6 +99,7 @@ app.post("/", async (c) => { feePerKwh: body.feePerKwh ?? 0, pricingMode: body.pricingMode ?? "fixed", deviceName: body.deviceName?.trim() || null, + passwordHash, }) .returning() .catch((err: Error) => { @@ -102,7 +107,8 @@ app.post("/", async (c) => { throw err; }); - return c.json({ ...created, connectors: [] }, 201); + // 明文密码仅在创建时返回一次,之后不可再查 + return c.json({ ...created, passwordHash: undefined, plainPassword, connectors: [] }, 201); }); /** GET /api/charge-points/connections — list currently active OCPP WebSocket connections */ @@ -215,4 +221,25 @@ app.delete("/:id", async (c) => { return c.json({ success: true }); }); +/** POST /api/charge-points/:id/reset-password — regenerate OCPP Basic Auth password */ +app.post("/:id/reset-password", async (c) => { + if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403); + const db = useDrizzle(); + const id = c.req.param("id"); + + const plainPassword = generateOcppPassword(); + const passwordHash = await hashOcppPassword(plainPassword); + + const [updated] = await db + .update(chargePoint) + .set({ passwordHash }) + .where(eq(chargePoint.id, id)) + .returning({ id: chargePoint.id, chargePointIdentifier: chargePoint.chargePointIdentifier }); + + if (!updated) return c.json({ error: "Not found" }, 404); + + // 明文密码仅返回一次 + return c.json({ ...updated, plainPassword }); +}); + export default app; diff --git a/apps/web/app/dashboard/charge-points/[id]/page.tsx b/apps/web/app/dashboard/charge-points/[id]/page.tsx index 8386701..c566624 100644 --- a/apps/web/app/dashboard/charge-points/[id]/page.tsx +++ b/apps/web/app/dashboard/charge-points/[id]/page.tsx @@ -15,9 +15,10 @@ import { Spinner, Table, TextField, + Tooltip, } from "@heroui/react"; -import { ArrowLeft, Pencil, ArrowRotateRight } from "@gravity-ui/icons"; -import { api } from "@/lib/api"; +import { ArrowLeft, Pencil, ArrowRotateRight, Key, Copy, Check } from "@gravity-ui/icons"; +import { api, type ChargePointPasswordReset } from "@/lib/api"; import { useSession } from "@/lib/auth-client"; import dayjs from "@/lib/dayjs"; import InfoSection from "@/components/info-section"; @@ -105,6 +106,29 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id feePerKwh: "0", }); + // reset password + const [resetBusy, setResetBusy] = useState(false); + const [resetResult, setResetResult] = useState(null); + const [resetCopied, setResetCopied] = useState(false); + + const handleResetPassword = async () => { + if (!cp) return; + setResetBusy(true); + try { + const result = await api.chargePoints.resetPassword(cp.id); + setResetResult(result); + } finally { + setResetBusy(false); + } + }; + + const handleCopyResetPassword = (text: string) => { + navigator.clipboard.writeText(text).then(() => { + setResetCopied(true); + setTimeout(() => setResetCopied(false), 2000); + }); + }; + const { isFetching: refreshing, ...cpQuery } = useQuery({ queryKey: ["chargePoint", id], queryFn: () => api.chargePoints.get(id), @@ -237,10 +261,21 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id {isAdmin && ( - + <> + + 重置 OCPP 连接密码 + + + + + + )} @@ -552,6 +587,57 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id )} + {/* Reset password result modal */} + {isAdmin && ( + { + if (!open) { setResetResult(null); setResetCopied(false); } + }} + > + + + + + 密码已重置 — 请保存新密码 + + +

+ ⚠️ 此密码仅显示一次,关闭后无法再次查看。旧密码已立即失效,请更新固件配置。 +

+
+

新 OCPP Basic Auth 密码

+
+ + {resetResult?.plainPassword} + + + {resetCopied ? "已复制" : "复制密码"} + + + + +
+
+
+ + + +
+
+
+
+ )} + {/* Edit modal */} {isAdmin && ( (null); const [deleting, setDeleting] = useState(false); const [qrTarget, setQrTarget] = useState(null); + const [createdCp, setCreatedCp] = useState(null); + const [copied, setCopied] = useState(false); const { data: chargePoints = [], refetch: refetchList, @@ -168,9 +173,11 @@ export default function ChargePointsPage() { feePerKwh: formData.pricingMode === "fixed" ? fee : 0, deviceName: formData.deviceName.trim() || null, }); + await refetchList(); + setFormOpen(false); } else { - // Create - await api.chargePoints.create({ + // Create — capture plainPassword for one-time display + const created = await api.chargePoints.create({ chargePointIdentifier: formData.chargePointIdentifier.trim(), chargePointVendor: formData.chargePointVendor.trim() || undefined, chargePointModel: formData.chargePointModel.trim() || undefined, @@ -179,9 +186,10 @@ export default function ChargePointsPage() { feePerKwh: formData.pricingMode === "fixed" ? fee : 0, deviceName: formData.deviceName.trim() || undefined, }); + await refetchList(); + setFormOpen(false); + setCreatedCp(created); } - await refetchList(); - setFormOpen(false); } finally { setFormBusy(false); } @@ -201,6 +209,13 @@ export default function ChargePointsPage() { const isEdit = formTarget !== null; + const handleCopyPassword = (text: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + const { data: sessionData } = useSession(); const isAdmin = sessionData?.user?.role === "admin"; @@ -268,9 +283,7 @@ export default function ChargePointsPage() { - setFormData((f) => ({ ...f, deviceName: e.target.value })) - } + onChange={(e) => setFormData((f) => ({ ...f, deviceName: e.target.value }))} />
@@ -444,6 +457,95 @@ export default function ChargePointsPage() { )} + {/* OCPP Password Modal — shown once after creation */} + {isAdmin && ( + { + if (!open) { + setCreatedCp(null); + setCopied(false); + } + }} + > + + + + + 充电桩已创建 + + +

+ 充电桩认证密码只显示一次,请立即烧录或妥善保存 +

+ + + + + + + + + + + {copied ? "已复制" : "复制密码"} + + + + + + + +
+ + + /ocpp/${createdCp?.chargePointIdentifier ?? ""}`} + className="font-mono text-xs" + /> + +

+ 固件连接时需设置 HTTP 头: +
+ + Authorization: Basic <base64({createdCp?.chargePointIdentifier} + :<password>)> + +

+
+
+ + + +
+
+
+
+ )} + diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index e7e8001..eef75b5 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -160,6 +160,18 @@ export type UserRow = { createdAt: string; }; +export type ChargePointCreated = ChargePoint & { + /** 仅在创建时返回一次的明文密码,之后不可再查 */ + plainPassword: string; +}; + +export type ChargePointPasswordReset = { + id: string; + chargePointIdentifier: string; + /** 仅在重置时返回一次的新明文密码 */ + plainPassword: string; +}; + export type PaginatedTransactions = { data: Transaction[]; total: number; @@ -221,7 +233,7 @@ export const api = { pricingMode?: "fixed" | "tou"; deviceName?: string; }) => - apiFetch("/api/charge-points", { + apiFetch("/api/charge-points", { method: "POST", body: JSON.stringify(data), }), @@ -242,6 +254,10 @@ export const api = { }), delete: (id: string) => apiFetch<{ success: true }>(`/api/charge-points/${id}`, { method: "DELETE" }), + resetPassword: (id: string) => + apiFetch(`/api/charge-points/${id}/reset-password`, { + method: "POST", + }), }, transactions: { list: (params?: {