feat(csms): 添加 OCPP 鉴权
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
32
apps/csms/src/lib/ocpp-auth.ts
Normal file
32
apps/csms/src/lib/ocpp-auth.ts
Normal 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)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user