Config UI: Add authentication (#11732)
This commit is contained in:
parent
86c5d88015
commit
dc13701d79
40 changed files with 1241 additions and 57 deletions
|
|
@ -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
58
assets/js/auth.js
Normal 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;
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
98
assets/js/components/LoginModal.vue
Normal file
98
assets/js/components/LoginModal.vue
Normal 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>
|
||||
177
assets/js/components/PasswordModal.vue
Normal file
177
assets/js/components/PasswordModal.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
14
cmd/password.go
Normal 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
47
cmd/password_reset.go
Normal 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
49
cmd/password_set.go
Normal 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
6
core/keys/auth.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package keys
|
||||
|
||||
const (
|
||||
AdminPassword = "adminPassword"
|
||||
JwtSecret = "jwtSecretKey"
|
||||
)
|
||||
|
|
@ -28,6 +28,7 @@ const (
|
|||
TariffPriceHome = "tariffPriceHome"
|
||||
TariffPriceLoadpoints = "tariffPriceLoadpoints"
|
||||
Vehicles = "vehicles"
|
||||
PasswordConfigured = "passwordConfigured"
|
||||
|
||||
// meters
|
||||
GridMeter = "gridMeter"
|
||||
|
|
|
|||
2
go.mod
2
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
|
||||
|
|
|
|||
21
i18n/de.toml
21
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₂"
|
||||
|
|
|
|||
22
i18n/en.toml
22
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₂"
|
||||
|
|
|
|||
8
server/db/settings/api.go
Normal file
8
server/db/settings/api.go
Normal 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)
|
||||
}
|
||||
66
server/db/settings/mock.go
Normal file
66
server/db/settings/mock.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
138
server/http_auth.go
Normal 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
106
tests/auth.spec.js
Normal 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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
8
tests/password.sql
Normal 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');
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
160
util/auth/auth.go
Normal 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
82
util/auth/auth_test.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue