Config UI: Add authentication (#11732)

This commit is contained in:
Michael Geers 2024-04-03 21:17:27 +02:00 • committed by GitHub
parent 86c5d88015
commit dc13701d79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1241 additions and 57 deletions

View file

@ -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 });

58
assets/js/auth.js Normal file
View file

@ -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;

View file

@ -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">

View file

@ -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();

View file

@ -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"
>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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"),

View file

@ -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);
}

View file

@ -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 () {

14
cmd/password.go Normal file
View file

@ -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)
}

47
cmd/password_reset.go Normal file
View file

@ -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()
}

49
cmd/password_set.go Normal file
View file

@ -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()
}

6
core/keys/auth.go Normal file
View file

@ -0,0 +1,6 @@
package keys
const (
AdminPassword = "adminPassword"
JwtSecret = "jwtSecretKey"
)

View file

@ -28,6 +28,7 @@ const (
TariffPriceHome = "tariffPriceHome"
TariffPriceLoadpoints = "tariffPriceLoadpoints"
Vehicles = "vehicles"
PasswordConfigured = "passwordConfigured"
// meters
GridMeter = "gridMeter"

2
go.mod
View file

@ -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

View file

@ -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₂"

View file

@ -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₂"

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)},

138
server/http_auth.go Normal file
View file

@ -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)
})
}
}

106
tests/auth.spec.js Normal file
View file

@ -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();
});

View file

@ -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();

View file

@ -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");

View file

@ -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) {

View file

@ -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();

View file

@ -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");

View file

@ -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();

8
tests/password.sql Normal file
View file

@ -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');

View file

@ -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 () => {

View file

@ -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();

View file

@ -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 () => {

View file

@ -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();

View file

@ -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();

View file

@ -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");

160
util/auth/auth.go Normal file
View file

@ -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
}

82
util/auth/auth_test.go Normal file
View file

@ -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")
}