Harden Anker Solix OCPP control (token hashing, step-up, audit, TLS)
Security pass over the OCPP charger-control feature added in a1519f6, since
remotely actuating a physical charger is a real side effect.
Token hygiene:
- Per-charger control tokens are stored as SHA-256 hashes + a last-4 hint,
never plaintext. The token is shown once at generation; the status endpoint
returns only the hint. Added a revoke endpoint that also drops any live
session using the revoked token.
Step-up + confirmation:
- Destructive actions (reset, unlock) require confirm:true AND a password
re-authentication (verified against PocketBase). The Charging UI collects the
password inline for reset.
- Per user+charger rate limit (30/min) on control commands.
Transport + provenance:
- OCPP_REQUIRE_TLS (default on) rejects plaintext ws:// charger connections;
OCPP_PUBLIC_URL pins the advertised endpoint instead of trusting request
headers.
- Proxy-mode upstream URL is validated against a *.anker.com allowlist, so a
spoofed ocpp-info response can't redirect the proxy.
Durable audit:
- New control_audit PocketBase collection (added to setup-pocketbase.mjs);
every control action, token generate/revoke and charger connect is persisted
best-effort in addition to a structured log line.
Startup:
- The control-token index is warmed from PocketBase on startup so a charger
reconnecting after a restart resolves immediately.
Tests:
- Unit tests for token hashing/eviction/revoke (no plaintext at rest),
rate limiter, destructive-action classifier, upstream allowlist, TLS
enforcement, and re-auth guards. A full-stack E2E (control_e2e_test.go)
drives the real Handler with a stand-in PocketBase and a simulated charge
point, proving step-up (400/401/200) and audit persistence end to end.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
a1519f6e89
commit
19a7d48feb
13 files changed
+883
-44
No files matched your search
@@ -99,6 +99,30 @@ async function doAction(action, body) {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset reboots the charger — a destructive action the server gates behind an
|
||||
// explicit confirmation AND a password re-authentication (step-up). Reveal the
|
||||
// inline password prompt; the actual call happens in confirmReset().
|
||||
const resetPrompt = ref(false);
|
||||
const resetPassword = ref("");
|
||||
|
||||
function askReset() {
|
||||
ctlError.value = "";
|
||||
resetPassword.value = "";
|
||||
resetPrompt.value = true;
|
||||
}
|
||||
|
||||
async function confirmReset() {
|
||||
if (!resetPassword.value) return;
|
||||
resetPrompt.value = false;
|
||||
await doAction("reset", { hard: false, confirm: true, password: resetPassword.value });
|
||||
resetPassword.value = "";
|
||||
}
|
||||
|
||||
function cancelReset() {
|
||||
resetPrompt.value = false;
|
||||
resetPassword.value = "";
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCtlMode();
|
||||
await refreshCtl();
|
||||
@@ -215,9 +239,27 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="dh-btn dh-btn-ghost mt-3 w-full" :disabled="ctlBusy === 'reset'" @click="doAction('reset', { hard: false })">
|
||||
<button v-if="!resetPrompt" class="dh-btn dh-btn-ghost mt-3 w-full" :disabled="ctlBusy === 'reset'" @click="askReset">
|
||||
{{ t("charging.control.reset") }}
|
||||
</button>
|
||||
<!-- Step-up: destructive reset requires re-entering the password. -->
|
||||
<div v-else class="mt-3 rounded-control border border-danger/40 bg-danger-soft p-3">
|
||||
<p class="text-xs font-medium text-danger">{{ t("charging.control.resetConfirm") }}</p>
|
||||
<input
|
||||
v-model="resetPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="dh-input mt-2"
|
||||
:placeholder="t('charging.control.resetPassword')"
|
||||
@keyup.enter="confirmReset"
|
||||
/>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button class="dh-btn dh-btn-ghost grow" @click="cancelReset">{{ t("common.cancel") }}</button>
|
||||
<button class="dh-btn dh-btn-danger grow" :disabled="!resetPassword || ctlBusy === 'reset'" @click="confirmReset">
|
||||
{{ t("charging.control.reset") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="mt-3 text-xs text-muted">{{ t("charging.control.connectHint") }}</p>
|
||||
<p v-if="ctlError" class="mt-2 text-sm text-danger">{{ ctlError }}</p>
|
||||
|
||||
@@ -450,9 +450,10 @@ const ankerControlMode = computed(() => anker.value?.controlMode || "off");
|
||||
|
||||
// --- Anker Solix OCPP control (per-charger provisioning + connection status) ---
|
||||
const ankerCtlSerial = ref("");
|
||||
const ankerCtl = ref(null); // { endpoint, token, connected, status, ... }
|
||||
const ankerCtl = ref(null); // { endpoint, hasToken, tokenHint, connected, status, ... }
|
||||
const ankerCtlLoading = ref(false);
|
||||
const ankerCtlError = ref("");
|
||||
const ankerNewToken = ref(""); // freshly generated token, shown once
|
||||
|
||||
async function loadAnkerControl() {
|
||||
const sn = ankerCtlSerial.value.trim();
|
||||
@@ -472,14 +473,36 @@ async function generateAnkerToken() {
|
||||
const sn = ankerCtlSerial.value.trim();
|
||||
if (!sn) return;
|
||||
ankerCtlError.value = "";
|
||||
ankerNewToken.value = "";
|
||||
try {
|
||||
await api.ankerControlToken(sn);
|
||||
// The token is returned exactly once — capture it here to show the operator.
|
||||
const res = await api.ankerControlToken(sn);
|
||||
ankerNewToken.value = res.token || "";
|
||||
await loadAnkerControl();
|
||||
} catch (e) {
|
||||
ankerCtlError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeAnkerToken() {
|
||||
const sn = ankerCtlSerial.value.trim();
|
||||
if (!sn) return;
|
||||
if (!confirm(t("settings.integrations.controlRevokeConfirm"))) return;
|
||||
ankerCtlError.value = "";
|
||||
ankerNewToken.value = "";
|
||||
try {
|
||||
await api.ankerControlRevoke(sn);
|
||||
await loadAnkerControl();
|
||||
} catch (e) {
|
||||
ankerCtlError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the one-time token reveal whenever the operator switches charger.
|
||||
watch(ankerCtlSerial, () => {
|
||||
ankerNewToken.value = "";
|
||||
});
|
||||
|
||||
function applyAnkerView(body) {
|
||||
anker.value = body;
|
||||
if (ankerScope.value === "org" && !body.canEditOrg) ankerScope.value = "user";
|
||||
@@ -1134,6 +1157,20 @@ onBeforeUnmount(() => {
|
||||
<button class="dh-btn dh-btn-primary" :disabled="!ankerCtlSerial.trim()" @click="generateAnkerToken">
|
||||
{{ t("settings.integrations.controlGenerate") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="ankerCtl && ankerCtl.hasToken"
|
||||
class="dh-btn dh-btn-ghost !text-danger"
|
||||
:disabled="!ankerCtlSerial.trim()"
|
||||
@click="revokeAnkerToken"
|
||||
>
|
||||
{{ t("settings.integrations.controlRevoke") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The token is shown exactly once, right after generation. -->
|
||||
<div v-if="ankerNewToken" class="mt-3 rounded-control border border-warning/40 bg-warning-soft px-3 py-2">
|
||||
<p class="text-xs font-semibold text-warning">{{ t("settings.integrations.controlTokenOnce") }}</p>
|
||||
<code class="data mt-1 block break-all text-sm text-body">{{ ankerNewToken }}</code>
|
||||
</div>
|
||||
|
||||
<div v-if="ankerCtl" class="mt-3 grid gap-2 text-sm">
|
||||
@@ -1141,9 +1178,9 @@ onBeforeUnmount(() => {
|
||||
<span class="text-muted">{{ t("settings.integrations.controlEndpoint") }}: </span>
|
||||
<code class="data break-all text-body">{{ ankerCtl.endpoint }}</code>
|
||||
</div>
|
||||
<div v-if="ankerCtl.token">
|
||||
<div v-if="ankerCtl.hasToken">
|
||||
<span class="text-muted">{{ t("settings.integrations.controlToken") }}: </span>
|
||||
<code class="data break-all text-body">{{ ankerCtl.token }}</code>
|
||||
<code class="data text-body">••••{{ ankerCtl.tokenHint }}</code>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="dh-badge" :class="ankerCtl.connected ? 'dh-badge-success' : 'dh-badge-warning'">
|
||||
|
||||
Reference in new issue
Block a user