feat(csms): 添加 OCPP 鉴权

This commit is contained in:
2026-03-16 16:53:33 +08:00
parent 4885cf6778
commit cf0861f8f6
8 changed files with 328 additions and 21 deletions

View File

@@ -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
* 格式:<salt>:<hash>,均为 hex 编码
* 首次创建充电桩时自动生成,明文密码仅在创建/重置时返回一次
*/
passwordHash: varchar('password_hash', { length: 255 }),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),

View File

@@ -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)
}),

View File

@@ -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')
}
/** 将明文密码哈希为存储格式 `<salt_hex>:<hash_hex>` */
export async function hashOcppPassword(password: string): Promise<string> {
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<boolean> {
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)
}

View File

@@ -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));
}

View File

@@ -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<HonoEnv>();
@@ -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;

View File

@@ -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<ChargePointPasswordReset | null>(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
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
{isAdmin && (
<Button size="sm" variant="secondary" onPress={openEdit}>
<Pencil className="size-4" />
</Button>
<>
<Tooltip>
<Tooltip.Content> OCPP </Tooltip.Content>
<Tooltip.Trigger>
<Button size="sm" variant="ghost" isDisabled={resetBusy} onPress={handleResetPassword}>
{resetBusy ? <Spinner size="sm" /> : <Key className="size-4" />}
</Button>
</Tooltip.Trigger>
</Tooltip>
<Button size="sm" variant="secondary" onPress={openEdit}>
<Pencil className="size-4" />
</Button>
</>
)}
</div>
</div>
@@ -552,6 +587,57 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
)}
</div>
{/* Reset password result modal */}
{isAdmin && (
<Modal
isOpen={resetResult !== null}
onOpenChange={(open) => {
if (!open) { setResetResult(null); setResetCopied(false); }
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-md">
<Modal.Header>
<Modal.Heading> </Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-4">
<p className="text-sm text-warning font-medium">
</p>
<div className="space-y-1.5">
<p className="text-xs text-muted font-medium"> OCPP Basic Auth </p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md bg-surface-secondary px-3 py-2 text-sm font-mono text-foreground select-all">
{resetResult?.plainPassword}
</code>
<Tooltip>
<Tooltip.Content>{resetCopied ? "已复制" : "复制密码"}</Tooltip.Content>
<Tooltip.Trigger>
<Button
isIconOnly
size="sm"
variant="ghost"
onPress={() => resetResult && handleCopyResetPassword(resetResult.plainPassword)}
>
{resetCopied ? <Check className="size-4 text-success" /> : <Copy className="size-4" />}
</Button>
</Tooltip.Trigger>
</Tooltip>
</div>
</div>
</Modal.Body>
<Modal.Footer className="flex justify-end">
<Button onPress={() => { setResetResult(null); setResetCopied(false); }}>
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
{/* Edit modal */}
{isAdmin && (
<Modal

View File

@@ -6,6 +6,7 @@ import {
Button,
Chip,
Input,
InputGroup,
Label,
ListBox,
Modal,
@@ -22,11 +23,13 @@ import {
TrashBin,
ArrowRotateRight,
QrCode,
Copy,
Check,
} from "@gravity-ui/icons";
import { QRCodeSVG } from "qrcode.react";
import Link from "next/link";
import { ScrollFade } from "@/components/scroll-fade";
import { api, type ChargePoint } from "@/lib/api";
import { api, type ChargePoint, type ChargePointCreated } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
@@ -123,6 +126,8 @@ export default function ChargePointsPage() {
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
const [deleting, setDeleting] = useState(false);
const [qrTarget, setQrTarget] = useState<ChargePoint | null>(null);
const [createdCp, setCreatedCp] = useState<ChargePointCreated | null>(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() {
<Input
placeholder="1号楼A区01号桩"
value={formData.deviceName}
onChange={(e) =>
setFormData((f) => ({ ...f, deviceName: e.target.value }))
}
onChange={(e) => setFormData((f) => ({ ...f, deviceName: e.target.value }))}
/>
</TextField>
<div className="grid grid-cols-2 gap-3">
@@ -444,6 +457,95 @@ export default function ChargePointsPage() {
</Modal>
)}
{/* OCPP Password Modal — shown once after creation */}
{isAdmin && (
<Modal
isOpen={createdCp !== null}
onOpenChange={(open) => {
if (!open) {
setCreatedCp(null);
setCopied(false);
}
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-md">
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-4">
<p className="text-sm text-warning font-medium">
</p>
<TextField fullWidth isReadOnly>
<Label className="text-sm font-medium"></Label>
<Input value={createdCp?.chargePointIdentifier ?? ""} className="font-mono" />
</TextField>
<TextField fullWidth isReadOnly>
<Label className="text-sm font-medium">OCPP Basic Auth </Label>
<InputGroup>
<InputGroup.Input
value={createdCp?.plainPassword ?? ""}
className="font-mono select-all"
/>
<InputGroup.Suffix>
<Tooltip>
<Tooltip.Content>{copied ? "已复制" : "复制密码"}</Tooltip.Content>
<Tooltip.Trigger>
<Button
isIconOnly
size="sm"
variant="ghost"
onPress={() =>
createdCp && handleCopyPassword(createdCp.plainPassword)
}
>
{copied ? (
<Check className="size-4 text-success" />
) : (
<Copy className="size-4" />
)}
</Button>
</Tooltip.Trigger>
</Tooltip>
</InputGroup.Suffix>
</InputGroup>
</TextField>
<div className="space-y-1.5">
<TextField fullWidth isReadOnly>
<Label className="text-sm font-medium"> WebSocket </Label>
<Input
value={`wss://<your-server>/ocpp/${createdCp?.chargePointIdentifier ?? ""}`}
className="font-mono text-xs"
/>
</TextField>
<p className="text-xs text-muted">
HTTP
<br />
<code className="text-foreground">
Authorization: Basic &lt;base64({createdCp?.chargePointIdentifier}
:&lt;password&gt;)&gt;
</code>
</p>
</div>
</Modal.Body>
<Modal.Footer className="flex justify-end">
<Button
onPress={() => {
setCreatedCp(null);
setCopied(false);
}}
>
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="充电桩列表" className="min-w-200">

View File

@@ -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<ChargePoint>("/api/charge-points", {
apiFetch<ChargePointCreated>("/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<ChargePointPasswordReset>(`/api/charge-points/${id}/reset-password`, {
method: "POST",
}),
},
transactions: {
list: (params?: {