initial load

This commit is contained in:
leshe4ka46 2024-04-14 18:28:42 +03:00
commit b128ebd501
Signed by: leshe4ka
SSH Key Fingerprint: SHA256:8KQ7Kw26acmm2HT2UJmE1J0rKhEJTtx33MVbGLovO1I
27 changed files with 21028 additions and 0 deletions

23
.gitignore vendored Normal file

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

9
LICENSE Normal file

@ -0,0 +1,9 @@
The MIT License (MIT)
Copyright (c) 2023 Anthony Schneider
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

1
README.md Normal file

@ -0,0 +1 @@
# WebApp for authenticating users in net

19511
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

62
package.json Normal file

@ -0,0 +1,62 @@
{
"name": "login-page",
"version": "0.0.1",
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@fontsource/roboto": "^5.0.5",
"@mui/icons-material": "^5.14.1",
"@mui/material": "^5.14.1",
"@mui/styled-engine-sc": "^5.12.0",
"@mui/x-date-pickers": "^6.10.2",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^0.25.0",
"dayjs": "^1.11.9",
"mui-chips-input": "^2.1.3",
"nth-check": "^2.0.1",
"ra-data-json-server": "^4.12.4",
"react": "^18.2.0",
"react-admin": "^4.12.4",
"react-color": "^2.19.3",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-hot-toast": "^2.4.1",
"react-material-ui-carousel": "^3.4.2",
"react-router": "^6.14.2",
"react-router-dom": "^6.14.2",
"react-scripts": "5.0.1",
"recharts": "^2.7.3",
"styled-components": "^5.3.11",
"tinycolor2": "^1.6.0",
"typescript": "^4.5.5",
"u2f-api": "^1.2.1",
"webpack": "^5.91.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:8080"
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

43
public/index.html Normal file

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Вход</title>
</head>
<body data-externalurl="">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

@ -0,0 +1,28 @@
import { Grid } from "@mui/material";
import React, { useState } from "react";
import Webauthn from "./Webauthn.tsx";
interface Props {
LoggedIn: boolean;
U2FSupported: boolean;
WebauthnSupported: boolean;
setBothUsername(username: string): void;
setFinalStage(state: boolean): void;
isSecure: boolean;
mac: string;
}
const SecurityKey = function(props: Props) {
const [debugMessage, setDebugMessage] = useState("");
return (
<Grid container>
{ props.isSecure && props.WebauthnSupported ? <Webauthn mac={props.mac} setFinalStage={props.setFinalStage} Discoverable={true} loggedIn={props.LoggedIn} setDebugMessage={setDebugMessage} setBothUsername={props.setBothUsername} /> : null }
<Grid item xs={12}>
Debug Message: { debugMessage }
</Grid>
</Grid>
);
}
export default SecurityKey;

184
src/components/Webauthn.tsx Normal file

@ -0,0 +1,184 @@
import React from "react";
import {
performAssertionCeremony,
performAttestationCeremony,
} from "../services/WebauthnService.ts";
import { AssertionResult, AttestationResult } from "../models/Webauthn.ts";
import { Button, Grid, Box } from "@mui/material";
import KeyIcon from "@mui/icons-material/Key";
import { getInfo } from "../services/APIService.ts";
//import axios from "axios";
//import { hexMD5 } from "../utils/MD5.js";
import { radiusLogin } from "../services/ClientService.ts";
interface Props {
setDebugMessage: React.Dispatch<React.SetStateAction<string>>;
Discoverable: boolean;
setBothUsername(username: string): void;
setFinalStage(state: boolean): void;
loggedIn: boolean;
mac: string;
}
const Webauthn = function (props: Props) {
const handleDiscoverableLoginSuccess = async () => {
const info = await getInfo();
if (info != null) {
props.setBothUsername(info.username);
}
};
const handleAttestationClick = async (discoverable: boolean = false) => {
props.setDebugMessage("Attempting Webauthn Attestation");
const result = await performAttestationCeremony(discoverable);
switch (result) {
case AttestationResult.Success:
props.setDebugMessage("Successful attestation.");
handleDiscoverableLoginSuccess()
break;
case AttestationResult.FailureSupport:
props.setDebugMessage(
"Your browser does not appear to support the configuration."
);
break;
case AttestationResult.FailureSyntax:
props.setDebugMessage(
"The attestation challenge was rejected as malformed or incompatible by your browser."
);
break;
case AttestationResult.FailureWebauthnNotSupported:
props.setDebugMessage(
"Your browser does not support the WebAuthN protocol."
);
break;
case AttestationResult.FailureUserConsent:
props.setDebugMessage("You cancelled the attestation request.");
break;
case AttestationResult.FailureUserVerificationOrResidentKey:
props.setDebugMessage(
"Your device does not support user verification or resident keys but this was required."
);
break;
case AttestationResult.FailureExcluded:
props.setDebugMessage("You have registered this device already.");
break;
case AttestationResult.FailureUnknown:
props.setDebugMessage("An unknown error occurred.");
break;
}
};
const radiusAuth = () => {
console.log("auth");
radiusLogin().then((ret)=>{
if (ret) {
props.setFinalStage(true);
}
})
/*let url: string = searchParams.get('to')!;
let mac: string = searchParams.get('mac')!;
var bodyFormData = new FormData();
bodyFormData.append("username", mac);
bodyFormData.append(
"password",
hexMD5(
(document.getElementById("chap-id") as HTMLInputElement)?.value +
"8ud8HevunaNXmcTEcjkBWAzX0iuhc6JF" +
(document.getElementById("chap-challenge") as HTMLInputElement)?.value
)
);
axios({
method: "post",
url: url,
data: bodyFormData,
headers: { "Content-Type": "multipart/form-data" },
})
.then(function (response) {
//handle success
console.log(response);
})
.catch(function (response) {
//handle error
console.log(response);
});*/
};
const handleAssertionClick = async () => {
props.setDebugMessage("Attempting Webauthn Assertion");
//alert(searchParams.get('to'));
const result = await performAssertionCeremony(props.Discoverable, props.mac);
switch (result) {
case AssertionResult.Success:
props.setDebugMessage("Successful assertion.");
if (props.Discoverable) {
await handleDiscoverableLoginSuccess();
}
break;
case AssertionResult.FailureUserConsent:
props.setDebugMessage("You cancelled the request.");
break;
case AssertionResult.FailureU2FFacetID:
props.setDebugMessage(
"The server responded with an invalid Facet ID for the URL."
);
break;
case AssertionResult.FailureSyntax:
props.setDebugMessage(
"The assertion challenge was rejected as malformed or incompatible by your browser."
);
break;
case AssertionResult.FailureWebauthnNotSupported:
props.setDebugMessage(
"Your browser does not support the WebAuthN protocol."
);
break;
case AssertionResult.FailureUnknownSecurity:
props.setDebugMessage("An unknown security error occurred.");
break;
case AssertionResult.FailureUnknown:
props.setDebugMessage("An unknown error occurred.");
break;
default:
props.setDebugMessage("An unexpected error occurred.");
break;
}
};
return (
<Grid container>
<Grid item xs={12}>
{props.loggedIn ? (
<Box>
<Button
fullWidth
variant="contained"
onClick={async () => {
await handleAttestationClick(true);
}}>
Создать ключ
</Button>
<Box sx={{ m: 0.5 }} />
<Button fullWidth onClick={radiusAuth}>
Продожить без создания ключа
</Button>
</Box>
) : (
<Button
fullWidth
variant="contained"
onClick={async () => {
await handleAssertionClick();
}}
startIcon={<KeyIcon />}>
Вход с ключом
</Button>
) }
</Grid>
</Grid>
);
};
export default Webauthn;

12
src/constants/API.ts Normal file

@ -0,0 +1,12 @@
import { getEmbeddedVariable } from "../utils/Configuration.ts";
const ExternalURL = getEmbeddedVariable("externalurl")
export const AttestationPath = ExternalURL + "/api/webauthn/attestation";
export const DiscoverableAttestationPath = AttestationPath + "?discoverable=true";
export const AssertionPath = ExternalURL + "/api/webauthn/assertion";
export const DiscoverableAssertionPath = AssertionPath + "?discoverable=true";
export const LoginPath = ExternalURL + "/api/login";
export const LogoutPath = ExternalURL + "/api/logout";
export const InfoPath = ExternalURL + "/api/info";
export const ManualLogin = ExternalURL + "/api/radius/login";

33
src/index.css Normal file

@ -0,0 +1,33 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/*
body {
background: #a2a09b;
background: -webkit-linear-gradient(315deg, hsla(236.6, 0%, 53.52%, 1) 0, hsla(236.6, 0%, 53.52%, 0) 70%), -webkit-linear-gradient(65deg, hsla(220.75, 34.93%, 26.52%, 1) 10%, hsla(220.75, 34.93%, 26.52%, 0) 80%), -webkit-linear-gradient(135deg, hsla(46.42, 36.62%, 83.92%, 1) 15%, hsla(46.42, 36.62%, 83.92%, 0) 80%), -webkit-linear-gradient(205deg, hsla(191.32, 50.68%, 56.45%, 1) 100%, hsla(191.32, 50.68%, 56.45%, 0) 70%);
background: linear-gradient(135deg, hsla(236.6, 0%, 53.52%, 1) 0, hsla(236.6, 0%, 53.52%, 0) 70%), linear-gradient(25deg, hsla(220.75, 34.93%, 26.52%, 1) 10%, hsla(220.75, 34.93%, 26.52%, 0) 80%), linear-gradient(315deg, hsla(46.42, 36.62%, 83.92%, 1) 15%, hsla(46.42, 36.62%, 83.92%, 0) 80%), linear-gradient(245deg, hsla(191.32, 50.68%, 56.45%, 1) 100%, hsla(191.32, 50.68%, 56.45%, 0) 70%)
}
html {
margin: 0;
padding: 0;
border: 0;
font-family: sans-serif, Arial
}
body,
html {
min-height: 100%;
overflow-x: hidden
}*/

19
src/index.js Normal file

@ -0,0 +1,19 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import Login from "./pages/LoginPage.tsx";
import "@fontsource/roboto/300.css"; // Import the font for font-weight: 300
import "@fontsource/roboto/400.css"; // Import the font for font-weight: 400
import "@fontsource/roboto/500.css"; // Import the font for font-weight: 500
import "@fontsource/roboto/700.css"; // Import the font for font-weight: 700
import { ThemeContextProvider } from "./utils/ThemeContext";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<ThemeContextProvider>
<Login />
</ThemeContextProvider>
</React.StrictMode>
);

