diff --git a/assets/js/api.js b/assets/js/api.js
index 9ba3b5b46..45859733f 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 000000000..ba85b006d
--- /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 cf9ef3bb6..ee4eba811 100644
--- a/assets/js/components/Config/GeneralConfig.vue
+++ b/assets/js/components/Config/GeneralConfig.vue
@@ -27,11 +27,17 @@
-
+
- Password
-
*******
- edit
+
+ edit
diff --git a/assets/js/components/GenericModal.vue b/assets/js/components/GenericModal.vue
index fbd3a0d5c..8e4c0ba0c 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"
>
@@ -17,6 +19,7 @@
{{ title }}
@@ -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 000000000..eb815eb12
--- /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 000000000..78a6d205e
--- /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 000000000..1ee504a5c
--- /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 000000000..0cd323617
--- /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 8138e2df5..dc50cec70 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 2857020e2..be137471d 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 80928cbb6..5a3e4f4ad 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 0c922293e..c4aa20a01 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 000000000..832c1880c
--- /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 000000000..378587fb4
--- /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 2d3451ade..26a4e6b65 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 a8b0b3f79..0724677d8 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 000000000..e7b93089a
--- /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/tests/auth.spec.js b/tests/auth.spec.js
new file mode 100644
index 000000000..c1e78d105
--- /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 3da7488a1..490d7c795 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 207eabc95..d27fc5e7e 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 061985980..da69d3272 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 e4e03285c..4cafc8322 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 302ad1795..e7244f712 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 076f1ea0d..d21d9f00e 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 000000000..f0aa8794e
--- /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 b2a48d847..db0e0ce71 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 85c3a1ad4..20f98bb2e 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 68229525a..dc2dd845b 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 00202e460..249517505 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 9eb0ea405..381655f71 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 42cde807c..a02f8375c 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.go b/util/auth/auth.go
new file mode 100644
index 000000000..01597b899
--- /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
+}
diff --git a/util/auth/auth_test.go b/util/auth/auth_test.go
new file mode 100644
index 000000000..415bff7cd
--- /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")
+}