Password protection for security settings API

Also implemented the base functionallity to protect further API endpoints.
This commit is contained in:
Thomas Basler 2022-11-03 21:00:13 +01:00
parent af4b47beeb
commit 8d14dbd367
16 changed files with 295 additions and 13 deletions

View File

@ -168,7 +168,7 @@ Users report that [ESP_Flasher](https://github.com/Jason2866/ESP_Flasher/release
## First configuration
* After the initial flashing of the microcontroller, an Access Point called "OpenDTU-*" is opened. The default password is "openDTU42".
* Use a web browser to open the address [http://192.168.4.1](http://192.168.4.1)
* Navigate to Settings --> Network Settings and enter your WiFi credentials
* Navigate to Settings --> Network Settings and enter your WiFi credentials. The username to access the config menu is "admin" and the password the same as for accessing the Access Point (default: "openDTU42").
* OpenDTU then simultaneously connects to your WiFi AP with this credentials. Navigate to Info --> Network and look into section "Network Interface (Station)" for the IP address received via DHCP.
* When OpenDTU is connected to a configured WiFI AP, the "OpenDTU-*" Access Point is closed after 3 minutes.
* OpenDTU needs access to a working NTP server to get the current date & time. Both are sent to the inverter with each request. Default NTP server is pool.ntp.org. If your network has different requirements please change accordingly (Settings --> NTP Settings).

View File

@ -24,6 +24,8 @@ public:
void init();
void loop();
static bool checkCredentials(AsyncWebServerRequest* request);
private:
AsyncWebServer _server;
AsyncEventSource _events;

View File

@ -12,5 +12,7 @@ private:
void onPasswordGet(AsyncWebServerRequest* request);
void onPasswordPost(AsyncWebServerRequest* request);
void onAuthenticateGet(AsyncWebServerRequest* request);
AsyncWebServer* _server;
};

View File

@ -9,6 +9,7 @@
#define ACCESS_POINT_NAME "OpenDTU-"
#define ACCESS_POINT_PASSWORD "openDTU42"
#define AUTH_USERNAME "admin"
#define ADMIN_TIMEOUT 180
#define WIFI_RECONNECT_TIMEOUT 15

View File

@ -5,6 +5,7 @@
#include "WebApi.h"
#include "ArduinoJson.h"
#include "AsyncJson.h"
#include "Configuration.h"
#include "defaults.h"
WebApiClass::WebApiClass()
@ -55,4 +56,22 @@ void WebApiClass::loop()
_webApiWsLive.loop();
}
bool WebApiClass::checkCredentials(AsyncWebServerRequest* request)
{
CONFIG_T& config = Configuration.get();
if (request->authenticate(AUTH_USERNAME, config.Security_Password)) {
return true;
}
AsyncWebServerResponse* r = request->beginResponse(401);
// WebAPI should set the X-Requested-With to prevent browser internal auth dialogs
if (!request->hasHeader("X-Requested-With")) {
r->addHeader(F("WWW-Authenticate"), F("Basic realm=\"Login Required\""));
}
request->send(r);
return false;
}
WebApiClass WebApi;

View File