24
src/models/API.ts Normal file

@ -0,0 +1,24 @@
export interface ErrorResponse {
status: "KO";
message: string;
}
export interface Response<T> {
status: "OK";
data: T;
}
export interface OptionalDataResponse<T> {
status: "OK";
data?: T;
}
export type OptionalDataServiceResponse<T> = OptionalDataResponse<T> | ErrorResponse;
export type ServiceResponse<T> = Response<T> | ErrorResponse;
export type SignInResponse = { redirect: string } | undefined;
export interface LoginBody {
username: string;
password: string;
mac: string;
}

3
src/models/Info.ts Normal file

@ -0,0 +1,3 @@
export interface Info {
username: string;
}

127
src/models/Webauthn.ts Normal file

@ -0,0 +1,127 @@
export interface PublicKeyCredentialCreationOptionsStatus {
options?: PublicKeyCredentialCreationOptions;
status: number;
}
export interface CredentialCreation {
publicKey: PublicKeyCredentialCreationOptionsJSON;
}
export interface PublicKeyCredentialCreationOptionsJSON
extends Omit<PublicKeyCredentialCreationOptions, "challenge" | "excludeCredentials" | "user"> {
challenge: string;
excludeCredentials?: PublicKeyCredentialDescriptorJSON[];
user: PublicKeyCredentialUserEntityJSON;
}
export interface PublicKeyCredentialRequestOptionsStatus {
options?: PublicKeyCredentialRequestOptions;
status: number;
}
export interface CredentialRequest {
publicKey: PublicKeyCredentialRequestOptionsJSON;
}
export interface PublicKeyCredentialRequestOptionsJSON
extends Omit<PublicKeyCredentialRequestOptions, "allowCredentials" | "challenge"> {
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
challenge: string;
}
export interface PublicKeyCredentialDescriptorJSON extends Omit<PublicKeyCredentialDescriptor, "id"> {
id: string;
}
export interface PublicKeyCredentialUserEntityJSON extends Omit<PublicKeyCredentialUserEntity, "id"> {
id: string;
}
export interface AuthenticatorAssertionResponseJSON
extends Omit<AuthenticatorAssertionResponse, "authenticatorData" | "clientDataJSON" | "signature" | "userHandle"> {
authenticatorData: string;
clientDataJSON: string;
signature: string;
userHandle: string;
}
export interface AuthenticatorAttestationResponseFuture extends AuthenticatorAttestationResponse {
getTransports?: () => AuthenticatorTransport[];
getAuthenticatorData?: () => ArrayBuffer;
getPublicKey?: () => ArrayBuffer;
getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[];
}
export interface AttestationPublicKeyCredential extends PublicKeyCredential {
response: AuthenticatorAttestationResponseFuture;
}
export interface AuthenticatorAttestationResponseJSON
extends Omit<AuthenticatorAttestationResponseFuture, "clientDataJSON" | "attestationObject"> {
clientDataJSON: string;
attestationObject: string;
}
export interface AttestationPublicKeyCredentialJSON
extends Omit<AttestationPublicKeyCredential, "response" | "rawId" | "getClientExtensionResults"> {
rawId: string;
response: AuthenticatorAttestationResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
transports?: AuthenticatorTransport[];
}
export interface PublicKeyCredentialJSON
extends Omit<PublicKeyCredential, "rawId" | "response" | "getClientExtensionResults"> {
rawId: string;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
response: AuthenticatorAssertionResponseJSON;
mac: string;
}
export enum AttestationResult {
Success = 1,
Failure,
FailureExcluded,
FailureUserConsent,
FailureUserVerificationOrResidentKey,
FailureSyntax,
FailureSupport,
FailureUnknown,
FailureWebauthnNotSupported,
}
export interface AttestationPublicKeyCredentialResult {
credential?: AttestationPublicKeyCredential;
result: AttestationResult;
}
export interface AttestationPublicKeyCredentialResultJSON {
credential?: AttestationPublicKeyCredentialJSON;
result: AttestationResult;
}
export enum AssertionResult {
Success = 1,
Failure,
FailureUserConsent,
FailureU2FFacetID,
FailureSyntax,
FailureUnknown,
FailureUnknownSecurity,
FailureWebauthnNotSupported,
}
export interface DiscoverableAssertionResult {
result: AssertionResult;
username: string;
}
export interface AssertionPublicKeyCredentialResult {
credential?: PublicKeyCredential;
result: AssertionResult;
}
export interface AssertionPublicKeyCredentialResultJSON {
credential?: PublicKeyCredentialJSON;
result: AssertionResult;
}

