Repository: evcc-io/evcc
Pull request #11732 (2024-01-17 10:40:26)

Config UI: Add authentication
Add password authentication to secure sensitive configuration data. Limited scope to one "admin" user. 

- 🔐 admin password can be set via api once(!)
- 🎟️ using JWT tokens via httpOnly cookie (90d validity)
- 🔮 using bcrypt to store encrypted admin pw hash in db

addresses #6029

**TODOs**

- [x] login UI
- [x] pw reset via CLI -> `evcc password [set|reset]`
- [x] secure all /config routes
- [x] e2e test: login, existing flow
- [x] e2e test: set password, logout
- [x] move auth from site to global
- [x] pw change function

**Ideas for some time in the future**
- Option to require auth for all evcc UI/APIs
- Support for multiple users or integration tokens

**Video**

https://github.com/evcc-io/evcc/assets/152287/3bb456b0-2130-4ab6-b25e-e2c9ba243347



VolkerK62: ist das dann Zwang, oder geht es auch ohne?
naltatis: Für alles, was Config-UI ist, würde ich das als "Pflicht" sehen. Das offen im Netzwerk stehen zu haben (auch im eigenen) ist keine gute Idee. Da wird ja später auch so etwas wie Scripting/Plugins drüber möglich sein.
VolkerK62: Ah, also nur, wenn die Config betroffen ist. Nicht beim normalen Zugriff aufs UI.
andig: > admin password can be set via api once(!)

I don't think thats a good idea. First user could run any script. We should either do this during 

    evcc configure

or/plus add a cli command.
naltatis: > I don't think thats a good idea. First user could run any script. We should either do this during
> evcc configure

Ja, wir müssen noch über den Flow sprechen, wie das initiale Setzen des Passworts passiert. Vmtl. müssen wir das bei der Einrichtung durch die UI erzwingen. Das Passwort in die Umgebungsvariable oder evcc.yaml (via `evcc configure`) zu schreiben ist keine Lösung. Unser Ziel ist es ja eine 100% via Webbrowser-Einrichtung zu ermöglichen.
AnBo83: Moin, da immer mehr über die UI zu konfigurieren ist wird dieses Thema immer aktueller. Da sich seit einem Monat nichts getan hat wollte ich mal vorsichtig nachfragen wie weit die Implementierung ist?
naltatis: Ongoing. Mehrere Baustellen, die hier zusammenführen.
andig: Die Rückfragen halten uns offen gesagt nur von der Arbeit ab. Wenn sich etwas tut wird es hier sichtbar...
naltatis: Mandatory "Set Password" Modal (wip)

