Skip to content

Commit

Permalink
Add authentication to the Web UI.
Browse files Browse the repository at this point in the history
This commit hooks up the Web UI to use our authentication APIs. It can
now query for a list of acceptable authentication methods and show the
user a web form for filling out the required credentials.

The UI will monitor requests and if it detects an Unauthorized status
will immediately open the auth flow. So as with the CLI, auth happens
automagically when required and not otherwise.
  • Loading branch information
simonwo committed Mar 28, 2024
1 parent cf745b7 commit 447fe32
Show file tree
Hide file tree
Showing 17 changed files with 2,348 additions and 1,812 deletions.
2 changes: 2 additions & 0 deletions webui/.yarnrc.yml
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
nodeLinker: node-modules

yarnPath: .yarn/releases/yarn-4.1.1.cjs
2 changes: 1 addition & 1 deletion webui/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ module.exports = {
moduleNameMapper: {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
"<rootDir>/tests/mocks/fileMock.js",
"\\.(css|less)$": "<rootDir>/tests/mocks/styleMock.js",
"\\.(css|less)$": "<rootDir>/tests/mocks/styleMock.ts",
"^@pages/(.*)$": "<rootDir>/src/pages/$1",
"^@components/(.*)$": "<rootDir>/src/components/$1",
"\\.svg$": "<rootDir>/tests/mocks/svgMock.mjs",
Expand Down
4 changes: 3 additions & 1 deletion webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"@jest/globals": "^29.7.0",
"@testing-library/jest-dom": "^6.4.1",
"@testing-library/react": "^14.2.1",
"@types/json-schema": "^7.0.15",
"@types/node": "^20.11.16",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.2.1",
Expand All @@ -35,6 +36,7 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"react-svg": "^16.1.33",
"react-toastify": "^10.0.5",
"sass": "^1.70.0",
"semver": "^7.5.4",
"typescript": "^5.3.3",
Expand Down Expand Up @@ -108,5 +110,5 @@
"description": "WebUI for Bacalhau",
"main": "./src/index.tsx",
"homepage": ".",
"packageManager": "yarn@4.1.0"
"packageManager": "yarn@4.1.1"
}
7 changes: 6 additions & 1 deletion webui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import React from "react"
import { ToastContainer } from "react-toastify"
import 'react-toastify/dist/ReactToastify.css';
import { BrowserRouter as Router, Route, Routes } from "react-router-dom"
import { TableSettingsContextProvider } from "./context/TableSettingsContext"
import { Home } from "./pages/Home/Home"
import { JobsDashboard } from "./pages/JobsDashboard/JobsDashboard"
import { NodesDashboard } from "./pages/NodesDashboard/NodesDashboard"
import { Settings } from "./pages/Settings/Settings"
import { JobDetail } from "./pages/JobDetail/JobDetail"
import { Flow } from "./pages/Auth/Flow";

const App = () => (
<TableSettingsContextProvider>
<ToastContainer position="top-center"/>
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/JobsDashboard" element={<JobsDashboard />} />
<Route path="/JobDetail/:jobId" element={<JobDetail />} />
<Route path="/NodesDashboard" element={<NodesDashboard />} />
<Route path="/Auth" element={<Flow />} />
<Route path="/Settings" element={<Settings />} />
<Route path="/JobDetail/:jobId" element={<JobDetail />} />
</Routes>
</Router>
</TableSettingsContextProvider>
Expand Down
25 changes: 2 additions & 23 deletions webui/src/components/ActionButton/ActionButton.module.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@import "../../styles/variables";
@import "../../styles/button";

.column {
display: flex;
Expand All @@ -9,29 +10,7 @@
}

.actionButton {
background-color: rgba($light-blue, 0.5);
color: $text-color-dark;
padding: 5px 15px;
border-radius: 20px;
border: none;
font-size: 14px;
cursor: pointer;
display: flex;
column-gap: 1ch;
align-items: center;

&:hover {
background-color: rgba($light-blue, 0.7);
}
}

.viewIcon {
width: 18px;
height: 18px;
}

.actionButton:active {
background-color: rgba($light-blue, 0.7);
@include button;
}

.viewIcon {
Expand Down
61 changes: 61 additions & 0 deletions webui/src/helpers/authInterfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { JSONSchema7 } from "json-schema";
import { ListRequest, ListResponse } from "./baseInterfaces";

// A request to list the authentication methods that the node supports.
export interface ListAuthnMethodsRequest extends ListRequest { }

// A response listing the authentication methods that the node supports.
export interface ListAuthnMethodsResponse extends ListResponse {
// The name of a method mapped to that method's requirements.
// The name must be subsequently used to submit data.
Methods: {[key: string]: Requirement}
}

// A requirement of an authn method, with the params varying per-type.
export type Requirement = {
type: "challenge"
params: ChallengeRequirement
} | {
type: "ask"
params: AskRequirement
}

// The "ask" type just gives the client a JSON Schema that describes the fields
// it needs to collect from the user and submit.
export type AskRequirement = JSONSchema7

// The "challenge" type gives the client an input phrase it must sign using its
// private key.
export interface ChallengeRequirement {
InputPhrase: string
}

// A request to authenticate using a given method, including any credentials.
export interface AuthnRequest {
Name: string
MethodData: AskRequest | ChallengeRequest
}

// The "ask" type needs the fields requested from the user by the JSON Schema.
export type AskRequest = {[key: string]: string}

// The "challenge" type needs the signature of the phrase and the associated
// public key.
export interface ChallengeRequest {
PhraseSignature: string
PublicKey: string
}

export interface AuthnResponse {
Authentication: Authentication
}

// A response from trying to authenticate.
export interface Authentication {
// Whether the authentication was successful.
success: boolean
// Any additional info about why authentication was successful or not.
reason?: string
// The token the client should use in subsequent API requests.
token?: string
}
20 changes: 20 additions & 0 deletions webui/src/pages/Auth/AskInput.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
form {
display: grid;
grid-template-columns: 4fr 6fr;
row-gap: 1em;
column-gap: 1em;

h3 {
margin: 0;
grid-column: span 2;
}

label {
text-transform: capitalize;
}

label[data-required=true]::after {
content: "*";
color: red;
}
}
74 changes: 74 additions & 0 deletions webui/src/pages/Auth/AskInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React from "react";
import { JSONSchema7, JSONSchema7Definition } from "json-schema";
import { AskRequest, AskRequirement, AuthnRequest } from "../../helpers/authInterfaces";
import "./AskInput.module.scss"

interface AskInputProps {
name: string
requirement: AskRequirement
authenticate: (req: AuthnRequest) => void
cancel: () => void
}

function propertyToInputType(property: JSONSchema7Definition): React.HTMLInputTypeAttribute {
if (typeof property === "boolean") {
return "text"
}

if (property.writeOnly) {
return "password"
}

switch (property.type) {
case "number":
case "integer":
return "number"
case "boolean":
return "checkbox"
default:
return "text"
}
}

export const AskInput: React.FC<AskInputProps> = (props: AskInputProps) => {
const requirement = props.requirement
const properties = requirement.properties ?? {}
const fields = Object.keys(properties)
const required = requirement.required ?? []

// Sort by fields listed in required order
fields.sort((a, b) => required.indexOf(a) - required.indexOf(b))

const inputs = fields.map(field => {
const property = properties[field] as JSONSchema7
return <>
<label htmlFor={field} data-required={required.includes(field)}>
{field}
</label>
<input
name={field}
type={propertyToInputType(property)}
required={required.includes(field)} />
</>
})

const submit: React.FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault()

const formData = new FormData(event.currentTarget)
const objectData: AskRequest = {}
formData.forEach((value, key) => {objectData[key] = value.toString()})

const request: AuthnRequest = {Name: props.name, MethodData: objectData}
props.authenticate(request)

return false
}

return <form onSubmit={submit} onReset={props.cancel}>
<h3>Authenticate using {props.name.replaceAll(/[^A-Za-z0-9]/g, ' ')}</h3>
{...inputs}
<input type="reset" value="Cancel"/>
<input type="submit" value="Authenticate"/>
</form>
}
41 changes: 41 additions & 0 deletions webui/src/pages/Auth/Flow.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
@import "../../styles/container";
@import "../../styles/button";
@import "../../styles/variables";

.flow {
@include container;
width: 50%;
margin-left: auto;
margin-right: auto;
min-width: 250px;

button, input[type=submit], input[type=reset] {
@include button;
}

input[type=reset] {
background-color: $grey-label;
color: $grey-text;

&:hover {
background-color: rgba($grey-label, 0.7);
}

&:active {
background-color: rgba($grey-label, 0.8);
}
}

h3 {
margin: 0;
}

ul {
list-style: none;
padding: 0;

li {
margin-bottom: 1em;
}
}
}
47 changes: 47 additions & 0 deletions webui/src/pages/Auth/Flow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React, { useState } from "react"
import { toast } from "react-toastify"
import { Layout } from "../../layout/Layout"
import { AuthnRequest } from "../../helpers/authInterfaces"
import { bacalhauAPI } from "../../services/bacalhau"
import { useLocation, useNavigate } from "react-router-dom"
import { AxiosError } from "axios"
import styles from "./Flow.module.scss";
import { MethodPicker } from "./MethodPicker"

export const Flow: React.FC<{}> = ({ }) => {
const location = useLocation()
const navigate = useNavigate()
const [inputForm, setInputForm] = useState<React.ReactElement>()

const submitAuthnRequest = (req: AuthnRequest) => {
bacalhauAPI.authenticate(req).then(auth => {
if (!auth.success) {
toast.error("Failed to authenticate you: " + auth.reason)
return
}

if ("prev" in location.state && "pathname" in location.state.prev) {
// If we were navigated here from an auth error on another page,
// return to that page to continue what we were doing.
navigate(location.state.prev.pathname)
} else {
toast.info("Authentication successful.")
}
}).catch(error => {
let errorText = error
if (error instanceof AxiosError) {
errorText = error.response?.statusText
}
toast.error("Failed to authenticate you: " + errorText)
})
}

return <Layout pageTitle="Authenticate">
<div className={styles.flow}>
{inputForm ?? <MethodPicker
setInputForm={setInputForm}
authenticate={submitAuthnRequest} />
}
</div>
</Layout>
}
Loading

0 comments on commit 447fe32

Please sign in to comment.