initial load
This commit is contained in:
commit
b128ebd501
23
.gitignore
vendored
Normal file
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
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
1
README.md
Normal file
@ -0,0 +1 @@
|
||||
# WebApp for authenticating users in net
|
19511
package-lock.json
generated
Normal file
19511
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
package.json
Normal file
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
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
43
public/index.html
Normal file
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
BIN
public/logo192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
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
3
public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
28
src/components/SecurityKey.tsx
Normal file
28
src/components/SecurityKey.tsx
Normal file
@ -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
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
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
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
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
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
3
src/models/Info.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface Info {
|
||||
username: string;
|
||||
}
|
127
src/models/Webauthn.ts
Normal file
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
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>
|
||||
);
|
||||
}
|
32
src/services/APIService.ts
Normal file
32
src/services/APIService.ts
Normal file
@ -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);
|
||||
}
|
24
src/services/ClientService.ts
Normal file
24
src/services/ClientService.ts
Normal file
@ -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;
|
||||
}
|
350
src/services/WebauthnService.ts
Normal file
350
src/services/WebauthnService.ts
Normal file
@ -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
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);
|
||||
}
|
8
src/utils/Configuration.ts
Normal file
8
src/utils/Configuration.ts
Normal file
@ -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
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
5
webpack.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports={
|
||||
resolve:{
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user