221
src/pages/LoginPage.tsx Normal file

@ -0,0 +1,221 @@
import * as React from "react";
import Avatar from "@mui/material/Avatar";
import Button from "@mui/material/Button";
import CssBaseline from "@mui/material/CssBaseline";
import TextField from "@mui/material/TextField";
import Box from "@mui/material/Box";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import {
Typography,
Container,
IconButton,
CircularProgress,
Grid,
} from "@mui/material/";
import { ThemeProvider } from "@mui/material/styles";
import { useThemeContext } from "../utils/ThemeContext";
import Brightness4Icon from "@mui/icons-material/Brightness4";
import Brightness7Icon from "@mui/icons-material/Brightness7";
import LogoutIcon from "@mui/icons-material/Logout";
import u2fApi from "u2f-api";
import { logout, login } from "../services/ClientService.ts";
import {
isWebauthnSecure,
isWebauthnSupported,
} from "../services/WebauthnService.ts";
import { getInfo } from "../services/APIService.ts";
import SecurityKey from "../components/SecurityKey.tsx";
export default function Login() {
const searchParams = new URLSearchParams(document.location.search);
const { currentTheme, isDarkMode, toggleDarkMode } = useThemeContext();
const [error, setError] = React.useState(false);
const [errorText, setErrorText] = React.useState("");
const [mac, setMac] = React.useState("totallyTempMac");
const [username, setUsername] = React.useState("");
const [password, setPassword] = React.useState("");
const [loggedIn, setLoggedIn] = React.useState(false);
const [finalStage, setFinalStage] = React.useState(false);
const [isSecure, setIsSecure] = React.useState(false);
const [u2fSupported, setU2FSupported] = React.useState(false);
const [webauthnSupported, setWebauthnSupported] = React.useState(false);
//const [platformAuthenticator, setPlatformAuthenticator] =
React.useState(false);
React.useEffect(() => {
(async () => {
const info = await getInfo();
if (info && info.username !== "") {
setUsername(info.username);
setLoggedIn(true);
}
})();
}, [setUsername]);
const assertionSuccess = (a: string) => {
setUsername(a);
setLoggedIn(true);
setFinalStage(true);
};
React.useEffect(() => {
setIsSecure(isWebauthnSecure());
}, [setIsSecure]);
React.useEffect(() => {
setWebauthnSupported(isWebauthnSupported());
/*(async () => {
const wpa = await isWebauthnPlatformAuthenticatorAvailable();
setPlatformAuthenticator(wpa);
})();*/
}, [/*setPlatformAuthenticator,*/ setWebauthnSupported]);
React.useEffect(() => {
u2fApi.ensureSupport().then(
() => setU2FSupported(true),
() => setU2FSupported(false)
);
}, [setU2FSupported]);
React.useEffect(() => {
setMac(searchParams.get("mac")!);
}, []);
const handleLoginClick = async () => {
let mac: string = searchParams.get("mac")!;
if (username === "" || password === "") {
setError(true);
setErrorText("");
} else {
setError(false);
setErrorText("");
}
const success = await login(username, password, mac);
if (success) {
setUsername(username);
setError(false);
setErrorText("");
setLoggedIn(true);
} else {
setError(true);
setErrorText("Неверный логин или пароль");
}
};
return (
<ThemeProvider theme={currentTheme}>
<Container component="main" maxWidth="xs">
<CssBaseline />
<Grid
container
direction="row"
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
textAlign: "center",
}}>
<Grid item xs>
<IconButton
onClick={() => {
toggleDarkMode(!isDarkMode);
}}
color="inherit">
{isDarkMode ? <Brightness7Icon /> : <Brightness4Icon />}
</IconButton>
</Grid>
<Grid item xs={5}></Grid>
<Grid item xs>
{loggedIn ? (
<IconButton
onClick={async () => {
await logout();
setLoggedIn(false);
setFinalStage(false);
await getInfo();
}}
color="inherit">
<LogoutIcon />
</IconButton>
) : null}
</Grid>
</Grid>
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}>
<Avatar sx={{ m: 1, bgcolor: "primary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Вход в сеть
</Typography>
<Box sx={{ mt: 1 }}>
{!finalStage ? (
<Box>
{!loggedIn ? (
<Box>
<TextField
margin="normal"
required
fullWidth
id="login"
label="Имя пользователя"
name="login"
autoComplete="login"
autoFocus
value={username}
error={error}
helperText={errorText}
onChange={e => {
setUsername(e.target.value);
}}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Пароль"
type="password"
id="password"
autoComplete="current-password"
value={password}
error={error}
helperText={errorText}
onChange={e => {
setPassword(e.target.value);
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
onClick={handleLoginClick}>
Вход
</Button>
</Box>
) : undefined}
<SecurityKey
mac={mac}
setFinalStage={setFinalStage}
U2FSupported={u2fSupported}
WebauthnSupported={webauthnSupported}
LoggedIn={loggedIn}
setBothUsername={assertionSuccess}
isSecure={isSecure}
/>
</Box>
) : (
<CircularProgress />
)}
</Box>
</Box>
</Container>
</ThemeProvider>
);
}

@ -0,0 +1,32 @@
import axios, {AxiosResponse} from "axios";
import {ErrorResponse, ServiceResponse} from "../models/API.ts";
import {Info} from "../models/Info.ts";
import {InfoPath} from "../constants/API.ts";
function toErrorResponse<T>(resp: AxiosResponse<ServiceResponse<T>>): ErrorResponse | undefined {
if (resp.data && "status" in resp.data && resp.data["status"] === "KO") {
return resp.data as ErrorResponse;
}
return undefined;
}
export function toData<T>(resp: AxiosResponse<ServiceResponse<T>>): T | undefined {
if (resp.data && "status" in resp.data && resp.data["status"] === "OK") {
return resp.data.data as T;
}
return undefined;
}
export function hasServiceError<T>(resp: AxiosResponse<ServiceResponse<T>>) {
const errResp = toErrorResponse(resp);
if (errResp && errResp.status === "KO") {
return { errored: true, message: errResp.message };
}
return { errored: false, message: null };
}
export async function getInfo(): Promise<Info | undefined> {
const response = await axios.get<ServiceResponse<Info>>(InfoPath);
return toData<Info>(response);
}

@ -0,0 +1,24 @@
import axios from "axios";
import { LoginBody } from "../models/API.ts";
import { LoginPath, LogoutPath, ManualLogin } from "../constants/API.ts";
export async function login(username: string, password: string, mac: string): Promise<boolean> {
const body: LoginBody = {
username,
password,
mac
};
const response = await axios.post<any>(LoginPath, body).catch((err)=>{});
return response!=null && response.status === 200;
}
export async function logout(): Promise<boolean> {
const response = await axios.get<any>(LogoutPath);
return response.status === 200;
}
export async function radiusLogin(): Promise<boolean> {
const response = await axios.post<any>(ManualLogin).catch((err)=>{});
return response!=null && response.status === 200;
}

@ -0,0 +1,350 @@
import { getBase64WebEncodingFromBytes, getBytesFromBase64 } from '../utils/Base64.ts';
import {
AssertionPublicKeyCredentialResult,
AssertionResult,
AttestationPublicKeyCredential,
AttestationPublicKeyCredentialJSON,
AttestationPublicKeyCredentialResult,
AttestationResult,
AuthenticatorAttestationResponseFuture, CredentialCreation, CredentialRequest,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialCreationOptionsStatus,
PublicKeyCredentialDescriptorJSON,
PublicKeyCredentialJSON,
PublicKeyCredentialRequestOptionsJSON,
PublicKeyCredentialRequestOptionsStatus
} from '../models/Webauthn.ts';
import axios, { AxiosResponse } from "axios";
import { OptionalDataServiceResponse, ServiceResponse, SignInResponse } from "../models/API.ts";
import { AssertionPath, AttestationPath, DiscoverableAssertionPath, DiscoverableAttestationPath } from "../constants/API.ts";
export function isWebauthnSecure(): boolean {
if (window.isSecureContext) {
return true;
}
return (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
}
export function isWebauthnSupported(): boolean {
return window?.PublicKeyCredential !== undefined && typeof window.PublicKeyCredential === "function";
}
export async function isWebauthnPlatformAuthenticatorAvailable(): Promise<boolean> {
if (!isWebauthnSupported()) {
return false;
}
return window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
}
function arrayBufferEncode(value: ArrayBuffer): string {
return getBase64WebEncodingFromBytes(new Uint8Array(value));
}
function arrayBufferDecode(value: string): ArrayBuffer {
return getBytesFromBase64(value);
}
function decodePublicKeyCredentialDescriptor(descriptor: PublicKeyCredentialDescriptorJSON): PublicKeyCredentialDescriptor {
return {
id: arrayBufferDecode(descriptor.id),
type: descriptor.type,
transports: descriptor.transports,
}
}
function decodePublicKeyCredentialCreationOptions(options: PublicKeyCredentialCreationOptionsJSON): PublicKeyCredentialCreationOptions {
return {
attestation: options.attestation,
authenticatorSelection: options.authenticatorSelection,
challenge: arrayBufferDecode(options.challenge),
excludeCredentials: options.excludeCredentials?.map(decodePublicKeyCredentialDescriptor),
extensions: options.extensions,
pubKeyCredParams: options.pubKeyCredParams,
rp: options.rp,
timeout: options.timeout,
user: {
displayName: options.user.displayName,
id: arrayBufferDecode(options.user.id),
name: options.user.name,
},
};
}
function decodePublicKeyCredentialRequestOptions(options: PublicKeyCredentialRequestOptionsJSON): PublicKeyCredentialRequestOptions {
let allowCredentials: PublicKeyCredentialDescriptor[] | undefined = undefined;
if (options.allowCredentials?.length !== 0) {
allowCredentials = options.allowCredentials?.map(decodePublicKeyCredentialDescriptor);
}
return {
allowCredentials: allowCredentials,
challenge: arrayBufferDecode(options.challenge),
extensions: options.extensions,
rpId: options.rpId,
timeout: options.timeout,
userVerification: options.userVerification,
};
}
function encodeAttestationPublicKeyCredential(credential: AttestationPublicKeyCredential): AttestationPublicKeyCredentialJSON {
const response = credential.response as AuthenticatorAttestationResponseFuture;
let transports: AuthenticatorTransport[] | undefined;
if (response?.getTransports !== undefined && typeof response.getTransports === 'function') {
transports = response.getTransports();
}
return {
id: credential.id,
type: credential.type,
rawId: arrayBufferEncode(credential.rawId),
clientExtensionResults: credential.getClientExtensionResults(),
response: {
attestationObject: arrayBufferEncode(response.attestationObject),
clientDataJSON: arrayBufferEncode(response.clientDataJSON),
},
transports: transports,
};
}
function encodeAssertionPublicKeyCredential(credential: PublicKeyCredential, mac:string): PublicKeyCredentialJSON {
const response = credential.response as AuthenticatorAssertionResponse;
let userHandle: string;
if (response.userHandle == null) {
userHandle = "";
} else {
userHandle = arrayBufferEncode(response.userHandle)
}
return {
id: credential.id,
type: credential.type,
rawId: arrayBufferEncode(credential.rawId),
clientExtensionResults: credential.getClientExtensionResults(),
response: {
authenticatorData: arrayBufferEncode(response.authenticatorData),
clientDataJSON: arrayBufferEncode(response.clientDataJSON),
signature: arrayBufferEncode(response.signature),
userHandle: userHandle,
},
mac:mac,
authenticatorAttachment: null
};
}
function getAttestationResultFromDOMException(exception: DOMException): AttestationResult {
// Docs for this section:
// https://w3c.github.io/webauthn/#sctn-op-make-cred
switch (exception.name) {
case 'UnknownError':
// § 6.3.2 Step 1 and Step 8.
return AttestationResult.FailureSyntax;
case 'NotSupportedError':
// § 6.3.2 Step 2.
return AttestationResult.FailureSupport;
case 'InvalidStateError':
// § 6.3.2 Step 3.
return AttestationResult.FailureExcluded;
case 'NotAllowedError':
// § 6.3.2 Step 3 and Step 6.
return AttestationResult.FailureUserConsent;
// § 6.3.2 Step 4.
case 'ConstraintError':
return AttestationResult.FailureUserVerificationOrResidentKey;
default:
console.error(`Unhandled DOMException occurred during WebAuthN attestation: ${exception}`);
return AttestationResult.FailureUnknown;
}
}
function getAssertionResultFromDOMException(exception: DOMException, requestOptions: PublicKeyCredentialRequestOptions): AssertionResult {
// Docs for this section:
// https://w3c.github.io/webauthn/#sctn-op-get-assertion
switch (exception.name) {
case 'UnknownError':
// § 6.3.3 Step 1 and Step 12.
return AssertionResult.FailureSyntax;
case 'NotAllowedError':
// § 6.3.3 Step 6 and Step 7.
return AssertionResult.FailureUserConsent;
case 'SecurityError':
// § 10.1 and 10.2 Step 3.
if (requestOptions.extensions?.appid !== undefined) {
return AssertionResult.FailureU2FFacetID;
} else {
return AssertionResult.FailureUnknownSecurity;
}
default:
console.error(`Unhandled DOMException occurred during WebAuthN assertion: ${exception}`);
return AssertionResult.FailureUnknown;
}
}
async function getAttestationCreationOptions(discoverable: boolean): Promise<PublicKeyCredentialCreationOptionsStatus> {
let response: AxiosResponse<ServiceResponse<CredentialCreation>>;
if (discoverable) {
response = await axios.get<ServiceResponse<CredentialCreation>>(DiscoverableAttestationPath);
} else {
response = await axios.get<ServiceResponse<CredentialCreation>>(AttestationPath);
}
if (response.data.status !== "OK" || response.data.data == null) {
return {
status: response.status,
};
}
return {
options: decodePublicKeyCredentialCreationOptions(response.data.data.publicKey),
status: response.status,
};
}
async function getAssertionRequestOptions(discoverable: boolean): Promise<PublicKeyCredentialRequestOptionsStatus> {
let response: AxiosResponse<ServiceResponse<CredentialRequest>>;
if (discoverable) {
response = await axios.get<ServiceResponse<CredentialRequest>>(DiscoverableAssertionPath);
} else {
response = await axios.get<ServiceResponse<CredentialRequest>>(AssertionPath);
}
if (response.data.status !== "OK" || response.data.data == null) {
return {
status: response.status,
}
}
return {
options: decodePublicKeyCredentialRequestOptions(response.data.data.publicKey),
status: response.status,
};
}
async function getAttestationPublicKeyCredentialResult(creationOptions: PublicKeyCredentialCreationOptions): Promise<AttestationPublicKeyCredentialResult> {
const result: AttestationPublicKeyCredentialResult = {
result: AttestationResult.Success,
};
try {
result.credential = (await navigator.credentials.create({publicKey: creationOptions})) as AttestationPublicKeyCredential;
} catch(e) {
result.result = AttestationResult.Failure;
const exception = e as DOMException;
if (exception !== undefined) {
result.result = getAttestationResultFromDOMException(exception);
return result;
} else {
console.error(`Unhandled exception occurred during WebAuthN attestation: ${e}`);
}
}
if (result.credential == null) {
result.result = AttestationResult.Failure;
} else {
result.result = AttestationResult.Success;
}
return result;
}
async function getAssertionPublicKeyCredentialResult(requestOptions: PublicKeyCredentialRequestOptions): Promise<AssertionPublicKeyCredentialResult> {
const result: AssertionPublicKeyCredentialResult = {
result: AssertionResult.Success,
};
try {
result.credential = (await navigator.credentials.get({publicKey: requestOptions})) as PublicKeyCredential;
} catch(e) {
result.result = AssertionResult.Failure;
const exception = e as DOMException;
if (exception !== undefined) {
result.result = getAssertionResultFromDOMException(exception, requestOptions);
return result;
} else {
console.error(`Unhandled exception occurred during WebAuthN assertion: ${e}`);
}
}
if (result.credential == null) {
result.result = AssertionResult.Failure;
} else {
result.result = AssertionResult.Success;
}
return result;
}
async function postAttestationPublicKeyCredentialResult(credential: AttestationPublicKeyCredential): Promise<AxiosResponse<OptionalDataServiceResponse<any>>> {
const credentialJSON = encodeAttestationPublicKeyCredential(credential);
return axios.post<OptionalDataServiceResponse<any>>(AttestationPath, credentialJSON);
}
async function postAssertionPublicKeyCredentialResult(credential: PublicKeyCredential, discoverable: boolean, mac: string) {
const credentialJSON = encodeAssertionPublicKeyCredential(credential,mac);
if (discoverable) {
return axios.post<ServiceResponse<SignInResponse>>(DiscoverableAssertionPath, credentialJSON);
}
return axios.post<ServiceResponse<SignInResponse>>(AssertionPath, credentialJSON);
}
export async function performAttestationCeremony(discoverable: boolean = false): Promise<AttestationResult> {
const attestationCreationOpts = await getAttestationCreationOptions(discoverable);
if (attestationCreationOpts.status !== 200 || attestationCreationOpts.options == null) {
return AttestationResult.Failure;
}
const attestationResult = await getAttestationPublicKeyCredentialResult(attestationCreationOpts.options);
if (attestationResult.result !== AttestationResult.Success) {
return attestationResult.result;
} else if (attestationResult.credential == null) {
return AttestationResult.Failure;
}
const response = await postAttestationPublicKeyCredentialResult(attestationResult.credential);
if (response.data.status === "OK" && (response.status === 200 || response.status === 201)) {
return AttestationResult.Success;
}
return AttestationResult.Failure;
}
export async function performAssertionCeremony(discoverable: boolean = false, mac:string): Promise<AssertionResult> {
const assertionRequestOpts = await getAssertionRequestOptions(discoverable);
if (assertionRequestOpts.status !== 200 || assertionRequestOpts.options == null) {
return AssertionResult.Failure;
}
const assertionResult = await getAssertionPublicKeyCredentialResult(assertionRequestOpts.options);
if (assertionResult.result !== AssertionResult.Success) {
return assertionResult.result;
} else if (assertionResult.credential == null) {
return AssertionResult.Failure;
}
const response = await postAssertionPublicKeyCredentialResult(assertionResult.credential, discoverable, mac);
if (response.data.status === "OK" && response.status === 200) {
return AssertionResult.Success;
}
return AssertionResult.Failure;
}

160
src/utils/Base64.ts Normal file

@ -0,0 +1,160 @@
/*
This file is a work taken from the following location:
https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727
It has been modified using the below algorithms to make it work for the standard
web encoding.
MIT License
Copyright (c) 2020 Egor Nepomnyaschih
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
/*
// This constant can also be computed with the following algorithm:
const base64Chars = [],
A = "A".charCodeAt(0),
a = "a".charCodeAt(0),
n = "0".charCodeAt(0);
for (let i = 0; i < 26; ++i) {
base64Chars.push(String.fromCharCode(A + i));
}
for (let i = 0; i < 26; ++i) {
base64Chars.push(String.fromCharCode(a + i));
}
for (let i = 0; i < 10; ++i) {
base64Chars.push(String.fromCharCode(n + i));
}
base64Chars.push("+");
base64Chars.push("/");
*/
const base64Chars = [
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
"N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "+", "/",
];
/*
// This constant can also be computed with the following algorithm:
const l = 256, base64codes = new Uint8Array(l);
for (let i = 0; i < l; ++i) {
base64codes[i] = 255; // invalid character
}
base64Chars.forEach((char, index) => {
base64codes[char.charCodeAt(0)] = index;
});
base64codes["=".charCodeAt(0)] = 0; // ignored anyway, so we just need to
prevent an error
*/
const base64Codes = [
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 62, 255, 255, 255, 63, 52, 53, 54, 55, 56, 57, 58, 59,
60, 61, 255, 255, 255, 0, 255, 255, 255, 0, 1, 2, 3, 4,
5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
19, 20, 21, 22, 23, 24, 25, 255, 255, 255, 255, 255, 255, 26,
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51
];
function getBase64Code(charCode : number) {
if (charCode >= base64Codes.length) {
throw new Error("Unable to parse base64 string.");
}
const code = base64Codes[charCode];
if (code === 255) {
throw new Error("Unable to parse base64 string.");
}
return code;
}
export function getBase64FromBytes(bytes : number[] | Uint8Array) : string {
let result = "", i, l = bytes.length;
for (i = 2; i < l; i += 3) {
result += base64Chars[bytes[i - 2] >> 2];
result +=
base64Chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)];
result += base64Chars[((bytes[i - 1] & 0x0F) << 2) | (bytes[i] >> 6)];
result += base64Chars[bytes[i] & 0x3F];
}
if (i === l + 1) { // 1 octet yet to write
result += base64Chars[bytes[i - 2] >> 2];
result += base64Chars[(bytes[i - 2] & 0x03) << 4];
result += "==";
}
if (i === l) { // 2 octets yet to write
result += base64Chars[bytes[i - 2] >> 2];
result +=
base64Chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)];
result += base64Chars[(bytes[i - 1] & 0x0F) << 2];
result += "=";
}
return result;
}
export function getBase64WebEncodingFromBytes(bytes
: number[] | Uint8Array)
: string {
return getBase64FromBytes(bytes)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
export function getBytesFromBase64(str : string) : Uint8Array {
if (str.length % 4 !== 0) {
throw new Error("Unable to parse base64 string.");
}
const index = str.indexOf("=");
if (index !== -1 && index < str.length - 2) {
throw new Error("Unable to parse base64 string.");
}
let missingOctets = str.endsWith("==") ? 2
: str.endsWith("=") ? 1
: 0,
n = str.length, result = new Uint8Array(3 * (n / 4)), buffer;
for (let i = 0, j = 0; i < n; i += 4, j += 3) {
buffer = (getBase64Code(str.charCodeAt(i)) << 18) |
(getBase64Code(str.charCodeAt(i + 1)) << 12) |
(getBase64Code(str.charCodeAt(i + 2)) << 6) |
getBase64Code(str.charCodeAt(i + 3));
result[j] = buffer >> 16;
result[j + 1] = (buffer >> 8) & 0xFF;
result[j + 2] = buffer & 0xFF;
}
return result.subarray(0, result.length - missingOctets);
}

@ -0,0 +1,8 @@
export function getEmbeddedVariable(variableName: string) {
const value = document.body.getAttribute(`data-${variableName}`);
if (value === null) {
throw new Error(`No ${variableName} embedded variable detected`);
}
return value;
}

121
src/utils/ThemeContext.js Normal file

@ -0,0 +1,121 @@
// ThemeContext.js
import { createContext, useContext, useState } from "react";
import { createTheme } from "@mui/material/styles";
import tinycolor from "tinycolor2";
export const darkTheme = createTheme({
palette: {
mode: "dark",
primary: {
main: '#004fa1',
},
},
});
export const lightTheme = createTheme({
palette: {
mode: "light",
primary: {
main: '#004fa1',
},
},
});
function lightenColor(primaryColor, amount) {
const baseColor = tinycolor(primaryColor);
const lightenedColor = baseColor.lighten(amount).toHexString();
return lightenedColor;
}
function darkenColor(primaryColor, amount) {
const baseColor = tinycolor(primaryColor);
const darkenedColor = baseColor.darken(amount).toHexString();
return darkenedColor;
}
const ThemeContext = createContext();
export const useThemeContext = () => {
return useContext(ThemeContext);
};
export const colorIsDark = (hexColor) => {
const threshold = 76; // this is the closest match I could find for the default material UI value
const baseColor = tinycolor(hexColor);
const luminance = baseColor.getLuminance() * 255;
return luminance < threshold;
};
export const ThemeContextProvider = ({ children }) => {
const [currentTheme, setCurrentTheme] = useState((window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)?darkTheme:lightTheme);
const [isDarkMode, setIsDarkMode] = useState(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
const [colorPickerColor, setColorPickerColor] = useState("#1976d2"); // Default initial color
const [userInputColor, setUserInputColor] = useState("#1976d2"); // Default initial color
const [open, setOpen] = useState(true);
const toggleDrawer = () => {
setOpen(!open);
};
const [muted, setMuted] = useState(true); // Track muted state, false = unmuted, true = muted
const handleThemeChange = (color) => {
const secondaryColor = darkenColor(color, 16);
const backgroundColorDefault = lightenColor(color, 6);
const backgroundColorPaper = lightenColor(color, 4);
const themeMode = colorIsDark(color) ? "dark" : "light";
const newTheme = createTheme({
palette: {
primary: {
main: color,
},
secondary: {
main: secondaryColor,
},
background: {
default: backgroundColorDefault,
paper: backgroundColorPaper,
},
mode: themeMode,
},
});
setCurrentTheme(newTheme);
};
const toggleDarkMode = () => {
setIsDarkMode(!isDarkMode);
if (isDarkMode) {
setCurrentTheme(lightTheme);
} else {
setCurrentTheme(darkTheme);
}
};
const handleColorChange = (event) => {
setColorPickerColor(event.target.value);
setUserInputColor(event.target.value);
//possibly darken color picker color
};
return (
<ThemeContext.Provider
value={{
currentTheme,
handleThemeChange,
isDarkMode,
toggleDarkMode,
colorPickerColor,
userInputColor,
handleColorChange,
open,
toggleDrawer,
muted,
setMuted,
}}
>
{children}
</ThemeContext.Provider>
);
};

5
webpack.config.js Normal file

@ -0,0 +1,5 @@
module.exports={
resolve:{
extensions: ['.js', '.jsx', '.ts', '.tsx'],
}
}