![matching](https://github.com/evcc-io/evcc/assets/152287/2efc1b7d-b95f-45a8-8fca-955ea3518fb8)

![empty](https://github.com/evcc-io/evcc/assets/152287/8ae98fc8-6f9e-40d2-98c5-ce1335d5487b)

naltatis: Set initial password flow.

https://github.com/evcc-io/evcc/assets/152287/ceda525e-244e-429b-856a-eca9b6d82582


naltatis: **Video**

https://github.com/evcc-io/evcc/assets/152287/3bb456b0-2130-4ab6-b25e-e2c9ba243347

andig: Schick, ich hab nur Kleinigkeiten.
naltatis: @andig Ich hab jetzt erstmal alle UI relevanten Stellen (Passwort anlegen/aktualisieren, Login) abgeklemmt. Damit können wir diesen PR in den Master mergen. Auch die entsprechenden Tests sind erstmal geskippt. Alle Stellen sind mit einem entsprechenden TODO versehen (`... once auth is released`).

Zusammen mit dem ersten echten Use-Case (https://github.com/evcc-io/evcc/pull/11739) würden wir das Feature dann komplett aktivieren.

Einzige offene Frage: jetzt rein oder kurz nach dem nächsten Release?

Patch:
diff --git a/assets/js/api.js b/assets/js/api.js
index 9ba3b5b4..45859733 100644
--- a/assets/js/api.js
+++ b/assets/js/api.js
@@ -1,4 +1,5 @@
 import axios from "axios";
+import { openLoginModal } from "./auth";
 
 const { protocol, hostname, port, pathname } = window.location;
 
@@ -15,13 +16,19 @@ const api = axios.create({
 api.interceptors.response.use(
   (response) => response,
   (error) => {
+    // handle unauthorized errors
+    if (error.response?.status === 401) {
+      openLoginModal();
+      return Promise.reject(error);
+    }
+
     const message = [`${error.message}.`];
     if (error.response?.data?.error) {
       message.push(`${error.response.data.error}.`);
     }
     if (error.config) {
       const method = error.config.method.toUpperCase();
-      const url = error.config.baseURL + error.config.url;
+      const url = error.request.responseURL;
       message.push(`${method} ${url}`);
     }
     window.app.raise({ message });
diff --git a/assets/js/auth.js b/assets/js/auth.js
new file mode 100644
index 00000000..ba85b006
--- /dev/null
+++ b/assets/js/auth.js
@@ -0,0 +1,58 @@
+import { reactive, watch } from "vue";
+import api from "./api";
+import Modal from "bootstrap/js/dist/modal";
+
+const auth = reactive({
+  configured: true,
+  loggedIn: false,
+});
+
+export async function updateAuthStatus() {
+  try {
+    const res = await api.get("/auth/status", {
+      validateStatus: (code) => [200, 501].includes(code),
+    });
+    if (res.status === 501) {
+      auth.configured = false;
+    }
+    if (res.status === 200) {
+      auth.configured = true;
+      auth.loggedIn = res.data === true;
+    }
+  } catch (e) {
+    console.log("unable to fetch auth status", e);
+  }
+}
+
+export async function logout() {
+  try {
+    await api.post("/auth/logout");
+    await updateAuthStatus();
+  } catch (e) {
+    console.log("unable to logout", e);
+  }
+}
+
+export function isLoggedIn() {
+  return auth.loggedIn;
+}
+
+export function isConfigured() {
+  return auth.configured;
+}
+
+export function openLoginModal() {
+  const modal = Modal.getOrCreateInstance(document.getElementById("loginModal"));
+  modal.show();
+}
+
+// show/hide password modal based on auth status
+watch(
+  () => auth.configured,
+  (configured) => {
+    const modal = Modal.getOrCreateInstance(document.getElementById("passwordModal"));
+    configured ? modal.hide() : modal.show();
+  }
+);
+
+export default auth;
diff --git a/assets/js/components/Config/GeneralConfig.vue b/assets/js/components/Config/GeneralConfig.vue
index cf9ef3bb..ee4eba81 100644
--- a/assets/js/components/Config/GeneralConfig.vue
+++ b/assets/js/components/Config/GeneralConfig.vue
@@ -27,11 +27,17 @@
 				</a>
 			</dd>
 		</dl>
-		<dl class="row wip">
+		<dl class="row wip" data-testid="generalconfig-password">
 			<dt class="col-sm-4">Password</dt>
 			<dd class="col-sm-8">
 				*******
-				<a href="#" class="ms-2 d-inline-block text-muted" @click.prevent="todo">edit</a>
+				<!-- TODO: update this once auth is released -->
+				<a
+					href="#"
+					class="ms-2 d-inline-block text-muted"
+					@click.prevent="todo() /* openModal('passwordModal')*/"
+					>edit</a
+				>
 			</dd>
 		</dl>
 		<dl class="row wip">
diff --git a/assets/js/components/GenericModal.vue b/assets/js/components/GenericModal.vue
index fbd3a0d5..8e4c0ba0 100644
--- a/assets/js/components/GenericModal.vue
+++ b/assets/js/components/GenericModal.vue
@@ -4,10 +4,12 @@
 			:id="id"
 			ref="modal"
 			class="modal fade text-dark"
-			data-bs-backdrop="true"
+			:class="sizeClass"
 			tabindex="-1"
 			role="dialog"
 			aria-hidden="true"
+			:data-bs-backdrop="uncloseable ? 'static' : 'true'"
+			:data-bs-keyboard="uncloseable ? 'false' : 'true'"
 			:data-testid="dataTestid"
 		>
 			<div class="modal-dialog modal-dialog-centered" role="document">
@@ -17,6 +19,7 @@
 							{{ title }}
 						</h5>
 						<button
+							v-if="!uncloseable"
 							type="button"
 							class="btn-close"
 							data-bs-dismiss="modal"
@@ -41,27 +44,29 @@ export default {
 		id: String,
 		title: String,
 		dataTestid: String,
+		uncloseable: Boolean,
+		size: String,
 	},
-	emits: ["visibilityChanged"],
-	watch: {
-		isModalVisible(visible) {
-			this.$emit("changed", visible);
-		},
-	},
+	emits: ["open", "closed"],
 	mounted() {
-		this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
-		this.$refs.modal.addEventListener("hide.bs.modal", this.modalInvisible);
+		this.$refs.modal.addEventListener("show.bs.modal", this.handleShow);
+		this.$refs.modal.addEventListener("hidden.bs.modal", this.handleHidden);
 	},
 	unmounted() {
-		this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
-		this.$refs.modal?.removeEventListener("hide.bs.modal", this.modalInvisible);
+		this.$refs.modal?.removeEventListener("show.bs.modal", this.handleShow);
+		this.$refs.modal?.removeEventListener("hidden.bs.modal", this.handleHidden);
+	},
+	computed: {
+		sizeClass() {
+			return this.size ? `modal-${this.size}` : "";
+		},
 	},
 	methods: {
-		modalVisible() {
-			this.isModalVisible = true;
+		handleShow() {
+			this.$emit("open");
 		},
-		modalInvisible() {
-			this.isModalVisible = false;
+		handleHidden() {
+			this.$emit("closed");
 		},
 		open() {
 			Modal.getOrCreateInstance(this.$refs.modal).show();
diff --git a/assets/js/components/HelpModal.vue b/assets/js/components/HelpModal.vue
index 45b99d93..e367d568 100644
--- a/assets/js/components/HelpModal.vue
+++ b/assets/js/components/HelpModal.vue
@@ -105,7 +105,7 @@
 					<div class="modal-footer d-flex justify-content-between">
 						<button
 							type="button"
-							class="btn btn-outline-secondary"
+							class="btn btn-link text-muted"
 							data-bs-dismiss="modal"
 							@click="openHelpModal"
 						>
diff --git a/assets/js/components/LoginModal.vue b/assets/js/components/LoginModal.vue
new file mode 100644
index 00000000..430d5050
--- /dev/null
+++ b/assets/js/components/LoginModal.vue
@@ -0,0 +1,98 @@
+<template>
+	<GenericModal
+		id="loginModal"
+		:title="$t('loginModal.title')"
+		size="sm"
+		data-testid="login-modal"
+		@open="open"
+		@closed="closed"
+	>
+		<form v-if="modalVisible" @submit.prevent="login">
+			<p v-if="error" class="text-danger">{{ $t("loginModal.error") }}{{ error }}</p>
+
+			<div class="mb-4">
+				<label for="loginPassword" class="col-form-label">
+					<div class="w-100">
+						<span class="label">{{ $t("loginModal.password") }}</span>
+					</div>
+				</label>
+				<input
+					id="loginPassword"
+					v-model="password"
+					ref="password"
+					class="form-control"
+					autocomplete="current-password"
+					type="password"
+					required
+				/>
+			</div>
+
+			<button type="submit" class="btn btn-primary w-100 mb-3" :disabled="loading">
+				{{ $t("loginModal.login") }}
+			</button>
+		</form>
+	</GenericModal>
+</template>
+
+<script>
+import GenericModal from "./GenericModal.vue";
+import Modal from "bootstrap/js/dist/modal";
+import api from "../api";
+import { updateAuthStatus } from "../auth";
+
+export default {
+	name: "LoginModal",
+	components: { GenericModal },
+	data: () => {
+		return {
+			modalVisible: false,
+			password: "",
+			loading: false,
+			error: "",
+		};
+	},
+	methods: {
+		open() {
+			this.modalVisible = true;
+		},
+		closed() {
+			this.modalVisible = false;
+			this.password = "";
+			this.loading = false;
+			this.error = "";
+		},
+		focus() {
+			console.log(this.$refs.password);
+			this.$refs.password.focus();
+		},
+		closeModal() {
+			Modal.getOrCreateInstance(document.getElementById("loginModal")).hide();
+		},
+		async login() {
+			this.loading = true;
+			this.error = "";
+
+			try {
+				const data = { password: this.password };
+				const res = await api.post("/auth/login", data, {
+					validateStatus: (code) => [200, 401].includes(code),
+				});
+				if (res.status === 200) {
+					this.closeModal();
+					await updateAuthStatus();
+					this.$router.push({ path: "/config" });
+					this.password = "";
+				}
+				if (res.status === 401) {
+					this.error = this.$t("loginModal.invalid");
+				}
+			} catch (err) {
+				console.error(err);
+				this.error = err.message;
+			} finally {
+				this.loading = false;
+			}
+		},
+	},
+};
+</script>
diff --git a/assets/js/components/PasswordModal.vue b/assets/js/components/PasswordModal.vue
new file mode 100644
index 00000000..a49091f6
--- /dev/null
+++ b/assets/js/components/PasswordModal.vue
@@ -0,0 +1,177 @@
+<template>
+	<GenericModal
+		id="passwordModal"
+		:title="title"
+		ref="modal"
+		data-testid="password-modal"
+		:uncloseable="!updateMode"
+		@open="open"
+		@closed="closed"
+	>
+		<p v-if="error" class="text-danger">{{ $t("passwordModal.error") }} "{{ error.trim() }}"</p>
+		<p v-if="!updateMode" class="mb-4">{{ $t("passwordModal.description") }}</p>
+		<form
+			v-if="modalVisible"
+			ref="form"
+			:class="{ 'was-validated': showValidation }"
+			novalidate
+			@submit="setPassword"
+		>
+			<!-- password manager hint -->
+			<input
+				class="d-none"
+				type="text"
+				name="username"
+				id="username"
+				autocomplete="username"
+				value="admin"
+			/>
+			<FormRow
+				v-if="updateMode"
+				id="passwordCurrent"
+				:label="$t('passwordModal.labelCurrent')"
+			>
+				<input
+					id="passwordCurrent"
+					type="password"
+					v-model="passwordCurrent"
+					class="form-control"
+					autocomplete="current-password"
+				/>
+			</FormRow>
+			<FormRow id="passwordNew" :label="$t('passwordModal.labelNew')">
+				<input
+					id="passwordNew"
+					type="password"
+					v-model="passwordNew"
+					class="form-control"
+					autocomplete="new-password"
+					required
+				/>
+				<div class="invalid-feedback">
+					{{ $t("passwordModal.empty") }}
+				</div>
+			</FormRow>
+			<FormRow id="passwordRepeat" :label="$t('passwordModal.labelRepeat')">
+				<input
+					id="passwordRepeat"
+					type="password"
+					ref="passwordRepeat"
+					v-model="passwordRepeat"
+					class="form-control"
+					autocomplete="new-password"
+				/>
+				<div v-if="!passwordsMatch" class="invalid-feedback">
+					{{ $t("passwordModal.noMatch") }}
+				</div>
+			</FormRow>
+
+			<div class="d-flex justify-content-end">
+				<button
+					type="submit"
+					class="btn btn-primary justify-self-right"
+					:disabled="loading"
+				>
+					<span
+						v-if="loading"
+						class="spinner-border spinner-border-sm"
+						role="status"
+						aria-hidden="true"
+					></span>
+					{{ submitText }}
+				</button>
+			</div>
+		</form>
+	</GenericModal>
+</template>
+
+<script>
+import GenericModal from "./GenericModal.vue";
+import FormRow from "./FormRow.vue";
+import api from "../api";
+import { updateAuthStatus, isConfigured } from "../auth";
+
+export default {
+	name: "PasswordModal",
+	components: { FormRow, GenericModal },
+	data: () => {
+		return {
+			modalVisible: false,
+			passwordCurrent: "",
+			passwordNew: "",
+			passwordRepeat: "",
+			showValidation: false,
+			loading: false,
+			error: false,
+		};
+	},
+	computed: {
+		passwordsMatch: function () {
+			return this.passwordNew === this.passwordRepeat;
+		},
+		passwordEmpty: function () {
+			return this.passwordNew.length === 0;
+		},
+		updateMode: function () {
+			return isConfigured();
+		},
+		title: function () {
+			return this.updateMode
+				? this.$t("passwordModal.titleUpdate")
+				: this.$t("passwordModal.titleNew");
+		},
+		submitText: function () {
+			return this.updateMode
+				? this.$t("passwordModal.updatePassword")
+				: this.$t("passwordModal.newPassword");
+		},
+	},
+	methods: {
+		open: function () {
+			this.modalVisible = true;
+		},
+		closed: function () {
+			this.modalVisible = false;
+			this.passwordCurrent = "";
+			this.passwordNew = "";
+			this.passwordRepeat = "";
+			this.error = false;
+			this.loading = false;
+			this.showValidation = false;
+		},
+		setPassword: async function (e) {
+			this.$refs.passwordRepeat.setCustomValidity(
+				this.passwordsMatch && !this.passwordEmpty ? "" : "invalid"
+			);
+
+			e.preventDefault();
+			e.stopPropagation();
+			this.showValidation = true;
+
+			if (this.$refs.form.checkValidity()) {
+				await this.savePassword();
+				await updateAuthStatus();
+			}
+		},
+		savePassword: async function () {
+			this.loading = true;
+			this.error = null;
+			try {
+				const data = {
+					current: this.passwordCurrent,
+					new: this.passwordNew,
+				};
+				await api.put("/auth/password", data);
+				this.loading = false;
+				this.$refs.modal?.close();
+			} catch (error) {
+				console.error(error);
+				this.error = error.response.data;
+				this.showValidation = false;
+			} finally {
+				this.loading = false;
+			}
+		},
+	},
+};
+</script>
diff --git a/assets/js/components/TopNavigation.vue b/assets/js/components/TopNavigation.vue
index 102a0d40..9a6da9d2 100644
--- a/assets/js/components/TopNavigation.vue
+++ b/assets/js/components/TopNavigation.vue
@@ -57,6 +57,12 @@
 				</router-link>
 			</li>
 			<li><hr class="dropdown-divider" /></li>
+			<li v-if="showLogout">
+				<button type="button" class="dropdown-item" @click="logout">
+					{{ $t("header.logout") }}
+				</button>
+			</li>
+
 			<template v-if="providerLogins.length > 0">
 				<li><hr class="dropdown-divider" /></li>
 				<li>
@@ -108,6 +114,7 @@ import "@h2d2/shopicons/es/regular/moonstars";
 import "@h2d2/shopicons/es/regular/menu";
 import "@h2d2/shopicons/es/regular/newtab";
 import collector from "../mixins/collector";
+import { logout, isLoggedIn, openLoginModal } from "../auth";
 
 import baseAPI from "../baseapi";
 import { isApp, sendToApp } from "../utils/native";
@@ -155,6 +162,9 @@ export default {
 		batteryModalAvailable() {
 			return this.batteryConfigured;
 		},
+		showLogout() {
+			return isLoggedIn();
+		},
 	},
 	mounted() {
 		const $el = document.getElementById("topNavigatonDropdown");
@@ -193,6 +203,13 @@ export default {
 		openNativeSettings() {
 			sendToApp({ type: "settings" });
 		},
+		async login() {
+			openLoginModal();
+		},
+		async logout() {
+			await logout();
+			this.$router.push({ path: "/" });
+		},
 	},
 };
 </script>
diff --git a/assets/js/router.js b/assets/js/router.js
index 373c98a3..5afe30d9 100644
--- a/assets/js/router.js
+++ b/assets/js/router.js
@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from "vue-router";
 import Modal from "bootstrap/js/dist/modal";
 import Main from "./views/Main.vue";
 import { ensureCurrentLocaleMessages } from "./i18n";
+import { openLoginModal, updateAuthStatus, isLoggedIn } from "./auth";
 
 function hideAllModals() {
   [...document.querySelectorAll(".modal.show")].forEach((modal) => {
@@ -17,7 +18,18 @@ export default function setupRouter(i18n) {
     history: createWebHashHistory(),
     routes: [
       { path: "/", component: Main, props: true },
-      { path: "/config", component: () => import("./views/Config.vue"), props: true },
+      {
+        path: "/config",
+        component: () => import("./views/Config.vue"),
+        beforeEnter: async () => {
+          await updateAuthStatus();
+          if (!isLoggedIn()) {
+            openLoginModal();
+            return false;
+          }
+        },
+        props: true,
+      },
       {
         path: "/sessions",
         component: () => import("./views/ChargingSessions.vue"),
diff --git a/assets/js/theme.js b/assets/js/theme.js
index 3cb51dcc..130532d2 100644
--- a/assets/js/theme.js
+++ b/assets/js/theme.js
@@ -58,7 +58,6 @@ function updateMetaThemeForBackdrop() {
   const $backdrop = document.querySelector("[data-bs-backdrop=true][aria-modal=true]");
   // dark if there is a backdrop, otherwise use the current theme
   const theme = $backdrop ? THEME_DARK : getCurrentTheme();
-  console.log("backdrop", theme);
   setMetaThemeColor(theme);
 }
 
diff --git a/assets/js/views/App.vue b/assets/js/views/App.vue
index 9a01e69a..ad67f082 100644
--- a/assets/js/views/App.vue
+++ b/assets/js/views/App.vue
@@ -5,6 +5,8 @@
 		<GlobalSettingsModal v-bind="globalSettingsProps" />
 		<BatterySettingsModal v-if="batteryModalAvailabe" v-bind="batterySettingsProps" />
 		<HelpModal />
+		<PasswordModal />
+		<LoginModal />
 	</div>
 </template>
 
@@ -12,8 +14,11 @@
 import store from "../store";
 import GlobalSettingsModal from "../components/GlobalSettingsModal.vue";
 import BatterySettingsModal from "../components/BatterySettingsModal.vue";
+import PasswordModal from "../components/PasswordModal.vue";
+import LoginModal from "../components/LoginModal.vue";
 import HelpModal from "../components/HelpModal.vue";
 import collector from "../mixins/collector";
+import { updateAuthStatus } from "../auth";
 
 // assume offline if not data received for 60 seconds
 let lastDataReceived = new Date();
@@ -27,14 +32,20 @@ setInterval(() => {
 
 export default {
 	name: "App",
-	components: { GlobalSettingsModal, HelpModal, BatterySettingsModal },
+	components: {
+		GlobalSettingsModal,
+		HelpModal,
+		BatterySettingsModal,
+		PasswordModal,
+		LoginModal,
+	},
 	mixins: [collector],
 	props: {
 		notifications: Array,
 		offline: Boolean,
 	},
 	data: () => {
-		return { reconnectTimeout: null, ws: null };
+		return { reconnectTimeout: null, ws: null, authNotConfigured: false };
 	},
 	head() {
 		const siteTitle = store.state.siteTitle;
@@ -47,6 +58,9 @@ export default {
 				this.reload();
 			}
 		},
+		offline: function () {
+			updateAuthStatus();
+		},
 	},
 	computed: {
 		version: function () {
@@ -65,6 +79,7 @@ export default {
 	mounted: function () {
 		this.connect();
 		document.addEventListener("visibilitychange", this.pageVisibilityChanged, false);
+		updateAuthStatus();
 	},
 	unmounted: function () {
 		this.disconnect();
@@ -78,6 +93,7 @@ export default {
 				this.disconnect();
 			} else {
 				this.connect();
+				updateAuthStatus();
 			}
 		},
 		reconnect: function () {
diff --git a/cmd/password.go b/cmd/password.go
new file mode 100644
index 00000000..eb815eb1
--- /dev/null
+++ b/cmd/password.go
@@ -0,0 +1,14 @@
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+)
+
+var passwordCmd = &cobra.Command{
+	Use:   "password",
+	Short: "Password administration",
+}
+
+func init() {
+	rootCmd.AddCommand(passwordCmd)
+}
diff --git a/cmd/password_reset.go b/cmd/password_reset.go
new file mode 100644
index 00000000..78a6d205
--- /dev/null
+++ b/cmd/password_reset.go
@@ -0,0 +1,47 @@
+package cmd
+
+import (
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/evcc-io/evcc/util/auth"
+	"github.com/spf13/cobra"
+)
+
+var passwordResetCmd = &cobra.Command{
+	Use:   "reset",
+	Short: "Reset password",
+	Args:  cobra.ExactArgs(0),
+	Run:   runPasswordReset,
+}
+
+func init() {
+	passwordCmd.AddCommand(passwordResetCmd)
+}
+
+func runPasswordReset(cmd *cobra.Command, args []string) {
+	// load config
+	if err := loadConfigFile(&conf); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	// setup environment
+	if err := configureEnvironment(cmd, conf); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	prompt := &survey.Confirm{
+		Message: "Are you sure?",
+		Help:    "help",
+	}
+
+	var confirm bool
+	if err := survey.AskOne(prompt, &confirm); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	if confirm {
+		auth.New().RemoveAdminPassword()
+	}
+
+	// wait for shutdown
+	<-shutdownDoneC()
+}
diff --git a/cmd/password_set.go b/cmd/password_set.go
new file mode 100644
index 00000000..1ee504a5
--- /dev/null
+++ b/cmd/password_set.go
@@ -0,0 +1,49 @@
+package cmd
+
+import (
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/evcc-io/evcc/util/auth"
+	"github.com/spf13/cobra"
+)
+
+var passwordSetCmd = &cobra.Command{
+	Use:   "set",
+	Short: "Set password",
+	Args:  cobra.ExactArgs(0),
+	Run:   runPasswordSet,
+}
+
+func init() {
+	passwordCmd.AddCommand(passwordSetCmd)
+}
+
+func runPasswordSet(cmd *cobra.Command, args []string) {
+	// load config
+	if err := loadConfigFile(&conf); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	// setup environment
+	if err := configureEnvironment(cmd, conf); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	prompt := &survey.Password{
+		Message: "Password",
+		Help:    "help",
+	}
+
+	var password string
+	if err := survey.AskOne(prompt, &password); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	if password == "" {
+		log.FATAL.Fatal("password cannot be empty")
+	} else {
+		auth.New().SetAdminPassword(password)
+	}
+
+	// wait for shutdown
+	<-shutdownDoneC()
+}
diff --git a/core/keys/auth.go b/core/keys/auth.go
new file mode 100644
index 00000000..0cd32361
--- /dev/null
+++ b/core/keys/auth.go
@@ -0,0 +1,6 @@
+package keys
+
+const (
+	AdminPassword = "adminPassword"
+	JwtSecret     = "jwtSecretKey"
+)
diff --git a/core/keys/site.go b/core/keys/site.go
index 8138e2df..dc50cec7 100644
--- a/core/keys/site.go
+++ b/core/keys/site.go
@@ -28,6 +28,7 @@ const (
 	TariffPriceHome       = "tariffPriceHome"
 	TariffPriceLoadpoints = "tariffPriceLoadpoints"
 	Vehicles              = "vehicles"
+	PasswordConfigured    = "passwordConfigured"
 
 	// meters
 	GridMeter     = "gridMeter"
diff --git a/go.mod b/go.mod
index 2857020e..be137471 100644
--- a/go.mod
+++ b/go.mod
@@ -184,7 +184,7 @@ require (
 	github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
 	gitlab.com/c0b/go-ordered-json v0.0.0-20201030195603-febf46534d5a // indirect
 	go.uber.org/multierr v1.11.0 // indirect
-	golang.org/x/crypto v0.21.0 // indirect
+	golang.org/x/crypto v0.21.0
 	golang.org/x/mod v0.16.0 // indirect
 	golang.org/x/sys v0.18.0 // indirect
 	golang.org/x/term v0.18.0 // indirect
diff --git a/i18n/de.toml b/i18n/de.toml
index 80928cbb..5a3e4f4a 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -197,6 +197,14 @@ description = "Unter normalen Umständen sollte ein Neustart nicht notwendig sei
 disclaimer = "Hinweis: evcc beendet sich und verlässt sich darauf, vom Betriebssystem neu gestartet zu werden."
 modalTitle = "Sicher, dass du neu starten möchtest?"
 
+[loginModal]
+cancel = "Abbrechen"
+error = "Login fehlgeschlagen: "
+invalid = "Passwort ist ungültig."
+login = "Anmelden"
+password = "Passwort"
+title = "Authentifizierung"
+
 [main]
 vehicles = "Parkplatz"
 
@@ -380,6 +388,19 @@ modalTitle = "Meldungen"
 message = "Keine Verbindung zum Server."
 reload = "Erneut laden?"
 
+[passwordModal]
+description = "Setze ein Passwort, um die Konfiguration zu schützen. Die Hauptansicht bleibt ohne Login zugänglich."
+empty = "Passwort darf nicht leer sein"
+error = "Fehler: "
+labelCurrent = "Aktuelles Passwort"
+labelNew = "Neues Passwort"
+labelRepeat = "Neues Passwort wiederholen"
+newPassword = "Passwort setzen"
+noMatch = "Passwörter stimmen nicht überein"
+titleNew = "Administrator Passwort setzen"
+titleUpdate = "Administrator Passwort ändern"
+updatePassword = "Passwort ändern"
+
 [session]
 cancel = "Abbrechen"
 co2 = "CO₂"
diff --git a/i18n/en.toml b/i18n/en.toml
index 0c922293..c4aa20a0 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -172,6 +172,7 @@ blog = "Blog"
 docs = "Documentation"
 github = "GitHub"
 login = "Vehicle Logins"
+logout = "Logout"
 nativeSettings = "Change Server"
 needHelp = "Need Help?"
 sessions = "Charging Sessions"
@@ -194,6 +195,14 @@ description = "Under normal circumstances restarting should not be necessary. Pl
 disclaimer = "Note: evcc will terminate and rely on the operating system to restart the service."
 modalTitle = "Are you sure you want to restart?"
 
+[loginModal]
+cancel = "Cancel"
+error = "Login failed: "
+invalid = "Password is invalid."
+login = "Login"
+password = "Password"
+title = "Authentication"
+
 [main]
 vehicles = "Parking"
 
@@ -377,6 +386,19 @@ modalTitle = "Notifications"
 message = "Not connected to a server."
 reload = "Reload?"
 
+[passwordModal]
+description = "Set a password to protect the configuration settings. Using the main screen is still possible without login."
+empty = "Password should not be empty"
+error = "Error: "
+labelCurrent = "Current password"
+labelNew = "New password"
+labelRepeat = "Repeat password"
+newPassword = "Create password"
+noMatch = "Passwords do not match"
+titleNew = "Set Administrator Password"
+titleUpdate = "Update Administrator Password"
+updatePassword = "Update password"
+
 [session]
 cancel = "Cancel"
 co2 = "CO₂"
diff --git a/server/db/settings/api.go b/server/db/settings/api.go
new file mode 100644
index 00000000..832c1880
--- /dev/null
+++ b/server/db/settings/api.go
@@ -0,0 +1,8 @@
+package settings
+
+//go:generate mockgen -package settings -destination mock.go -mock_names API=MockAPI github.com/evcc-io/evcc/server/db/settings API
+
+type API interface {
+	String(key string) (string, error)
+	SetString(key string, value string)
+}
diff --git a/server/db/settings/mock.go b/server/db/settings/mock.go
new file mode 100644
index 00000000..378587fb
--- /dev/null
+++ b/server/db/settings/mock.go
@@ -0,0 +1,66 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/evcc-io/evcc/server/db/settings (interfaces: API)
+//
+// Generated by this command:
+//
+//	mockgen -package settings -destination mock.go -mock_names API=MockAPI github.com/evcc-io/evcc/server/db/settings API
+//
+
+// Package settings is a generated GoMock package.
+package settings
+
+import (
+	reflect "reflect"
+
+	gomock "go.uber.org/mock/gomock"
+)
+
+// MockAPI is a mock of API interface.
+type MockAPI struct {
+	ctrl     *gomock.Controller
+	recorder *MockAPIMockRecorder
+}
+
+// MockAPIMockRecorder is the mock recorder for MockAPI.
+type MockAPIMockRecorder struct {
+	mock *MockAPI
+}
+
+// NewMockAPI creates a new mock instance.
+func NewMockAPI(ctrl *gomock.Controller) *MockAPI {
+	mock := &MockAPI{ctrl: ctrl}
+	mock.recorder = &MockAPIMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockAPI) EXPECT() *MockAPIMockRecorder {
+	return m.recorder
+}
+
+// SetString mocks base method.
+func (m *MockAPI) SetString(arg0, arg1 string) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "SetString", arg0, arg1)
+}
+
+// SetString indicates an expected call of SetString.
+func (mr *MockAPIMockRecorder) SetString(arg0, arg1 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetString", reflect.TypeOf((*MockAPI)(nil).SetString), arg0, arg1)
+}
+
+// String mocks base method.
+func (m *MockAPI) String(arg0 string) (string, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "String", arg0)
+	ret0, _ := ret[0].(string)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// String indicates an expected call of String.
+func (mr *MockAPIMockRecorder) String(arg0 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "String", reflect.TypeOf((*MockAPI)(nil).String), arg0)
+}
diff --git a/server/db/settings/setting.go b/server/db/settings/setting.go
index 2d3451ad..26a4e6b6 100644
--- a/server/db/settings/setting.go
+++ b/server/db/settings/setting.go
@@ -149,3 +149,14 @@ func Json(key string, res any) error {
 	}
 	return err
 }
+
+// wrapping Settings into a struct for better decoupling
+type Settings struct{}
+
+func (s Settings) String(key string) (string, error) {
+	return String(key)
+}
+
+func (s Settings) SetString(key string, value string) {
+	SetString(key, value)
+}
diff --git a/server/http.go b/server/http.go
index a8b0b3f7..0724677d 100644
--- a/server/http.go
+++ b/server/http.go
@@ -9,6 +9,7 @@ import (
 	"github.com/evcc-io/evcc/core/site"
 	"github.com/evcc-io/evcc/server/assets"
 	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/auth"
 	"github.com/evcc-io/evcc/util/telemetry"
 	"github.com/go-http-utils/etag"
 	"github.com/gorilla/handlers"
@@ -94,23 +95,12 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
 		handlers.AllowedHeaders([]string{"Content-Type"}),
 	))
 
+	auth := auth.New()
+
 	// site api
 	routes := map[string]route{
 		"health":                  {"GET", "/health", healthHandler(site)},
 		"state":                   {"GET", "/state", stateHandler(cache)},
-		"config":                  {"GET", "/config/templates/{class:[a-z]+}", templatesHandler},
-		"products":                {"GET", "/config/products/{class:[a-z]+}", productsHandler},
-		"devices":                 {"GET", "/config/devices/{class:[a-z]+}", devicesHandler},
-		"device":                  {"GET", "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceConfigHandler},
-		"devicestatus":            {"GET", "/config/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
-		"site":                    {"GET", "/config/site", siteHandler(site)},
-		"dirty":                   {"GET", "/config/dirty", boolGetHandler(ConfigDirty)},
-		"updatesite":              {"PUT", "/config/site", updateSiteHandler(site)},
-		"newdevice":               {"POST", "/config/devices/{class:[a-z]+}", newDeviceHandler},
-		"updatedevice":            {"PUT", "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
-		"deletedevice":            {"DELETE", "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
-		"testconfig":              {"POST", "/config/test/{class:[a-z]+}", testConfigHandler},
-		"testmerged":              {"POST", "/config/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler},
 		"buffersoc":               {"POST", "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
 		"bufferstartsoc":          {"POST", "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
 		"batterydischargecontrol": {"POST", "/batterydischargecontrol/{value:[a-z]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)},
@@ -123,12 +113,40 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
 		"deletesession":           {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler},
 		"telemetry":               {"GET", "/settings/telemetry", boolGetHandler(telemetry.Enabled)},
 		"telemetry2":              {"POST", "/settings/telemetry/{value:[a-z]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
+		"password":                {"PUT", "/auth/password", updatePasswordHandler(auth)},
+		"auth":                    {"GET", "/auth/status", authStatusHandler(auth)},
+		"login":                   {"POST", "/auth/login", loginHandler(auth)},
+		"logout":                  {"POST", "/auth/logout", logoutHandler},
 	}
 
 	for _, r := range routes {
 		api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
 	}
 
+	// config ui (secured)
+	configApi := api.PathPrefix("/config").Subrouter()
+	configApi.Use(ensureAuthHandler(auth))
+
+	configRoutes := map[string]route{
+		"templates":    {"GET", "/templates/{class:[a-z]+}", templatesHandler},
+		"products":     {"GET", "/products/{class:[a-z]+}", productsHandler},
+		"devices":      {"GET", "/devices/{class:[a-z]+}", devicesHandler},
+		"device":       {"GET", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceConfigHandler},
+		"devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
+		"site":         {"GET", "/site", siteHandler(site)},
+		"dirty":        {"GET", "/dirty", boolGetHandler(ConfigDirty)},
+		"updatesite":   {"PUT", "/site", updateSiteHandler(site)},
+		"newdevice":    {"POST", "/devices/{class:[a-z]+}", newDeviceHandler},
+		"updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
+		"deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
+		"testconfig":   {"POST", "/test/{class:[a-z]+}", testConfigHandler},
+		"testmerged":   {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler},
+	}
+
+	for _, r := range configRoutes {
+		configApi.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
+	}
+
 	// vehicle api
 	vehicles := map[string]route{
 		"minsoc":   {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/minsoc/{value:[0-9]+}", minSocHandler(site)},
diff --git a/server/http_auth.go b/server/http_auth.go
new file mode 100644
index 00000000..e7b93089
--- /dev/null
+++ b/server/http_auth.go
@@ -0,0 +1,138 @@
+package server
+
+import (
+	"encoding/json"
+	"net/http"
+	"time"
+
+	"github.com/evcc-io/evcc/util/auth"
+	"github.com/gorilla/mux"
+)
+
+const authCookieName = "auth"
+
+type updatePasswordRequest struct {
+	Current string `json:"current"`
+	New     string `json:"new"`
+}
+
+type loginRequest struct {
+	Password string `json:"password"`
+}
+
+func updatePasswordHandler(auth auth.Auth) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req updatePasswordRequest
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		// update password
+		if auth.IsAdminPasswordConfigured() {
+			if !auth.IsAdminPasswordValid(req.Current) {
+				http.Error(w, "Invalid password", http.StatusBadRequest)
+				return
+			}
+
+			if err := auth.SetAdminPassword(req.New); err != nil {
+				http.Error(w, err.Error(), http.StatusInternalServerError)
+				return
+			}
+
+			w.WriteHeader(http.StatusAccepted)
+			return
+		}
+
+		// create new password
+		if err := auth.SetAdminPassword(req.New); err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		w.WriteHeader(http.StatusCreated)
+	}
+}
+
+// read jwt from header and cookie
+func jwtFromRequest(r *http.Request) string {
+	tokenString := r.Header.Get("Authorization")
+	if tokenString == "" {
+		if cookie, _ := r.Cookie(authCookieName); cookie != nil {
+			tokenString = cookie.Value
+		}
+	}
+
+	return tokenString
+}
+
+// authStatusHandler login status (true/false) based on jwt token. Error if admin password is not configured
+func authStatusHandler(auth auth.Auth) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if !auth.IsAdminPasswordConfigured() {
+			http.Error(w, "Not implemented", http.StatusNotImplemented)
+			return
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		ok, err := auth.ValidateJwtToken(jwtFromRequest(r))
+		if err != nil || !ok {
+			w.Write([]byte("false"))
+			return
+		}
+		w.Write([]byte("true"))
+	}
+}
+
+func loginHandler(auth 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 !auth.IsAdminPasswordValid(req.Password) {
+			http.Error(w, "Invalid password", http.StatusUnauthorized)
+			return
+		}
+
+		lifetime := time.Hour * 24 * 90 // 90 day valid
+		tokenString, err := auth.GenerateJwtToken(lifetime)
+		if err != nil {
+			http.Error(w, "Failed to generate JWT token.", http.StatusInternalServerError)
+			return
+		}
+
+		http.SetCookie(w, &http.Cookie{
+			Name:     authCookieName,
+			Value:    tokenString,
+			Path:     "/",
+			HttpOnly: true,
+			Expires:  time.Now().Add(lifetime),
+		})
+	}
+}
+
+func logoutHandler(w http.ResponseWriter, r *http.Request) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     authCookieName,
+		Path:     "/",
+		HttpOnly: true,
+	})
+}
+
+func ensureAuthHandler(auth auth.Auth) mux.MiddlewareFunc {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			// check jwt token
+			ok, err := auth.ValidateJwtToken(jwtFromRequest(r))
+			if !ok || err != nil {
+				http.Error(w, "Unauthorized", http.StatusUnauthorized)
+				return
+			}
+
+			// all clear, continue
+			next.ServeHTTP(w, r)
+		})
+	}
+}
diff --git a/util/auth/auth.go b/util/auth/auth.go
new file mode 100644
index 00000000..01597b89
--- /dev/null
+++ b/util/auth/auth.go
@@ -0,0 +1,160 @@
+package auth
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"errors"
+	"time"
+
+	"github.com/evcc-io/evcc/core/keys"
+	"github.com/evcc-io/evcc/server/db/settings"
+	"github.com/golang-jwt/jwt/v5"
+	"golang.org/x/crypto/bcrypt"
+)
+
+// TODO: remove this once auth is released
+const disabled = true
+
+const admin = "admin"
+
+// Auth is the Auth api
+type Auth interface {
+	RemoveAdminPassword()
+	SetAdminPassword(string) error
+	IsAdminPasswordValid(string) bool
+	GenerateJwtToken(time.Duration) (string, error)
+	ValidateJwtToken(string) (bool, error)
+	IsAdminPasswordConfigured() bool
+}
+
+type auth struct {
+	settings settings.API
+}
+
+func New() Auth {
+	return &auth{settings: new(settings.Settings)}
+}
+
+func NewMock(settings settings.API) Auth {
+	return &auth{settings: settings}
+}
+
+func (a *auth) hashPassword(password string) (string, error) {
+	bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	return string(bytes), err
+}
+
+func (a *auth) getAdminPasswordHash() string {
+	if pw, err := a.settings.String(keys.AdminPassword); err == nil {
+		return pw
+	}
+	return ""
+}
+
+// RemoveAdminPassword resets the admin password. For recovery mode via cli.
+func (a *auth) RemoveAdminPassword() {
+	a.settings.SetString(keys.AdminPassword, "")
+	a.settings.SetString(keys.JwtSecret, "")
+}
+
+// IsAdminPasswordConfigured checks if the admin password is already set
+func (a *auth) IsAdminPasswordConfigured() bool {
+	// TODO: remove this once auth is released
+	if disabled {
+		return true
+	}
+
+	return a.getAdminPasswordHash() != ""
+}
+
+// SetAdminPassword sets the admin password if not already set
+func (a *auth) SetAdminPassword(password string) error {
+	// TODO: remove this once auth is released
+	if disabled {
+		return errors.New("not implemented")
+	}
+
+	if password == "" {
+		return errors.New("password cannot be empty")
+	}
+
+	hashed, err := a.hashPassword(password)
+	if err != nil {
+		return err
+	}
+
+	a.settings.SetString(keys.AdminPassword, hashed)
+	return nil
+}
+
+// IsAdminPasswordValid checks if the given password matches the admin password
+func (a *auth) IsAdminPasswordValid(password string) bool {
+	adminHash := a.getAdminPasswordHash()
+	if adminHash == "" {
+		return false
+	}
+
+	return bcrypt.CompareHashAndPassword([]byte(adminHash), []byte(password)) == nil
+}
+
+func (a *auth) generateRandomKey(length int) (string, error) {
+	bytes := make([]byte, length)
+	if _, err := rand.Read(bytes); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(bytes), nil
+}
+
+// getJwtSecret returns the JWT secret from the settings or generates a new one
+func (a *auth) getJwtSecret() ([]byte, error) {
+	jwtSecret, err := a.settings.String(keys.JwtSecret)
+
+	// generate new secret if it doesn't exist yet -> new installation
+	if err != nil || jwtSecret == "" {
+		jwtSecret, err = a.generateRandomKey(32)
+		if err != nil {
+			return nil, err
+		}
+		a.settings.SetString(keys.JwtSecret, jwtSecret)
+	}
+
+	return []byte(jwtSecret), nil
+}
+
+// GenerateJwtToken generates an admin user JWT token with the given lifetime
+func (a *auth) GenerateJwtToken(lifetime time.Duration) (string, error) {
+	claims := &jwt.RegisteredClaims{
+		Subject:   admin,
+		ExpiresAt: jwt.NewNumericDate(time.Now().Add(lifetime)),
+	}
+
+	if jwtSecret, err := a.getJwtSecret(); err != nil {
+		return "", err
+	} else {
+		token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+		return token.SignedString(jwtSecret)
+	}
+}
+
+// ValidateJwtToken validates the given JWT token
+func (a *auth) ValidateJwtToken(tokenString string) (bool, error) {
+	// TODO: remove this once auth is released
+	if disabled {
+		return true, nil
+	}
+
+	jwtSecret, err := a.getJwtSecret()
+	if err != nil {
+		return false, err
+	}
+
+	// read token
+	var claims jwt.RegisteredClaims
+	if _, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) {
+		return jwtSecret, nil
+	}, jwt.WithSubject(admin)); err != nil {
+		return false, err
+	}
+
+	return true, nil
+}


Test patch:
diff --git a/tests/auth.spec.js b/tests/auth.spec.js
new file mode 100644
index 00000000..c1e78d10
--- /dev/null
+++ b/tests/auth.spec.js
@@ -0,0 +1,106 @@
+import { test, expect } from "@playwright/test";
+import { start, stop } from "./evcc";
+
+test.beforeEach(async ({ page }) => {
+  await start("basics.evcc.yaml");
+  await page.goto("/");
+});
+
+test.afterEach(async () => {
+  await stop();
+});
+
+// TODO: activate this once auth is released
+test.skip("set initial password", async ({ page }) => {
+  const modal = page.getByTestId("password-modal");
+
+  await expect(modal).toBeVisible();
+  await expect(modal.getByRole("heading", { name: "Set Administrator Password" })).toBeVisible();
+
+  // empty password
+  await modal.getByRole("button", { name: "Create Password" }).click();
+  await expect(modal.getByText("Password should not be empty")).toBeVisible();
+
+  // invalid repeat
+  await modal.getByLabel("New password").fill("foo");
+  await modal.getByLabel("Repeat password").fill("bar");
+  await modal.getByRole("button", { name: "Create Password" }).click();
+  await expect(modal.getByText("Passwords do not match")).toBeVisible();
+
+  // success
+  await modal.getByLabel("New password").fill("secret");
+  await modal.getByLabel("Repeat password").fill("secret");
+  await modal.getByRole("button", { name: "Create Password" }).click();
+  await expect(modal).not.toBeVisible();
+});
+
+test.skip("login", async ({ page }) => {
+  // set initial password
+  const modal = page.getByTestId("password-modal");
+  await modal.getByLabel("New password").fill("secret");
+  await modal.getByLabel("Repeat password").fill("secret");
+  await modal.getByRole("button", { name: "Create Password" }).click();
+
+  // go to config
+  await page.getByTestId("topnavigation-button").click();
+  await page.getByRole("button", { name: "Settings" }).click();
+  await page.getByLabel("Experimental 🧪").click();
+  await page.getByRole("button", { name: "Close" }).click();
+  await page.getByTestId("topnavigation-button").click();
+  await page.getByRole("link", { name: "Configuration" }).click();
+
+  // login modal
+  const login = page.getByTestId("login-modal");
+  await expect(login).toBeVisible();
+  await expect(login.getByRole("heading", { name: "Authentication" })).toBeVisible();
+
+  // enter wrong password
+  await login.getByLabel("Password").fill("wrong");
+  await login.getByRole("button", { name: "Login" }).click();
+  await expect(login.getByText("Login failed: Password is invalid.")).toBeVisible();
+
+  // enter correct password
+  await login.getByLabel("Password").fill("secret");
+  await login.getByRole("button", { name: "Login" }).click();
+  await expect(login).not.toBeVisible();
+  await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
+});
+
+test.skip("update password", async ({ page }) => {
+  const oldPassword = "secret";
+  const newPassword = "newsecret";
+
+  // set initial password
+  const modal = page.getByTestId("password-modal");
+  await modal.getByLabel("New password").fill(oldPassword);
+  await modal.getByLabel("Repeat password").fill(oldPassword);
+  await modal.getByRole("button", { name: "Create Password" }).click();
+
+  // login modal
+  page.goto("/#/config");
+  const loginOld = page.getByTestId("login-modal");
+  await loginOld.getByLabel("Password").fill(oldPassword);
+  await loginOld.getByRole("button", { name: "Login" }).click();
+
+  // update password
+  await page.getByTestId("generalconfig-password").getByRole("link", { name: "edit" }).click();
+  await expect(modal.getByRole("heading", { name: "Update Administrator Password" })).toBeVisible();
+  await modal.getByLabel("Current password").fill(oldPassword);
+  await modal.getByLabel("New password").fill(newPassword);
+  await modal.getByLabel("Repeat password").fill(newPassword);
+  await modal.getByRole("button", { name: "Update Password" }).click();
+  await expect(
+    modal.getByRole("heading", { name: "Update Administrator Password" })
+  ).not.toBeVisible();
+
+  // logout
+  await page.getByTestId("topnavigation-button").click();
+  await page.getByRole("button", { name: "Logout" }).click();
+
+  // login modal
+  page.goto("/#/config");
+  const loginNew = page.getByTestId("login-modal");
+  await loginNew.getByLabel("Password").fill(newPassword);
+  await loginNew.getByRole("button", { name: "Login" }).click();
+  await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
+});
diff --git a/tests/basics.spec.js b/tests/basics.spec.js
index 3da7488a..490d7c79 100644
--- a/tests/basics.spec.js
+++ b/tests/basics.spec.js
@@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test";
 import { start, stop } from "./evcc";
 
 test.beforeAll(async () => {
-  await start("basics.evcc.yaml");
+  await start("basics.evcc.yaml", "password.sql");
 });
 test.afterAll(async () => {
   await stop();
diff --git a/tests/config.spec.js b/tests/config.spec.js
index 207eabc9..d27fc5e7 100644
--- a/tests/config.spec.js
+++ b/tests/config.spec.js
@@ -6,12 +6,18 @@ const CONFIG_EMPTY = "config-empty.evcc.yaml";
 const CONFIG_WITH_VEHICLE = "config-with-vehicle.evcc.yaml";
 
 test.beforeAll(async () => {
-  await start(CONFIG_EMPTY);
+  await start(CONFIG_EMPTY, "password.sql");
 });
 test.afterAll(async () => {
   await stop();
 });
 
+async function login() {
+  // TODO: uncomment this once auth is released
+  // await page.locator("#loginPassword").fill("secret");
+  // await page.getByRole("button", { name: "Login" }).click();
+}
+
 test.describe("basics", async () => {
   test("navigation to config", async ({ page }) => {
     await page.goto("/");
@@ -21,10 +27,12 @@ test.describe("basics", async () => {
     await page.getByRole("button", { name: "Close" }).click();
     await page.getByTestId("topnavigation-button").click();
     await page.getByRole("link", { name: "Configuration" }).click();
+    await login(page);
     await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
   });
   test("alert box should always be visible", async ({ page }) => {
     await page.goto("/#/config");
+    await login(page);
     await expect(page.getByRole("alert")).toBeVisible();
   });
 });
@@ -32,6 +40,7 @@ test.describe("basics", async () => {
 test.describe("vehicles", async () => {
   test("create, edit and delete vehicles", async ({ page }) => {
     await page.goto("/#/config");
+    await login(page);
 
     await expect(page.getByTestId("vehicle")).toHaveCount(0);
     const vehicleModal = page.getByTestId("vehicle-modal");
@@ -79,6 +88,7 @@ test.describe("vehicles", async () => {
 
   test("config should survive restart", async ({ page }) => {
     await page.goto("/#/config");
+    await login(page);
 
     await expect(page.getByTestId("vehicle")).toHaveCount(0);
     const vehicleModal = page.getByTestId("vehicle-modal");
@@ -108,9 +118,10 @@ test.describe("vehicles", async () => {
   });
 
   test("mixed config (yaml + db)", async ({ page }) => {
-    await cleanRestart(CONFIG_WITH_VEHICLE);
+    await cleanRestart(CONFIG_WITH_VEHICLE, "password.sql");
 
     await page.goto("/#/config");
+    await login(page);
 
     await expect(page.getByTestId("vehicle")).toHaveCount(1);
     const vehicleModal = page.getByTestId("vehicle-modal");
@@ -143,6 +154,7 @@ test.describe("meters", async () => {
     await page.getByRole("button", { name: "Apply changes" }).click();
 
     await page.goto("/#/config");
+    await login(page);
 
     await expect(page.getByTestId("battery")).toHaveCount(0);
 
@@ -195,6 +207,8 @@ test.describe("general", async () => {
 
     // change value in config
     await page.goto("/#/config");
+    await login(page);
+
     await expect(page.getByTestId("generalconfig-title")).toContainText("Hello World");
     await page.getByTestId("generalconfig-title").getByRole("link", { name: "edit" }).click();
     const modal = page.getByTestId("title-modal");
diff --git a/tests/evcc.js b/tests/evcc.js
index 06198598..da69d327 100644
--- a/tests/evcc.js
+++ b/tests/evcc.js
@@ -9,10 +9,10 @@ const BASE_URL = playwrightConfig.use.baseURL;
 const DB_PATH = "./evcc.db";
 const BINARY = "./evcc";
 
-export async function start(config, database) {
+export async function start(config, sqlDumps) {
   await _clean();
-  if (database) {
-    await _restoreDatabase(database);
+  if (sqlDumps) {
+    await _restoreDatabase(sqlDumps);
   }
   await _start(config);
 }
@@ -27,15 +27,21 @@ export async function restart(config) {
   await _start(config);
 }
 
-export async function cleanRestart(config) {
+export async function cleanRestart(config, sqlDumps) {
   await _stop();
   await _clean();
+  if (sqlDumps) {
+    await _restoreDatabase(sqlDumps);
+  }
   await _start(config);
 }
 
-async function _restoreDatabase(database) {
-  console.log("loading database", { database });
-  execSync(`sqlite3 ${DB_PATH} < tests/${database}`);
+async function _restoreDatabase(sqlDumps) {
+  const dumps = Array.isArray(sqlDumps) ? sqlDumps : [sqlDumps];
+  for (const dump of dumps) {
+    console.log("loading database", dump);
+    execSync(`sqlite3 ${DB_PATH} < tests/${dump}`);
+  }
 }
 
 async function _start(config) {
diff --git a/tests/heating.spec.js b/tests/heating.spec.js
index e4e03285..4cafc832 100644
--- a/tests/heating.spec.js
+++ b/tests/heating.spec.js
@@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test";
 import { start, stop } from "./evcc";
 
 test.beforeAll(async () => {
-  await start("heating.evcc.yaml");
+  await start("heating.evcc.yaml", "password.sql");
 });
 test.afterAll(async () => {
   await stop();
diff --git a/tests/limits.spec.js b/tests/limits.spec.js
index 302ad179..e7244f71 100644
--- a/tests/limits.spec.js
+++ b/tests/limits.spec.js
@@ -12,7 +12,7 @@ test.afterAll(async () => {
 });
 
 test.beforeEach(async ({ page }) => {
-  await start(CONFIG);
+  await start(CONFIG, "password.sql");
 
   await page.goto(SIMULATOR_URL);
   await page.getByLabel("Grid Power").fill("500");
diff --git a/tests/modals.spec.js b/tests/modals.spec.js
index 076f1ea0..d21d9f00 100644
--- a/tests/modals.spec.js
+++ b/tests/modals.spec.js
@@ -7,9 +7,15 @@ const SIMULATOR_CONFIG = "simulator.evcc.yaml";
 
 const UI_ROUTES = ["/", "/#/sessions", "/#/config"];
 
+async function login() {
+  // TODO: uncomment this once auth is released
+  // await page.locator("#loginPassword").fill("secret");
+  // await page.getByRole("button", { name: "Login" }).click();
+}
+
 test.describe("Basics", async () => {
   test.beforeAll(async () => {
-    await start(BASICS_CONFIG);
+    await start(BASICS_CONFIG, "password.sql");
   });
 
   test.afterAll(async () => {
@@ -19,6 +25,9 @@ test.describe("Basics", async () => {
   test("Menu options. No battery and grid.", async ({ page }) => {
     for (const route of UI_ROUTES) {
       await page.goto(route);
+      if (route === "/#/config") {
+        await login(page);
+      }
 
       await page.getByTestId("topnavigation-button").click();
       await expect(page.getByRole("button", { name: "General Settings" })).toBeVisible();
@@ -47,7 +56,7 @@ test.describe("Basics", async () => {
 
 test.describe("Advanced", async () => {
   test.beforeAll(async () => {
-    await start(SIMULATOR_CONFIG);
+    await start(SIMULATOR_CONFIG, "password.sql");
     await startSimulator();
   });
 
@@ -59,6 +68,9 @@ test.describe("Advanced", async () => {
   test("Menu options. All available.", async ({ page }) => {
     for (const route of UI_ROUTES) {
       await page.goto(route);
+      if (route === "/#/config") {
+        await login(page);
+      }
 
       await page.getByTestId("topnavigation-button").click();
       await expect(page.getByRole("button", { name: "General Settings" })).toBeVisible();
diff --git a/tests/password.sql b/tests/password.sql
new file mode 100644
index 00000000..f0aa8794
--- /dev/null
+++ b/tests/password.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS `settings` (
+    `key` text
+  , `value` text
+  , PRIMARY KEY(`key`)
+);
+
+-- password: secret
+INSERT INTO settings("key", value) VALUES('adminPassword', '$2a$10$HNLoqiTO5oLwopczA/wcPOebfO79S.hnAA5HOkx5p6o3g5a2E30v2');
diff --git a/tests/plan.spec.js b/tests/plan.spec.js
index b2a48d84..db0e0ce7 100644
--- a/tests/plan.spec.js
+++ b/tests/plan.spec.js
@@ -4,7 +4,7 @@ import { start, stop } from "./evcc";
 const CONFIG = "plan.evcc.yaml";
 
 test.beforeEach(async () => {
-  await start(CONFIG);
+  await start(CONFIG, "password.sql");
 });
 
 test.afterEach(async () => {
diff --git a/tests/sessions.spec.js b/tests/sessions.spec.js
index 85c3a1ad..20f98bb2 100644
--- a/tests/sessions.spec.js
+++ b/tests/sessions.spec.js
@@ -5,7 +5,7 @@ const mobile = devices["iPhone 12 Mini"].viewport;
 const desktop = devices["Desktop Chrome"].viewport;
 
 test.beforeAll(async () => {
-  await start("basics.evcc.yaml", "sessions.sql");
+  await start("basics.evcc.yaml", ["password.sql", "sessions.sql"]);
 });
 test.afterAll(async () => {
   await stop();
diff --git a/tests/smart-cost.spec.js b/tests/smart-cost.spec.js
index 68229525..dc2dd845 100644
--- a/tests/smart-cost.spec.js
+++ b/tests/smart-cost.spec.js
@@ -5,7 +5,7 @@ import { startSimulator, stopSimulator, SIMULATOR_URL } from "./simulator";
 const CONFIG = "simulator.evcc.yaml";
 
 test.beforeAll(async () => {
-  await start(CONFIG);
+  await start(CONFIG, "password.sql");
   await startSimulator();
 });
 test.afterAll(async () => {
diff --git a/tests/statistics.spec.js b/tests/statistics.spec.js
index 00202e46..24951750 100644
--- a/tests/statistics.spec.js
+++ b/tests/statistics.spec.js
@@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test";
 import { start, stop } from "./evcc";
 
 test.beforeAll(async () => {
-  await start("statistics.evcc.yaml", "statistics.sql");
+  await start("statistics.evcc.yaml", ["password.sql", "statistics.sql"]);
 });
 test.afterAll(async () => {
   await stop();
diff --git a/tests/vehicle-error.spec.js b/tests/vehicle-error.spec.js
index 9eb0ea40..381655f7 100644
--- a/tests/vehicle-error.spec.js
+++ b/tests/vehicle-error.spec.js
@@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test";
 import { start, stop } from "./evcc";
 
 test.beforeAll(async () => {
-  await start("vehicle-error.evcc.yaml");
+  await start("vehicle-error.evcc.yaml", "password.sql");
 });
 test.afterAll(async () => {
   await stop();
diff --git a/tests/vehicle-settings.spec.js b/tests/vehicle-settings.spec.js
index 42cde807..a02f8375 100644
--- a/tests/vehicle-settings.spec.js
+++ b/tests/vehicle-settings.spec.js
@@ -12,7 +12,7 @@ test.afterAll(async () => {
 });
 
 test.beforeEach(async ({ page }) => {
-  await start(CONFIG);
+  await start(CONFIG, "password.sql");
 
   await page.goto(SIMULATOR_URL);
   await page.getByLabel("Grid Power").fill("500");
diff --git a/util/auth/auth_test.go b/util/auth/auth_test.go
new file mode 100644
index 00000000..415bff7c
--- /dev/null
+++ b/util/auth/auth_test.go
@@ -0,0 +1,82 @@
+package auth
+
+import (
+	"testing"
+	"time"
+
+	"github.com/evcc-io/evcc/core/keys"
+	"github.com/evcc-io/evcc/server/db/settings"
+	"github.com/stretchr/testify/assert"
+	"go.uber.org/mock/gomock"
+)
+
+func TestSetAdminPassword(t *testing.T) {
+	t.Skip("skipped until auth is released")
+
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mock := settings.NewMockAPI(ctrl)
+	auth := NewMock(mock)
+	password := "testpassword"
+
+	mock.EXPECT().SetString(keys.AdminPassword, gomock.Not(gomock.Eq("")))
+	assert.Nil(t, auth.SetAdminPassword(password)) // success
+}
+
+func TestRemoveAdminPassword(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mock := settings.NewMockAPI(ctrl)
+	auth := NewMock(mock)
+
+	mock.EXPECT().SetString(keys.JwtSecret, "")
+	mock.EXPECT().SetString(keys.AdminPassword, "")
+	auth.RemoveAdminPassword()
+}
+
+func TestIsAdminPasswordValid(t *testing.T) {
+	t.Skip("skipped until auth is released")
+
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mock := settings.NewMockAPI(ctrl)
+	auth := NewMock(mock)
+
+	validPw := "testpassword"
+	invalidPw := "wrongpassword"
+
+	// password not set, reject
+	mock.EXPECT().String(keys.AdminPassword).Return("", nil).Times(1)
+	assert.False(t, auth.IsAdminPasswordValid(validPw))
+
+	// password set, accept
+	var storedHash string
+	mock.EXPECT().SetString(keys.AdminPassword, gomock.Not(gomock.Eq(""))).Do(func(_ string, hash string) { storedHash = hash })
+	auth.SetAdminPassword(validPw)
+	mock.EXPECT().String(keys.AdminPassword).Return(storedHash, nil).Times(2)
+	assert.True(t, auth.IsAdminPasswordValid(validPw))
+
+	// password set, wrong password
+	assert.False(t, auth.IsAdminPasswordValid(invalidPw))
+}
+
+func TestJwtToken(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mock := settings.NewMockAPI(ctrl)
+	auth := NewMock(mock)
+
+	mock.EXPECT().String(keys.JwtSecret).Return("somesecret", nil).AnyTimes()
+
+	lifetime := time.Hour
+	tokenString, err := auth.GenerateJwtToken(lifetime)
+	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")
+}