@ -6,6 +6,7 @@
#include "ArduinoJson.h"
#include "AsyncJson.h"
#include "Configuration.h"
#include "WebApi.h"
#include "helper.h"
void WebApiSecurityClass::init(AsyncWebServer* server)
@ -16,6 +17,7 @@ void WebApiSecurityClass::init(AsyncWebServer* server)
_server->on("/api/security/password", HTTP_GET, std::bind(&WebApiSecurityClass::onPasswordGet, this, _1));
_server->on("/api/security/password", HTTP_POST, std::bind(&WebApiSecurityClass::onPasswordPost, this, _1));
_server->on("/api/security/authenticate", HTTP_GET, std::bind(&WebApiSecurityClass::onAuthenticateGet, this, _1));
}
void WebApiSecurityClass::loop()
@ -24,6 +26,10 @@ void WebApiSecurityClass::loop()
void WebApiSecurityClass::onPasswordGet(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject root = response->getRoot();
const CONFIG_T& config = Configuration.get();
@ -36,6 +42,10 @@ void WebApiSecurityClass::onPasswordGet(AsyncWebServerRequest* request)
void WebApiSecurityClass::onPasswordPost(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("warning");
@ -87,6 +97,21 @@ void WebApiSecurityClass::onPasswordPost(AsyncWebServerRequest* request)
retMsg[F("type")] = F("success");
retMsg[F("message")] = F("Settings saved!");
response->setLength();
request->send(response);
}
void WebApiSecurityClass::onAuthenticateGet(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("success");
retMsg[F("message")] = F("Authentication successfull!");
response->setLength();
request->send(response);
}

View File

@ -14,6 +14,7 @@
"@popperjs/core": "^2.11.6",
"bootstrap": "^5.2.2",
"bootstrap-icons-vue": "^1.8.1",
"mitt": "^3.0.0",
"spark-md5": "^3.0.2",
"vue": "^3.2.41",
"vue-router": "^4.1.6"

View File

@ -73,19 +73,41 @@
</li>
</ul>
</div>
<form class="d-flex" role="search" v-if="isLogged">
<button class="btn btn-outline-danger" @click="signout">Logout</button>
</form>
</div>
</nav>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { logout, isLoggedIn } from '@/utils/authentication';
import { BIconSun } from 'bootstrap-icons-vue';
export default defineComponent({
components: {
BIconSun,
},
data() {
return {
isLogged: this.isLoggedIn(),
}
},
created() {
this.$emitter.on("logged-in", () => {
this.isLogged = this.isLoggedIn();
});
},
methods: {
isLoggedIn,
logout,
signout(e: Event) {
e.preventDefault();
this.logout();
this.isLogged = this.isLoggedIn();
this.$router.push('/');
},
onClick() {
this.$refs.navbarCollapse && (this.$refs.navbarCollapse as HTMLElement).classList.remove("show");
}

9
webapp/src/emitter.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
import mitt from 'mitt';
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$emitter: Emitter;
}
}
export { } // Important! See note.

View File

@ -1,12 +1,16 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import mitt from 'mitt';
import './scss/styles.scss'
import "bootstrap"
const app = createApp(App)
const emitter = mitt();
app.config.globalProperties.$emitter = emitter;
app.use(router)
app.mount('#app')

View File

@ -13,6 +13,7 @@ import DtuAdminView from '@/views/DtuAdminView.vue'
import FirmwareUpgradeView from '@/views/FirmwareUpgradeView.vue'
import ConfigAdminView from '@/views/ConfigAdminView.vue'
import SecurityAdminView from '@/views/SecurityAdminView.vue'
import LoginView from '@/views/LoginView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -23,6 +24,11 @@ const router = createRouter({
name: 'Home',
component: HomeView
},
{
path: '/login',
name: 'Login',
component: LoginView
},
{
path: '/about',
name: 'About',
@ -89,6 +95,23 @@ const router = createRouter({
component: SecurityAdminView
}
]
})
});
router.beforeEach((to, from, next) => {
// redirect to login page if not logged in and trying to access a restricted page
const publicPages = ['/', '/login', '/about', '/info/network', '/info/system', '/info/ntp', '/info/mqtt',
'/settings/network', '/settings/ntp', '/settings/mqtt', '/settings/inverter', '/settings/dtu', '/firmware/upgrade', '/settings/config', ];
const authRequired = !publicPages.includes(to.path);
const loggedIn = localStorage.getItem('user');
if (authRequired && !loggedIn) {
return next({
path: '/login',
query: { returnUrl: to.path }
});
}
next();
});
export default router;

View File

@ -0,0 +1,78 @@
export function authHeader(): HeadersInit {
// return authorization header with basic auth credentials
let user = JSON.parse(localStorage.getItem('user') || "");
if (user && user.authdata) {
return { 'Authorization': 'Basic ' + user.authdata };
} else {
return {};
}
}
export function logout() {
// remove user from local storage to log user out
localStorage.removeItem('user');
}
export function isLoggedIn(): boolean {
return (localStorage.getItem('user') != null);
}
export function login(username: String, password: String) {
const requestOptions = {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Authorization': 'Basic ' + window.btoa(username + ':' + password)
},
};
return fetch('/api/security/authenticate', requestOptions)
.then(handleAuthResponse)
.then(retVal => {
// login successful if there's a user in the response
if (retVal) {
// store user details and basic auth credentials in local storage
// to keep user logged in between page refreshes
retVal.authdata = window.btoa(username + ':' + password);
localStorage.setItem('user', JSON.stringify(retVal));
}
return retVal;
});
}
export function handleResponse(response: Response) {
return response.text().then(text => {
const data = text && JSON.parse(text);
if (!response.ok) {
if (response.status === 401) {
// auto logout if 401 response returned from api
logout();
location.reload();
}
const error = (data && data.message) || response.statusText;
return Promise.reject(error);
}
return data;
});
}
function handleAuthResponse(response: Response) {
return response.text().then(text => {
const data = text && JSON.parse(text);
if (!response.ok) {
if (response.status === 401) {
// auto logout if 401 response returned from api
logout();
}
const error = "Invalid credentials";
return Promise.reject(error);
}
return data;
});
}

