Add long-lived api key (BC) (#29431)
This commit is contained in:
parent
a0383838c4
commit
ead9296337
25 changed files with 1657 additions and 112 deletions
|
|
@ -35,6 +35,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
|
|||
| [Easee Architecture](docs/agents/easee-architecture.md) | Easee charger (REST+SignalR, async correlation, concurrency) |
|
||||
| [Plugin System](docs/agents/plugin-system.md) | Plugin layer (HTTP, MQTT, Modbus, SunSpec, JS) |
|
||||
| [Web UI & API](docs/agents/web-ui-api.md) | REST API, WebSocket, Vue frontend, authentication |
|
||||
| [API Security](docs/agents/api-security.md) | Auth modes, JWT/API key/session, two-tier checks, credential storage |
|
||||
|
||||
### Loading guide by task type
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
|
|||
- **Vehicle implementation** — hardware-integrations
|
||||
- **UI/frontend work** — web-ui-api
|
||||
- **API endpoint work** — web-ui-api + core-domain
|
||||
- **Auth / login / API key / permissions** — api-security + web-ui-api
|
||||
- **Config/template work** — plugin-system
|
||||
- **Control loop / charging logic** — core-domain
|
||||
- **Bug in any area** — core-domain + relevant topic file(s)
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ a:hover {
|
|||
}
|
||||
|
||||
.btn {
|
||||
--bs-btn-border-width: 2px;
|
||||
--bs-btn-border-width: 1px;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
|
|
|
|||
|
|
@ -297,16 +297,13 @@ export default defineComponent({
|
|||
return r;
|
||||
},
|
||||
async downloadBackup() {
|
||||
if (dispatchDownload("/api/system/backup", "POST", { password: this.password })) {
|
||||
const headers = { "X-Admin-Password": this.password };
|
||||
if (dispatchDownload("/api/db/backup", headers)) {
|
||||
this.closeConfirmModal();
|
||||
return;
|
||||
}
|
||||
const res = await this.call(
|
||||
api.post(
|
||||
"/system/backup",
|
||||
{ password: this.password },
|
||||
{ responseType: "blob", validateStatus }
|
||||
)
|
||||
api.get("/db/backup", { headers, responseType: "blob", validateStatus })
|
||||
);
|
||||
if (res) {
|
||||
this.closeConfirmModal();
|
||||
|
|
@ -314,11 +311,13 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
async restoreDatabase() {
|
||||
const headers = { "X-Admin-Password": this.password };
|
||||
const formData = new FormData();
|
||||
formData.append("password", this.password);
|
||||
formData.append("file", this.file!);
|
||||
|
||||
const res = await this.call(api.post("/system/restore", formData, { validateStatus }));
|
||||
const res = await this.call(
|
||||
api.post("/db/restore", formData, { headers, validateStatus })
|
||||
);
|
||||
|
||||
if (res) {
|
||||
this.hideBackupRestoreModal = true;
|
||||
|
|
@ -328,12 +327,9 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
async resetDatabase() {
|
||||
const headers = { "X-Admin-Password": this.password };
|
||||
const res = await this.call(
|
||||
api.post(
|
||||
"/system/reset",
|
||||
{ password: this.password, ...this.selectedReset },
|
||||
{ validateStatus }
|
||||
)
|
||||
api.post("/db/reset", this.selectedReset, { headers, validateStatus })
|
||||
);
|
||||
|
||||
if (res) {
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@
|
|||
</GeneralConfigEntry>
|
||||
|
||||
<GeneralConfigEntry
|
||||
test-id="generalconfig-password"
|
||||
:label="$t('config.general.password')"
|
||||
text="*******"
|
||||
@edit="openModal('passwordupdate')"
|
||||
test-id="generalconfig-security"
|
||||
:label="$t('config.security.title')"
|
||||
:text="$t(`config.general.${authDisabled ? 'off' : 'on'}`)"
|
||||
@edit="openModal('security')"
|
||||
/>
|
||||
|
||||
<GeneralConfigEntry
|
||||
|
|
@ -86,6 +86,9 @@ export default {
|
|||
},
|
||||
emits: ["site-changed"],
|
||||
computed: {
|
||||
authDisabled() {
|
||||
return store.state?.authDisabled === true;
|
||||
},
|
||||
title() {
|
||||
return store.state?.siteTitle || "";
|
||||
},
|
||||
|
|
|
|||
211
assets/js/components/Config/Security/ApiKeyModal.vue
Normal file
211
assets/js/components/Config/Security/ApiKeyModal.vue
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<template>
|
||||
<GenericModal
|
||||
id="apiKeyModal"
|
||||
ref="modal"
|
||||
config-modal-name="apikey"
|
||||
:title="title"
|
||||
data-testid="api-key-modal"
|
||||
@open="onOpen"
|
||||
@closed="onClosed"
|
||||
>
|
||||
<div v-if="authDisabled" class="alert alert-warning">
|
||||
{{ $t("config.security.authDisabledHint") }}
|
||||
</div>
|
||||
|
||||
<ErrorMessage :error="error" />
|
||||
|
||||
<form v-if="view === 'overview'" @submit.prevent="submitGenerate">
|
||||
<p>{{ $t("config.apiKey.description") }}</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="apiKeyPassword" class="col-form-label">
|
||||
<span class="label">{{ $t("loginModal.password") }}</span>
|
||||
</label>
|
||||
<input
|
||||
id="apiKeyPassword"
|
||||
v-model="password"
|
||||
class="form-control"
|
||||
autocomplete="current-password"
|
||||
type="password"
|
||||
required
|
||||
:disabled="authDisabled"
|
||||
/>
|
||||
<p v-if="passwordError" class="text-danger my-2">{{ passwordError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="authDisabled || loading || !password"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="spinner-border spinner-border-sm me-1"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span v-if="configured">
|
||||
{{ $t("config.apiKey.regenerate") }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t("config.apiKey.generate") }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template v-else-if="view === 'reveal'">
|
||||
<p>{{ $t("config.apiKey.revealSuccess") }}</p>
|
||||
|
||||
<FormRow id="apiKeyReveal" :label="$t('config.apiKey.keyLabel')">
|
||||
<input
|
||||
id="apiKeyReveal"
|
||||
type="text"
|
||||
class="form-control border font-monospace"
|
||||
:value="revealedKey"
|
||||
readonly
|
||||
/>
|
||||
<CopyLink :text="revealedKey || ''" />
|
||||
</FormRow>
|
||||
|
||||
<FormRow id="apiKeyExample" :label="$t('config.apiKey.exampleLabel')">
|
||||
<pre
|
||||
id="apiKeyExample"
|
||||
class="form-control border font-monospace small mb-2 api-key-example"
|
||||
>{{ curlExample }}</pre
|
||||
>
|
||||
<CopyLink :text="curlExample" />
|
||||
</FormRow>
|
||||
|
||||
<div class="mt-4 small text-muted">
|
||||
<strong class="text-evcc">{{ $t("general.note") }}</strong>
|
||||
{{ $t("config.apiKey.shownOnce") }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">
|
||||
{{ $t("config.general.close") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</GenericModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import GenericModal from "../../Helper/GenericModal.vue";
|
||||
import ErrorMessage from "../../Helper/ErrorMessage.vue";
|
||||
import CopyLink from "../../Helper/CopyLink.vue";
|
||||
import FormRow from "../FormRow.vue";
|
||||
import api from "@/api";
|
||||
import type { AxiosError } from "axios";
|
||||
|
||||
type View = "overview" | "reveal";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ApiKeyModal",
|
||||
components: { GenericModal, ErrorMessage, CopyLink, FormRow },
|
||||
props: {
|
||||
authDisabled: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
view: "overview" as View,
|
||||
configured: false,
|
||||
password: "",
|
||||
passwordError: "",
|
||||
error: "" as string | null,
|
||||
loading: false,
|
||||
revealedKey: null as string | null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
title(): string {
|
||||
return this.view === "reveal"
|
||||
? this.$t("config.apiKey.revealTitle")
|
||||
: this.$t("config.apiKey.title");
|
||||
},
|
||||
curlExample(): string {
|
||||
const key = this.revealedKey ?? "";
|
||||
const url = `${window.location.origin}/api/system/backup`;
|
||||
return [
|
||||
`curl -X POST ${url} \\`,
|
||||
` -H "Authorization: Bearer ${key}" \\`,
|
||||
` -o evcc-backup.db`,
|
||||
].join("\n");
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onOpen() {
|
||||
this.resetState();
|
||||
await this.loadStatus();
|
||||
},
|
||||
onClosed() {
|
||||
this.resetState();
|
||||
},
|
||||
resetState() {
|
||||
this.view = "overview";
|
||||
this.password = "";
|
||||
this.passwordError = "";
|
||||
this.error = "";
|
||||
this.loading = false;
|
||||
this.revealedKey = null;
|
||||
},
|
||||
async loadStatus() {
|
||||
try {
|
||||
const res = await api.get("auth/apikey");
|
||||
this.configured = !!res.data?.configured;
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
}
|
||||
},
|
||||
async submitGenerate() {
|
||||
if (this.authDisabled) return;
|
||||
if (this.configured && !window.confirm(this.$t("config.apiKey.regenerateWarning"))) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.passwordError = "";
|
||||
this.error = "";
|
||||
try {
|
||||
const res = await api.post(
|
||||
"auth/apikey",
|
||||
{ password: this.password },
|
||||
{ validateStatus: (s) => s === 200 || s === 401 }
|
||||
);
|
||||
if (res.status === 401) {
|
||||
this.passwordError = this.$t("loginModal.invalid");
|
||||
return;
|
||||
}
|
||||
this.revealedKey = res.data?.key ?? null;
|
||||
this.configured = true;
|
||||
this.password = "";
|
||||
this.view = "reveal";
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
handleError(err: unknown) {
|
||||
const axiosErr = err as AxiosError<{ error?: string } | string>;
|
||||
const data = axiosErr.response?.data;
|
||||
if (typeof data === "string") {
|
||||
this.error = data;
|
||||
} else if (data && typeof data === "object" && "error" in data) {
|
||||
this.error = (data as { error?: string }).error || axiosErr.message;
|
||||
} else {
|
||||
this.error = axiosErr.message || String(err);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.api-key-example {
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
98
assets/js/components/Config/Security/SecurityModal.vue
Normal file
98
assets/js/components/Config/Security/SecurityModal.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<GenericModal
|
||||
id="securityModal"
|
||||
config-modal-name="security"
|
||||
:title="$t('config.security.title')"
|
||||
data-testid="security-modal"
|
||||
@open="onOpen"
|
||||
>
|
||||
<div v-if="authDisabled" class="alert alert-warning">
|
||||
{{ $t("config.security.authDisabledHint") }}
|
||||
</div>
|
||||
<p v-else>{{ $t("config.security.description") }}</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6>{{ $t("config.security.passwordTitle") }}</h6>
|
||||
<p>{{ $t("config.security.passwordDescription") }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
:disabled="authDisabled"
|
||||
@click="open('passwordupdate')"
|
||||
>
|
||||
{{ $t("config.security.updatePassword") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<div class="mb-3">
|
||||
<h6>{{ $t("config.apiKey.title") }}</h6>
|
||||
<p>{{ $t("config.apiKey.description") }}</p>
|
||||
|
||||
<template v-if="apiKeyConfigured">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control font-monospace"
|
||||
:value="fakeKey"
|
||||
readonly
|
||||
aria-label="API Key"
|
||||
/>
|
||||
<div class="d-flex justify-content-end mt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link btn-sm text-muted px-0"
|
||||
:disabled="authDisabled"
|
||||
@click="open('apikey')"
|
||||
>
|
||||
{{ $t("config.apiKey.regenerateLink") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<PlaceholderButton v-else :disabled="authDisabled" @click="open('apikey')">
|
||||
<shopicon-regular-plus class="me-1"></shopicon-regular-plus>
|
||||
<span>{{ $t("config.apiKey.generate") }}</span>
|
||||
</PlaceholderButton>
|
||||
</div>
|
||||
</GenericModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import GenericModal from "../../Helper/GenericModal.vue";
|
||||
import PlaceholderButton from "../../Helper/PlaceholderButton.vue";
|
||||
import { openModal } from "@/configModal";
|
||||
import api from "@/api";
|
||||
import "@h2d2/shopicons/es/regular/plus";
|
||||
|
||||
const FAKE_KEY = "evcc_" + "•".repeat(30);
|
||||
|
||||
export default defineComponent({
|
||||
name: "SecurityModal",
|
||||
components: { GenericModal, PlaceholderButton },
|
||||
props: {
|
||||
authDisabled: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
apiKeyConfigured: false,
|
||||
fakeKey: FAKE_KEY,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async onOpen() {
|
||||
try {
|
||||
const res = await api.get("auth/apikey");
|
||||
this.apiKeyConfigured = !!res.data?.configured;
|
||||
} catch {
|
||||
this.apiKeyConfigured = false;
|
||||
}
|
||||
},
|
||||
open(name: string) {
|
||||
if (this.authDisabled) return;
|
||||
openModal(name);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
45
assets/js/components/Helper/PlaceholderButton.vue
Normal file
45
assets/js/components/Helper/PlaceholderButton.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="root d-flex align-items-center justify-content-center"
|
||||
:disabled="disabled"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "PlaceholderButton",
|
||||
props: {
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ["click"],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.root {
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--evcc-gray-50);
|
||||
padding: 1.5rem;
|
||||
background: none;
|
||||
width: 100%;
|
||||
color: var(--evcc-gray);
|
||||
transition:
|
||||
border-color var(--evcc-transition-fast) linear,
|
||||
color var(--evcc-transition-fast) linear;
|
||||
}
|
||||
.root:hover:not(:disabled),
|
||||
.root:focus-within:not(:disabled) {
|
||||
border-color: var(--evcc-default-text);
|
||||
color: var(--evcc-default-text);
|
||||
}
|
||||
.root:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -15,7 +15,7 @@ export function hasAppCapability(capability: string): boolean {
|
|||
|
||||
type AppMessage =
|
||||
| { type: "online" | "offline" | "settings" }
|
||||
| { type: "download"; url: string; method?: string; body?: unknown };
|
||||
| { type: "download"; url: string; headers?: Record<string, string> };
|
||||
|
||||
export function sendToApp(data: AppMessage) {
|
||||
window.ReactNativeWebView?.postMessage(JSON.stringify(data));
|
||||
|
|
@ -27,9 +27,9 @@ export function handleDownloadClick(event: Event, url: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function dispatchDownload(url: string, method?: string, body?: unknown): boolean {
|
||||
export function dispatchDownload(url: string, headers?: Record<string, string>): boolean {
|
||||
if (!hasAppCapability("download")) return false;
|
||||
const absolute = new URL(url, window.location.href).toString();
|
||||
sendToApp({ type: "download", url: absolute, method, body });
|
||||
sendToApp({ type: "download", url: absolute, headers });
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -451,6 +451,8 @@
|
|||
/>
|
||||
<OcppModal :ocpp="ocpp" />
|
||||
<BackupRestoreModal v-bind="backupRestoreProps" />
|
||||
<SecurityModal :auth-disabled="authDisabled" />
|
||||
<ApiKeyModal :auth-disabled="authDisabled" />
|
||||
<PasswordModal update-mode />
|
||||
<SponsorModal :error="hasClassError('sponsorship')" @changed="loadDirty" />
|
||||
</div>
|
||||
|
|
@ -549,6 +551,8 @@ import BackupRestoreModal from "@/components/Config/BackupRestoreModal.vue";
|
|||
import WelcomeBanner from "../components/Config/WelcomeBanner.vue";
|
||||
import AuthSuccessBanner from "../components/Config/AuthSuccessBanner.vue";
|
||||
import PasswordModal from "../components/Auth/PasswordModal.vue";
|
||||
import SecurityModal from "../components/Config/Security/SecurityModal.vue";
|
||||
import ApiKeyModal from "../components/Config/Security/ApiKeyModal.vue";
|
||||
import AuthProvidersCard from "../components/Config/AuthProvidersCard.vue";
|
||||
|
||||
export default defineComponent({
|
||||
|
|
@ -606,6 +610,8 @@ export default defineComponent({
|
|||
WelcomeBanner,
|
||||
AuthSuccessBanner,
|
||||
PasswordModal,
|
||||
SecurityModal,
|
||||
ApiKeyModal,
|
||||
AuthProvidersCard,
|
||||
},
|
||||
mixins: [formatter, collector],
|
||||
|
|
@ -873,9 +879,12 @@ export default defineComponent({
|
|||
Object.values(store.state.messagingEvents ?? {}).some((e) => !e.disabled)
|
||||
);
|
||||
},
|
||||
authDisabled() {
|
||||
return store.state?.authDisabled || false;
|
||||
},
|
||||
backupRestoreProps() {
|
||||
return {
|
||||
authDisabled: store.state?.authDisabled || false,
|
||||
authDisabled: this.authDisabled,
|
||||
};
|
||||
},
|
||||
circuitsRoot() {
|
||||
|
|
|
|||
|
|
@ -3,4 +3,5 @@ package keys
|
|||
const (
|
||||
AdminPassword = "adminPassword"
|
||||
JwtSecret = "jwtSecretKey"
|
||||
ApiKey = "apiKey"
|
||||
)
|
||||
|
|
|
|||
116
docs/agents/api-security.md
Normal file
116
docs/agents/api-security.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# API Security & Authentication
|
||||
|
||||
How evcc authenticates HTTP requests and how endpoints are classified by
|
||||
sensitivity.
|
||||
|
||||
## Threat Model
|
||||
|
||||
evcc is designed for use within a trusted home network. The auth layer
|
||||
protects credential management, configuration changes, and system operations
|
||||
(logs, backup/restore/reset, shutdown). Read-only state and basic charging
|
||||
controls are intentionally unauthenticated.
|
||||
|
||||
## Auth Modes
|
||||
|
||||
| Mode | Trigger | Behavior |
|
||||
|------------|-----------------------|---------------------------------------------------|
|
||||
| `Enabled` | default | password required; JWT or API key accepted |
|
||||
| `Disabled` | `--disable-auth` flag | all auth checks skipped |
|
||||
| `Locked` | demo mode | mutating endpoints return 403; reads still work |
|
||||
|
||||
Mode is fixed at startup. The frontend mirrors the mode so admin actions can
|
||||
be greyed out and a banner shown.
|
||||
|
||||
## Endpoint Sensitivity Tiers
|
||||
|
||||
Three tiers, by what the caller has to prove:
|
||||
|
||||
**Public.** No auth. State, loadpoint controls, login. Anyone on the
|
||||
network can read and operate.
|
||||
|
||||
**Secure.** Requires a valid session: either the auth cookie (browser, JWT)
|
||||
or an API key in the `Authorization: Bearer …` header (automation). Used
|
||||
for configuration and system administration.
|
||||
|
||||
**Critical.** Secure plus an additional admin-password check inside the
|
||||
handler. Used for destructive or credential-scoped operations.
|
||||
|
||||
For some Critical endpoints (backup, restore, reset) the password check is
|
||||
**skipped when the caller is authenticated via API key**, so unattended automation
|
||||
doesn't need to embed the admin password. For credential-management
|
||||
endpoints (rotate API key, change admin password) the password check is
|
||||
**strict**: a leaked API key must not be able to rotate itself or change
|
||||
the admin password.
|
||||
|
||||
Disabling auth short-circuits all checks.
|
||||
|
||||
## Sessions
|
||||
|
||||
Two transports, no overlap:
|
||||
|
||||
- Browsers use a session cookie (JWT, 90-day TTL, issued on login).
|
||||
- Automation uses an API key in the `Authorization: Bearer …` header.
|
||||
|
||||
API keys are random alphanumeric strings prefixed `evcc_`. The prefix makes
|
||||
leaked keys recognizable to secret-scanning tools.
|
||||
|
||||
A single API key per installation; regenerating replaces the previous one.
|
||||
Plaintext is shown to the user **once** at generation time and cannot be
|
||||
retrieved afterwards.
|
||||
|
||||
## Credential Storage
|
||||
|
||||
Admin password and API key are stored as bcrypt hashes. The JWT signing
|
||||
secret is a per-installation random value. Plaintext credentials are never
|
||||
persisted.
|
||||
|
||||
Removing the admin password (CLI recovery) also clears the JWT secret and the
|
||||
API key, which invalidates all outstanding sessions and any previously-issued
|
||||
API key. Regenerating the API key replaces the stored hash; the previous key
|
||||
stops working immediately.
|
||||
|
||||
## API Key Lifecycle
|
||||
|
||||
Two operations:
|
||||
|
||||
- **Status.** Whether a key is configured. Secure tier; never returns
|
||||
plaintext.
|
||||
- **Regenerate.** Critical tier, strict password check. Returns the new
|
||||
plaintext key exactly once.
|
||||
|
||||
There is no delete operation: regenerating and discarding the new key
|
||||
achieves the same effect (the previous key stops working immediately).
|
||||
|
||||
## Endpoint Matrix
|
||||
|
||||
| Endpoint category | Tier | Additional Requirements |
|
||||
|----------------------------------------------|-----------|------------------------------------|
|
||||
| State / read-only / basic charging control | Public | |
|
||||
| Set or update admin password | Public | admin password |
|
||||
| Configuration | Secure | |
|
||||
| System: logs, cache, shutdown | Secure | |
|
||||
| API key status | Secure | |
|
||||
| System: backup / restore / reset | Critical | api key or admin password |
|
||||
| API key regenerate | Critical | admin password |
|
||||
|
||||
**Public** endpoints accept any caller. **Secure** endpoints require a
|
||||
valid session (cookie or API key). **Critical** endpoints require extra
|
||||
authentication in the form of an admin password (or, for some, an API
|
||||
key).
|
||||
|
||||
## Frontend
|
||||
|
||||
Auth management lives under **General Config → Security**, which links to
|
||||
two sub-flows: change admin password, and manage the API key. The API key
|
||||
flow has a reveal view that shows the plaintext exactly once with a
|
||||
copy-to-clipboard link.
|
||||
|
||||
When auth is disabled, the security modals show a warning banner and
|
||||
disable all action buttons. This is UI-only; the backend still accepts the
|
||||
underlying calls so legitimate automation against a disabled-auth instance
|
||||
keeps working.
|
||||
|
||||
## OpenAPI
|
||||
|
||||
The OpenAPI spec declares two security schemes (cookie and bearer);
|
||||
protected operations accept either.
|
||||
|
|
@ -33,7 +33,11 @@
|
|||
- `GET /config/evcc.yaml` — YAML export
|
||||
|
||||
### System (`/system/...`, auth required)
|
||||
- Log viewing, cache clear, DB backup/restore/reset, shutdown
|
||||
- Log viewing (`/log`, `/log/areas`), cache clear, shutdown
|
||||
|
||||
### Database (`/db/...`, auth + second factor required)
|
||||
- Backup download, restore from file, selective reset
|
||||
- Second factor: admin password in request body, or API key via Bearer token (bypasses password check)
|
||||
|
||||
### Handler Pattern
|
||||
Generic `handler[T]` with type conversion, setter, getter.
|
||||
|
|
@ -65,7 +69,7 @@ Specialized: `floatHandler`, `intHandler`, `boolHandler`, `durationHandler`.
|
|||
- HttpOnly cookie (`auth`) with `SameSite=Strict`
|
||||
- Also accepts `Authorization: Bearer <token>` header
|
||||
- Modes: Disabled, Locked (demo), Configured (password)
|
||||
- Protects `/api/config` and `/api/system`
|
||||
- Protects `/api/config`, `/api/system`, and `/api/db`
|
||||
|
||||
## MQTT Integration
|
||||
|
||||
|
|
|
|||
23
i18n/de.json
23
i18n/de.json
|
|
@ -51,6 +51,21 @@
|
|||
"hex": "Hex-Farbe"
|
||||
},
|
||||
"config": {
|
||||
"apiKey": {
|
||||
"description": "Gibt Skripten und Automatisierungsaufgaben wie geplanten Backups sicheren Zugriff, ohne dein Administrator-Passwort zu teilen.",
|
||||
"exampleLabel": "Ausprobieren: Backup mit curl herunterladen",
|
||||
"generate": "API-Schlüssel erstellen",
|
||||
"generateConfirm": "Gib dein Administrator-Passwort ein, um einen neuen API-Schlüssel zu erstellen.",
|
||||
"keyLabel": "API-Schlüssel",
|
||||
"regenerate": "API-Schlüssel neu erstellen",
|
||||
"regenerateConfirm": "Gib dein Administrator-Passwort ein, um den API-Schlüssel neu zu erstellen.",
|
||||
"regenerateLink": "Neu erstellen",
|
||||
"regenerateWarning": "Beim Neuerstellen wird der bestehende Schlüssel ungültig und Automatisierungen, die ihn nutzen, funktionieren nicht mehr. Fortfahren?",
|
||||
"revealSuccess": "Dein neuer API-Schlüssel ist einsatzbereit.",
|
||||
"revealTitle": "API-Schlüssel erstellt",
|
||||
"shownOnce": "Dieser Schlüssel wird nur einmal angezeigt. Bewahre ihn sicher auf. Du kannst ihn später nicht erneut abrufen.",
|
||||
"title": "API-Schlüssel"
|
||||
},
|
||||
"aux": {
|
||||
"description": "Gerät, das seinen Verbrauch basierend auf verfügbarem Überschuss (z. B. smarter Heizstab) selbstständig anpasst. evcc erwartet, dass dieses Gerät selbstständig seine Leistungsaufnahme reduziert, wenn es notwendig ist.",
|
||||
"titleAdd": "Intelligenten Verbraucher hinzufügen",
|
||||
|
|
@ -695,6 +710,14 @@
|
|||
"system": "System",
|
||||
"vehicles": "Fahrzeuge"
|
||||
},
|
||||
"security": {
|
||||
"authDisabledHint": "Authentifizierung ist deaktiviert. Anmeldedaten können nicht verwaltet werden.",
|
||||
"description": "Verwalte Authentifizierung und Anmeldedaten dieser Instanz.",
|
||||
"passwordDescription": "Aktualisiere das Passwort, mit dem du dich an der Konfigurationsoberfläche anmeldest.",
|
||||
"passwordTitle": "Administrator-Passwort",
|
||||
"title": "Sicherheit",
|
||||
"updatePassword": "Passwort ändern"
|
||||
},
|
||||
"shm": {
|
||||
"cardTitle": "Sunny Home Manager",
|
||||
"description": "evcc ist mit einer Integration für den SMA Sunny Home Manager (SHM) mittels SEMP-Protokoll ausgestattet. Wenn dieser im selben Netzwerk läuft, sollte dir nach der Anmeldung in deinem Sunny Portal-Konto automatisch angeboten werden, alle in evcc konfigurierten Ladepunkte als neu erkannte Verbraucher hinzuzufügen. Alles sollte sofort einsatzbereit sein, ohne dass hier weitere Anpassungen erforderlich sind.",
|
||||
|
|
|
|||
24
i18n/en.json
24
i18n/en.json
|
|
@ -51,6 +51,21 @@
|
|||
"hex": "Hex color"
|
||||
},
|
||||
"config": {
|
||||
"apiKey": {
|
||||
"description": "Give scripts and automation tasks like scheduled backups secure access without sharing your admin password.",
|
||||
"exampleLabel": "Try it: download a backup with curl",
|
||||
"generate": "Generate API Key",
|
||||
"generateConfirm": "Enter your admin password to generate a new API key.",
|
||||
"keyLabel": "API Key",
|
||||
"regenerate": "Regenerate API Key",
|
||||
"regenerateConfirm": "Enter your admin password to regenerate the API key.",
|
||||
"regenerateLink": "Regenerate",
|
||||
"regenerateWarning": "Regenerating will invalidate the existing key and any automation using it will stop working. Continue?",
|
||||
"revealSuccess": "Your new API key is ready to use.",
|
||||
"revealTitle": "API Key Created",
|
||||
"shownOnce": "This key is shown only once. Store it somewhere safe. You cannot retrieve it later.",
|
||||
"title": "API Key"
|
||||
},
|
||||
"aux": {
|
||||
"description": "Device that adjusts its consumption based on available surplus (like smart water heaters). evcc expects that this device reduces its power consumption if needed.",
|
||||
"titleAdd": "Add Self-Regulating Consumer",
|
||||
|
|
@ -260,7 +275,6 @@
|
|||
"noFileSelected": "No file selected.",
|
||||
"off": "off",
|
||||
"on": "on",
|
||||
"password": "Password",
|
||||
"readFromFile": "Read from file",
|
||||
"remove": "Remove",
|
||||
"required": "required",
|
||||
|
|
@ -695,6 +709,14 @@
|
|||
"system": "System",
|
||||
"vehicles": "Vehicles"
|
||||
},
|
||||
"security": {
|
||||
"authDisabledHint": "Authentication is disabled. Credential management is unavailable.",
|
||||
"description": "Manage authentication and credentials for this instance.",
|
||||
"passwordDescription": "Update the password used to log in to the configuration UI.",
|
||||
"passwordTitle": "Admin password",
|
||||
"title": "Security",
|
||||
"updatePassword": "Update password"
|
||||
},
|
||||
"shm": {
|
||||
"cardTitle": "Sunny Home Manager",
|
||||
"description": "evcc is equipped with integration for the SMA Sunny Home Manager (SHM) via SEMP protocol. If it is running on the same network, after logging into your Sunny Portal account, you should automatically be offered to add all chargers configured in evcc as newly discovered consumers. Everything should be ready to use immediately, without any adjustments required below.",
|
||||
|
|
|
|||
|
|
@ -275,6 +275,11 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
|
|||
for _, r := range routes {
|
||||
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
|
||||
}
|
||||
|
||||
// API key endpoints require an authenticated session.
|
||||
ensureAuth := ensureAuthHandler(auth)
|
||||
api.Methods("GET").Path("/apikey").Handler(ensureAuth(apiKeyStatusHandler(auth)))
|
||||
api.Methods("POST").Path("/apikey").Handler(ensureAuth(regenerateApiKeyHandler(auth)))
|
||||
}
|
||||
|
||||
{ // api/config
|
||||
|
|
@ -374,14 +379,10 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
|
|||
api := api.PathPrefix("/system").Subrouter()
|
||||
api.Use(ensureAuthHandler(auth))
|
||||
|
||||
// system api
|
||||
routes := map[string]route{
|
||||
"log": {"GET", "/log", logHandler},
|
||||
"logareas": {"GET", "/log/areas", logAreasHandler},
|
||||
"clearcache": {"DELETE", "/cache", clearCacheHandler},
|
||||
"backup": {"POST", "/backup", getBackup(auth)},
|
||||
"restore": {"POST", "/restore", restoreDatabase(auth, shutdown)},
|
||||
"reset": {"POST", "/reset", resetDatabase(auth, shutdown)},
|
||||
"shutdown": {"POST", "/shutdown", func(w http.ResponseWriter, r *http.Request) {
|
||||
shutdown()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
|
@ -392,4 +393,19 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
|
|||
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
|
||||
}
|
||||
}
|
||||
|
||||
{ // api/db — destructive DB operations; require session+X-Admin-Password or API key
|
||||
api := api.PathPrefix("/db").Subrouter()
|
||||
api.Use(ensureDbAuth(auth))
|
||||
|
||||
routes := map[string]route{
|
||||
"backup": {"GET", "/backup", getBackup()},
|
||||
"restore": {"POST", "/restore", restoreDatabase(shutdown)},
|
||||
"reset": {"POST", "/reset", resetDatabase(shutdown)},
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,22 +66,38 @@ func updatePasswordHandler(authObject auth.Auth) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// read jwt from header and cookie
|
||||
func jwtFromRequest(r *http.Request) string {
|
||||
// read from header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if token, ok := strings.CutPrefix(authHeader, "Bearer "); ok {
|
||||
// apiKeyFromRequest returns the API key from the Authorization: Bearer header, or "" if absent
|
||||
func apiKeyFromRequest(r *http.Request) string {
|
||||
token, _ := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
return token
|
||||
}
|
||||
|
||||
// read from cookie
|
||||
// jwtFromCookie returns the session JWT from the auth cookie, or "" if absent
|
||||
func jwtFromCookie(r *http.Request) string {
|
||||
if cookie, _ := r.Cookie(authCookieName); cookie != nil {
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// validateAuth accepts a valid API key from the Authorization header, or a valid session JWT from the auth cookie
|
||||
func validateAuth(authObject auth.Auth, r *http.Request) bool {
|
||||
if key := apiKeyFromRequest(r); key != "" {
|
||||
return authObject.ValidateApiKey(key)
|
||||
}
|
||||
return authObject.ValidateJwtToken(jwtFromCookie(r))
|
||||
}
|
||||
|
||||
// requireAdminPassword passes when --disable-auth is set or the supplied password matches.
|
||||
// Writes 401 and returns false otherwise.
|
||||
func requireAdminPassword(w http.ResponseWriter, authObject auth.Auth, password string) bool {
|
||||
if authObject.GetAuthMode() == auth.Disabled || authObject.IsAdminPasswordValid(password) {
|
||||
return true
|
||||
}
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
|
||||
// authStatusHandler login status (true/false) based on jwt token. Error if admin password is not configured
|
||||
func authStatusHandler(authObject auth.Auth) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -101,12 +117,11 @@ func authStatusHandler(authObject auth.Auth) http.HandlerFunc {
|
|||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
ok, err := authObject.ValidateJwtToken(jwtFromRequest(r))
|
||||
if err != nil || !ok {
|
||||
w.Write([]byte("false"))
|
||||
return
|
||||
}
|
||||
if validateAuth(authObject, r) {
|
||||
w.Write([]byte("true"))
|
||||
} else {
|
||||
w.Write([]byte("false"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -179,14 +194,78 @@ func ensureAuthHandler(authObject auth.Auth) mux.MiddlewareFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// check jwt token
|
||||
ok, err := authObject.ValidateJwtToken(jwtFromRequest(r))
|
||||
if !ok || err != nil {
|
||||
if !validateAuth(authObject, r) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func apiKeyStatusHandler(authObject auth.Auth) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
jsonWrite(w, map[string]bool{"configured": authObject.IsApiKeyConfigured()})
|
||||
}
|
||||
}
|
||||
|
||||
// regenerateApiKeyHandler creates or rotates the API key. Requires the admin password (a leaked API key cannot self-rotate)
|
||||
func regenerateApiKeyHandler(authObject auth.Auth) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !requireAdminPassword(w, authObject, req.Password) {
|
||||
return
|
||||
}
|
||||
|
||||
key, err := authObject.SetApiKey()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jsonWrite(w, map[string]string{"key": key})
|
||||
}
|
||||
}
|
||||
|
||||
// ensureDbAuth guards /db/ endpoints: API key Bearer passes directly;
|
||||
// session users must also supply the admin password in X-Admin-Password header.
|
||||
func ensureDbAuth(authObject auth.Auth) mux.MiddlewareFunc {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if authObject.GetAuthMode() == auth.Disabled {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if authObject.GetAuthMode() == auth.Locked {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if key := apiKeyFromRequest(r); key != "" {
|
||||
if authObject.ValidateApiKey(key) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !authObject.ValidateJwtToken(jwtFromCookie(r)) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !authObject.IsAdminPasswordValid(r.Header.Get("X-Admin-Password")) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// all clear, continue
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import (
|
|||
"github.com/evcc-io/evcc/server/db"
|
||||
"github.com/evcc-io/evcc/server/db/settings"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/auth"
|
||||
"github.com/evcc-io/evcc/util/encode"
|
||||
"github.com/evcc-io/evcc/util/jq"
|
||||
"github.com/evcc-io/evcc/util/logstash"
|
||||
|
|
@ -317,24 +316,8 @@ func logHandler(w http.ResponseWriter, r *http.Request) {
|
|||
jsonWrite(w, log)
|
||||
}
|
||||
|
||||
// adminPasswordValid validates the admin password and returns true if valid
|
||||
func adminPasswordValid(authObject auth.Auth, password string) bool {
|
||||
return authObject.GetAuthMode() == auth.Disabled || authObject.IsAdminPasswordValid(password)
|
||||
}
|
||||
|
||||
func getBackup(authObject auth.Auth) http.HandlerFunc {
|
||||
func getBackup() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !adminPasswordValid(authObject, req.Password) {
|
||||
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := settings.Persist(); err != nil {
|
||||
http.Error(w, "Synching DB failed", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -378,7 +361,7 @@ func createLocalDatabaseBackup(ctx context.Context) error {
|
|||
return db.Backup(ctx, db.FilePath()+".bak")
|
||||
}
|
||||
|
||||
func restoreDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc {
|
||||
func restoreDatabase(shutdown func()) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// cap upload size to bound disk usage
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 256<<20)
|
||||
|
|
@ -389,10 +372,7 @@ func restoreDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
var (
|
||||
password string
|
||||
tmpName string
|
||||
)
|
||||
var tmpName string
|
||||
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
|
|
@ -405,15 +385,6 @@ func restoreDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc {
|
|||
}
|
||||
|
||||
switch part.FormName() {
|
||||
case "password":
|
||||
b, err := io.ReadAll(io.LimitReader(part, 1<<10))
|
||||
part.Close()
|
||||
if err != nil {
|
||||
http.Error(w, "Upload failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
password = string(b)
|
||||
|
||||
case "file":
|
||||
tmpFile, err := os.CreateTemp("", "evcc-restore-*.db")
|
||||
if err != nil {
|
||||
|
|
@ -437,11 +408,6 @@ func restoreDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
if !adminPasswordValid(authObject, password) {
|
||||
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if tmpName == "" {
|
||||
http.Error(w, "Missing file", http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -472,24 +438,17 @@ func restoreDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func resetDatabase(authObject auth.Auth, shutdown func()) http.HandlerFunc {
|
||||
func resetDatabase(shutdown func()) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
Sessions bool `json:"sessions"`
|
||||
Settings bool `json:"settings"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !adminPasswordValid(authObject, req.Password) {
|
||||
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
settings.Persist()
|
||||
|
||||
if err := createLocalDatabaseBackup(r.Context()); err != nil {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
{
|
||||
"components": {
|
||||
"parameters": {
|
||||
"adminPassword": {
|
||||
"description": "Admin password (required for session auth, not needed for API key)",
|
||||
"in": "header",
|
||||
"name": "X-Admin-Password",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Password"
|
||||
}
|
||||
},
|
||||
"batteryMode": {
|
||||
"in": "path",
|
||||
"name": "batteryMode",
|
||||
|
|
@ -749,6 +757,11 @@
|
|||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"description": "Long-lived API key (prefixed `evcc_`, managed via /auth/apikey).\nGrants access to /api/db/* without requiring the X-Admin-Password header.\n",
|
||||
"scheme": "bearer",
|
||||
"type": "http"
|
||||
},
|
||||
"cookieAuth": {
|
||||
"in": "cookie",
|
||||
"name": "auth",
|
||||
|
|
@ -766,6 +779,100 @@
|
|||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
"/auth/apikey": {
|
||||
"get": {
|
||||
"description": "Reports whether an API key has been generated. The key itself is\nonly returned once at creation time (POST), never on subsequent\nreads.\n",
|
||||
"operationId": "getApiKeyStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"configured": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Success"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "API key status",
|
||||
"tags": [
|
||||
"auth"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Generates a fresh API key, replacing any existing one. The\nreturned key is shown only once; store it immediately.\n\nEven when the request is authenticated via API key (Bearer), the\nadmin password must be supplied in the request body to prevent a\nleaked key from rotating itself. The password check is skipped\nwhen the server is started with `--disable-auth`.\n",
|
||||
"operationId": "regenerateApiKey",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"password": {
|
||||
"$ref": "#/components/schemas/Password"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"key": {
|
||||
"description": "The new API key (cleartext, shown once).",
|
||||
"example": "evcc_aB3xYz7k0Pq2sN5mWtR4",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Success"
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid admin password"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden in demo mode"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Generate or rotate the API key",
|
||||
"tags": [
|
||||
"auth"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"description": "Administrator login. Returns authorization cookie required for all protected endpoints.",
|
||||
|
|
@ -1033,6 +1140,150 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/db/backup": {
|
||||
"get": {
|
||||
"description": "Downloads the SQLite database as a backup file. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt.",
|
||||
"operationId": "downloadBackup",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/adminPassword"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "SQLite database file"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Download database backup",
|
||||
"tags": [
|
||||
"db"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/db/reset": {
|
||||
"post": {
|
||||
"description": "Selectively deletes sessions and/or settings from the database. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt. The instance restarts after a successful reset.",
|
||||
"operationId": "resetDatabase",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/adminPassword"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"sessions": {
|
||||
"description": "Delete all charging sessions",
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"description": "Delete all settings, configs, and meters",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Reset successful, instance is restarting"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Reset database",
|
||||
"tags": [
|
||||
"db"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/db/restore": {
|
||||
"post": {
|
||||
"description": "Restores the database from a previously downloaded backup file. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt. The instance restarts after a successful restore.",
|
||||
"operationId": "restoreBackup",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/adminPassword"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "SQLite database backup file",
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Restore successful, instance is restarting"
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid file or missing fields"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Restore database backup",
|
||||
"tags": [
|
||||
"db"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/gridsessions": {
|
||||
"get": {
|
||||
"description": "Returns a list of HEMS grid limitation events.",
|
||||
|
|
@ -1089,6 +1340,95 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/history/energy": {
|
||||
"get": {
|
||||
"description": "Returns aggregated energy history data. Aggregate granularity defaults to 15 minutes. Supports CSV export.",
|
||||
"operationId": "getEnergyHistory",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Start time (RFC3339)",
|
||||
"in": "query",
|
||||
"name": "from",
|
||||
"schema": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "End time (RFC3339)",
|
||||
"in": "query",
|
||||
"name": "to",
|
||||
"schema": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Aggregation interval. Examples: 15m, 1h, day, month",
|
||||
"in": "query",
|
||||
"name": "aggregate",
|
||||
"schema": {
|
||||
"default": "15m",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Group results by loadpoint",
|
||||
"in": "query",
|
||||
"name": "grouped",
|
||||
"schema": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Response format",
|
||||
"in": "query",
|
||||
"name": "format",
|
||||
"schema": {
|
||||
"default": "json",
|
||||
"enum": [
|
||||
"json",
|
||||
"csv"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header.",
|
||||
"in": "query",
|
||||
"name": "lang",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"text/csv": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Energy history data"
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid parameters or database offline"
|
||||
}
|
||||
},
|
||||
"summary": "Energy history",
|
||||
"tags": [
|
||||
"experimental"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/loadpoints/{id}/batteryboost/{enable}": {
|
||||
"post": {
|
||||
"description": "Enable or disable battery boost. When active, the maximum available home battery power is added until the home battery is drained to configured SoC limit. Note: boost will not work while the battery is on hold (e.g. during fast charging or planned charging with discharge prevention enabled).",
|
||||
|
|
@ -2218,6 +2558,9 @@
|
|||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Clear cache",
|
||||
|
|
@ -2294,6 +2637,9 @@
|
|||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Logs",
|
||||
|
|
@ -2329,6 +2675,9 @@
|
|||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "List of all log areas",
|
||||
|
|
@ -2352,6 +2701,9 @@
|
|||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Shutdown evcc",
|
||||
|
|
@ -2644,6 +2996,12 @@
|
|||
{
|
||||
"name": "battery"
|
||||
},
|
||||
{
|
||||
"name": "db"
|
||||
},
|
||||
{
|
||||
"name": "experimental"
|
||||
},
|
||||
{
|
||||
"name": "general"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,6 +26,15 @@ call changePassword {
|
|||
}
|
||||
```
|
||||
|
||||
## getApiKeyStatus
|
||||
|
||||
Reports whether an API key has been generated. The key itself is
|
||||
only returned once at creation time (POST), never on subsequent
|
||||
reads.
|
||||
|
||||
|
||||
**Tags:** auth
|
||||
|
||||
## getAuthStatus
|
||||
|
||||
Whether the current user is logged in.
|
||||
|
|
@ -58,6 +67,33 @@ Logout and delete authorization cookie
|
|||
|
||||
**Tags:** auth
|
||||
|
||||
## regenerateApiKey
|
||||
|
||||
Generates a fresh API key, replacing any existing one. The
|
||||
returned key is shown only once; store it immediately.
|
||||
|
||||
Even when the request is authenticated via API key (Bearer), the
|
||||
admin password must be supplied in the request body to prevent a
|
||||
leaked key from rotating itself. The password check is skipped
|
||||
when the server is started with `--disable-auth`.
|
||||
|
||||
|
||||
**Tags:** auth
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| requestBody | object | The JSON request body. |
|
||||
|
||||
**Example call:**
|
||||
|
||||
```json
|
||||
call regenerateApiKey {
|
||||
"requestBody": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## disableExternalBatteryControl
|
||||
|
||||
Default evcc control behavior is restored
|
||||
|
|
@ -210,6 +246,98 @@ call setResidualPower {
|
|||
}
|
||||
```
|
||||
|
||||
## downloadBackup
|
||||
|
||||
Downloads the SQLite database as a backup file. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt.
|
||||
|
||||
**Tags:** db
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| X-Admin-Password | string | Admin password (required for session auth, not needed for API key) |
|
||||
|
||||
**Example call:**
|
||||
|
||||
```json
|
||||
call downloadBackup {
|
||||
"X-Admin-Password": "example"
|
||||
}
|
||||
```
|
||||
|
||||
## resetDatabase
|
||||
|
||||
Selectively deletes sessions and/or settings from the database. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt. The instance restarts after a successful reset.
|
||||
|
||||
**Tags:** db
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| X-Admin-Password | string | Admin password (required for session auth, not needed for API key) |
|
||||
| requestBody | object | The JSON request body. |
|
||||
|
||||
**Example call:**
|
||||
|
||||
```json
|
||||
call resetDatabase {
|
||||
"X-Admin-Password": "example",
|
||||
"requestBody": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## restoreBackup
|
||||
|
||||
Restores the database from a previously downloaded backup file. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt. The instance restarts after a successful restore.
|
||||
|
||||
**Tags:** db
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| X-Admin-Password | string | Admin password (required for session auth, not needed for API key) |
|
||||
|
||||
**Example call:**
|
||||
|
||||
```json
|
||||
call restoreBackup {
|
||||
"X-Admin-Password": "example"
|
||||
}
|
||||
```
|
||||
|
||||
## getEnergyHistory
|
||||
|
||||
Returns aggregated energy history data. Aggregate granularity defaults to 15 minutes. Supports CSV export.
|
||||
|
||||
**Tags:** experimental
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| aggregate | string | Aggregation interval. Examples: 15m, 1h, day, month |
|
||||
| format | string | Response format |
|
||||
| from | string | Start time (RFC3339) |
|
||||
| grouped | boolean | Group results by loadpoint |
|
||||
| lang | string | Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header. |
|
||||
| to | string | End time (RFC3339) |
|
||||
|
||||
**Example call:**
|
||||
|
||||
```json
|
||||
call getEnergyHistory {
|
||||
"aggregate": "example",
|
||||
"format": "example",
|
||||
"from": "example",
|
||||
"grouped": true,
|
||||
"lang": "example",
|
||||
"to": "example"
|
||||
}
|
||||
```
|
||||
|
||||
## getState
|
||||
|
||||
Returns the complete state of the system. This structure is used by the UI. It can be filtered by JQ to only return a subset of the data.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ servers:
|
|||
tags:
|
||||
- name: auth
|
||||
- name: battery
|
||||
- name: db
|
||||
- name: experimental
|
||||
- name: general
|
||||
- name: loadpoints
|
||||
- name: sessions
|
||||
|
|
@ -98,6 +100,72 @@ paths:
|
|||
enum:
|
||||
- "true"
|
||||
- "false"
|
||||
/auth/apikey:
|
||||
get:
|
||||
operationId: getApiKeyStatus
|
||||
summary: API key status
|
||||
description: |
|
||||
Reports whether an API key has been generated. The key itself is
|
||||
only returned once at creation time (POST), never on subsequent
|
||||
reads.
|
||||
tags:
|
||||
- auth
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
configured:
|
||||
type: boolean
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
post:
|
||||
operationId: regenerateApiKey
|
||||
summary: Generate or rotate the API key
|
||||
description: |
|
||||
Generates a fresh API key, replacing any existing one. The
|
||||
returned key is shown only once; store it immediately.
|
||||
|
||||
Even when the request is authenticated via API key (Bearer), the
|
||||
admin password must be supplied in the request body to prevent a
|
||||
leaked key from rotating itself. The password check is skipped
|
||||
when the server is started with `--disable-auth`.
|
||||
tags:
|
||||
- auth
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
password:
|
||||
$ref: "#/components/schemas/Password"
|
||||
responses:
|
||||
"200":
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: The new API key (cleartext, shown once).
|
||||
example: evcc_aB3xYz7k0Pq2sN5mWtR4
|
||||
"401":
|
||||
description: Invalid admin password
|
||||
"403":
|
||||
description: Forbidden in demo mode
|
||||
/batterydischargecontrol/{enable}:
|
||||
post:
|
||||
operationId: setBatteryDischargeControl
|
||||
|
|
@ -896,6 +964,7 @@ paths:
|
|||
url: https://docs.evcc.io/en/docs/reference/configuration/log
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- system
|
||||
parameters:
|
||||
|
|
@ -940,6 +1009,7 @@ paths:
|
|||
description: "Returns a list of all log areas (e.g. `lp-1`, `site`, `db`)."
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- system
|
||||
responses:
|
||||
|
|
@ -961,6 +1031,7 @@ paths:
|
|||
description: "Clears all cached data. This resets all cached values from tariffs, vehicle APIs, and other components that use caching."
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- system
|
||||
responses:
|
||||
|
|
@ -975,6 +1046,7 @@ paths:
|
|||
description: "Shut down instance. There is no reboot command. We expect the underlying system (docker, systemd, etc.) to restart the evcc instance once it's terminated."
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- system
|
||||
responses:
|
||||
|
|
@ -1149,6 +1221,148 @@ paths:
|
|||
properties:
|
||||
result:
|
||||
$ref: "#/components/schemas/PlanStrategy"
|
||||
/db/backup:
|
||||
get:
|
||||
operationId: downloadBackup
|
||||
summary: Download database backup
|
||||
description: "Downloads the SQLite database as a backup file. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt."
|
||||
tags:
|
||||
- db
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/adminPassword"
|
||||
responses:
|
||||
"200":
|
||||
description: SQLite database file
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
/db/restore:
|
||||
post:
|
||||
operationId: restoreBackup
|
||||
summary: Restore database backup
|
||||
description: "Restores the database from a previously downloaded backup file. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt. The instance restarts after a successful restore."
|
||||
tags:
|
||||
- db
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/adminPassword"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- file
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: SQLite database backup file
|
||||
responses:
|
||||
"204":
|
||||
description: Restore successful, instance is restarting
|
||||
"400":
|
||||
description: Invalid file or missing fields
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
/db/reset:
|
||||
post:
|
||||
operationId: resetDatabase
|
||||
summary: Reset database
|
||||
description: "Selectively deletes sessions and/or settings from the database. Session users must supply the admin password in the X-Admin-Password header. API key holders via Bearer token are exempt. The instance restarts after a successful reset."
|
||||
tags:
|
||||
- db
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/adminPassword"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
sessions:
|
||||
type: boolean
|
||||
description: Delete all charging sessions
|
||||
settings:
|
||||
type: boolean
|
||||
description: Delete all settings, configs, and meters
|
||||
responses:
|
||||
"204":
|
||||
description: Reset successful, instance is restarting
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
/history/energy:
|
||||
get:
|
||||
operationId: getEnergyHistory
|
||||
summary: Energy history
|
||||
description: "Returns aggregated energy history data. Aggregate granularity defaults to 15 minutes. Supports CSV export."
|
||||
tags:
|
||||
- experimental
|
||||
parameters:
|
||||
- name: from
|
||||
in: query
|
||||
description: Start time (RFC3339)
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- name: to
|
||||
in: query
|
||||
description: End time (RFC3339)
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- name: aggregate
|
||||
in: query
|
||||
description: "Aggregation interval. Examples: 15m, 1h, day, month"
|
||||
schema:
|
||||
type: string
|
||||
default: 15m
|
||||
- name: grouped
|
||||
in: query
|
||||
description: Group results by loadpoint
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
- name: format
|
||||
in: query
|
||||
description: Response format
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- json
|
||||
- csv
|
||||
default: json
|
||||
- name: lang
|
||||
in: query
|
||||
description: Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header.
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Energy history data
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
text/csv:
|
||||
schema:
|
||||
type: string
|
||||
"400":
|
||||
description: Invalid parameters or database offline
|
||||
components:
|
||||
schemas:
|
||||
BatteryMode:
|
||||
|
|
@ -1445,6 +1659,12 @@ components:
|
|||
minimum: 0
|
||||
maximum: 6
|
||||
parameters:
|
||||
adminPassword:
|
||||
name: X-Admin-Password
|
||||
in: header
|
||||
description: Admin password (required for session auth, not needed for API key)
|
||||
schema:
|
||||
$ref: "#/components/schemas/Password"
|
||||
id:
|
||||
name: id
|
||||
description: Loadpoint index starting at 1
|
||||
|
|
@ -1693,3 +1913,9 @@ components:
|
|||
type: apiKey
|
||||
in: cookie
|
||||
name: auth
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: |
|
||||
Long-lived API key (prefixed `evcc_`, managed via /auth/apikey).
|
||||
Grants access to /api/db/* without requiring the X-Admin-Password header.
|
||||
|
|
|
|||
176
tests/api-key.spec.ts
Normal file
176
tests/api-key.spec.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { test, expect, type Page, type Locator } from "@playwright/test";
|
||||
import { start, stop, baseUrl } from "./evcc";
|
||||
import { expectModalHidden, expectModalVisible } from "./utils";
|
||||
|
||||
test.use({ baseURL: baseUrl() });
|
||||
|
||||
const BASIC = "basics.evcc.yaml";
|
||||
const PASSWORD = "secret";
|
||||
|
||||
async function loginAndOpenApiKey(page: Page): Promise<Locator> {
|
||||
await page.goto("/#/config");
|
||||
|
||||
const loginModal = page.getByTestId("login-modal");
|
||||
await expectModalVisible(loginModal);
|
||||
await loginModal.getByLabel("Administrator Password").fill(PASSWORD);
|
||||
await loginModal.getByRole("button", { name: "Login" }).click();
|
||||
await expectModalHidden(loginModal);
|
||||
|
||||
return openApiKeyModal(page);
|
||||
}
|
||||
|
||||
async function openApiKeyModal(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("generalconfig-security").getByRole("button", { name: "edit" }).click();
|
||||
const securityModal = page.getByTestId("security-modal");
|
||||
await expectModalVisible(securityModal);
|
||||
await securityModal.getByRole("button", { name: "Generate API Key" }).click();
|
||||
|
||||
const apiKeyModal = page.getByTestId("api-key-modal");
|
||||
await expectModalVisible(apiKeyModal);
|
||||
return apiKeyModal;
|
||||
}
|
||||
|
||||
async function generateKey(
|
||||
page: Page,
|
||||
modal: Locator,
|
||||
action: "Generate API Key" | "Regenerate API Key"
|
||||
): Promise<string> {
|
||||
if (action === "Regenerate API Key") {
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
}
|
||||
await modal.getByLabel("Administrator Password").fill(PASSWORD);
|
||||
await modal.getByRole("button", { name: action, exact: true }).click();
|
||||
|
||||
const keyInput = modal.getByLabel("API Key", { exact: true });
|
||||
await expect(keyInput).toBeVisible();
|
||||
const key = await keyInput.inputValue();
|
||||
expect(key).toMatch(/^evcc_/);
|
||||
expect(key.length).toBeGreaterThan(10);
|
||||
return key;
|
||||
}
|
||||
|
||||
test("generate first key", async ({ page }) => {
|
||||
await start(BASIC, "password.sql", "");
|
||||
const modal = await loginAndOpenApiKey(page);
|
||||
|
||||
await expect(modal.getByRole("button", { name: "Generate API Key" })).toBeVisible();
|
||||
|
||||
const key = await generateKey(page, modal, "Generate API Key");
|
||||
expect(key).toMatch(/^evcc_/);
|
||||
|
||||
await modal.getByRole("button", { name: "Close" }).last().click();
|
||||
await expectModalHidden(modal);
|
||||
|
||||
// closing reveal returns to security modal — now offering Regenerate
|
||||
const securityModal = page.getByTestId("security-modal");
|
||||
await expectModalVisible(securityModal);
|
||||
await expect(securityModal.getByRole("button", { name: "Regenerate" })).toBeVisible();
|
||||
|
||||
await stop();
|
||||
});
|
||||
|
||||
test("regenerate replaces old key", async ({ page, request }) => {
|
||||
await start(BASIC, "password.sql", "");
|
||||
const modal = await loginAndOpenApiKey(page);
|
||||
|
||||
const first = await generateKey(page, modal, "Generate API Key");
|
||||
await modal.getByRole("button", { name: "Close" }).last().click();
|
||||
await expectModalHidden(modal);
|
||||
|
||||
const securityModal = page.getByTestId("security-modal");
|
||||
await expectModalVisible(securityModal);
|
||||
await securityModal.getByRole("button", { name: "Regenerate" }).click();
|
||||
await expectModalVisible(modal);
|
||||
const second = await generateKey(page, modal, "Regenerate API Key");
|
||||
await modal.getByRole("button", { name: "Close" }).last().click();
|
||||
expect(second).not.toBe(first);
|
||||
|
||||
const oldRes = await request.get("/api/config/site", {
|
||||
headers: { Authorization: `Bearer ${first}` },
|
||||
});
|
||||
expect(oldRes.status()).toBe(401);
|
||||
|
||||
const newRes = await request.get("/api/config/site", {
|
||||
headers: { Authorization: `Bearer ${second}` },
|
||||
});
|
||||
expect(newRes.status()).toBe(200);
|
||||
|
||||
await stop();
|
||||
});
|
||||
|
||||
test("api key authenticates protected endpoints and bypasses backup pw", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await start(BASIC, "password.sql", "");
|
||||
const modal = await loginAndOpenApiKey(page);
|
||||
const key = await generateKey(page, modal, "Generate API Key");
|
||||
|
||||
const ok = await request.get("/api/config/site", {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
expect(ok.status()).toBe(200);
|
||||
|
||||
// backup without X-Admin-Password, bypass via API key
|
||||
const backup = await request.get("/api/db/backup", {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
expect(backup.status()).toBe(200);
|
||||
expect(backup.headers()["content-disposition"] || "").toContain("evcc-backup-");
|
||||
|
||||
const unauth = await request.get("/api/config/site");
|
||||
expect(unauth.status()).toBe(401);
|
||||
|
||||
await stop();
|
||||
});
|
||||
|
||||
test("api key cannot rotate itself without admin password", async ({ page, request }) => {
|
||||
await start(BASIC, "password.sql", "");
|
||||
const modal = await loginAndOpenApiKey(page);
|
||||
const key = await generateKey(page, modal, "Generate API Key");
|
||||
|
||||
const bad = await request.post("/api/auth/apikey", {
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
data: { password: "" },
|
||||
});
|
||||
expect(bad.status()).toBe(401);
|
||||
|
||||
const good = await request.post("/api/auth/apikey", {
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
data: { password: PASSWORD },
|
||||
});
|
||||
expect(good.status()).toBe(200);
|
||||
const body = await good.json();
|
||||
expect(body.key).toMatch(/^evcc_/);
|
||||
expect(body.key).not.toBe(key);
|
||||
|
||||
await stop();
|
||||
});
|
||||
|
||||
test("api key cannot change admin password without correct current", async ({ page, request }) => {
|
||||
await start(BASIC, "password.sql", "");
|
||||
const modal = await loginAndOpenApiKey(page);
|
||||
const key = await generateKey(page, modal, "Generate API Key");
|
||||
|
||||
const bad = await request.put("/api/auth/password", {
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
data: { current: "", new: "anything" },
|
||||
});
|
||||
expect(bad.status()).toBe(400);
|
||||
|
||||
await stop();
|
||||
});
|
||||
|
||||
test("disable-auth shows banner and disables actions", async ({ page }) => {
|
||||
await start(BASIC, null, "--disable-auth");
|
||||
await page.goto("/#/config");
|
||||
|
||||
await page.getByTestId("generalconfig-security").getByRole("button", { name: "edit" }).click();
|
||||
const security = page.getByTestId("security-modal");
|
||||
await expectModalVisible(security);
|
||||
await expect(security.getByText(/Authentication is disabled/i)).toBeVisible();
|
||||
await expect(security.getByRole("button", { name: "Update password" })).toBeDisabled();
|
||||
await expect(security.getByRole("button", { name: "Generate API Key" })).toBeDisabled();
|
||||
|
||||
await stop();
|
||||
});
|
||||
|
|
@ -108,8 +108,11 @@ test("update password", async ({ page }) => {
|
|||
await loginModal.getByRole("button", { name: "Login" }).click();
|
||||
await expectModalHidden(loginModal);
|
||||
|
||||
// update password
|
||||
await page.getByTestId("generalconfig-password").getByRole("button", { name: "edit" }).click();
|
||||
// open security overview, then change password
|
||||
await page.getByTestId("generalconfig-security").getByRole("button", { name: "edit" }).click();
|
||||
const securityModal = page.getByTestId("security-modal");
|
||||
await expectModalVisible(securityModal);
|
||||
await securityModal.getByRole("button", { name: "Update password" }).click();
|
||||
const modal = page.getByTestId("password-update-modal");
|
||||
await expectModalVisible(modal);
|
||||
await expect(modal.getByRole("heading", { name: "Update Administrator Password" })).toBeVisible();
|
||||
|
|
@ -121,6 +124,11 @@ test("update password", async ({ page }) => {
|
|||
modal.getByRole("heading", { name: "Update Administrator Password" })
|
||||
).not.toBeVisible();
|
||||
|
||||
// close security modal that reappears underneath
|
||||
await expectModalVisible(securityModal);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectModalHidden(securityModal);
|
||||
|
||||
// logout
|
||||
const menu = await openMoreMenu(page);
|
||||
await menu.getByRole("button", { name: "Logout" }).click();
|
||||
|
|
@ -140,7 +148,9 @@ test("update password", async ({ page }) => {
|
|||
await expectModalHidden(loginNew);
|
||||
|
||||
// revert to old password
|
||||
await page.getByTestId("generalconfig-password").getByRole("button", { name: "edit" }).click();
|
||||
await page.getByTestId("generalconfig-security").getByRole("button", { name: "edit" }).click();
|
||||
await expectModalVisible(securityModal);
|
||||
await securityModal.getByRole("button", { name: "Update password" }).click();
|
||||
await expectModalVisible(modal);
|
||||
await modal.getByLabel("Current password").fill(newPassword);
|
||||
await modal.getByLabel("New password").fill(oldPassword);
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ test.describe("backup and restore", async () => {
|
|||
});
|
||||
|
||||
test.describe("backup in app context", async () => {
|
||||
test("download backup dispatches POST event with password body", async ({ page }) => {
|
||||
test("download backup dispatches GET event with X-Admin-Password header", async ({ page }) => {
|
||||
await enableAppContext(page);
|
||||
await start();
|
||||
await page.goto("/#/config");
|
||||
|
|
@ -271,9 +271,8 @@ test.describe("backup in app context", async () => {
|
|||
await backupConfirmModal.getByRole("button", { name: "Download backup" }).click();
|
||||
expect(await expectAppEvent(page)).toMatchObject({
|
||||
type: "download",
|
||||
url: expect.stringContaining("/api/system/backup"),
|
||||
method: "POST",
|
||||
body: { password: "" },
|
||||
url: expect.stringContaining("/api/db/backup"),
|
||||
headers: { "X-Admin-Password": "" },
|
||||
});
|
||||
await stop();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,9 +9,12 @@ import (
|
|||
"github.com/evcc-io/evcc/core/keys"
|
||||
"github.com/evcc-io/evcc/server/db/settings"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/sethvargo/go-password/password"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const ApiKeyPrefix = "evcc_"
|
||||
|
||||
const admin = "admin"
|
||||
|
||||
// Possible authentication modes
|
||||
|
|
@ -29,10 +32,14 @@ type Auth interface {
|
|||
SetAdminPassword(string) error
|
||||
IsAdminPasswordValid(string) bool
|
||||
GenerateJwtToken(time.Duration) (string, error)
|
||||
ValidateJwtToken(string) (bool, error)
|
||||
ValidateJwtToken(string) bool
|
||||
IsAdminPasswordConfigured() bool
|
||||
SetAuthMode(AuthMode)
|
||||
GetAuthMode() AuthMode
|
||||
|
||||
SetApiKey() (string, error)
|
||||
IsApiKeyConfigured() bool
|
||||
ValidateApiKey(string) bool
|
||||
}
|
||||
|
||||
type auth struct {
|
||||
|
|
@ -64,6 +71,7 @@ func (a *auth) getAdminPasswordHash() string {
|
|||
func (a *auth) RemoveAdminPassword() {
|
||||
a.settings.SetString(keys.AdminPassword, "")
|
||||
a.settings.SetString(keys.JwtSecret, "")
|
||||
a.settings.SetString(keys.ApiKey, "")
|
||||
}
|
||||
|
||||
// IsAdminPasswordConfigured checks if the admin password is already set
|
||||
|
|
@ -136,21 +144,17 @@ func (a *auth) GenerateJwtToken(lifetime time.Duration) (string, error) {
|
|||
}
|
||||
|
||||
// ValidateJwtToken validates the given JWT token
|
||||
func (a *auth) ValidateJwtToken(tokenString string) (bool, error) {
|
||||
func (a *auth) ValidateJwtToken(tokenString string) bool {
|
||||
jwtSecret, err := a.getJwtSecret()
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false
|
||||
}
|
||||
|
||||
// read token
|
||||
var claims jwt.RegisteredClaims
|
||||
if _, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (any, error) {
|
||||
_, err = jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (any, error) {
|
||||
return jwtSecret, nil
|
||||
}, jwt.WithSubject(admin)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}, jwt.WithSubject(admin))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (a *auth) SetAuthMode(authMode AuthMode) {
|
||||
|
|
@ -160,3 +164,35 @@ func (a *auth) SetAuthMode(authMode AuthMode) {
|
|||
func (a *auth) GetAuthMode() AuthMode {
|
||||
return a.authMode
|
||||
}
|
||||
|
||||
// IsApiKeyConfigured reports whether an API key has been generated
|
||||
func (a *auth) IsApiKeyConfigured() bool {
|
||||
hash, _ := a.settings.String(keys.ApiKey)
|
||||
return hash != ""
|
||||
}
|
||||
|
||||
// SetApiKey generates a new API key, stores its hash, and returns the cleartext key
|
||||
func (a *auth) SetApiKey() (string, error) {
|
||||
secret, err := password.Generate(30, 6, 0, false, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := ApiKeyPrefix + secret
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
a.settings.SetString(keys.ApiKey, string(hashed))
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ValidateApiKey returns true if the given token matches the stored key
|
||||
func (a *auth) ValidateApiKey(token string) bool {
|
||||
hash, err := a.settings.String(keys.ApiKey)
|
||||
if err != nil || hash == "" {
|
||||
return false
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(token)) == nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ func TestRemoveAdminPassword(t *testing.T) {
|
|||
|
||||
mock.EXPECT().SetString(keys.JwtSecret, "")
|
||||
mock.EXPECT().SetString(keys.AdminPassword, "")
|
||||
mock.EXPECT().SetString(keys.ApiKey, "")
|
||||
auth.RemoveAdminPassword()
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +61,33 @@ func TestIsAdminPasswordValid(t *testing.T) {
|
|||
assert.False(t, auth.IsAdminPasswordValid(invalidPw))
|
||||
}
|
||||
|
||||
func TestApiKey(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mock := settings.NewMockAPI(ctrl)
|
||||
auth := NewMock(mock)
|
||||
|
||||
// not configured
|
||||
mock.EXPECT().String(keys.ApiKey).Return("", nil).Times(1)
|
||||
assert.False(t, auth.IsApiKeyConfigured())
|
||||
|
||||
// generate
|
||||
var storedHash string
|
||||
mock.EXPECT().SetString(keys.ApiKey, gomock.Not(gomock.Eq(""))).
|
||||
Do(func(_, hash string) { storedHash = hash })
|
||||
key, err := auth.SetApiKey()
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, strings.HasPrefix(key, ApiKeyPrefix), "key should carry the evcc_ prefix")
|
||||
assert.Greater(t, len(key), len(ApiKeyPrefix)+15)
|
||||
|
||||
// validate the generated key
|
||||
mock.EXPECT().String(keys.ApiKey).Return(storedHash, nil).AnyTimes()
|
||||
assert.True(t, auth.ValidateApiKey(key))
|
||||
assert.False(t, auth.ValidateApiKey(key+"x"))
|
||||
assert.False(t, auth.ValidateApiKey("evcc_wrong"))
|
||||
}
|
||||
|
||||
func TestJwtToken(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
|
@ -73,6 +102,5 @@ func TestJwtToken(t *testing.T) {
|
|||
assert.Nil(t, err, "token generation failed")
|
||||
assert.NotEmpty(t, tokenString, "token is empty")
|
||||
|
||||
ok, err := auth.ValidateJwtToken(tokenString)
|
||||
assert.True(t, ok && err == nil, "token is invalid")
|
||||
assert.True(t, auth.ValidateJwtToken(tokenString), "token is invalid")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue