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;