Add authentication layer

This commit is contained in:
2024-06-29 14:43:56 +02:00
parent 738c53c8b9
commit e1739d9818
26 changed files with 1038 additions and 90 deletions

1
central_frontend/.env Normal file
View File

@ -0,0 +1 @@
VITE_APP_BACKEND=https://localhost:8443/web_api

View File

@ -0,0 +1 @@
VITE_APP_BACKEND=/web_api

View File

@ -1,5 +1,10 @@
import { AuthApi } from "./api/AuthApi";
import { ServerApi } from "./api/ServerApi";
import { LoginRoute } from "./routes/LoginRoute";
export function App() {
return <LoginRoute />;
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
return <LoginRoute />;
return <>logged in todo</>;
}

View File

@ -0,0 +1,177 @@
import { AuthApi } from "./AuthApi";
interface RequestParams {
uri: string;
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
allowFail?: boolean;
jsonData?: any;
formData?: FormData;
upProgress?: (progress: number) => void;
downProgress?: (e: { progress: number; total: number }) => void;
}
interface APIResponse {
data: any;
status: number;
}
export class ApiError extends Error {
constructor(message: string, public code: number, public data: any) {
super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`);
}
}
export class APIClient {
/**
* Get backend URL
*/
static backendURL(): string {
const URL = import.meta.env.VITE_APP_BACKEND ?? "";
if (URL.length === 0) throw new Error("Backend URL undefined!");
return URL;
}
/**
* Check out whether the backend is accessed through
* HTTPS or not
*/
static IsBackendSecure(): boolean {
return this.backendURL().startsWith("https");
}
/**
* Perform a request on the backend
*/
static async exec(args: RequestParams): Promise<APIResponse> {
let body: string | undefined | FormData = undefined;
let headers: any = {};
// JSON request
if (args.jsonData) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(args.jsonData);
}
// Form data request
else if (args.formData) {
body = args.formData;
}
const url = this.backendURL() + args.uri;
let data;
let status: number;
// Make the request with XMLHttpRequest
if (args.upProgress) {
const res: XMLHttpRequest = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) =>
args.upProgress!(e.loaded / e.total)
);
xhr.addEventListener("load", () => resolve(xhr));
xhr.addEventListener("error", () =>
reject(new Error("File upload failed"))
);
xhr.addEventListener("abort", () =>
reject(new Error("File upload aborted"))
);
xhr.addEventListener("timeout", () =>
reject(new Error("File upload timeout"))
);
xhr.open(args.method, url, true);
xhr.withCredentials = true;
for (const key in headers) {
if (headers.hasOwnProperty(key))
xhr.setRequestHeader(key, headers[key]);
}
xhr.send(body);
});
status = res.status;
if (res.responseType === "json") data = JSON.parse(res.responseText);
else data = res.response;
}
// Make the request with fetch
else {
const res = await fetch(url, {
method: args.method,
body: body,
headers: headers,
credentials: "include",
});
// Process response
// JSON response
if (res.headers.get("content-type") === "application/json")
data = await res.json();
// Text / XML response
else if (
["application/xml", "text/plain"].includes(
res.headers.get("content-type") ?? ""
)
)
data = await res.text();
// Binary file, tracking download progress
else if (res.body !== null && args.downProgress) {
// Track download progress
const contentEncoding = res.headers.get("content-encoding");
const contentLength = contentEncoding
? null
: res.headers.get("content-length");
const total = parseInt(contentLength ?? "0", 10);
let loaded = 0;
const resInt = new Response(
new ReadableStream({
start(controller) {
const reader = res.body!.getReader();
const read = async () => {
try {
const ret = await reader.read();
if (ret.done) {
controller.close();
return;
}
loaded += ret.value.byteLength;
args.downProgress!({ progress: loaded, total });
controller.enqueue(ret.value);
read();
} catch (e) {
console.error(e);
controller.error(e);
}
};
read();
},
})
);
data = await resInt.blob();
}
// Do not track progress (binary file)
else data = await res.blob();
status = res.status;
}
// Handle expired tokens
if (status === 412) {
AuthApi.UnsetAuthenticated();
window.location.href = import.meta.env.VITE_APP_BASENAME;
}
if (!args.allowFail && (status < 200 || status > 299))
throw new ApiError("Request failed!", status, data);
return {
data: data,
status: status,
};
}
}

View File

@ -0,0 +1,70 @@
import { APIClient } from "./ApiClient";
export interface AuthInfo {
name: string;
}
const TokenStateKey = "auth-state";
export class AuthApi {
/**
* Check out whether user is signed in or not
*/
static get SignedIn(): boolean {
return localStorage.getItem(TokenStateKey) !== null;
}
/**
* Mark user as authenticated
*/
static SetAuthenticated() {
localStorage.setItem(TokenStateKey, "");
}
/**
* Un-mark user as authenticated
*/
static UnsetAuthenticated() {
localStorage.removeItem(TokenStateKey);
}
/**
* Authenticate using user and password
*/
static async AuthWithPassword(user: string, password: string): Promise<void> {
await APIClient.exec({
uri: "/auth/password_auth",
method: "POST",
jsonData: {
user,
password,
},
});
this.SetAuthenticated();
}
/**
* Get auth information
*/
static async GetAuthInfo(): Promise<AuthInfo> {
return (
await APIClient.exec({
uri: "/auth/info",
method: "GET",
})
).data;
}
/**
* Sign out
*/
static async SignOut(): Promise<void> {
await APIClient.exec({
uri: "/auth/sign_out",
method: "GET",
});
this.UnsetAuthenticated();
}
}

View File

@ -0,0 +1,29 @@
import { APIClient } from "./ApiClient";
export interface ServerConfig {
auth_disabled: boolean;
}
let config: ServerConfig | null = null;
export class ServerApi {
/**
* Get server configuration
*/
static async LoadConfig(): Promise<void> {
config = (
await APIClient.exec({
uri: "/server/config",
method: "GET",
})
).data;
}
/**
* Get cached configuration
*/
static get Config(): ServerConfig {
if (config === null) throw new Error("Missing configuration!");
return config;
}
}

View File

@ -72,10 +72,10 @@ export function ConfirmDialogProvider(
</DialogContent>
<DialogActions>
<Button onClick={() => handleClose(false)} autoFocus>
Annuler
Cancel
</Button>
<Button onClick={() => handleClose(true)} color="error">
{confirmButton ?? "Confirmer"}
{confirmButton ?? "Confirm"}
</Button>
</DialogActions>
</Dialog>

View File

@ -11,6 +11,8 @@ import { DarkThemeProvider } from "./hooks/context_providers/DarkThemeProvider";
import { LoadingMessageProvider } from "./hooks/context_providers/LoadingMessageProvider";
import { SnackbarProvider } from "./hooks/context_providers/SnackbarProvider";
import "./index.css";
import { ServerApi } from "./api/ServerApi";
import { AsyncWidget } from "./widgets/AsyncWidget";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
@ -19,7 +21,12 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<ConfirmDialogProvider>
<SnackbarProvider>
<LoadingMessageProvider>
<App />
<AsyncWidget
loadKey={1}
load={async () => await ServerApi.LoadConfig()}
errMsg="Failed to connect to backend to retrieve static config!"
build={() => <App />}
/>
</LoadingMessageProvider>
</SnackbarProvider>
</ConfirmDialogProvider>

View File

@ -1,16 +1,18 @@
import * as React from "react";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { Alert } from "@mui/material";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import CssBaseline from "@mui/material/CssBaseline";
import TextField from "@mui/material/TextField";
import FormControlLabel from "@mui/material/FormControlLabel";
import Checkbox from "@mui/material/Checkbox";
import Grid from "@mui/material/Grid";
import Link from "@mui/material/Link";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import * as React from "react";
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
import { AuthApi } from "../api/AuthApi";
function Copyright(props: any) {
return (
@ -31,12 +33,28 @@ function Copyright(props: any) {
}
export function LoginRoute() {
const loadingMessage = useLoadingMessage();
const [user, setUser] = React.useState("");
const [password, setPassword] = React.useState("");
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
const [error, setError] = React.useState<string | undefined>();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
// TODO
try {
loadingMessage.show("Signing in...");
setError(undefined);
await AuthApi.AuthWithPassword(user, password);
location.href = "/";
} catch (e) {
console.error("Failed to perform login!", e);
setError(`Failed to authenticate! ${e}`);
} finally {
loadingMessage.hide();
}
};
return (
@ -73,6 +91,9 @@ export function LoginRoute() {
<Typography component="h1" variant="h5">
SolarEnergy
</Typography>
{error && <Alert severity="error">{error}</Alert>}
<Box
component="form"
noValidate

View File

@ -0,0 +1,92 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material";
import { useEffect, useRef, useState } from "react";
enum State {
Loading,
Ready,
Error,
}
export function AsyncWidget(p: {
loadKey: any;
load: () => Promise<void>;
errMsg: string;
build: () => React.ReactElement;
ready?: boolean;
errAdditionalElement?: () => React.ReactElement;
}): React.ReactElement {
const [state, setState] = useState(State.Loading);
const counter = useRef<any | null>(null);
const load = async () => {
try {
setState(State.Loading);
await p.load();
setState(State.Ready);
} catch (e) {
console.error(e);
setState(State.Error);
}
};
useEffect(() => {
if (counter.current === p.loadKey) return;
counter.current = p.loadKey;
load();
});
if (state === State.Error)
return (
<Box
component="div"
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
flex: "1",
flexDirection: "column",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}}
>
<Alert
variant="outlined"
severity="error"
style={{ margin: "0px 15px 15px 15px" }}
>
{p.errMsg}
</Alert>
<Button onClick={load}>Try again</Button>
{p.errAdditionalElement && p.errAdditionalElement()}
</Box>
);
if (state === State.Loading || p.ready === false)
return (
<Box
component="div"
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
flex: "1",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}}
>
<CircularProgress />
</Box>
);
return p.build();
}