View File

@ -1,12 +1,19 @@
import { timestampToString } from './time';
import { formatNumber } from './number';
import { login, logout, isLoggedIn } from './authentication';
export {
timestampToString,
formatNumber,
login,
logout,
isLoggedIn,
};
export default {
timestampToString,
formatNumber,
login,
logout,
isLoggedIn,
}

View File

@ -0,0 +1,88 @@
<template>
<BasePage :title="'Login'" :isLoading="dataLoading">
<BootstrapAlert v-model="showAlert" dismissible :variant="alertType">
{{ alertMessage }}
</BootstrapAlert>
<div class="card">
<div class="card-header text-bg-danger">System Login</div>
<div class="card-body">
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="username">Username</label>
<input type="text" v-model="username" name="username" class="form-control"
:class="{ 'is-invalid': submitted && !username }" />
<div v-show="submitted && !username" class="invalid-feedback">Username is required</div>
</div>
<div class="form-group">
<label htmlFor="password">Password</label>
<input type="password" v-model="password" name="password" class="form-control"
:class="{ 'is-invalid': submitted && !password }" />
<div v-show="submitted && !password" class="invalid-feedback">Password is required</div>
</div>
<div class="form-group">
<button class="btn btn-primary" :disabled="dataLoading">Login</button>
</div>
</form>
</div>
</div>
</BasePage>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import router from '@/router';
import { login } from '@/utils';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
export default defineComponent({
components: {
BasePage,
BootstrapAlert,
},
data() {
return {
dataLoading: false,
alertMessage: "",
alertType: "info",
showAlert: false,
returnUrl: '',
username: '',
password: '',
submitted: false,
};
},
created() {
// get return url from route parameters or default to '/'
this.returnUrl = this.$route.query.returnUrl?.toString() || '/';
},
methods: {
handleSubmit(e: Event) {
this.submitted = true;
const { username, password } = this;
// stop here if form is invalid
if (!(username && password)) {
return;
}
this.dataLoading = true;
login(username, password)
.then(
() => {
this.$emitter.emit("logged-in");
router.push(this.returnUrl);
},
error => {
this.alertMessage = error;
this.alertType = 'danger';
this.showAlert = true;
this.dataLoading = false;
}
)
}
}
});
</script>

View File

@ -26,8 +26,8 @@
<div class="alert alert-secondary" role="alert">
<b>Hint:</b>
The administrator password is used to connect to the device when in AP mode.
It must be 8..64 characters.
The administrator password is used to access this web interface (user 'admin'), but also to
connect to the device when in AP mode. It must be 8..64 characters.
</div>
</div>
@ -41,6 +41,7 @@
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import { handleResponse, authHeader } from '@/utils/authentication';
import type { SecurityConfig } from '@/types/SecurityConfig';
export default defineComponent({
@ -65,8 +66,8 @@ export default defineComponent({
methods: {
getPasswordConfig() {
this.dataLoading = true;
fetch("/api/security/password")
.then((response) => response.json())
fetch("/api/security/password", { headers: authHeader() })
.then(handleResponse)
.then(
(data) => {
this.securityConfigList = data;
@ -90,15 +91,10 @@ export default defineComponent({
fetch("/api/security/password", {
method: "POST",
headers: authHeader(),
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then(handleResponse)
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -1474,6 +1474,11 @@ minimatch@^5.1.0:
dependencies:
brace-expansion "^2.0.1"
mitt@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.0.tgz#69ef9bd5c80ff6f57473e8d89326d01c414be0bd"
integrity sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ==
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"